diff --git a/src-tauri/src/commands/skill.rs b/src-tauri/src/commands/skill.rs index b81c5096..e7f51f80 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 5fc02ee0..6727059e 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 11b399ad..0a4f45cd 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) )", [], ) @@ -369,7 +371,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)?; } @@ -458,7 +462,7 @@ impl Database { Ok(()) } - /// v1 -> v2 迁移:添加使用统计表和完整字段 + /// v1 -> v2 迁移:添加使用统计表和完整字段,重构 skills 表 fn migrate_v1_to_v2(conn: &Connection) -> Result<(), AppError> { // providers 表字段 Self::add_column_if_missing( @@ -554,6 +558,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 af735ecd..f52c2114 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 22823f19..4285652e 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/App.tsx b/src/App.tsx index ecc966bf..c0170db9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,7 +24,6 @@ import { import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env"; import { useProviderActions } from "@/hooks/useProviderActions"; import { extractErrorMessage } from "@/utils/errorUtils"; -import { cn } from "@/lib/utils"; import { AppSwitcher } from "@/components/AppSwitcher"; import { ProviderList } from "@/components/providers/ProviderList"; import { AddProviderDialog } from "@/components/providers/AddProviderDialog"; @@ -65,7 +64,8 @@ function App() { const { data, isLoading, refetch } = useProvidersQuery(activeApp); const providers = useMemo(() => data?.providers ?? {}, [data]); const currentProviderId = data?.currentProviderId ?? ""; - const isClaudeApp = activeApp === "claude"; + // Skills 功能仅支持 Claude 和 Codex + const hasSkillsSupport = activeApp === "claude" || activeApp === "codex"; // 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作 const { @@ -291,6 +291,7 @@ function App() { setCurrentView("providers")} + initialApp={activeApp} /> ); case "mcp": @@ -478,21 +479,17 @@ function App() {
- + {hasSkillsSupport && ( + + )} {/* TODO: Agents 功能开发中,暂时隐藏入口 */} {/* {isClaudeApp && (