From 987fc46e0643bebe7f0c4be3dcd705e131636704 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 28 Jan 2026 11:56:36 +0800 Subject: [PATCH] feat(skills): add install from ZIP file feature - Add open_zip_file_dialog command for selecting ZIP files - Add install_from_zip service method with recursive skill scanning - Add install_skills_from_zip Tauri command - Add frontend API methods and useInstallSkillsFromZip hook - Add "Install from ZIP" button in Skills management page - Support local skill ID format: local:{directory} - Add i18n translations for new feature and error messages --- src-tauri/src/commands/import_export.rs | 14 ++ src-tauri/src/commands/skill.rs | 13 ++ src-tauri/src/lib.rs | 2 + src-tauri/src/services/skill.rs | 187 +++++++++++++++++++ src/App.tsx | 12 ++ src/components/skills/UnifiedSkillsPanel.tsx | 48 ++++- src/hooks/useSkills.ts | 20 ++ src/i18n/locales/en.json | 11 +- src/i18n/locales/zh.json | 11 +- src/lib/api/index.ts | 1 + src/lib/api/skills.ts | 15 ++ src/lib/errors/skillErrorParser.ts | 2 + 12 files changed, 333 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/commands/import_export.rs b/src-tauri/src/commands/import_export.rs index 78bcc713e..5e944bdc9 100644 --- a/src-tauri/src/commands/import_export.rs +++ b/src-tauri/src/commands/import_export.rs @@ -109,3 +109,17 @@ pub async fn open_file_dialog( Ok(result.map(|p| p.to_string())) } + +/// 打开 ZIP 文件选择对话框 +#[tauri::command] +pub async fn open_zip_file_dialog( + app: tauri::AppHandle, +) -> Result, String> { + let dialog = app.dialog(); + let result = dialog + .file() + .add_filter("ZIP", &["zip"]) + .blocking_pick_file(); + + Ok(result.map(|p| p.to_string())) +} diff --git a/src-tauri/src/commands/skill.rs b/src-tauri/src/commands/skill.rs index ca6bcc38f..9651807cc 100644 --- a/src-tauri/src/commands/skill.rs +++ b/src-tauri/src/commands/skill.rs @@ -249,3 +249,16 @@ pub fn remove_skill_repo( .map_err(|e| e.to_string())?; Ok(true) } + +/// 从 ZIP 文件安装 Skills +#[tauri::command] +pub fn install_skills_from_zip( + file_path: String, + current_app: String, + app_state: State<'_, AppState>, +) -> Result, String> { + let app_type = parse_app_type(¤t_app)?; + let path = std::path::Path::new(&file_path); + + SkillService::install_from_zip(&app_state.db, path, &app_type).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ddebd7935..27abea279 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -850,6 +850,7 @@ pub fn run() { commands::import_config_from_file, commands::save_file_dialog, commands::open_file_dialog, + commands::open_zip_file_dialog, commands::sync_current_providers_live, // Deep link import commands::parse_deeplink, @@ -879,6 +880,7 @@ pub fn run() { commands::get_skill_repos, commands::add_skill_repo, commands::remove_skill_repo, + commands::install_skills_from_zip, // Auto launch commands::set_auto_launch, commands::get_auto_launch_status, diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 641560770..ab361a0eb 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -1110,6 +1110,193 @@ impl SkillService { Ok(()) } + // ========== 从 ZIP 文件安装 ========== + + /// 从本地 ZIP 文件安装 Skills + /// + /// 流程: + /// 1. 解压 ZIP 到临时目录 + /// 2. 扫描目录查找包含 SKILL.md 的技能 + /// 3. 复制到 SSOT 并保存到数据库 + /// 4. 同步到当前应用目录 + pub fn install_from_zip( + db: &Arc, + zip_path: &Path, + current_app: &AppType, + ) -> Result> { + // 解压到临时目录 + let temp_dir = Self::extract_local_zip(zip_path)?; + + // 扫描所有包含 SKILL.md 的目录 + let skill_dirs = Self::scan_skills_in_dir(&temp_dir)?; + + if skill_dirs.is_empty() { + let _ = fs::remove_dir_all(&temp_dir); + return Err(anyhow!(format_skill_error( + "NO_SKILLS_IN_ZIP", + &[], + Some("checkZipContent"), + ))); + } + + let ssot_dir = Self::get_ssot_dir()?; + let mut installed = Vec::new(); + let existing_skills = db.get_all_installed_skills()?; + + for skill_dir in skill_dirs { + // 获取目录名称作为安装名 + let install_name = skill_dir + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + // 检查是否已有同名 directory 的 skill + let conflict = existing_skills + .values() + .find(|s| s.directory.eq_ignore_ascii_case(&install_name)); + + if let Some(existing) = conflict { + log::warn!( + "Skill directory '{}' already exists (from {}), skipping", + install_name, + existing.id + ); + continue; + } + + // 解析元数据 + let skill_md = skill_dir.join("SKILL.md"); + let (name, description) = if skill_md.exists() { + match Self::parse_skill_metadata_static(&skill_md) { + Ok(meta) => ( + meta.name.unwrap_or_else(|| install_name.clone()), + meta.description, + ), + Err(_) => (install_name.clone(), None), + } + } else { + (install_name.clone(), None) + }; + + // 复制到 SSOT + let dest = ssot_dir.join(&install_name); + if dest.exists() { + let _ = fs::remove_dir_all(&dest); + } + Self::copy_dir_recursive(&skill_dir, &dest)?; + + // 创建 InstalledSkill 记录 + let skill = InstalledSkill { + id: format!("local:{install_name}"), + name, + description, + directory: install_name.clone(), + repo_owner: None, + repo_name: None, + repo_branch: None, + readme_url: None, + apps: SkillApps::only(current_app), + installed_at: chrono::Utc::now().timestamp(), + }; + + // 保存到数据库 + db.save_skill(&skill)?; + + // 同步到当前应用目录 + Self::sync_to_app_dir(&install_name, current_app)?; + + log::info!( + "Skill {} installed from ZIP, enabled for {:?}", + skill.name, + current_app + ); + installed.push(skill); + } + + // 清理临时目录 + let _ = fs::remove_dir_all(&temp_dir); + + Ok(installed) + } + + /// 解压本地 ZIP 文件到临时目录 + fn extract_local_zip(zip_path: &Path) -> Result { + let file = fs::File::open(zip_path) + .with_context(|| format!("Failed to open ZIP file: {}", zip_path.display()))?; + + let mut archive = zip::ZipArchive::new(file) + .with_context(|| format!("Failed to read ZIP file: {}", zip_path.display()))?; + + if archive.is_empty() { + return Err(anyhow!(format_skill_error( + "EMPTY_ARCHIVE", + &[], + Some("checkZipContent"), + ))); + } + + let temp_dir = tempfile::tempdir()?; + let temp_path = temp_dir.path().to_path_buf(); + let _ = temp_dir.keep(); // Keep the directory, we'll clean up later + + for i in 0..archive.len() { + let mut file = archive.by_index(i)?; + let file_path = match file.enclosed_name() { + Some(path) => path.to_owned(), + None => continue, + }; + + let outpath = temp_path.join(&file_path); + + if file.is_dir() { + fs::create_dir_all(&outpath)?; + } else { + if let Some(parent) = outpath.parent() { + fs::create_dir_all(parent)?; + } + let mut outfile = fs::File::create(&outpath)?; + std::io::copy(&mut file, &mut outfile)?; + } + } + + Ok(temp_path) + } + + /// 递归扫描目录查找包含 SKILL.md 的技能目录 + fn scan_skills_in_dir(dir: &Path) -> Result> { + let mut skill_dirs = Vec::new(); + Self::scan_skills_recursive(dir, &mut skill_dirs)?; + Ok(skill_dirs) + } + + /// 递归扫描辅助函数 + fn scan_skills_recursive(current: &Path, results: &mut Vec) -> Result<()> { + // 检查当前目录是否包含 SKILL.md + let skill_md = current.join("SKILL.md"); + if skill_md.exists() { + results.push(current.to_path_buf()); + // 找到后不再递归子目录(一个 skill 目录) + return Ok(()); + } + + // 递归子目录 + if let Ok(entries) = fs::read_dir(current) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + // 跳过隐藏目录 + let dir_name = entry.file_name().to_string_lossy().to_string(); + if dir_name.starts_with('.') { + continue; + } + Self::scan_skills_recursive(&path, results)?; + } + } + } + + Ok(()) + } + // ========== 仓库管理(保留原有逻辑)========== /// 列出仓库 diff --git a/src/App.tsx b/src/App.tsx index 8a609efbb..b13116a24 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import { Search, Download, BarChart2, + FolderArchive, } from "lucide-react"; import type { Provider, VisibleApps } from "@/types"; import type { EnvConflict } from "@/types/env"; @@ -829,6 +830,17 @@ function App() { )} {currentView === "skills" && ( <> +