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
This commit is contained in:
Jason
2026-07-04 16:03:19 +08:00
parent b3e5e32c89
commit 8f018a2d45
22 changed files with 1955 additions and 3 deletions
+1
View File
@@ -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;
+382
View File
@@ -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<T> {
pub claude: T,
pub codex: T,
}
impl<T> PerApp<T> {
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<Option<String>>,
/// 每 app 启用的 MCP server id 集合
pub mcp: PerApp<Vec<String>>,
/// 每 app 启用的 Skill id 集合
pub skills: PerApp<Vec<String>>,
/// 每 app 激活的 prompt id(None = 快照时无激活项,应用时不动)
pub prompts: PerApp<Option<String>>,
}
/// 计算从当前启用状态到目标集合的最小 toggle 集
///
/// 返回 (需要执行的 (id, enabled) 列表, payload 中已不存在于 DB 的悬空 id 列表)
fn plan_toggles(
current: &[(String, bool)],
target_ids: &[String],
) -> (Vec<(String, bool)>, Vec<String>) {
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<ProfilePayload, AppError> {
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<Profile>, Option<String>), 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<Profile, AppError> {
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<String>,
resnapshot: bool,
) -> Result<Profile, AppError> {
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<Vec<String>, 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(&current, 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 diffSkillService 返回 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(&current, 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. PromptNone = 不动;已激活则幂等跳过,避免无谓的文件写与备份)
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<String> {
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<Option<String>> = 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(&current, &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(&current, &[]);
assert_eq!(toggles, vec![("a".to_string(), false)]);
assert!(dangling.is_empty());
}
}