From e6514ec75967bf65b90d514c2d703f47bf20692a Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Tue, 9 Dec 2025 16:50:08 +0800 Subject: [PATCH] feat(skill): add database migration and Gemini support for multi-app skills - Refactor skills table from single key to (directory, app_type) composite primary key - Add migration logic to convert existing skill records - Support skill installation/uninstallation for Claude/Codex/Gemini independently - Add new Tauri commands: get_skills_for_app, install_skill_for_app, uninstall_skill_for_app - Update frontend API and components to support app-specific skill operations --- src-tauri/src/commands/skill.rs | 92 +++++++++++++++++++++++----- src-tauri/src/database/dao/skills.rs | 26 ++++++-- src-tauri/src/database/schema.rs | 88 ++++++++++++++++++++++++-- src-tauri/src/lib.rs | 3 + src-tauri/src/services/skill.rs | 34 +++++++++- src/components/skills/SkillsPage.tsx | 19 ++++-- src/lib/api/skills.ts | 26 ++++++-- 7 files changed, 248 insertions(+), 40 deletions(-) diff --git a/src-tauri/src/commands/skill.rs b/src-tauri/src/commands/skill.rs index b81c50965..e7f51f80f 100644 --- a/src-tauri/src/commands/skill.rs +++ b/src-tauri/src/commands/skill.rs @@ -1,3 +1,4 @@ +use crate::app_config::AppType; use crate::error::format_skill_error; use crate::services::skill::SkillState; use crate::services::{Skill, SkillRepo, SkillService}; @@ -8,15 +9,46 @@ use tauri::State; pub struct SkillServiceState(pub Arc); +/// 解析 app 参数为 AppType +fn parse_app_type(app: &str) -> Result { + match app.to_lowercase().as_str() { + "claude" => Ok(AppType::Claude), + "codex" => Ok(AppType::Codex), + "gemini" => Ok(AppType::Gemini), + _ => Err(format!("不支持的 app 类型: {app}")), + } +} + +/// 根据 app_type 生成带前缀的 skill key +fn get_skill_key(app_type: &AppType, directory: &str) -> String { + let prefix = match app_type { + AppType::Claude => "claude", + AppType::Codex => "codex", + AppType::Gemini => "gemini", + }; + format!("{prefix}:{directory}") +} + #[tauri::command] pub async fn get_skills( service: State<'_, SkillServiceState>, app_state: State<'_, AppState>, ) -> Result, String> { + get_skills_for_app("claude".to_string(), service, app_state).await +} + +#[tauri::command] +pub async fn get_skills_for_app( + app: String, + _service: State<'_, SkillServiceState>, + app_state: State<'_, AppState>, +) -> Result, String> { + let app_type = parse_app_type(&app)?; + let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?; + let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?; let skills = service - .0 .list_skills(repos) .await .map_err(|e| e.to_string())?; @@ -26,16 +58,19 @@ pub async fn get_skills( let existing_states = app_state.db.get_skills().unwrap_or_default(); for skill in &skills { - if skill.installed && !existing_states.contains_key(&skill.directory) { - // 本地有该 skill,但数据库中没有记录,自动添加 - if let Err(e) = app_state.db.update_skill_state( - &skill.directory, - &SkillState { - installed: true, - installed_at: Utc::now(), - }, - ) { - log::warn!("同步本地 skill {} 状态到数据库失败: {}", skill.directory, e); + if skill.installed { + let key = get_skill_key(&app_type, &skill.directory); + if !existing_states.contains_key(&key) { + // 本地有该 skill,但数据库中没有记录,自动添加 + if let Err(e) = app_state.db.update_skill_state( + &key, + &SkillState { + installed: true, + installed_at: Utc::now(), + }, + ) { + log::warn!("同步本地 skill {key} 状态到数据库失败: {e}"); + } } } } @@ -49,11 +84,23 @@ pub async fn install_skill( service: State<'_, SkillServiceState>, app_state: State<'_, AppState>, ) -> Result { + install_skill_for_app("claude".to_string(), directory, service, app_state).await +} + +#[tauri::command] +pub async fn install_skill_for_app( + app: String, + directory: String, + _service: State<'_, SkillServiceState>, + app_state: State<'_, AppState>, +) -> Result { + let app_type = parse_app_type(&app)?; + let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?; + // 先在不持有写锁的情况下收集仓库与技能信息 let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?; let skills = service - .0 .list_skills(repos) .await .map_err(|e| e.to_string())?; @@ -93,16 +140,16 @@ pub async fn install_skill( }; service - .0 .install_skill(directory.clone(), repo) .await .map_err(|e| e.to_string())?; } + let key = get_skill_key(&app_type, &directory); app_state .db .update_skill_state( - &directory, + &key, &SkillState { installed: true, installed_at: Utc::now(), @@ -119,16 +166,29 @@ pub fn uninstall_skill( service: State<'_, SkillServiceState>, app_state: State<'_, AppState>, ) -> Result { + uninstall_skill_for_app("claude".to_string(), directory, service, app_state) +} + +#[tauri::command] +pub fn uninstall_skill_for_app( + app: String, + directory: String, + _service: State<'_, SkillServiceState>, + app_state: State<'_, AppState>, +) -> Result { + let app_type = parse_app_type(&app)?; + let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?; + service - .0 .uninstall_skill(directory.clone()) .map_err(|e| e.to_string())?; // Remove from database by setting installed = false + let key = get_skill_key(&app_type, &directory); app_state .db .update_skill_state( - &directory, + &key, &SkillState { installed: false, installed_at: Utc::now(), diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs index 5fc02ee09..6727059e9 100644 --- a/src-tauri/src/database/dao/skills.rs +++ b/src-tauri/src/database/dao/skills.rs @@ -13,18 +13,22 @@ impl Database { pub fn get_skills(&self) -> Result, AppError> { let conn = lock_conn!(self.conn); let mut stmt = conn - .prepare("SELECT key, installed, installed_at FROM skills ORDER BY key ASC") + .prepare("SELECT directory, app_type, installed, installed_at FROM skills ORDER BY directory ASC, app_type ASC") .map_err(|e| AppError::Database(e.to_string()))?; let skill_iter = stmt .query_map([], |row| { - let key: String = row.get(0)?; - let installed: bool = row.get(1)?; - let installed_at_ts: i64 = row.get(2)?; + let directory: String = row.get(0)?; + let app_type: String = row.get(1)?; + let installed: bool = row.get(2)?; + let installed_at_ts: i64 = row.get(3)?; let installed_at = chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default(); + // 构建复合 key:"app_type:directory" + let key = format!("{app_type}:{directory}"); + Ok(( key, SkillState { @@ -44,11 +48,21 @@ impl Database { } /// 更新 Skill 状态 + /// key 格式为 "app_type:directory" pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> { + // 解析 key + let (app_type, directory) = if let Some(idx) = key.find(':') { + let (app, dir) = key.split_at(idx); + (app, &dir[1..]) // 跳过冒号 + } else { + // 向后兼容:如果没有前缀,默认为 claude + ("claude", key) + }; + let conn = lock_conn!(self.conn); conn.execute( - "INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)", - params![key, state.installed, state.installed_at.timestamp()], + "INSERT OR REPLACE INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)", + params![directory, app_type, state.installed, state.installed_at.timestamp()], ) .map_err(|e| AppError::Database(e.to_string()))?; Ok(()) diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 14ae7e58b..2569cdf47 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -96,9 +96,11 @@ impl Database { // 5. Skills 表 conn.execute( "CREATE TABLE IF NOT EXISTS skills ( - key TEXT PRIMARY KEY, + directory TEXT NOT NULL, + app_type TEXT NOT NULL, installed BOOLEAN NOT NULL DEFAULT 0, - installed_at INTEGER NOT NULL DEFAULT 0 + installed_at INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (directory, app_type) )", [], ) @@ -391,7 +393,9 @@ impl Database { Self::set_user_version(conn, 1)?; } 1 => { - log::info!("迁移数据库从 v1 到 v2(添加使用统计表和完整字段)"); + log::info!( + "迁移数据库从 v1 到 v2(添加使用统计表和完整字段,重构 skills 表)" + ); Self::migrate_v1_to_v2(conn)?; Self::set_user_version(conn, 2)?; } @@ -480,7 +484,7 @@ impl Database { Ok(()) } - /// v1 -> v2 迁移:添加使用统计表和完整字段 + /// v1 -> v2 迁移:添加使用统计表和完整字段,重构 skills 表 fn migrate_v1_to_v2(conn: &Connection) -> Result<(), AppError> { // providers 表字段 Self::add_column_if_missing( @@ -576,6 +580,82 @@ impl Database { .map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?; Self::seed_model_pricing(conn)?; + // 重构 skills 表(添加 app_type 字段) + Self::migrate_skills_table(conn)?; + + Ok(()) + } + + /// 迁移 skills 表:从单 key 主键改为 (directory, app_type) 复合主键 + fn migrate_skills_table(conn: &Connection) -> Result<(), AppError> { + // 检查是否已经是新表结构 + if Self::has_column(conn, "skills", "app_type")? { + log::info!("skills 表已经包含 app_type 字段,跳过迁移"); + return Ok(()); + } + + log::info!("开始迁移 skills 表..."); + + // 1. 重命名旧表 + conn.execute("ALTER TABLE skills RENAME TO skills_old", []) + .map_err(|e| AppError::Database(format!("重命名旧 skills 表失败: {e}")))?; + + // 2. 创建新表 + conn.execute( + "CREATE TABLE skills ( + directory TEXT NOT NULL, + app_type TEXT NOT NULL, + installed BOOLEAN NOT NULL DEFAULT 0, + installed_at INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (directory, app_type) + )", + [], + ) + .map_err(|e| AppError::Database(format!("创建新 skills 表失败: {e}")))?; + + // 3. 迁移数据:解析 key 格式(如 "claude:my-skill" 或 "codex:foo") + // 旧数据如果没有前缀,默认为 claude + let mut stmt = conn + .prepare("SELECT key, installed, installed_at FROM skills_old") + .map_err(|e| AppError::Database(format!("查询旧 skills 数据失败: {e}")))?; + + let old_skills: Vec<(String, bool, i64)> = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, bool>(1)?, + row.get::<_, i64>(2)?, + )) + }) + .map_err(|e| AppError::Database(format!("读取旧 skills 数据失败: {e}")))? + .collect::, _>>() + .map_err(|e| AppError::Database(format!("解析旧 skills 数据失败: {e}")))?; + + let count = old_skills.len(); + + for (key, installed, installed_at) in old_skills { + // 解析 key: "app:directory" 或 "directory"(默认 claude) + let (app_type, directory) = if let Some(idx) = key.find(':') { + let (app, dir) = key.split_at(idx); + (app.to_string(), dir[1..].to_string()) // 跳过冒号 + } else { + ("claude".to_string(), key.clone()) + }; + + conn.execute( + "INSERT INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![directory, app_type, installed, installed_at], + ) + .map_err(|e| { + AppError::Database(format!("迁移 skill {key} 到新表失败: {e}")) + })?; + } + + // 4. 删除旧表 + conn.execute("DROP TABLE skills_old", []) + .map_err(|e| AppError::Database(format!("删除旧 skills 表失败: {e}")))?; + + log::info!("skills 表迁移完成,共迁移 {count} 条记录"); Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d6a9eabfd..a17190c27 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -635,8 +635,11 @@ pub fn run() { commands::restore_env_backup, // Skill management commands::get_skills, + commands::get_skills_for_app, commands::install_skill, + commands::install_skill_for_app, commands::uninstall_skill, + commands::uninstall_skill_for_app, commands::get_skill_repos, commands::add_skill_repo, commands::remove_skill_repo, diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 22823f19d..4285652e4 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -7,6 +7,7 @@ use std::fs; use std::path::{Path, PathBuf}; use tokio::time::timeout; +use crate::app_config::AppType; use crate::error::format_skill_error; /// 技能对象 @@ -106,11 +107,16 @@ pub struct SkillMetadata { pub struct SkillService { http_client: Client, install_dir: PathBuf, + app_type: AppType, } impl SkillService { pub fn new() -> Result { - let install_dir = Self::get_install_dir()?; + Self::new_for_app(AppType::Claude) + } + + pub fn new_for_app(app_type: AppType) -> Result { + let install_dir = Self::get_install_dir_for_app(&app_type)?; // 确保目录存在 fs::create_dir_all(&install_dir)?; @@ -122,16 +128,38 @@ impl SkillService { .timeout(std::time::Duration::from_secs(10)) .build()?, install_dir, + app_type, }) } - fn get_install_dir() -> Result { + fn get_install_dir_for_app(app_type: &AppType) -> Result { let home = dirs::home_dir().context(format_skill_error( "GET_HOME_DIR_FAILED", &[], Some("checkPermission"), ))?; - Ok(home.join(".claude").join("skills")) + + let dir = match app_type { + AppType::Claude => home.join(".claude").join("skills"), + AppType::Codex => { + // 检查是否有自定义 Codex 配置目录 + if let Some(custom) = crate::settings::get_codex_override_dir() { + custom.join("skills") + } else { + home.join(".codex").join("skills") + } + } + AppType::Gemini => { + // 为 Gemini 预留,暂时使用默认路径 + home.join(".gemini").join("skills") + } + }; + + Ok(dir) + } + + pub fn app_type(&self) -> &AppType { + &self.app_type } } diff --git a/src/components/skills/SkillsPage.tsx b/src/components/skills/SkillsPage.tsx index f8df8c30a..2bb9e186e 100644 --- a/src/components/skills/SkillsPage.tsx +++ b/src/components/skills/SkillsPage.tsx @@ -19,11 +19,17 @@ import { RefreshCw, Search } from "lucide-react"; import { toast } from "sonner"; import { SkillCard } from "./SkillCard"; import { RepoManagerPanel } from "./RepoManagerPanel"; -import { skillsApi, type Skill, type SkillRepo } from "@/lib/api/skills"; +import { + skillsApi, + type Skill, + type SkillRepo, + type AppType, +} from "@/lib/api/skills"; import { formatSkillError } from "@/lib/errors/skillErrorParser"; interface SkillsPageProps { onClose?: () => void; + initialApp?: AppType; } export interface SkillsPageHandle { @@ -32,7 +38,7 @@ export interface SkillsPageHandle { } export const SkillsPage = forwardRef( - ({ onClose: _onClose }, ref) => { + ({ onClose: _onClose, initialApp = "claude" }, ref) => { const { t } = useTranslation(); const [skills, setSkills] = useState([]); const [repos, setRepos] = useState([]); @@ -42,11 +48,13 @@ export const SkillsPage = forwardRef( const [filterStatus, setFilterStatus] = useState< "all" | "installed" | "uninstalled" >("all"); + // 使用 initialApp,不允许切换 + const selectedApp = initialApp; const loadSkills = async (afterLoad?: (data: Skill[]) => void) => { try { setLoading(true); - const data = await skillsApi.getAll(); + const data = await skillsApi.getAll(selectedApp); setSkills(data); if (afterLoad) { afterLoad(data); @@ -84,6 +92,7 @@ export const SkillsPage = forwardRef( useEffect(() => { Promise.all([loadSkills(), loadRepos()]); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useImperativeHandle(ref, () => ({ @@ -93,7 +102,7 @@ export const SkillsPage = forwardRef( const handleInstall = async (directory: string) => { try { - await skillsApi.install(directory); + await skillsApi.install(directory, selectedApp); toast.success(t("skills.installSuccess", { name: directory })); await loadSkills(); } catch (error) { @@ -122,7 +131,7 @@ export const SkillsPage = forwardRef( const handleUninstall = async (directory: string) => { try { - await skillsApi.uninstall(directory); + await skillsApi.uninstall(directory, selectedApp); toast.success(t("skills.uninstallSuccess", { name: directory })); await loadSkills(); } catch (error) { diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts index 372baf90a..862813a2a 100644 --- a/src/lib/api/skills.ts +++ b/src/lib/api/skills.ts @@ -19,17 +19,31 @@ export interface SkillRepo { enabled: boolean; } +export type AppType = "claude" | "codex" | "gemini"; + export const skillsApi = { - async getAll(): Promise { - return await invoke("get_skills"); + async getAll(app: AppType = "claude"): Promise { + if (app === "claude") { + return await invoke("get_skills"); + } + return await invoke("get_skills_for_app", { app }); }, - async install(directory: string): Promise { - return await invoke("install_skill", { directory }); + async install(directory: string, app: AppType = "claude"): Promise { + if (app === "claude") { + return await invoke("install_skill", { directory }); + } + return await invoke("install_skill_for_app", { app, directory }); }, - async uninstall(directory: string): Promise { - return await invoke("uninstall_skill", { directory }); + async uninstall( + directory: string, + app: AppType = "claude", + ): Promise { + if (app === "claude") { + return await invoke("uninstall_skill", { directory }); + } + return await invoke("uninstall_skill_for_app", { app, directory }); }, async getRepos(): Promise {