diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 564ee2ae2..4a9fc7539 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -174,6 +174,12 @@ pub struct InstalledSkill { pub apps: SkillApps, /// 安装时间(Unix 时间戳) pub installed_at: i64, + /// 内容哈希(SHA-256,用于更新检测) + #[serde(skip_serializing_if = "Option::is_none")] + pub content_hash: Option, + /// 最近更新时间(Unix 时间戳,0 = 从未更新) + #[serde(default)] + pub updated_at: i64, } /// 未管理的 Skill(在应用目录中发现但未被 CC Switch 管理) diff --git a/src-tauri/src/commands/skill.rs b/src-tauri/src/commands/skill.rs index e5c848cb8..a55b5e6f7 100644 --- a/src-tauri/src/commands/skill.rs +++ b/src-tauri/src/commands/skill.rs @@ -8,7 +8,7 @@ use crate::app_config::{AppType, InstalledSkill, UnmanagedSkill}; use crate::error::format_skill_error; use crate::services::skill::{ DiscoverableSkill, ImportSkillSelection, Skill, SkillBackupEntry, SkillRepo, SkillService, - SkillUninstallResult, + SkillUninstallResult, SkillUpdateInfo, }; use crate::store::AppState; use std::sync::Arc; @@ -134,6 +134,33 @@ pub async fn discover_available_skills( .map_err(|e| e.to_string()) } +/// 检查 Skills 更新 +#[tauri::command] +pub async fn check_skill_updates( + service: State<'_, SkillServiceState>, + app_state: State<'_, AppState>, +) -> Result, String> { + service + .0 + .check_updates(&app_state.db) + .await + .map_err(|e| e.to_string()) +} + +/// 更新单个 Skill +#[tauri::command] +pub async fn update_skill( + id: String, + service: State<'_, SkillServiceState>, + app_state: State<'_, AppState>, +) -> Result { + service + .0 + .update_skill(&app_state.db, &id) + .await + .map_err(|e| e.to_string()) +} + // ========== 兼容旧 API 的命令 ========== /// 获取技能列表(兼容旧 API) diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs index 2254cb061..befedca12 100644 --- a/src-tauri/src/database/dao/skills.rs +++ b/src-tauri/src/database/dao/skills.rs @@ -22,7 +22,8 @@ impl Database { let mut stmt = conn .prepare( "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, - readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at + readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, + installed_at, content_hash, updated_at FROM skills ORDER BY name ASC", ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -45,6 +46,8 @@ impl Database { opencode: row.get(11)?, }, installed_at: row.get(12)?, + content_hash: row.get(13)?, + updated_at: row.get::<_, i64>(14).unwrap_or(0), }) }) .map_err(|e| AppError::Database(e.to_string()))?; @@ -63,7 +66,8 @@ impl Database { let mut stmt = conn .prepare( "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, - readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at + readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, + installed_at, content_hash, updated_at FROM skills WHERE id = ?1", ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -85,6 +89,8 @@ impl Database { opencode: row.get(11)?, }, installed_at: row.get(12)?, + content_hash: row.get(13)?, + updated_at: row.get::<_, i64>(14).unwrap_or(0), }) }); @@ -101,8 +107,9 @@ impl Database { conn.execute( "INSERT OR REPLACE INTO skills (id, name, description, directory, repo_owner, repo_name, repo_branch, - readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, + installed_at, content_hash, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", params![ skill.id, skill.name, @@ -117,6 +124,8 @@ impl Database { skill.apps.gemini, skill.apps.opencode, skill.installed_at, + skill.content_hash, + skill.updated_at, ], ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -152,6 +161,23 @@ impl Database { Ok(affected > 0) } + /// 更新 Skill 的内容哈希和更新时间 + pub fn update_skill_hash( + &self, + id: &str, + content_hash: &str, + updated_at: i64, + ) -> Result { + let conn = lock_conn!(self.conn); + let affected = conn + .execute( + "UPDATE skills SET content_hash = ?1, updated_at = ?2 WHERE id = ?3", + params![content_hash, updated_at, id], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(affected > 0) + } + // ========== SkillRepo CRUD(保持原有) ========== /// 获取所有 Skill 仓库 diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 99b7de62a..d093c0971 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -44,7 +44,7 @@ use std::sync::Mutex; /// 当前 Schema 版本号 /// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑 -pub(crate) const SCHEMA_VERSION: i32 = 6; +pub(crate) const SCHEMA_VERSION: i32 = 7; /// 安全地序列化 JSON,避免 unwrap panic pub(crate) fn to_json_string(value: &T) -> Result { diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 1a7198d6f..e85d8827a 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -93,7 +93,9 @@ impl Database { enabled_codex BOOLEAN NOT NULL DEFAULT 0, enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0, - installed_at INTEGER NOT NULL DEFAULT 0 + installed_at INTEGER NOT NULL DEFAULT 0, + content_hash TEXT, + updated_at INTEGER NOT NULL DEFAULT 0 )", [], ) @@ -393,6 +395,11 @@ impl Database { Self::migrate_v5_to_v6(conn)?; Self::set_user_version(conn, 6)?; } + 6 => { + log::info!("迁移数据库从 v6 到 v7(Skills 更新检测支持)"); + Self::migrate_v6_to_v7(conn)?; + Self::set_user_version(conn, 7)?; + } _ => { return Err(AppError::Database(format!( "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}" @@ -1045,6 +1052,14 @@ impl Database { Ok(()) } + /// v6 -> v7: Skills 更新检测支持(content_hash + updated_at) + fn migrate_v6_to_v7(conn: &Connection) -> Result<(), AppError> { + Self::add_column_if_missing(conn, "skills", "content_hash", "TEXT")?; + Self::add_column_if_missing(conn, "skills", "updated_at", "INTEGER NOT NULL DEFAULT 0")?; + log::info!("v6 -> v7 迁移完成:已添加 content_hash 和 updated_at 列"); + Ok(()) + } + /// 插入默认模型定价数据 /// 格式: (model_id, display_name, input, output, cache_read, cache_creation) /// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3b33f6f9e..dca642610 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -963,6 +963,8 @@ pub fn run() { commands::scan_unmanaged_skills, commands::import_skills_from_apps, commands::discover_available_skills, + commands::check_skill_updates, + commands::update_skill, // Skill management (legacy API compatibility) commands::get_skills, commands::get_skills_for_app, diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 7a36b0424..f26803d34 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -160,6 +160,20 @@ pub struct SkillUninstallResult { pub backup_path: Option, } +/// Skill 更新检测结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillUpdateInfo { + /// Skill ID + pub id: String, + /// Skill 名称 + pub name: String, + /// 当前本地哈希 + pub current_hash: Option, + /// 远程最新哈希 + pub remote_hash: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SkillBackupEntry { @@ -632,6 +646,12 @@ impl SkillService { )); // 创建 InstalledSkill 记录 + // 计算内容哈希 + let content_hash = Self::compute_dir_hash(&dest).map(Some).unwrap_or_else(|e| { + log::warn!("Failed to compute content hash for {}: {e}", install_name); + None + }); + let installed_skill = InstalledSkill { id: skill.key.clone(), name: skill.name.clone(), @@ -647,6 +667,8 @@ impl SkillService { readme_url, apps: SkillApps::only(current_app), installed_at: chrono::Utc::now().timestamp(), + content_hash, + updated_at: 0, }; // 保存到数据库 @@ -706,6 +728,330 @@ impl SkillService { Ok(SkillUninstallResult { backup_path }) } + // ========== 更新检测 ========== + + /// 计算目录内容的 SHA-256 哈希 + /// + /// 递归遍历目录下所有非隐藏文件,按相对路径字典序排列, + /// 将 "相对路径\0内容\0" 逐文件 feed 给同一个 hasher。 + pub fn compute_dir_hash(dir: &Path) -> Result { + use sha2::{Digest, Sha256}; + + let mut files: Vec = Vec::new(); + Self::collect_files_for_hash(dir, dir, &mut files)?; + files.sort(); + + let mut hasher = Sha256::new(); + for file_path in &files { + let relative = file_path.strip_prefix(dir).unwrap_or(file_path); + let rel_str = relative.to_string_lossy().replace('\\', "/"); + hasher.update(rel_str.as_bytes()); + hasher.update(b"\0"); + let content = fs::read(file_path) + .with_context(|| format!("读取文件失败: {}", file_path.display()))?; + hasher.update(&content); + hasher.update(b"\0"); + } + + Ok(format!("{:x}", hasher.finalize())) + } + + /// 递归收集目录下所有非隐藏文件 + fn collect_files_for_hash(base: &Path, current: &Path, files: &mut Vec) -> Result<()> { + let entries = fs::read_dir(current) + .with_context(|| format!("读取目录失败: {}", current.display()))?; + for entry in entries { + let entry = entry?; + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with('.') { + continue; + } + let path = entry.path(); + if path.is_dir() { + Self::collect_files_for_hash(base, &path, files)?; + } else { + files.push(path); + } + } + Ok(()) + } + + /// 检查所有已安装 Skill 的更新 + /// + /// 仅检查有 repo_owner 的 Skill(本地 Skill 跳过), + /// 按仓库分组下载,避免重复下载同一仓库。 + pub async fn check_updates(&self, db: &Arc) -> Result> { + let skills = db.get_all_installed_skills()?; + let mut updates = Vec::new(); + + // 按 (owner, name, branch) 分组 + let mut repo_groups: HashMap<(String, String, String), Vec> = + HashMap::new(); + + for skill in skills.into_values() { + let (owner, name, branch) = + match (&skill.repo_owner, &skill.repo_name, &skill.repo_branch) { + (Some(o), Some(n), Some(b)) => (o.clone(), n.clone(), b.clone()), + (Some(o), Some(n), None) => (o.clone(), n.clone(), "main".to_string()), + _ => continue, + }; + repo_groups + .entry((owner, name, branch)) + .or_default() + .push(skill); + } + + let ssot_dir = Self::get_ssot_dir()?; + + for ((owner, name, branch), group_skills) in &repo_groups { + let repo = SkillRepo { + owner: owner.clone(), + name: name.clone(), + branch: branch.clone(), + enabled: true, + }; + + // 下载仓库 ZIP + let (temp_dir, _used_branch) = match timeout( + std::time::Duration::from_secs(60), + self.download_repo(&repo), + ) + .await + { + Ok(Ok(result)) => result, + Ok(Err(e)) => { + log::warn!("检查更新时下载 {}/{} 失败: {e}", owner, name); + continue; + } + Err(_) => { + log::warn!("检查更新时下载 {}/{} 超时", owner, name); + continue; + } + }; + + // 扫描仓库中的所有 Skill 目录 + let mut remote_skills: Vec = Vec::new(); + let _ = self.scan_dir_recursive(&temp_dir, &temp_dir, &repo, &mut remote_skills); + + for skill in group_skills { + // 在远程仓库中找到匹配的 Skill 目录 + let remote_match = remote_skills.iter().find(|rs| { + // 匹配方式:安装名称的最后一段 + let remote_install_name = + rs.directory.rsplit('/').next().unwrap_or(&rs.directory); + remote_install_name.eq_ignore_ascii_case(&skill.directory) + }); + + let remote_skill_dir = match remote_match { + Some(rs) => temp_dir.join(&rs.directory), + None => continue, + }; + + if !remote_skill_dir.exists() { + continue; + } + + let remote_hash = match Self::compute_dir_hash(&remote_skill_dir) { + Ok(h) => h, + Err(e) => { + log::warn!("计算远程哈希失败 {}: {e}", skill.id); + continue; + } + }; + + // 本地哈希:优先数据库,否则实时计算 + let local_hash = match &skill.content_hash { + Some(h) => Some(h.clone()), + None => { + let local_dir = ssot_dir.join(&skill.directory); + if local_dir.exists() { + match Self::compute_dir_hash(&local_dir) { + Ok(h) => { + let _ = db.update_skill_hash(&skill.id, &h, 0); + Some(h) + } + Err(_) => None, + } + } else { + None + } + } + }; + + if local_hash.as_deref() != Some(&remote_hash) { + updates.push(SkillUpdateInfo { + id: skill.id.clone(), + name: skill.name.clone(), + current_hash: local_hash, + remote_hash, + }); + } + } + + let _ = fs::remove_dir_all(&temp_dir); + } + + Ok(updates) + } + + /// 更新单个 Skill(重新下载并替换本地文件) + pub async fn update_skill(&self, db: &Arc, skill_id: &str) -> Result { + let skill = db + .get_installed_skill(skill_id)? + .ok_or_else(|| anyhow!("Skill not found: {skill_id}"))?; + + let (owner, name, branch) = match (&skill.repo_owner, &skill.repo_name) { + (Some(o), Some(n)) => ( + o.clone(), + n.clone(), + skill + .repo_branch + .clone() + .unwrap_or_else(|| "main".to_string()), + ), + _ => return Err(anyhow!("Cannot update local skill: {skill_id}")), + }; + + let repo = SkillRepo { + owner: owner.clone(), + name: name.clone(), + branch: branch.clone(), + enabled: true, + }; + + let ssot_dir = Self::get_ssot_dir()?; + + // 下载仓库 + let (temp_dir, used_branch) = timeout( + std::time::Duration::from_secs(60), + self.download_repo(&repo), + ) + .await + .map_err(|_| { + anyhow!(format_skill_error( + "DOWNLOAD_TIMEOUT", + &[("owner", &owner), ("name", &name), ("timeout", "60")], + Some("checkNetwork"), + )) + })??; + + // 在解压的仓库中查找 Skill 源目录 + let mut remote_skills: Vec = Vec::new(); + let _ = self.scan_dir_recursive(&temp_dir, &temp_dir, &repo, &mut remote_skills); + + let remote_match = remote_skills + .iter() + .find(|rs| { + let remote_install_name = rs.directory.rsplit('/').next().unwrap_or(&rs.directory); + remote_install_name.eq_ignore_ascii_case(&skill.directory) + }) + .ok_or_else(|| { + let _ = fs::remove_dir_all(&temp_dir); + anyhow!(format_skill_error( + "SKILL_DIR_NOT_FOUND", + &[("path", &skill.directory)], + Some("checkRepoUrl"), + )) + })?; + + let source = temp_dir.join(&remote_match.directory); + if !source.exists() { + let _ = fs::remove_dir_all(&temp_dir); + return Err(anyhow!(format_skill_error( + "SKILL_DIR_NOT_FOUND", + &[("path", &source.display().to_string())], + Some("checkRepoUrl"), + ))); + } + + // 备份旧文件 + let _ = Self::create_uninstall_backup(&skill); + + // 删除旧 SSOT 目录并复制新文件 + let dest = ssot_dir.join(&skill.directory); + if dest.exists() { + fs::remove_dir_all(&dest)?; + } + Self::copy_dir_recursive(&source, &dest)?; + let _ = fs::remove_dir_all(&temp_dir); + + // 计算新哈希 + 解析新元数据 + let new_hash = Self::compute_dir_hash(&dest).ok(); + let skill_md = dest.join("SKILL.md"); + let (new_name, new_description) = Self::read_skill_name_desc(&skill_md, &skill.directory); + + // 更新 readme_url + let doc_path = skill + .readme_url + .as_deref() + .and_then(Self::extract_doc_path_from_url) + .unwrap_or_else(|| format!("{}/SKILL.md", skill.directory.trim_end_matches('/'))); + let readme_url = Some(Self::build_skill_doc_url( + &owner, + &name, + &used_branch, + &doc_path, + )); + + let updated_skill = InstalledSkill { + id: skill.id.clone(), + name: new_name, + description: new_description, + directory: skill.directory.clone(), + repo_owner: skill.repo_owner.clone(), + repo_name: skill.repo_name.clone(), + repo_branch: Some(used_branch), + readme_url, + apps: skill.apps.clone(), + installed_at: skill.installed_at, + content_hash: new_hash, + updated_at: chrono::Utc::now().timestamp(), + }; + + db.save_skill(&updated_skill)?; + + // 同步到所有已启用的应用目录 + for app in updated_skill.apps.enabled_apps() { + if let Err(e) = Self::sync_to_app_dir(&updated_skill.directory, &app) { + log::warn!("同步更新后的 skill 到 {:?} 失败: {e}", app); + } + } + + log::info!("Skill {} 更新成功", updated_skill.name); + Ok(updated_skill) + } + + /// 为缺少 content_hash 的已安装 Skill 补算哈希 + pub fn backfill_content_hashes(db: &Arc) -> Result { + let skills = db.get_all_installed_skills()?; + let ssot_dir = Self::get_ssot_dir()?; + let mut count = 0; + + for skill in skills.values() { + if skill.content_hash.is_some() { + continue; + } + let skill_dir = ssot_dir.join(&skill.directory); + if !skill_dir.exists() { + continue; + } + match Self::compute_dir_hash(&skill_dir) { + Ok(hash) => { + let _ = db.update_skill_hash(&skill.id, &hash, 0); + count += 1; + } + Err(e) => { + log::warn!("补算哈希失败 {}: {e}", skill.id); + } + } + } + + if count > 0 { + log::info!("已为 {count} 个 Skill 补算内容哈希"); + } + Ok(count) + } + pub fn list_backups() -> Result> { let backup_dir = Self::get_backup_dir()?; let mut entries = Vec::new(); @@ -800,9 +1146,13 @@ impl SkillService { let mut restored_skill = metadata.skill; restored_skill.installed_at = Utc::now().timestamp(); restored_skill.apps = SkillApps::only(current_app); + restored_skill.updated_at = 0; Self::copy_dir_recursive(&backup_skill_dir, &restore_path)?; + // 重新计算内容哈希 + restored_skill.content_hash = Self::compute_dir_hash(&restore_path).ok(); + if let Err(err) = db.save_skill(&restored_skill) { let _ = fs::remove_dir_all(&restore_path); return Err(err.into()); @@ -991,6 +1341,10 @@ impl SkillService { let (id, repo_owner, repo_name, repo_branch, readme_url) = build_repo_info_from_lock(&agents_lock, &dir_name); + // 计算内容哈希 + let ssot_skill_dir = ssot_dir.join(&dir_name); + let content_hash = Self::compute_dir_hash(&ssot_skill_dir).ok(); + // 创建记录 let skill = InstalledSkill { id, @@ -1003,6 +1357,8 @@ impl SkillService { readme_url, apps, installed_at: chrono::Utc::now().timestamp(), + content_hash, + updated_at: 0, }; // 保存到数据库 @@ -1965,6 +2321,9 @@ impl SkillService { } Self::copy_dir_recursive(&skill_dir, &dest)?; + // 计算内容哈希 + let content_hash = Self::compute_dir_hash(&dest).ok(); + // 创建 InstalledSkill 记录 let skill = InstalledSkill { id: format!("local:{install_name}"), @@ -1977,6 +2336,8 @@ impl SkillService { readme_url: None, apps: SkillApps::only(current_app), installed_at: chrono::Utc::now().timestamp(), + content_hash, + updated_at: 0, }; // 保存到数据库 @@ -2288,6 +2649,8 @@ pub fn migrate_skills_to_ssot(db: &Arc) -> Result { let (id, repo_owner, repo_name, repo_branch, readme_url) = build_repo_info_from_lock(&agents_lock, &directory); + let content_hash = SkillService::compute_dir_hash(&ssot_path).ok(); + let skill = InstalledSkill { id, name, @@ -2299,6 +2662,8 @@ pub fn migrate_skills_to_ssot(db: &Arc) -> Result { readme_url, apps, installed_at: chrono::Utc::now().timestamp(), + content_hash, + updated_at: 0, }; db.save_skill(&skill)?; diff --git a/src/components/skills/UnifiedSkillsPanel.tsx b/src/components/skills/UnifiedSkillsPanel.tsx index 8ba3599d5..3a361d527 100644 --- a/src/components/skills/UnifiedSkillsPanel.tsx +++ b/src/components/skills/UnifiedSkillsPanel.tsx @@ -1,7 +1,14 @@ import React, { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Sparkles, Trash2, ExternalLink } from "lucide-react"; +import { + Sparkles, + Trash2, + ExternalLink, + RefreshCw, + Loader2, +} from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { TooltipProvider } from "@/components/ui/tooltip"; import { type ImportSkillSelection, @@ -15,7 +22,10 @@ import { useScanUnmanagedSkills, useImportSkillsFromApps, useInstallSkillsFromZip, + useCheckSkillUpdates, + useUpdateSkill, type InstalledSkill, + type SkillUpdateInfo, } from "@/hooks/useSkills"; import type { AppId } from "@/lib/api/types"; import { ConfirmDialog } from "@/components/ConfirmDialog"; @@ -44,6 +54,7 @@ export interface UnifiedSkillsPanelHandle { openImport: () => void; openInstallFromZip: () => void; openRestoreFromBackup: () => void; + checkUpdates: () => void; } function formatSkillBackupDate(unixSeconds: number): string { @@ -83,6 +94,22 @@ const UnifiedSkillsPanel = React.forwardRef< useScanUnmanagedSkills(); const importMutation = useImportSkillsFromApps(); const installFromZipMutation = useInstallSkillsFromZip(); + const { + data: skillUpdates, + refetch: checkUpdates, + isFetching: isCheckingUpdates, + } = useCheckSkillUpdates(); + const updateSkillMutation = useUpdateSkill(); + + const updatesMap = useMemo(() => { + const map: Record = {}; + if (skillUpdates) { + for (const u of skillUpdates) { + map[u.id] = u; + } + } + return map; + }, [skillUpdates]); const enabledCounts = useMemo(() => { const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 }; @@ -191,6 +218,33 @@ const UnifiedSkillsPanel = React.forwardRef< } }; + const handleCheckUpdates = async () => { + try { + const result = await checkUpdates(); + const updates = result.data || []; + if (updates.length === 0) { + toast.success(t("skills.noUpdates"), { closeButton: true }); + } else { + toast.info(t("skills.updatesFound", { count: updates.length }), { + closeButton: true, + }); + } + } catch (error) { + toast.error(t("common.error"), { description: String(error) }); + } + }; + + const handleUpdateSkill = async (skill: InstalledSkill) => { + try { + const updated = await updateSkillMutation.mutateAsync(skill.id); + toast.success(t("skills.updateSuccess", { name: updated.name }), { + closeButton: true, + }); + } catch (error) { + toast.error(t("skills.updateFailed"), { description: String(error) }); + } + }; + const handleOpenRestoreFromBackup = async () => { setRestoreDialogOpen(true); try { @@ -256,15 +310,35 @@ const UnifiedSkillsPanel = React.forwardRef< openImport: handleOpenImport, openInstallFromZip: handleInstallFromZip, openRestoreFromBackup: handleOpenRestoreFromBackup, + checkUpdates: handleCheckUpdates, })); return (
- +
+ + +
{isLoading ? ( @@ -290,8 +364,14 @@ const UnifiedSkillsPanel = React.forwardRef< handleUninstall(skill)} + onUpdate={() => handleUpdateSkill(skill)} isLast={index === skills.length - 1} /> ))} @@ -339,15 +419,21 @@ UnifiedSkillsPanel.displayName = "UnifiedSkillsPanel"; interface InstalledSkillListItemProps { skill: InstalledSkill; + hasUpdate?: boolean; + isUpdating?: boolean; onToggleApp: (id: string, app: AppId, enabled: boolean) => void; onUninstall: () => void; + onUpdate?: () => void; isLast?: boolean; } const InstalledSkillListItem: React.FC = ({ skill, + hasUpdate, + isUpdating, onToggleApp, onUninstall, + onUpdate, isLast, }) => { const { t } = useTranslation(); @@ -387,6 +473,14 @@ const InstalledSkillListItem: React.FC = ({ {sourceLabel} + {hasUpdate && ( + + {t("skills.updateAvailable")} + + )}
{skill.description && (

= ({ appIds={MCP_SKILLS_APP_IDS} /> -

+
+ {hasUpdate && onUpdate && ( + + )}