From 8f018a2d45a00c439ea9c8d37b3aec6eab0c0601 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 4 Jul 2026 16:03:19 +0800 Subject: [PATCH] feat: add project profiles for snapshot-based config switching Add a profile feature that captures the current provider, MCP, skills and prompt state for Claude Code and Codex as a named snapshot, and re-applies it in one click from the header switcher or the tray Projects submenu. - New profiles table (schema v12) with current marker in settings - ProfileService orchestrates the four existing switch primitives (provider first, then MCP diff, skills diff, prompt enable) - Best-effort apply: dangling references become warnings, no rollback - Header combobox switcher + snapshot-style manage dialog - Tray Projects submenu shared with the UI apply/event pipeline - i18n for zh/en/ja/zh-TW under the new profiles domain - Integration tests covering roundtrip, dangling refs and clear --- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/profile.rs | 134 ++++++ src-tauri/src/database/dao/mod.rs | 4 +- src-tauri/src/database/dao/profiles.rs | 196 +++++++++ src-tauri/src/database/mod.rs | 3 +- src-tauri/src/database/schema.rs | 37 ++ src-tauri/src/lib.rs | 11 +- src-tauri/src/services/mod.rs | 1 + src-tauri/src/services/profile.rs | 382 ++++++++++++++++++ src-tauri/src/tray.rs | 99 +++++ src-tauri/tests/profile_roundtrip.rs | 287 +++++++++++++ src/App.tsx | 9 + .../profiles/ProfileManageDialog.tsx | 211 ++++++++++ src/components/profiles/ProfileSwitcher.tsx | 236 +++++++++++ src/components/prompts/PromptPanel.tsx | 4 + src/i18n/locales/en.json | 28 ++ src/i18n/locales/ja.json | 28 ++ src/i18n/locales/zh-TW.json | 28 ++ src/i18n/locales/zh.json | 28 ++ src/lib/api/index.ts | 2 + src/lib/api/profiles.ts | 83 ++++ src/lib/query/profiles.ts | 145 +++++++ 22 files changed, 1955 insertions(+), 3 deletions(-) create mode 100644 src-tauri/src/commands/profile.rs create mode 100644 src-tauri/src/database/dao/profiles.rs create mode 100644 src-tauri/src/services/profile.rs create mode 100644 src-tauri/tests/profile_roundtrip.rs create mode 100644 src/components/profiles/ProfileManageDialog.tsx create mode 100644 src/components/profiles/ProfileSwitcher.tsx create mode 100644 src/lib/api/profiles.ts create mode 100644 src/lib/query/profiles.ts diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c41d7b44b..29a26710e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -18,6 +18,7 @@ mod model_fetch; mod omo; mod openclaw; mod plugin; +mod profile; mod prompt; mod provider; mod proxy; @@ -52,6 +53,7 @@ pub use model_fetch::*; pub use omo::*; pub use openclaw::*; pub use plugin::*; +pub use profile::*; pub use prompt::*; pub use provider::*; pub use proxy::*; diff --git a/src-tauri/src/commands/profile.rs b/src-tauri/src/commands/profile.rs new file mode 100644 index 000000000..f75eec55c --- /dev/null +++ b/src-tauri/src/commands/profile.rs @@ -0,0 +1,134 @@ +//! 项目 Profile 管理命令 + +use serde::Serialize; +use tauri::{Emitter, State}; + +use crate::database::Profile; +use crate::services::profile::{ProfilePayload, ProfileService, PROFILE_APPS}; +use crate::store::AppState; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfileDto { + pub id: String, + pub name: String, + pub payload: ProfilePayload, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option, +} + +impl From for ProfileDto { + fn from(profile: Profile) -> Self { + // 单条 payload 损坏不应拖垮整个列表:降级为默认值并记日志 + let payload = serde_json::from_str(&profile.payload).unwrap_or_else(|e| { + log::warn!( + "解析 profile '{}' payload 失败,使用默认值: {e}", + profile.id + ); + ProfilePayload::default() + }); + Self { + id: profile.id, + name: profile.name, + payload, + created_at: profile.created_at, + updated_at: profile.updated_at, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfilesResponse { + pub profiles: Vec, + pub current_id: Option, +} + +/// Profile 应用完成后的统一收尾:发事件 + 重建托盘菜单 +/// +/// UI 与托盘两个入口必须共用此函数,保证事件 payload 形状一致 +/// (前端 App.tsx 的 provider-switched 监听依赖该形状)。 +pub fn emit_profile_apply_events(app: &tauri::AppHandle, state: &AppState, profile_id: &str) { + for app_type in PROFILE_APPS.iter() { + let app_str = app_type.as_str(); + let (proxy_enabled, auto_failover_enabled) = state.db.get_proxy_flags_sync(app_str); + let provider_id = crate::settings::get_effective_current_provider(&state.db, app_type) + .ok() + .flatten() + .unwrap_or_default(); + let event_data = serde_json::json!({ + "appType": app_str, + "proxyEnabled": proxy_enabled, + "autoFailoverEnabled": auto_failover_enabled, + "providerId": provider_id, + }); + if let Err(e) = app.emit("provider-switched", event_data) { + log::error!("发射 provider-switched 事件失败: {e}"); + } + } + if let Err(e) = app.emit( + "profile-applied", + serde_json::json!({ "profileId": profile_id }), + ) { + log::error!("发射 profile-applied 事件失败: {e}"); + } + crate::tray::refresh_tray_menu(app); +} + +#[tauri::command] +pub fn list_profiles(state: State<'_, AppState>) -> Result { + let (profiles, current_id) = ProfileService::list(&state).map_err(|e| e.to_string())?; + Ok(ProfilesResponse { + profiles: profiles.into_iter().map(ProfileDto::from).collect(), + current_id, + }) +} + +#[tauri::command] +pub fn create_profile(state: State<'_, AppState>, name: String) -> Result { + ProfileService::create(&state, &name) + .map(ProfileDto::from) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn update_profile( + state: State<'_, AppState>, + id: String, + name: Option, + resnapshot: Option, +) -> Result { + ProfileService::update(&state, &id, name, resnapshot.unwrap_or(false)) + .map(ProfileDto::from) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn delete_profile(state: State<'_, AppState>, id: String) -> Result<(), String> { + ProfileService::delete(&state, &id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn clear_current_profile(state: State<'_, AppState>) -> Result<(), String> { + state + .db + .set_current_profile_id(None) + .map_err(|e| e.to_string()) +} + +/// 应用项目快照。 +/// +/// 注意:必须保持同步命令(跑在 Tauri 线程池)——`ProviderService::switch` +/// 内部使用 block_on 获取切换锁,放进 async 命令会在运行时线程上 panic。 +#[tauri::command] +pub fn apply_profile( + app: tauri::AppHandle, + state: State<'_, AppState>, + id: String, +) -> Result, String> { + let warnings = ProfileService::apply(&state, &id).map_err(|e| e.to_string())?; + emit_profile_apply_events(&app, &state, &id); + Ok(warnings) +} diff --git a/src-tauri/src/database/dao/mod.rs b/src-tauri/src/database/dao/mod.rs index 93ca2f578..bb78f3075 100644 --- a/src-tauri/src/database/dao/mod.rs +++ b/src-tauri/src/database/dao/mod.rs @@ -4,6 +4,7 @@ pub mod failover; pub mod mcp; +pub mod profiles; pub mod prompts; pub mod providers; pub mod providers_seed; @@ -15,5 +16,6 @@ pub mod universal_providers; pub mod usage_rollup; // 所有 DAO 方法都通过 Database impl 提供,无需单独导出 -// 导出 FailoverQueueItem 供外部使用 +// 导出 FailoverQueueItem / Profile 供外部使用 pub use failover::FailoverQueueItem; +pub use profiles::Profile; diff --git a/src-tauri/src/database/dao/profiles.rs b/src-tauri/src/database/dao/profiles.rs new file mode 100644 index 000000000..6a9891b54 --- /dev/null +++ b/src-tauri/src/database/dao/profiles.rs @@ -0,0 +1,196 @@ +//! 项目 Profile 数据访问对象 +//! +//! profiles 表存放按 app 快照的配置方案(供应商/MCP/Skills/Prompt), +//! payload 为原始 JSON 文本,解析在 service 层进行。 +//! current_profile_id 存放于 settings 表(key-value)。 + +use crate::database::{lock_conn, Database}; +use crate::error::AppError; +use rusqlite::params; + +const CURRENT_PROFILE_ID_KEY: &str = "current_profile_id"; + +/// 项目 Profile 记录 +#[derive(Debug, Clone)] +pub struct Profile { + pub id: String, + pub name: String, + /// 原始 JSON 快照文本(ProfilePayload),解析在 service 层 + pub payload: String, + pub sort_order: Option, + pub created_at: Option, + pub updated_at: Option, +} + +impl Database { + /// 获取所有项目(按 sort_order 优先、created_at 兜底排序) + pub fn get_all_profiles(&self) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let mut stmt = conn + .prepare( + "SELECT id, name, payload, sort_order, created_at, updated_at + FROM profiles + ORDER BY sort_order IS NULL, sort_order, created_at, id", + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + let rows = stmt + .query_map([], |row| { + Ok(Profile { + id: row.get(0)?, + name: row.get(1)?, + payload: row.get(2)?, + sort_order: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + }) + }) + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut profiles = Vec::new(); + for row in rows { + profiles.push(row.map_err(|e| AppError::Database(e.to_string()))?); + } + Ok(profiles) + } + + /// 获取单个项目 + pub fn get_profile(&self, id: &str) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let mut stmt = conn + .prepare( + "SELECT id, name, payload, sort_order, created_at, updated_at + FROM profiles WHERE id = ?1", + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + match stmt.query_row(params![id], |row| { + Ok(Profile { + id: row.get(0)?, + name: row.get(1)?, + payload: row.get(2)?, + sort_order: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + }) + }) { + Ok(profile) => Ok(Some(profile)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(AppError::Database(e.to_string())), + } + } + + /// 保存项目(插入或整行替换) + pub fn save_profile(&self, profile: &Profile) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "INSERT OR REPLACE INTO profiles + (id, name, payload, sort_order, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + profile.id, + profile.name, + profile.payload, + profile.sort_order, + profile.created_at, + profile.updated_at, + ], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + /// 删除项目,返回是否实际删除了记录 + pub fn delete_profile(&self, id: &str) -> Result { + let conn = lock_conn!(self.conn); + let affected = conn + .execute("DELETE FROM profiles WHERE id = ?1", params![id]) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(affected > 0) + } + + /// 读取当前激活的项目 id(未使用项目时为 None) + pub fn get_current_profile_id(&self) -> Result, AppError> { + self.get_setting(CURRENT_PROFILE_ID_KEY) + } + + /// 设置当前激活的项目 id;None 表示"不使用项目"(删除 key) + pub fn set_current_profile_id(&self, id: Option<&str>) -> Result<(), AppError> { + match id { + Some(id) => self.set_setting(CURRENT_PROFILE_ID_KEY, id), + None => { + let conn = lock_conn!(self.conn); + conn.execute( + "DELETE FROM settings WHERE key = ?1", + params![CURRENT_PROFILE_ID_KEY], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample(id: &str, name: &str, sort_order: Option) -> Profile { + Profile { + id: id.to_string(), + name: name.to_string(), + payload: r#"{"providers":{"claude":null,"codex":null}}"#.to_string(), + sort_order, + created_at: Some(1_000), + updated_at: Some(1_000), + } + } + + #[test] + fn test_profile_crud_roundtrip() -> Result<(), AppError> { + let db = Database::memory()?; + + db.save_profile(&sample("a", "Dev", Some(2)))?; + db.save_profile(&sample("b", "Draw", Some(1)))?; + db.save_profile(&sample("c", "Misc", None))?; + + // sort_order 优先,NULL 排最后 + let all = db.get_all_profiles()?; + assert_eq!( + all.iter().map(|p| p.id.as_str()).collect::>(), + vec!["b", "a", "c"] + ); + + let got = db.get_profile("a")?.expect("profile a exists"); + assert_eq!(got.name, "Dev"); + assert!(got.payload.contains("providers")); + + // 整行替换更新 + let mut updated = sample("a", "Dev Renamed", Some(2)); + updated.updated_at = Some(2_000); + db.save_profile(&updated)?; + let got = db.get_profile("a")?.expect("profile a exists"); + assert_eq!(got.name, "Dev Renamed"); + assert_eq!(got.updated_at, Some(2_000)); + + assert!(db.delete_profile("a")?); + assert!(!db.delete_profile("a")?); + assert!(db.get_profile("a")?.is_none()); + Ok(()) + } + + #[test] + fn test_current_profile_id_set_and_clear() -> Result<(), AppError> { + let db = Database::memory()?; + + assert_eq!(db.get_current_profile_id()?, None); + db.set_current_profile_id(Some("a"))?; + assert_eq!(db.get_current_profile_id()?, Some("a".to_string())); + db.set_current_profile_id(None)?; + assert_eq!(db.get_current_profile_id()?, None); + // 重复清除应幂等 + db.set_current_profile_id(None)?; + assert_eq!(db.get_current_profile_id()?, None); + Ok(()) + } +} diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index a92713210..e2c98fa50 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -38,6 +38,7 @@ pub(crate) use dao::proxy::{ PRICING_SOURCE_RESPONSE, }; pub use dao::FailoverQueueItem; +pub use dao::Profile; use crate::config::get_app_config_dir; use crate::error::AppError; @@ -49,7 +50,7 @@ use std::sync::Mutex; /// 当前 Schema 版本号 /// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑 -pub(crate) const SCHEMA_VERSION: i32 = 11; +pub(crate) const SCHEMA_VERSION: i32 = 12; /// 安全地序列化 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 f09547063..9ab8454bd 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -295,6 +295,20 @@ impl Database { ) .map_err(|e| AppError::Database(e.to_string()))?; + // 19. Profiles 表(项目配置方案:按 app 快照供应商/MCP/Skills/Prompt) + conn.execute( + "CREATE TABLE IF NOT EXISTS profiles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + payload TEXT NOT NULL, + sort_order INTEGER, + created_at INTEGER, + updated_at INTEGER + )", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + // 尝试添加 live_takeover_active 列到 proxy_config 表 let _ = conn.execute( "ALTER TABLE proxy_config ADD COLUMN live_takeover_active INTEGER NOT NULL DEFAULT 0", @@ -444,6 +458,11 @@ impl Database { Self::migrate_v10_to_v11(conn)?; Self::set_user_version(conn, 11)?; } + 11 => { + log::info!("迁移数据库从 v11 到 v12(添加项目 Profiles 表)"); + Self::migrate_v11_to_v12(conn)?; + Self::set_user_version(conn, 12)?; + } _ => { return Err(AppError::Database(format!( "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}" @@ -1270,6 +1289,24 @@ impl Database { Ok(()) } + /// v11 -> v12 迁移:添加项目 Profiles 表 + /// 与 create_tables_on_conn 中的建表语句保持一致(IF NOT EXISTS 保证幂等) + fn migrate_v11_to_v12(conn: &Connection) -> Result<(), AppError> { + conn.execute( + "CREATE TABLE IF NOT EXISTS profiles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + payload TEXT NOT NULL, + sort_order INTEGER, + created_at INTEGER, + updated_at INTEGER + )", + [], + ) + .map_err(|e| AppError::Database(format!("v11 -> v12 创建 profiles 表失败: {e}")))?; + 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 d5432f077..962606fb0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -42,7 +42,7 @@ pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_l pub use commands::open_provider_terminal; pub use commands::*; pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file}; -pub use database::Database; +pub use database::{Database, Profile}; pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest}; pub use error::AppError; pub use mcp::{ @@ -51,8 +51,10 @@ pub use mcp::{ sync_enabled_to_codex, sync_enabled_to_gemini, sync_single_server_to_claude, sync_single_server_to_codex, sync_single_server_to_gemini, }; +pub use prompt::Prompt; pub use provider::{Provider, ProviderMeta}; pub use services::{ + profile::{ProfilePayload, ProfileService}, skill::{migrate_skills_to_ssot, ImportSkillSelection}, ConfigService, EndpointLatency, McpService, PromptService, ProviderService, ProxyService, SkillService, SpeedtestService, @@ -1270,6 +1272,13 @@ pub fn run() { commands::enable_prompt, commands::import_prompt_from_file, commands::get_current_prompt_file_content, + // Profile management (项目配置方案) + commands::list_profiles, + commands::create_profile, + commands::update_profile, + commands::delete_profile, + commands::clear_current_profile, + commands::apply_profile, // model list fetch (OpenAI-compatible /v1/models) commands::fetch_models_for_config, // ours: endpoint speed test + custom endpoint management diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index ae43e079c..838c93b57 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -7,6 +7,7 @@ pub mod env_manager; pub mod mcp; pub mod model_fetch; pub mod omo; +pub mod profile; pub mod prompt; pub mod provider; pub mod proxy; diff --git a/src-tauri/src/services/profile.rs b/src-tauri/src/services/profile.rs new file mode 100644 index 000000000..e537d266e --- /dev/null +++ b/src-tauri/src/services/profile.rs @@ -0,0 +1,382 @@ +//! 项目 Profile 编排服务 +//! +//! Profile 是一份按 app 的配置快照(供应商 / MCP / Skills / Prompt), +//! 应用(apply)时复用现有切换原语批量落地: +//! - 供应商:`ProviderService::switch`(内建代理接管热切换与接管下禁切官方) +//! - MCP:`McpService::toggle_app`(改标志 + 单 server 物化) +//! - Skills:`SkillService::toggle_app`(改标志 + 单 skill 物化) +//! - Prompt:`PromptService::enable_prompt`(互斥激活 + 原子写 live) +//! +//! apply 为 best-effort:单项失败收集为 warning 继续,不整体回滚。 + +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; + +use crate::app_config::AppType; +use crate::database::Profile; +use crate::error::AppError; +use crate::services::{McpService, PromptService, ProviderService, SkillService}; +use crate::store::AppState; + +/// P1 支持的应用范围(扩展新 app 时同步扩展 PerApp 字段) +pub const PROFILE_APPS: [AppType; 2] = [AppType::Claude, AppType::Codex]; + +/// 按 app 分槽的载荷容器;字段名与 AppType 的 serde 小写形式一致 +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct PerApp { + pub claude: T, + pub codex: T, +} + +impl PerApp { + pub fn get(&self, app: &AppType) -> Option<&T> { + match app { + AppType::Claude => Some(&self.claude), + AppType::Codex => Some(&self.codex), + _ => None, + } + } + + pub fn get_mut(&mut self, app: &AppType) -> Option<&mut T> { + match app { + AppType::Claude => Some(&mut self.claude), + AppType::Codex => Some(&mut self.codex), + _ => None, + } + } +} + +/// Profile 的 JSON 快照结构(与前端 TS 类型严格对应) +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ProfilePayload { + /// 每 app 的当前供应商 id(None = 快照时无当前供应商,应用时不动) + pub providers: PerApp>, + /// 每 app 启用的 MCP server id 集合 + pub mcp: PerApp>, + /// 每 app 启用的 Skill id 集合 + pub skills: PerApp>, + /// 每 app 激活的 prompt id(None = 快照时无激活项,应用时不动) + pub prompts: PerApp>, +} + +/// 计算从当前启用状态到目标集合的最小 toggle 集 +/// +/// 返回 (需要执行的 (id, enabled) 列表, payload 中已不存在于 DB 的悬空 id 列表) +fn plan_toggles( + current: &[(String, bool)], + target_ids: &[String], +) -> (Vec<(String, bool)>, Vec) { + let existing: HashSet<&str> = current.iter().map(|(id, _)| id.as_str()).collect(); + let target: HashSet<&str> = target_ids.iter().map(|s| s.as_str()).collect(); + + let toggles = current + .iter() + .filter(|(id, enabled)| target.contains(id.as_str()) != *enabled) + .map(|(id, enabled)| (id.clone(), !enabled)) + .collect(); + + let dangling = target_ids + .iter() + .filter(|id| !existing.contains(id.as_str())) + .cloned() + .collect(); + + (toggles, dangling) +} + +pub struct ProfileService; + +impl ProfileService { + /// 抓取当前配置状态生成快照 + pub fn snapshot_current(state: &AppState) -> Result { + let mut payload = ProfilePayload::default(); + let mcp_servers = state.db.get_all_mcp_servers()?; + let skills = state.db.get_all_installed_skills()?; + + for app in PROFILE_APPS.iter() { + if let Some(slot) = payload.providers.get_mut(app) { + *slot = crate::settings::get_effective_current_provider(&state.db, app)?; + } + if let Some(slot) = payload.mcp.get_mut(app) { + *slot = mcp_servers + .values() + .filter(|s| s.apps.is_enabled_for(app)) + .map(|s| s.id.clone()) + .collect(); + } + if let Some(slot) = payload.skills.get_mut(app) { + *slot = skills + .values() + .filter(|s| s.apps.is_enabled_for(app)) + .map(|s| s.id.clone()) + .collect(); + } + if let Some(slot) = payload.prompts.get_mut(app) { + *slot = state + .db + .get_prompts(app.as_str())? + .values() + .find(|p| p.enabled) + .map(|p| p.id.clone()); + } + } + Ok(payload) + } + + /// 列出所有项目及当前激活项目 id + pub fn list(state: &AppState) -> Result<(Vec, Option), AppError> { + let profiles = state.db.get_all_profiles()?; + let current = state.db.get_current_profile_id()?; + Ok((profiles, current)) + } + + /// 以当前配置状态创建新项目 + pub fn create(state: &AppState, name: &str) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err(AppError::InvalidInput("Profile name is empty".to_string())); + } + let payload = Self::snapshot_current(state)?; + let now = chrono::Utc::now().timestamp(); + let profile = Profile { + id: uuid::Uuid::new_v4().to_string(), + name: name.to_string(), + payload: serde_json::to_string(&payload) + .map_err(|e| AppError::Config(format!("序列化 profile payload 失败: {e}")))?, + sort_order: None, + created_at: Some(now), + updated_at: Some(now), + }; + state.db.save_profile(&profile)?; + Ok(profile) + } + + /// 更新项目:重命名和/或以当前状态重拍快照 + pub fn update( + state: &AppState, + id: &str, + name: Option, + resnapshot: bool, + ) -> Result { + let mut profile = state + .db + .get_profile(id)? + .ok_or_else(|| AppError::InvalidInput(format!("Profile not found: {id}")))?; + + if let Some(name) = name { + let name = name.trim().to_string(); + if name.is_empty() { + return Err(AppError::InvalidInput("Profile name is empty".to_string())); + } + profile.name = name; + } + if resnapshot { + let payload = Self::snapshot_current(state)?; + profile.payload = serde_json::to_string(&payload) + .map_err(|e| AppError::Config(format!("序列化 profile payload 失败: {e}")))?; + } + profile.updated_at = Some(chrono::Utc::now().timestamp()); + state.db.save_profile(&profile)?; + Ok(profile) + } + + /// 删除项目;若删除的是当前激活项目,一并清除激活标记 + pub fn delete(state: &AppState, id: &str) -> Result<(), AppError> { + state.db.delete_profile(id)?; + if state.db.get_current_profile_id()?.as_deref() == Some(id) { + state.db.set_current_profile_id(None)?; + } + Ok(()) + } + + /// 应用项目快照(best-effort,返回 warnings) + /// + /// 顺序不可换:供应商切换(switch_normal 内部会按 DB 当前标志跑 MCP + /// sync_all_enabled)必须先于 MCP diff,否则 profile 的 MCP 目标态会被冲掉。 + pub fn apply(state: &AppState, profile_id: &str) -> Result, AppError> { + let profile = state + .db + .get_profile(profile_id)? + .ok_or_else(|| AppError::InvalidInput(format!("Profile not found: {profile_id}")))?; + let payload: ProfilePayload = serde_json::from_str(&profile.payload) + .map_err(|e| AppError::Config(format!("解析 profile payload 失败: {e}")))?; + + let mut warnings = Vec::new(); + + for app in PROFILE_APPS.iter() { + let app_str = app.as_str(); + + // 1. 供应商 + if let Some(Some(target_pid)) = payload.providers.get(app) { + let providers = state.db.get_all_providers(app_str)?; + if !providers.contains_key(target_pid) { + warnings.push(format!( + "[{app_str}] provider '{target_pid}' no longer exists, skipped" + )); + } else { + let current = crate::settings::get_effective_current_provider(&state.db, app)?; + if current.as_deref() != Some(target_pid.as_str()) { + match ProviderService::switch(state, app.clone(), target_pid) { + Ok(result) => warnings.extend(result.warnings), + Err(e) => warnings.push(format!( + "[{app_str}] switch provider '{target_pid}' failed: {e}" + )), + } + } + } + } + + // 2. MCP diff(最小 toggle:仅动目标态≠当前态的条目) + if let Some(target_ids) = payload.mcp.get(app) { + let servers = state.db.get_all_mcp_servers()?; + let current: Vec<(String, bool)> = servers + .values() + .map(|s| (s.id.clone(), s.apps.is_enabled_for(app))) + .collect(); + let (toggles, dangling) = plan_toggles(¤t, target_ids); + for id in dangling { + warnings.push(format!("[{app_str}] MCP '{id}' no longer exists, skipped")); + } + for (id, enabled) in toggles { + if let Err(e) = McpService::toggle_app(state, &id, app.clone(), enabled) { + warnings.push(format!( + "[{app_str}] toggle MCP '{id}' -> {enabled} failed: {e}" + )); + } + } + } + + // 3. Skills diff(SkillService 返回 anyhow::Result,收进 warning) + if let Some(target_ids) = payload.skills.get(app) { + let skills = state.db.get_all_installed_skills()?; + let current: Vec<(String, bool)> = skills + .values() + .map(|s| (s.id.clone(), s.apps.is_enabled_for(app))) + .collect(); + let (toggles, dangling) = plan_toggles(¤t, target_ids); + for id in dangling { + warnings.push(format!( + "[{app_str}] skill '{id}' no longer exists, skipped" + )); + } + for (id, enabled) in toggles { + if let Err(e) = SkillService::toggle_app(&state.db, &id, app, enabled) { + warnings.push(format!( + "[{app_str}] toggle skill '{id}' -> {enabled} failed: {e}" + )); + } + } + } + + // 4. Prompt(None = 不动;已激活则幂等跳过,避免无谓的文件写与备份) + if let Some(Some(target_prompt)) = payload.prompts.get(app) { + let prompts = state.db.get_prompts(app_str)?; + match prompts.get(target_prompt) { + None => warnings.push(format!( + "[{app_str}] prompt '{target_prompt}' no longer exists, skipped" + )), + Some(p) if p.enabled => {} + Some(_) => { + if let Err(e) = + PromptService::enable_prompt(state, app.clone(), target_prompt) + { + warnings.push(format!( + "[{app_str}] enable prompt '{target_prompt}' failed: {e}" + )); + } + } + } + } + } + + state.db.set_current_profile_id(Some(profile_id))?; + Ok(warnings) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn test_payload_serde_roundtrip() { + let payload = ProfilePayload { + providers: PerApp { + claude: Some("p1".into()), + codex: None, + }, + mcp: PerApp { + claude: ids(&["m1", "m2"]), + codex: vec![], + }, + skills: PerApp { + claude: vec![], + codex: ids(&["s1"]), + }, + prompts: PerApp { + claude: None, + codex: Some("pr1".into()), + }, + }; + let json = serde_json::to_string(&payload).unwrap(); + // per-app key 必须是 AppType serde 的小写形式 + assert!(json.contains("\"claude\"")); + assert!(json.contains("\"codex\"")); + let back: ProfilePayload = serde_json::from_str(&json).unwrap(); + assert_eq!(back, payload); + } + + #[test] + fn test_payload_tolerates_missing_fields() { + // 前向兼容:旧版/部分字段缺失时应落到默认值而不是报错 + let back: ProfilePayload = + serde_json::from_str(r#"{"providers":{"claude":"p1"}}"#).unwrap(); + assert_eq!(back.providers.claude, Some("p1".to_string())); + assert_eq!(back.providers.codex, None); + assert!(back.mcp.claude.is_empty()); + assert_eq!(back.prompts.codex, None); + + let empty: ProfilePayload = serde_json::from_str("{}").unwrap(); + assert_eq!(empty, ProfilePayload::default()); + } + + #[test] + fn test_per_app_get_only_supports_profile_apps() { + let per: PerApp> = PerApp::default(); + assert!(per.get(&AppType::Claude).is_some()); + assert!(per.get(&AppType::Codex).is_some()); + assert!(per.get(&AppType::Gemini).is_none()); + assert!(per.get(&AppType::ClaudeDesktop).is_none()); + } + + #[test] + fn test_plan_toggles_minimal_diff() { + let current = vec![ + ("a".to_string(), true), // 目标含 a:不动 + ("b".to_string(), false), // 目标含 b:开 + ("c".to_string(), true), // 目标不含 c:关 + ("d".to_string(), false), // 目标不含 d:不动 + ]; + let (toggles, dangling) = plan_toggles(¤t, &ids(&["a", "b", "ghost"])); + assert_eq!( + toggles, + vec![("b".to_string(), true), ("c".to_string(), false)] + ); + assert_eq!(dangling, ids(&["ghost"])); + } + + #[test] + fn test_plan_toggles_empty_target_disables_all_enabled() { + let current = vec![("a".to_string(), true), ("b".to_string(), false)]; + let (toggles, dangling) = plan_toggles(¤t, &[]); + assert_eq!(toggles, vec![("a".to_string(), false)]); + assert!(dangling.is_empty()); + } +} diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 0b159ae67..ace88c5af 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -54,6 +54,8 @@ pub struct TrayTexts { pub lightweight_mode: &'static str, pub quit: &'static str, pub _auto_label: &'static str, + pub projects_label: &'static str, + pub no_project_label: &'static str, } impl TrayTexts { @@ -66,6 +68,8 @@ impl TrayTexts { lightweight_mode: "Lightweight Mode", quit: "Quit", _auto_label: "Auto (Failover)", + projects_label: "Projects", + no_project_label: "No project", }, "ja" => Self { show_main: "メインウィンドウを開く", @@ -74,6 +78,8 @@ impl TrayTexts { lightweight_mode: "軽量モード", quit: "終了", _auto_label: "自動 (フェイルオーバー)", + projects_label: "プロジェクト", + no_project_label: "プロジェクトを使用しない", }, "zh-TW" => Self { show_main: "開啟主介面", @@ -82,6 +88,8 @@ impl TrayTexts { lightweight_mode: "輕量模式", quit: "退出", _auto_label: "自動 (故障轉移)", + projects_label: "專案", + no_project_label: "不使用專案", }, _ => Self { show_main: "打开主界面", @@ -90,6 +98,8 @@ impl TrayTexts { lightweight_mode: "轻量模式", quit: "退出", _auto_label: "自动 (故障转移)", + projects_label: "项目", + no_project_label: "不使用项目", }, } } @@ -326,6 +336,55 @@ fn sort_providers( sorted } +/// 处理项目 Profile 托盘事件,返回是否已处理 +/// +/// 事件 id 形如 `profile_`;`profile_none` 表示"不使用项目"(只清标记,不动配置)。 +pub fn handle_profile_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool { + let Some(suffix) = event_id.strip_prefix("profile_") else { + return false; + }; + + if suffix == "none" { + if let Some(app_state) = app.try_state::() { + if let Err(e) = app_state.db.set_current_profile_id(None) { + log::error!("清除当前项目失败: {e}"); + } + } + // 通知主窗口刷新(profileId=null 表示已清除当前项目) + if let Err(e) = app.emit("profile-applied", serde_json::json!({ "profileId": null })) { + log::error!("发射 profile-applied 事件失败: {e}"); + } + refresh_tray_menu(app); + return true; + } + + log::info!("应用项目: {suffix}"); + let app_handle = app.clone(); + let profile_id = suffix.to_string(); + tauri::async_runtime::spawn_blocking(move || { + let Some(app_state) = app_handle.try_state::() else { + return; + }; + match crate::services::profile::ProfileService::apply(app_state.inner(), &profile_id) { + Ok(warnings) => { + for warning in &warnings { + log::warn!("[Profile] 应用项目 {profile_id} 警告: {warning}"); + } + crate::commands::emit_profile_apply_events( + &app_handle, + app_state.inner(), + &profile_id, + ); + } + Err(e) => { + log::error!("应用项目 {profile_id} 失败: {e}"); + refresh_tray_menu(&app_handle); + } + } + }); + true +} + /// 处理供应商托盘事件 pub fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool { for section in TRAY_SECTIONS.iter() { @@ -605,6 +664,43 @@ pub fn create_tray_menu( menu_builder = menu_builder.separator(); } + // 项目 Profile 子菜单(Claude/Codex 至少一个可见且存在项目时才显示) + if visible_apps.is_visible(&AppType::Claude) || visible_apps.is_visible(&AppType::Codex) { + let profiles = app_state.db.get_all_profiles()?; + if !profiles.is_empty() { + let current_profile_id = app_state.db.get_current_profile_id()?.unwrap_or_default(); + let mut profiles_builder = + SubmenuBuilder::with_id(app, "submenu_profiles", tray_texts.projects_label); + for profile in &profiles { + let item = CheckMenuItem::with_id( + app, + format!("profile_{}", profile.id), + &profile.name, + true, + current_profile_id == profile.id, + None::<&str>, + ) + .map_err(|e| AppError::Message(format!("创建项目菜单项失败: {e}")))?; + profiles_builder = profiles_builder.item(&item); + } + let none_item = CheckMenuItem::with_id( + app, + "profile_none", + tray_texts.no_project_label, + true, + current_profile_id.is_empty(), + None::<&str>, + ) + .map_err(|e| AppError::Message(format!("创建不使用项目菜单项失败: {e}")))?; + let profiles_submenu = profiles_builder + .separator() + .item(&none_item) + .build() + .map_err(|e| AppError::Message(format!("构建项目子菜单失败: {e}")))?; + menu_builder = menu_builder.item(&profiles_submenu).separator(); + } + } + let lightweight_item = CheckMenuItem::with_id( app, "lightweight_mode", @@ -750,6 +846,9 @@ pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) { app.exit(0); } _ => { + if handle_profile_tray_event(app, event_id) { + return; + } if handle_provider_tray_event(app, event_id) { return; } diff --git a/src-tauri/tests/profile_roundtrip.rs b/src-tauri/tests/profile_roundtrip.rs new file mode 100644 index 000000000..6a420f791 --- /dev/null +++ b/src-tauri/tests/profile_roundtrip.rs @@ -0,0 +1,287 @@ +//! 项目 Profile 快照/应用的端到端集成测试 +//! +//! 全链路 apply 会写 live 配置文件——support.rs 已把 HOME 指向临时目录,安全。 + +use std::fs; + +use serde_json::json; + +use cc_switch_lib::{ + AppType, InstalledSkill, McpServer, McpService, ProfilePayload, ProfileService, Prompt, + PromptService, Provider, ProviderService, SkillApps, SkillService, +}; + +#[path = "support.rs"] +mod support; +use support::{create_test_state, ensure_test_home, reset_test_fs, test_mutex}; + +fn claude_provider(id: &str, token: &str) -> Provider { + Provider::with_id( + id.to_string(), + id.to_uppercase(), + json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": token, + "ANTHROPIC_BASE_URL": "https://api.test" + } + }), + None, + ) +} + +fn mcp_server(id: &str, claude_enabled: bool) -> McpServer { + serde_json::from_value(json!({ + "id": id, + "name": id, + "server": { "command": "echo", "args": [] }, + "apps": { "claude": claude_enabled } + })) + .expect("construct mcp server") +} + +fn prompt(id: &str, enabled: bool) -> Prompt { + Prompt { + id: id.to_string(), + name: id.to_uppercase(), + content: format!("# prompt {id}\n"), + description: None, + enabled, + created_at: Some(1_000), + updated_at: Some(1_000), + } +} + +fn installed_skill(id: &str, directory: &str, claude_enabled: bool) -> InstalledSkill { + InstalledSkill { + id: id.to_string(), + name: id.to_string(), + description: None, + directory: directory.to_string(), + repo_owner: None, + repo_name: None, + repo_branch: None, + readme_url: None, + apps: SkillApps { + claude: claude_enabled, + ..Default::default() + }, + installed_at: 1_000, + content_hash: None, + updated_at: 0, + } +} + +fn write_ssot_skill(directory: &str) { + let dir = SkillService::get_ssot_dir() + .expect("resolve skills SSOT dir") + .join(directory); + fs::create_dir_all(&dir).expect("create skill dir"); + fs::write( + dir.join("SKILL.md"), + format!("---\nname: {directory}\ndescription: Test skill\n---\n"), + ) + .expect("write SKILL.md"); +} + +#[test] +fn profile_snapshot_apply_roundtrip_restores_configuration() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let state = create_test_state().expect("create test state"); + + // ---- 种子数据:2 个 Claude 供应商(p1 为当前)+ 2 个 MCP + 1 个 Skill + 2 个 Prompt ---- + state + .db + .save_provider(AppType::Claude.as_str(), &claude_provider("p1", "key-1")) + .expect("save provider p1"); + state + .db + .save_provider(AppType::Claude.as_str(), &claude_provider("p2", "key-2")) + .expect("save provider p2"); + state + .db + .set_current_provider(AppType::Claude.as_str(), "p1") + .expect("set current provider p1"); + + // 让 live settings.json 与 p1 一致(switch_normal 回填需要) + let claude_dir = home.join(".claude"); + fs::create_dir_all(&claude_dir).expect("create .claude dir"); + fs::write( + claude_dir.join("settings.json"), + serde_json::to_string_pretty(&claude_provider("p1", "key-1").settings_config) + .expect("serialize p1 settings"), + ) + .expect("seed live settings.json"); + + state + .db + .save_mcp_server(&mcp_server("m1", true)) + .expect("save mcp m1"); + state + .db + .save_mcp_server(&mcp_server("m2", false)) + .expect("save mcp m2"); + + write_ssot_skill("test-skill"); + state + .db + .save_skill(&installed_skill("local:test-skill", "test-skill", true)) + .expect("save skill"); + + state + .db + .save_prompt(AppType::Claude.as_str(), &prompt("pr1", true)) + .expect("save prompt pr1"); + state + .db + .save_prompt(AppType::Claude.as_str(), &prompt("pr2", false)) + .expect("save prompt pr2"); + + // ---- 保存项目 A(快照当前状态)---- + let profile_a = ProfileService::create(&state, "Project A").expect("create profile A"); + let payload: ProfilePayload = + serde_json::from_str(&profile_a.payload).expect("parse profile A payload"); + assert_eq!(payload.providers.claude.as_deref(), Some("p1")); + assert_eq!(payload.mcp.claude, vec!["m1".to_string()]); + assert_eq!(payload.skills.claude, vec!["local:test-skill".to_string()]); + assert_eq!(payload.prompts.claude.as_deref(), Some("pr1")); + assert_eq!( + payload.providers.codex, None, + "codex has no current provider" + ); + assert!(payload.mcp.codex.is_empty()); + + // ---- 改动全部四类配置(走真实切换路径)---- + ProviderService::switch(&state, AppType::Claude, "p2").expect("switch to p2"); + McpService::toggle_app(&state, "m1", AppType::Claude, false).expect("disable m1"); + McpService::toggle_app(&state, "m2", AppType::Claude, true).expect("enable m2"); + SkillService::toggle_app(&state.db, "local:test-skill", &AppType::Claude, false) + .expect("disable skill"); + PromptService::enable_prompt(&state, AppType::Claude, "pr2").expect("enable pr2"); + + // ---- 应用项目 A:全部复原 ---- + let warnings = ProfileService::apply(&state, &profile_a.id).expect("apply profile A"); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + let current = state + .db + .get_current_provider(AppType::Claude.as_str()) + .expect("get current provider"); + assert_eq!(current.as_deref(), Some("p1"), "provider restored to p1"); + + let servers = state.db.get_all_mcp_servers().expect("get mcp servers"); + assert!(servers.get("m1").expect("m1").apps.claude, "m1 re-enabled"); + assert!(!servers.get("m2").expect("m2").apps.claude, "m2 disabled"); + + let skills = state.db.get_all_installed_skills().expect("get skills"); + assert!( + skills.get("local:test-skill").expect("skill").apps.claude, + "skill re-enabled" + ); + + let prompts = state + .db + .get_prompts(AppType::Claude.as_str()) + .expect("get prompts"); + assert!(prompts.get("pr1").expect("pr1").enabled, "pr1 re-enabled"); + assert!(!prompts.get("pr2").expect("pr2").enabled, "pr2 disabled"); + + let live_prompt = fs::read_to_string(claude_dir.join("CLAUDE.md")).expect("read CLAUDE.md"); + assert_eq!( + live_prompt, + prompt("pr1", true).content, + "live memory file restored" + ); + + assert_eq!( + state + .db + .get_current_profile_id() + .expect("get current profile id") + .as_deref(), + Some(profile_a.id.as_str()), + "profile A marked as current" + ); +} + +#[test] +fn profile_apply_reports_dangling_references_and_continues() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let state = create_test_state().expect("create test state"); + + state + .db + .save_mcp_server(&mcp_server("m1", false)) + .expect("save mcp m1"); + + // 手工构造引用了不存在资源的 payload + let payload = json!({ + "providers": { "claude": "ghost-provider" }, + "mcp": { "claude": ["m1", "ghost-mcp"] }, + "skills": { "claude": ["ghost-skill"] }, + "prompts": { "claude": "ghost-prompt" } + }); + let profile = cc_switch_lib::Profile { + id: "dangling-test".to_string(), + name: "Dangling".to_string(), + payload: payload.to_string(), + sort_order: None, + created_at: Some(1_000), + updated_at: Some(1_000), + }; + state.db.save_profile(&profile).expect("save profile"); + + let warnings = ProfileService::apply(&state, "dangling-test").expect("apply succeeds"); + assert_eq!( + warnings.len(), + 4, + "each dangling reference yields one warning: {warnings:?}" + ); + + // 有效条目照常生效:m1 被启用 + let servers = state.db.get_all_mcp_servers().expect("get mcp servers"); + assert!( + servers.get("m1").expect("m1").apps.claude, + "m1 enabled despite warnings" + ); + + // best-effort 完成后仍标记为当前项目 + assert_eq!( + state + .db + .get_current_profile_id() + .expect("get current profile id") + .as_deref(), + Some("dangling-test") + ); +} + +#[test] +fn clear_current_profile_only_clears_marker() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let state = create_test_state().expect("create test state"); + + state + .db + .set_current_profile_id(Some("some-profile")) + .expect("set current profile"); + state + .db + .set_current_profile_id(None) + .expect("clear current profile"); + assert_eq!( + state + .db + .get_current_profile_id() + .expect("get current profile id"), + None + ); +} diff --git a/src/App.tsx b/src/App.tsx index 0eaef98ad..48e95422e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -58,6 +58,7 @@ import { DRAG_REGION_STYLE, } from "@/lib/platform"; import { AppSwitcher } from "@/components/AppSwitcher"; +import { ProfileSwitcher } from "@/components/profiles/ProfileSwitcher"; import { ProviderList } from "@/components/providers/ProviderList"; import { AddProviderDialog } from "@/components/providers/AddProviderDialog"; import { EditProviderDialog } from "@/components/providers/EditProviderDialog"; @@ -381,6 +382,13 @@ function App() { } }); + // 托盘应用项目后刷新相关缓存(providers 由既有 provider-switched 监听承接) + useTauriEvent("profile-applied", async () => { + await queryClient.invalidateQueries({ queryKey: ["profiles"] }); + await queryClient.invalidateQueries({ queryKey: ["mcp", "all"] }); + await queryClient.invalidateQueries({ queryKey: ["skills"] }); + }); + useTauriEvent( "webdav-sync-status-updated", async (payload) => { @@ -1189,6 +1197,7 @@ function App() { CC Switch + + + + ) : ( + <> + + {profile.name} + + + + + + )} + + ))} + + + + + {confirm && ( + setConfirm(null)} + /> + )} + + ); +} diff --git a/src/components/profiles/ProfileSwitcher.tsx b/src/components/profiles/ProfileSwitcher.tsx new file mode 100644 index 000000000..3a4b4d831 --- /dev/null +++ b/src/components/profiles/ProfileSwitcher.tsx @@ -0,0 +1,236 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Check, + ChevronsUpDown, + FolderCog, + FolderOpen, + Plus, + X, +} from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import type { VisibleApps } from "@/types"; +import { + useApplyProfileMutation, + useClearProfileMutation, + useCreateProfileMutation, + useProfilesQuery, +} from "@/lib/query/profiles"; +import { ProfileManageDialog } from "./ProfileManageDialog"; + +interface ProfileSwitcherProps { + visibleApps: VisibleApps; +} + +/** + * 项目 Profile 切换器(header 左侧全局入口) + * + * Profile 是跨应用的配置快照(Claude Code + Codex 的供应商/MCP/Skills/记忆文件), + * 与右侧 AppSwitcher(仅切换查看的应用)语义不同。 + */ +export function ProfileSwitcher({ visibleApps }: ProfileSwitcherProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [isManageOpen, setIsManageOpen] = useState(false); + const [newName, setNewName] = useState(""); + + const { data } = useProfilesQuery(); + const applyMutation = useApplyProfileMutation(); + const clearMutation = useClearProfileMutation(); + const createMutation = useCreateProfileMutation(); + + // Profile 仅支持 Claude Code + Codex,两者都被隐藏时该功能无意义 + if (!visibleApps.claude && !visibleApps.codex) { + return null; + } + + const profiles = data?.profiles ?? []; + const currentId = data?.currentId ?? null; + const currentProfile = profiles.find((p) => p.id === currentId); + + const handleApply = (id: string) => { + setOpen(false); + if (id !== currentId) { + applyMutation.mutate(id); + } + }; + + const closeCreateDialog = () => { + setIsCreateOpen(false); + setNewName(""); + }; + + const handleCreate = () => { + const name = newName.trim(); + if (!name) return; + createMutation.mutate(name, { onSuccess: closeCreateDialog }); + }; + + return ( + <> + + + + + + + + + {t("profiles.empty")} + {profiles.length > 0 && ( + + {profiles.map((profile) => ( + handleApply(profile.id)} + > + + {profile.name} + + ))} + + )} +
+ + { + setOpen(false); + setIsCreateOpen(true); + }} + > + + {t("profiles.createFromCurrent")} + + {currentId && ( + { + setOpen(false); + clearMutation.mutate(); + }} + > + + {t("profiles.none")} + + )} + {profiles.length > 0 && ( + { + setOpen(false); + setIsManageOpen(true); + }} + > + + {t("profiles.manage")} + + )} + + + + + + + { + if (!open) closeCreateDialog(); + }} + > + + + {t("profiles.createFromCurrent")} + + {t("profiles.createDescription")} + + +
+ setNewName(e.target.value)} + placeholder={t("profiles.namePlaceholder")} + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate(); + }} + /> +
+ + + + +
+
+ + setIsManageOpen(false)} + /> + + ); +} diff --git a/src/components/prompts/PromptPanel.tsx b/src/components/prompts/PromptPanel.tsx index 77e4edb03..7f28d3490 100644 --- a/src/components/prompts/PromptPanel.tsx +++ b/src/components/prompts/PromptPanel.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { FileText } from "lucide-react"; import { type AppId } from "@/lib/api"; import { usePromptActions } from "@/hooks/usePromptActions"; +import { useTauriEvent } from "@/hooks/useTauriEvent"; import PromptListItem from "./PromptListItem"; import PromptFormPanel from "./PromptFormPanel"; import { ConfirmDialog } from "../ConfirmDialog"; @@ -59,6 +60,9 @@ const PromptPanel = React.forwardRef( }; }, [appId, reload]); + // 应用项目 Profile 会切换激活的 prompt(prompts 非 react-query,需主动 reload) + useTauriEvent("profile-applied", reload); + const handleAdd = () => { setEditingId(null); setIsFormOpen(true); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a020f5e1b..cd8c28fb7 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1866,6 +1866,34 @@ "deleteMessage": "Are you sure you want to delete prompt \"{{name}}\"?" } }, + "profiles": { + "switcherTooltip": "Projects: switch providers / MCP / Skills / memory files for Claude Code and Codex in one click", + "none": "No project", + "searchPlaceholder": "Search projects", + "empty": "No projects yet", + "createFromCurrent": "New project", + "createDescription": "Save the current provider, MCP, Skills and memory file configuration as a project you can switch to later.", + "manage": "Manage projects…", + "manageTitle": "Manage Projects", + "manageDescription": "Projects are configuration snapshots. To change a project, adjust your configuration first, then click \"Update from current\".", + "namePlaceholder": "e.g. Development, Drawing", + "rename": "Rename", + "updateSnapshot": "Update from current", + "updateSnapshotConfirm": "Overwrite the snapshot of project \"{{name}}\" with the current configuration?", + "delete": "Delete", + "deleteConfirmTitle": "Confirm Delete", + "deleteConfirmMessage": "Are you sure you want to delete project \"{{name}}\"? Your current configuration will not be changed.", + "createSuccess": "Project created", + "createFailed": "Failed to create project: {{detail}}", + "updateSuccess": "Project updated", + "updateFailed": "Failed to update project: {{detail}}", + "deleteSuccess": "Project deleted", + "deleteFailed": "Failed to delete project: {{detail}}", + "applySuccess": "Project applied", + "applyFailed": "Failed to apply project: {{detail}}", + "applyWarnings": "Project applied with {{warningCount}} warning(s):\n{{details}}", + "clearSuccess": "Switched to no project" + }, "workspace": { "title": "Workspace Files", "manage": "Workspace", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index f3d0f6a1d..449788aa7 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1866,6 +1866,34 @@ "deleteMessage": "プロンプト「{{name}}」を削除してもよろしいですか?" } }, + "profiles": { + "switcherTooltip": "プロジェクト:Claude Code と Codex のプロバイダー / MCP / Skills / メモリファイルをワンクリックで切り替え", + "none": "プロジェクトを使用しない", + "searchPlaceholder": "プロジェクトを検索", + "empty": "プロジェクトはまだありません", + "createFromCurrent": "新規プロジェクト", + "createDescription": "現在のプロバイダー、MCP、Skills、メモリファイルの設定をプロジェクトとして保存し、後からワンクリックで切り替えられます。", + "manage": "プロジェクトを管理…", + "manageTitle": "プロジェクト管理", + "manageDescription": "プロジェクトは設定のスナップショットです。内容を変更するには、先に設定を調整してから「現在の状態で更新」をクリックしてください。", + "namePlaceholder": "例:開発、イラスト", + "rename": "名前を変更", + "updateSnapshot": "現在の状態で更新", + "updateSnapshotConfirm": "プロジェクト「{{name}}」のスナップショットを現在の設定で上書きしますか?", + "delete": "削除", + "deleteConfirmTitle": "削除の確認", + "deleteConfirmMessage": "プロジェクト「{{name}}」を削除してもよろしいですか?現在の設定は変更されません。", + "createSuccess": "プロジェクトを作成しました", + "createFailed": "プロジェクトの作成に失敗しました: {{detail}}", + "updateSuccess": "プロジェクトを更新しました", + "updateFailed": "プロジェクトの更新に失敗しました: {{detail}}", + "deleteSuccess": "プロジェクトを削除しました", + "deleteFailed": "プロジェクトの削除に失敗しました: {{detail}}", + "applySuccess": "プロジェクトを適用しました", + "applyFailed": "プロジェクトの適用に失敗しました: {{detail}}", + "applyWarnings": "プロジェクトを適用しました(警告 {{warningCount}} 件):\n{{details}}", + "clearSuccess": "プロジェクトを使用しない状態に切り替えました" + }, "workspace": { "title": "ワークスペースファイル", "manage": "ワークスペース", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index db353195f..efb66be80 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1838,6 +1838,34 @@ "deleteMessage": "確定要刪除提示詞 \"{{name}}\" 嗎?" } }, + "profiles": { + "switcherTooltip": "專案:一鍵切換 Claude Code 與 Codex 的供應商 / MCP / Skills / 記憶檔案", + "none": "不使用專案", + "searchPlaceholder": "搜尋專案", + "empty": "尚無專案", + "createFromCurrent": "新建專案", + "createDescription": "把目前的供應商、MCP、Skills 和記憶檔案設定儲存為一個專案,之後可一鍵切換。", + "manage": "管理專案…", + "manageTitle": "管理專案", + "manageDescription": "專案是設定快照。要修改專案內容,先手動調好設定,再點「以目前狀態更新」。", + "namePlaceholder": "例如:開發、繪圖", + "rename": "重新命名", + "updateSnapshot": "以目前狀態更新", + "updateSnapshotConfirm": "用目前的設定狀態覆蓋專案 \"{{name}}\" 的快照?", + "delete": "刪除", + "deleteConfirmTitle": "確認刪除", + "deleteConfirmMessage": "確定要刪除專案 \"{{name}}\" 嗎?不會改動任何現有設定。", + "createSuccess": "專案已建立", + "createFailed": "建立專案失敗: {{detail}}", + "updateSuccess": "專案已更新", + "updateFailed": "更新專案失敗: {{detail}}", + "deleteSuccess": "專案已刪除", + "deleteFailed": "刪除專案失敗: {{detail}}", + "applySuccess": "專案已套用", + "applyFailed": "套用專案失敗: {{detail}}", + "applyWarnings": "專案已套用,{{warningCount}} 條警告:\n{{details}}", + "clearSuccess": "已切換為不使用專案" + }, "workspace": { "title": "Workspace 檔案管理", "manage": "Workspace", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 81fabc019..a3b5d116f 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1866,6 +1866,34 @@ "deleteMessage": "确定要删除提示词 \"{{name}}\" 吗?" } }, + "profiles": { + "switcherTooltip": "项目:一键切换 Claude Code 与 Codex 的供应商 / MCP / Skills / 记忆文件", + "none": "不使用项目", + "searchPlaceholder": "搜索项目", + "empty": "暂无项目", + "createFromCurrent": "新建项目", + "createDescription": "把当前的供应商、MCP、Skills 和记忆文件配置保存为一个项目,之后可一键切换。", + "manage": "管理项目…", + "manageTitle": "管理项目", + "manageDescription": "项目是配置快照。要修改项目内容,先手动调好配置,再点\"以当前状态更新\"。", + "namePlaceholder": "例如:开发、绘图", + "rename": "重命名", + "updateSnapshot": "以当前状态更新", + "updateSnapshotConfirm": "用当前的配置状态覆盖项目 \"{{name}}\" 的快照?", + "delete": "删除", + "deleteConfirmTitle": "确认删除", + "deleteConfirmMessage": "确定要删除项目 \"{{name}}\" 吗?不会改动任何现有配置。", + "createSuccess": "项目已创建", + "createFailed": "创建项目失败: {{detail}}", + "updateSuccess": "项目已更新", + "updateFailed": "更新项目失败: {{detail}}", + "deleteSuccess": "项目已删除", + "deleteFailed": "删除项目失败: {{detail}}", + "applySuccess": "项目已应用", + "applyFailed": "应用项目失败: {{detail}}", + "applyWarnings": "项目已应用,{{warningCount}} 条警告:\n{{details}}", + "clearSuccess": "已切换为不使用项目" + }, "workspace": { "title": "Workspace 文件管理", "manage": "Workspace", diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 47458b8c3..2c354bfc0 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -3,6 +3,7 @@ export { providersApi, universalProvidersApi } from "./providers"; export { settingsApi } from "./settings"; export { backupsApi } from "./settings"; export { mcpApi } from "./mcp"; +export { profilesApi } from "./profiles"; export { promptsApi } from "./prompts"; export { skillsApi } from "./skills"; export { usageApi } from "./usage"; @@ -17,6 +18,7 @@ export * as authApi from "./auth"; export * as copilotApi from "./copilot"; export type { ProviderSwitchEvent } from "./providers"; export type { Prompt } from "./prompts"; +export type { Profile, ProfilePayload, ProfilesResponse } from "./profiles"; export type { CopilotDeviceCodeResponse, CopilotAuthStatus, diff --git a/src/lib/api/profiles.ts b/src/lib/api/profiles.ts new file mode 100644 index 000000000..6526f2897 --- /dev/null +++ b/src/lib/api/profiles.ts @@ -0,0 +1,83 @@ +import { invoke } from "@tauri-apps/api/core"; + +/** + * 按 app 分槽的载荷容器(与后端 services/profile.rs 的 PerApp 严格对应) + */ +export interface PerApp { + claude: T; + codex: T; +} + +/** + * 项目 Profile 的配置快照(与后端 ProfilePayload 严格对应) + */ +export interface ProfilePayload { + providers: PerApp; + mcp: PerApp; + skills: PerApp; + prompts: PerApp; +} + +export interface Profile { + id: string; + name: string; + payload: ProfilePayload; + createdAt?: number; + updatedAt?: number; +} + +export interface ProfilesResponse { + profiles: Profile[]; + currentId: string | null; +} + +export const profilesApi = { + /** + * 获取所有项目及当前激活项目 id + */ + async list(): Promise { + return await invoke("list_profiles"); + }, + + /** + * 以当前配置状态创建新项目 + */ + async create(name: string): Promise { + return await invoke("create_profile", { name }); + }, + + /** + * 更新项目(重命名和/或以当前状态重拍快照) + */ + async update( + id: string, + options: { name?: string; resnapshot?: boolean }, + ): Promise { + return await invoke("update_profile", { + id, + name: options.name, + resnapshot: options.resnapshot, + }); + }, + + /** + * 删除项目 + */ + async delete(id: string): Promise { + return await invoke("delete_profile", { id }); + }, + + /** + * 应用项目快照,返回 warnings(best-effort,部分失败不中断) + */ + async apply(id: string): Promise { + return await invoke("apply_profile", { id }); + }, + + /** + * 不使用项目:仅清除激活标记,不改动任何配置 + */ + async clearCurrent(): Promise { + return await invoke("clear_current_profile"); + }, +}; diff --git a/src/lib/query/profiles.ts b/src/lib/query/profiles.ts new file mode 100644 index 000000000..dae856c23 --- /dev/null +++ b/src/lib/query/profiles.ts @@ -0,0 +1,145 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { profilesApi, providersApi } from "@/lib/api"; +import { extractErrorMessage } from "@/utils/errorUtils"; + +const updateTrayMenuSafely = async () => { + try { + await providersApi.updateTrayMenu(); + } catch (trayError) { + console.error("Failed to update tray menu after profile change", trayError); + } +}; + +export const useProfilesQuery = () => { + return useQuery({ + queryKey: ["profiles"], + queryFn: () => profilesApi.list(), + }); +}; + +export const useCreateProfileMutation = () => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: (name: string) => profilesApi.create(name), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["profiles"] }); + await updateTrayMenuSafely(); + toast.success(t("profiles.createSuccess"), { closeButton: true }); + }, + onError: (error: Error) => { + const detail = extractErrorMessage(error) || t("common.unknown"); + toast.error(t("profiles.createFailed", { detail }), { + closeButton: true, + }); + }, + }); +}; + +export const useUpdateProfileMutation = () => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: ({ + id, + name, + resnapshot, + }: { + id: string; + name?: string; + resnapshot?: boolean; + }) => profilesApi.update(id, { name, resnapshot }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["profiles"] }); + await updateTrayMenuSafely(); + toast.success(t("profiles.updateSuccess"), { closeButton: true }); + }, + onError: (error: Error) => { + const detail = extractErrorMessage(error) || t("common.unknown"); + toast.error(t("profiles.updateFailed", { detail }), { + closeButton: true, + }); + }, + }); +}; + +export const useDeleteProfileMutation = () => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: (id: string) => profilesApi.delete(id), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["profiles"] }); + await updateTrayMenuSafely(); + toast.success(t("profiles.deleteSuccess"), { closeButton: true }); + }, + onError: (error: Error) => { + const detail = extractErrorMessage(error) || t("common.unknown"); + toast.error(t("profiles.deleteFailed", { detail }), { + closeButton: true, + }); + }, + }); +}; + +export const useClearProfileMutation = () => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: () => profilesApi.clearCurrent(), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["profiles"] }); + await updateTrayMenuSafely(); + toast.success(t("profiles.clearSuccess"), { closeButton: true }); + }, + onError: (error: Error) => { + const detail = extractErrorMessage(error) || t("common.unknown"); + toast.error(t("profiles.applyFailed", { detail }), { + closeButton: true, + }); + }, + }); +}; + +export const useApplyProfileMutation = () => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: (id: string) => profilesApi.apply(id), + onSuccess: async (warnings) => { + await queryClient.invalidateQueries({ queryKey: ["profiles"] }); + await queryClient.invalidateQueries({ + queryKey: ["providers", "claude"], + }); + await queryClient.invalidateQueries({ queryKey: ["providers", "codex"] }); + await queryClient.invalidateQueries({ queryKey: ["mcp", "all"] }); + await queryClient.invalidateQueries({ queryKey: ["skills"] }); + await updateTrayMenuSafely(); + + if (warnings.length > 0) { + toast.warning( + t("profiles.applyWarnings", { + warningCount: warnings.length, + details: warnings.join("\n"), + }), + { closeButton: true, duration: 10000 }, + ); + } else { + toast.success(t("profiles.applySuccess"), { closeButton: true }); + } + }, + onError: (error: Error) => { + const detail = extractErrorMessage(error) || t("common.unknown"); + toast.error(t("profiles.applyFailed", { detail }), { + closeButton: true, + }); + }, + }); +};