From 41efeebcbb41b3c520a0e174596db18e39d93646 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Fri, 17 Jul 2026 21:47:44 +0800 Subject: [PATCH] feat(agents): add agent configuration and thinking aliases --- src-tauri/Cargo.lock | 61 + src-tauri/Cargo.toml | 2 + src-tauri/src/main.rs | 5096 +++++++++++++++++++++++++- src/App.tsx | 16 +- src/assets/icons/hermes.png | Bin 0 -> 39304 bytes src/assets/icons/openclaw.svg | 18 + src/assets/icons/opencode.svg | 17 + src/pages/AgentsPage.tsx | 783 ++++ src/pages/ApiAccessPage.tsx | 185 +- src/pages/AuthFileManagementPage.tsx | 1 + src/pages/ThinkingAliasesPage.tsx | 530 +++ src/services/agentModelPicker.ts | 52 + src/styles.css | 1533 +++++++- tests/agentModelPicker.test.ts | 52 + tests/providerConfig.test.ts | 21 +- tests/thinkingAliases.test.ts | 11 + 16 files changed, 8154 insertions(+), 224 deletions(-) create mode 100644 src/assets/icons/hermes.png create mode 100644 src/assets/icons/openclaw.svg create mode 100644 src/assets/icons/opencode.svg create mode 100644 src/pages/AgentsPage.tsx create mode 100644 src/pages/ThinkingAliasesPage.tsx create mode 100644 src/services/agentModelPicker.ts create mode 100644 tests/agentModelPicker.test.ts create mode 100644 tests/thinkingAliases.test.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f1e1a87..43d989b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -419,6 +419,7 @@ version = "0.1.0" dependencies = [ "flate2", "futures-util", + "json5", "libc", "reqwest 0.12.28", "serde", @@ -432,6 +433,7 @@ dependencies = [ "tokio", "tokio-util", "toml 0.8.2", + "toml_edit 0.20.2", "windows-sys 0.59.0", "yaml-edit", "yaml-serde-edit", @@ -1692,6 +1694,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + [[package]] name = "jsonptr" version = "0.6.3" @@ -2206,6 +2219,48 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "pest_meta" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +dependencies = [ + "pest", +] + [[package]] name = "phf" version = "0.13.1" @@ -3923,6 +3978,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unic-char-property" version = "0.9.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8cb40e7..79fcd62 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -11,6 +11,7 @@ tauri-build = { version = "2", features = [] } [dependencies] flate2 = "1" futures-util = "0.3" +json5 = "0.4" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -22,6 +23,7 @@ tauri = { version = "2", features = [] } tokio = { version = "1", features = ["macros", "sync", "time"] } tokio-util = "0.7" toml = "0.8" +toml_edit = "0.20" yaml-serde-edit = { version = "0.1", default-features = false } yaml-edit = "0.2" zip = { version = "2", default-features = false, features = ["deflate"] } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b2579e6..cb1a870 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -20,12 +20,10 @@ use tauri::{Emitter, Manager}; use tokio_util::sync::CancellationToken; use zip::ZipArchive; -const RELEASE_API_URL: &str = - "https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/latest"; const RELEASE_PAGE_URL: &str = "https://github.com/router-for-me/CLIProxyAPI/releases/latest"; -const RELEASE_TAG_PAGE_PREFIX: &str = "https://github.com/router-for-me/CLIProxyAPI/releases/tag/"; -const RELEASE_ASSETS_PAGE_PREFIX: &str = - "https://github.com/router-for-me/CLIProxyAPI/releases/expanded_assets/"; +const RELEASE_ATOM_URL: &str = "https://github.com/router-for-me/CLIProxyAPI/releases.atom"; +const RELEASE_DOWNLOAD_PREFIX: &str = + "https://github.com/router-for-me/CLIProxyAPI/releases/download/"; const CORE_INSTALL_PROGRESS_EVENT: &str = "core-install-progress"; const CORE_METADATA_FILE: &str = "cpa-gui-meta.json"; const CORE_CONFIG_FILE: &str = "config.yaml"; @@ -36,10 +34,18 @@ const OAUTH_DIR_NAME: &str = "oauth"; const DEFAULT_API_KEY: &str = "123456"; const DEFAULT_API_KEY_REMARK: &str = "内置密钥"; const DEFAULT_MANAGEMENT_SECRET_KEY: &str = "123456"; +const MANAGED_AGENT_PROVIDER_ID: &str = "cpa-gui"; +const CODEX_MODEL_CATALOG_FILE: &str = "cpa-gui-model-catalog.json"; +const DEFAULT_CODEX_CONTEXT_WINDOW: u64 = 128_000; +const CLAUDE_DESKTOP_PROFILE_ID: &str = "00000000-0000-4000-8000-000000831700"; +const AGENT_MODIFICATION_STATE_VERSION: u8 = 1; +const AGENT_PHASE_APPLYING: &str = "applying"; +const AGENT_PHASE_ACTIVE: &str = "active"; +const AGENT_PHASE_RESTORING: &str = "restoring"; +const AGENT_PHASE_RECOVERY: &str = "recovery"; const USER_AGENT: &str = "CPA-GUI/0.1.0 (+https://github.com/router-for-me/CLIProxyAPI)"; -const GITHUB_API_ACCEPT: &str = "application/vnd.github+json"; -const GITHUB_API_VERSION: &str = "2022-11-28"; static CORE_CONFIG_FILE_LOCK: Mutex<()> = Mutex::new(()); +static AGENT_CONFIG_FILE_LOCK: Mutex<()> = Mutex::new(()); #[derive(Default)] struct CoreDownloadState { @@ -200,6 +206,213 @@ struct GuiSettings { run_on_startup: bool, } +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct AgentConfigStatus { + id: String, + name: String, + supported_platform: bool, + installed: bool, + executable_path: Option, + launch_targets: Vec, + version: Option, + config_paths: Vec, + config_exists: bool, + config_valid: bool, + configured: bool, + current_model: Option, + modification_enabled: bool, + modification_state: String, + backup_available: bool, + applied_model: Option, + warnings: Vec, + error: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AgentLaunchTarget { + id: String, + label: String, + detail: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AgentConfigActionResult { + outcome: String, + enabled: bool, + model: Option, + changed_files: Vec, + conflict_files: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct AgentModelOption { + name: String, + alias: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct CodexModelDefinition { + id: String, + display_name: Option, + description: Option, + context_window: Option, + reasoning_levels: Vec, + supports_tools: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct CodexModelCatalogSpec { + id: String, + display_name: String, + description: String, + context_window: u64, + reasoning_levels: Vec, + supports_parallel_tool_calls: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct ThinkingAliasEntry { + source_model: String, + alias: String, + effort: Option, + provider: String, + kind: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct ThinkingAliasSource { + id: String, + model: String, + display_name: Option, + provider: String, + kind: String, + protocol: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum ThinkingAliasSourceLocation { + CodexOauth, + ConfigModel { + section: &'static str, + provider_index: usize, + model_index: usize, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ResolvedThinkingAliasSource { + source: ThinkingAliasSource, + location: ThinkingAliasSourceLocation, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AgentModificationRecord { + version: u8, + client: String, + phase: String, + model: String, + files: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AgentModificationFile { + path: PathBuf, + backup_path: PathBuf, + existed_before: bool, + original_sha256: Option, + managed_sha256: String, +} + +struct AgentModificationInspection { + enabled: bool, + state: String, + backup_available: bool, + applied_model: Option, + warnings: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AgentClient { + ClaudeCode, + ClaudeDesktop, + Codex, + OpenCode, + OpenClaw, + Hermes, +} + +#[derive(Clone, Debug)] +#[allow(dead_code)] // The desktop-app variants are constructed only on macOS/Windows builds. +enum CodexAppTarget { + Application(PathBuf), + #[cfg(target_os = "windows")] + WindowsAppId(String), +} + +impl AgentClient { + fn parse(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "claude-code" => Ok(Self::ClaudeCode), + "claude-desktop" => Ok(Self::ClaudeDesktop), + "codex" => Ok(Self::Codex), + "opencode" => Ok(Self::OpenCode), + "openclaw" => Ok(Self::OpenClaw), + "hermes" => Ok(Self::Hermes), + _ => Err(format!("不支持的智能体客户端: {value}")), + } + } + + fn id(self) -> &'static str { + match self { + Self::ClaudeCode => "claude-code", + Self::ClaudeDesktop => "claude-desktop", + Self::Codex => "codex", + Self::OpenCode => "opencode", + Self::OpenClaw => "openclaw", + Self::Hermes => "hermes", + } + } + + fn name(self) -> &'static str { + match self { + Self::ClaudeCode => "Claude Code", + Self::ClaudeDesktop => "Claude Desktop", + Self::Codex => "Codex", + Self::OpenCode => "OpenCode", + Self::OpenClaw => "OpenClaw", + Self::Hermes => "Hermes Agent", + } + } + + fn supported_platform(self) -> bool { + self != Self::ClaudeDesktop || cfg!(any(target_os = "windows", target_os = "macos")) + } + + fn executable_names(self) -> &'static [&'static str] { + match self { + Self::ClaudeCode => &["claude"], + Self::ClaudeDesktop => &[], + Self::Codex => &["codex"], + Self::OpenCode => &["opencode"], + Self::OpenClaw => &["openclaw"], + Self::Hermes => &["hermes"], + } + } +} + +struct AgentFileUpdate { + path: PathBuf, + after: String, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct GuiNetworkSettings { @@ -594,6 +807,2942 @@ fn get_gui_settings( Ok(GuiSettings::from(&config)) } +#[tauri::command] +fn get_agent_config_statuses( + app: tauri::AppHandle, + gui_config_state: tauri::State<'_, GuiConfigState>, +) -> Result, String> { + let home = app + .path() + .home_dir() + .map_err(|error| format!("无法获取用户目录: {error}"))?; + let port = gui_config_state.snapshot()?.port; + Ok([ + AgentClient::ClaudeCode, + AgentClient::ClaudeDesktop, + AgentClient::Codex, + AgentClient::OpenCode, + AgentClient::OpenClaw, + AgentClient::Hermes, + ] + .into_iter() + .map(|client| inspect_agent_config(client, &home, port)) + .collect()) +} + +#[tauri::command] +async fn get_agent_models( + gui_config_state: tauri::State<'_, GuiConfigState>, +) -> Result, String> { + let config = gui_config_state.snapshot()?; + fetch_agent_models(config.port).await +} + +#[tauri::command] +async fn get_thinking_aliases( + gui_config_state: tauri::State<'_, GuiConfigState>, +) -> Result, String> { + let config = gui_config_state.snapshot()?; + let content = fetch_management_config_yaml(&config).await?; + thinking_aliases_from_yaml(&content) +} + +#[tauri::command] +async fn get_thinking_alias_sources( + gui_config_state: tauri::State<'_, GuiConfigState>, +) -> Result, String> { + let config = gui_config_state.snapshot()?; + let content = fetch_management_config_yaml(&config).await?; + let definitions = fetch_codex_model_definitions(&config) + .await + .unwrap_or_default(); + Ok(resolved_thinking_alias_sources(&content, &definitions)? + .into_iter() + .map(|resolved| resolved.source) + .collect()) +} + +#[tauri::command] +async fn create_thinking_alias( + gui_config_state: tauri::State<'_, GuiConfigState>, + source_id: String, + alias: String, + effort: String, +) -> Result, String> { + let config = gui_config_state.snapshot()?; + let source_id = source_id.trim().to_string(); + if source_id.is_empty() { + return Err("请先选择原模型".to_string()); + } + let alias = validate_thinking_alias_model_id(&alias, "别名模型")?; + let effort = validate_thinking_alias_effort(&effort)?; + let content = fetch_management_config_yaml(&config).await?; + let definitions = fetch_codex_model_definitions(&config) + .await + .unwrap_or_default(); + let sources = resolved_thinking_alias_sources(&content, &definitions)?; + let source = sources + .iter() + .find(|source| source.source.id == source_id) + .cloned() + .ok_or_else(|| "原模型来源已经变化,请刷新后重新选择".to_string())?; + if source.source.model.eq_ignore_ascii_case(&alias) { + return Err("别名模型不能和原模型相同".to_string()); + } + + let available_models = fetch_agent_models(config.port).await?; + if available_models + .iter() + .any(|model| model.name.eq_ignore_ascii_case(&alias)) + { + return Err(format!("{alias} 已经是实际模型 ID,不能再作为别名")); + } + let document = serde_norway::from_str::(&content) + .map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?; + let root = document + .as_mapping() + .ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?; + if configured_model_alias_exists(root, &alias) { + return Err(format!("别名模型 {alias} 已存在")); + } + + let updated = add_thinking_alias_to_yaml(&content, &source, &alias, &effort)?; + put_management_config_yaml(&config, &updated).await?; + thinking_aliases_from_yaml(&updated) +} + +#[tauri::command] +async fn delete_thinking_alias( + gui_config_state: tauri::State<'_, GuiConfigState>, + alias: String, +) -> Result, String> { + let config = gui_config_state.snapshot()?; + let alias = validate_thinking_alias_model_id(&alias, "别名模型")?; + let content = fetch_management_config_yaml(&config).await?; + let updated = remove_thinking_alias_from_yaml(&content, &alias)?; + put_management_config_yaml(&config, &updated).await?; + thinking_aliases_from_yaml(&updated) +} + +async fn fetch_agent_models(port: u16) -> Result, String> { + if port == 0 { + return Err("内核端口无效".to_string()); + } + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(3)) + .timeout(Duration::from_secs(15)) + .build() + .map_err(|error| format!("创建模型列表客户端失败: {error}"))?; + let base_url = format!("http://127.0.0.1:{port}"); + let endpoints = [ + format!("{base_url}/v1/models"), + format!("{base_url}/models"), + ]; + + for (index, endpoint) in endpoints.iter().enumerate() { + let response = client + .get(endpoint) + .bearer_auth(DEFAULT_API_KEY) + .header(reqwest::header::ACCEPT, "application/json") + .header(reqwest::header::USER_AGENT, USER_AGENT) + .send() + .await + .map_err(|error| format!("请求本机模型列表失败: {error}"))?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|error| format!("读取本机模型列表失败: {error}"))?; + if status.is_success() { + let payload = serde_json::from_str::(&body).map_err(|error| { + format!( + "解析本机模型列表失败: {error}; body={}", + truncate_for_error(&body) + ) + })?; + return parse_agent_model_options(&payload); + } + + let can_try_legacy_path = index == 0 && matches!(status.as_u16(), 404 | 405); + if !can_try_legacy_path { + return Err(format_agent_models_error(status.as_u16(), &body)); + } + } + + Err("本机内核不支持模型列表接口".to_string()) +} + +#[tauri::command] +async fn set_agent_config_enabled( + app: tauri::AppHandle, + gui_config_state: tauri::State<'_, GuiConfigState>, + client: String, + model: String, + enabled: bool, + force_restore: bool, +) -> Result { + let client = AgentClient::parse(&client)?; + let home = app + .path() + .home_dir() + .map_err(|error| format!("无法获取用户目录: {error}"))?; + let config = gui_config_state.snapshot()?; + let port = config.port; + + if enabled { + validate_agent_can_enable(client, &home, port)?; + let available_models = fetch_agent_models(port).await?; + let model = + resolve_available_agent_model(&available_models, &validate_agent_model(&model)?)?; + let codex_models = if client == AgentClient::Codex { + Some(fetch_codex_model_catalog_specs(&config, &available_models).await) + } else { + None + }; + let _guard = AGENT_CONFIG_FILE_LOCK + .lock() + .map_err(|_| "智能体配置文件锁已损坏".to_string())?; + enable_agent_modification(client, &home, port, &model, codex_models.as_deref()) + } else { + let _guard = AGENT_CONFIG_FILE_LOCK + .lock() + .map_err(|_| "智能体配置文件锁已损坏".to_string())?; + disable_agent_modification(client, &home, port, force_restore) + } +} + +#[tauri::command] +async fn update_agent_config( + app: tauri::AppHandle, + gui_config_state: tauri::State<'_, GuiConfigState>, + client: String, + model: String, +) -> Result { + let client = AgentClient::parse(&client)?; + let home = app + .path() + .home_dir() + .map_err(|error| format!("无法获取用户目录: {error}"))?; + let config = gui_config_state.snapshot()?; + let port = config.port; + let available_models = fetch_agent_models(port).await?; + let model = resolve_available_agent_model(&available_models, &validate_agent_model(&model)?)?; + let codex_models = if client == AgentClient::Codex { + Some(fetch_codex_model_catalog_specs(&config, &available_models).await) + } else { + None + }; + let _guard = AGENT_CONFIG_FILE_LOCK + .lock() + .map_err(|_| "智能体配置文件锁已损坏".to_string())?; + update_agent_modification(client, &home, port, &model, codex_models.as_deref()) +} + +#[tauri::command] +fn launch_agent( + app: tauri::AppHandle, + client: String, + target: Option, +) -> Result<(), String> { + let client = AgentClient::parse(&client)?; + if !client.supported_platform() { + return Err(format!("当前平台不支持启动 {}", client.name())); + } + let home = app + .path() + .home_dir() + .map_err(|error| format!("无法获取用户目录: {error}"))?; + let requested_target = target + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + + if client == AgentClient::Codex && requested_target != Some("cli") { + if let Some(app_target) = find_codex_app_target(&home) { + return launch_codex_app(&app_target); + } + if requested_target == Some("app") { + return Err("未检测到 Codex App,请重新检测或改用 Codex CLI".to_string()); + } + } + if requested_target.is_some_and(|value| value != "cli" && value != "app") { + return Err("不支持的智能体启动方式".to_string()); + } + if client == AgentClient::ClaudeDesktop && requested_target == Some("cli") { + return Err("Claude Desktop 不支持 CLI 启动方式".to_string()); + } + if client != AgentClient::Codex + && client != AgentClient::ClaudeDesktop + && requested_target == Some("app") + { + return Err(format!("{} 不支持桌面 App 启动方式", client.name())); + } + + let executable = find_agent_executable(client, &home) + .ok_or_else(|| format!("未找到 {} 的可执行文件", client.name()))?; + if client == AgentClient::ClaudeDesktop { + launch_desktop_agent(&executable, client.name()) + } else { + launch_cli_agent(&executable, client.name(), &home) + } +} + +fn validate_agent_can_enable(client: AgentClient, home: &Path, port: u16) -> Result<(), String> { + if !client.supported_platform() { + return Err(format!( + "{} 当前仅支持在 Windows 和 macOS 上配置", + client.name() + )); + } + let detection = inspect_agent_config(client, home, port); + if !detection.installed { + return Err(format!("未检测到 {},请先安装后再配置", client.name())); + } + if !detection.config_valid { + return Err(detection + .error + .unwrap_or_else(|| "原配置格式异常,请先修复后再开启".to_string())); + } + Ok(()) +} + +fn validate_agent_model(value: &str) -> Result { + let model = value.trim(); + if model.is_empty() { + return Err("请先选择模型".to_string()); + } + if model.len() > 240 || model.chars().any(char::is_control) { + return Err("模型名称格式无效".to_string()); + } + Ok(model.to_string()) +} + +fn resolve_available_agent_model( + models: &[AgentModelOption], + model: &str, +) -> Result { + if models.is_empty() { + return Err("当前内核没有可选模型,无法修改智能体配置".to_string()); + } + models + .iter() + .find(|available| available.name.eq_ignore_ascii_case(model)) + .map(|available| available.name.clone()) + .ok_or_else(|| format!("模型 {model} 不在当前可用模型列表中,请刷新后重新选择")) +} + +fn parse_agent_model_options(payload: &serde_json::Value) -> Result, String> { + let source = payload + .as_array() + .or_else(|| payload.get("data").and_then(serde_json::Value::as_array)) + .or_else(|| payload.get("models").and_then(serde_json::Value::as_array)) + .ok_or_else(|| "本机模型列表响应缺少 data 或 models 数组".to_string())?; + let mut models = Vec::new(); + for item in source { + let name = if let Some(name) = item.as_str() { + name.trim().to_string() + } else { + ["id", "name", "model", "value"] + .into_iter() + .find_map(|key| item.get(key).and_then(serde_json::Value::as_str)) + .unwrap_or_default() + .trim() + .to_string() + }; + if name.is_empty() + || models + .iter() + .any(|model: &AgentModelOption| model.name.eq_ignore_ascii_case(&name)) + { + continue; + } + let alias = ["alias", "display_name", "displayName"] + .into_iter() + .find_map(|key| item.get(key).and_then(serde_json::Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case(&name)) + .map(str::to_string); + models.push(AgentModelOption { name, alias }); + } + Ok(models) +} + +async fn fetch_codex_model_catalog_specs( + config: &GuiConfigFile, + models: &[AgentModelOption], +) -> Vec { + let definitions = match fetch_codex_model_definitions(config).await { + Ok(definitions) => definitions, + Err(error) => { + eprintln!("读取 CPA Codex 模型定义失败,将使用默认模型参数: {error}"); + Vec::new() + } + }; + merge_codex_model_catalog_specs(models, &definitions) +} + +fn merge_codex_model_catalog_specs( + models: &[AgentModelOption], + definitions: &[CodexModelDefinition], +) -> Vec { + models + .iter() + .map(|model| { + let definition = definitions + .iter() + .find(|definition| definition.id.eq_ignore_ascii_case(&model.name)); + let reasoning_levels = definition + .map(|definition| definition.reasoning_levels.clone()) + .filter(|levels| !levels.is_empty()) + .unwrap_or_else(default_codex_reasoning_levels); + CodexModelCatalogSpec { + id: model.name.clone(), + display_name: definition + .and_then(|definition| definition.display_name.clone()) + .or_else(|| model.alias.clone()) + .unwrap_or_else(|| model.name.clone()), + description: definition + .and_then(|definition| definition.description.clone()) + .unwrap_or_else(|| format!("由 CPA 提供的模型 {}", model.name)), + context_window: definition + .and_then(|definition| definition.context_window) + .unwrap_or(DEFAULT_CODEX_CONTEXT_WINDOW), + reasoning_levels, + supports_parallel_tool_calls: definition + .and_then(|definition| definition.supports_tools) + .unwrap_or(true), + } + }) + .collect() +} + +fn default_codex_reasoning_levels() -> Vec { + ["low", "medium", "high", "xhigh"] + .into_iter() + .map(str::to_string) + .collect() +} + +fn parse_codex_model_definitions( + payload: &serde_json::Value, +) -> Result, String> { + let source = payload + .as_array() + .or_else(|| payload.get("models").and_then(serde_json::Value::as_array)) + .or_else(|| payload.get("data").and_then(serde_json::Value::as_array)) + .ok_or_else(|| "Codex 模型定义响应缺少 models 或 data 数组".to_string())?; + let mut definitions = Vec::new(); + for item in source { + let id = ["id", "ID", "name"] + .into_iter() + .find_map(|key| item.get(key).and_then(serde_json::Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()); + let Some(id) = id else { + continue; + }; + if definitions + .iter() + .any(|definition: &CodexModelDefinition| definition.id.eq_ignore_ascii_case(id)) + { + continue; + } + let display_name = ["display_name", "displayName", "DisplayName"] + .into_iter() + .find_map(|key| item.get(key).and_then(serde_json::Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let description = item + .get("description") + .or_else(|| item.get("Description")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let context_window = ["context_length", "contextLength", "ContextLength"] + .into_iter() + .find_map(|key| item.get(key).and_then(json_positive_u64)); + let reasoning_levels = item + .get("thinking") + .or_else(|| item.get("Thinking")) + .and_then(|thinking| thinking.get("levels").or_else(|| thinking.get("Levels"))) + .and_then(serde_json::Value::as_array) + .map(|levels| { + levels + .iter() + .filter_map(serde_json::Value::as_str) + .map(str::trim) + .map(str::to_ascii_lowercase) + .filter(|level| is_codex_reasoning_level(level)) + .fold(Vec::new(), |mut result, level| { + if !result.contains(&level) { + result.push(level); + } + result + }) + }) + .unwrap_or_default(); + let supports_tools = item + .get("supported_parameters") + .or_else(|| item.get("supportedParameters")) + .or_else(|| item.get("SupportedParameters")) + .and_then(serde_json::Value::as_array) + .map(|parameters| { + parameters.iter().any(|parameter| { + parameter + .as_str() + .is_some_and(|value| value.eq_ignore_ascii_case("tools")) + }) + }); + definitions.push(CodexModelDefinition { + id: id.to_string(), + display_name, + description, + context_window, + reasoning_levels, + supports_tools, + }); + } + Ok(definitions) +} + +fn json_positive_u64(value: &serde_json::Value) -> Option { + value + .as_u64() + .or_else(|| value.as_str()?.trim().parse::().ok()) + .filter(|value| *value > 0) +} + +fn is_codex_reasoning_level(value: &str) -> bool { + matches!( + value, + "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "ultra" + ) +} + +fn format_agent_models_error(status: u16, body: &str) -> String { + if let Ok(value) = serde_json::from_str::(body) { + let message = value + .get("error") + .and_then(|error| { + error + .as_str() + .or_else(|| error.get("message").and_then(serde_json::Value::as_str)) + }) + .or_else(|| value.get("message").and_then(serde_json::Value::as_str)) + .map(str::trim) + .filter(|message| !message.is_empty()); + if let Some(message) = message { + return format!("获取本机模型列表失败 ({status}): {message}"); + } + } + let body = body.trim(); + if body.is_empty() { + format!("获取本机模型列表失败 ({status})") + } else { + format!( + "获取本机模型列表失败 ({status}): {}", + truncate_for_error(body) + ) + } +} + +fn agent_config_paths(client: AgentClient, home: &Path) -> Vec { + match client { + AgentClient::ClaudeCode => { + let directory = home.join(".claude"); + let settings = directory.join("settings.json"); + let legacy = directory.join("claude.json"); + let settings_state_exists = agent_state_path(&[settings.clone()]) + .map(|path| path.exists()) + .unwrap_or(false); + let legacy_state_exists = agent_state_path(&[legacy.clone()]) + .map(|path| path.exists()) + .unwrap_or(false); + vec![if settings_state_exists { + settings + } else if legacy_state_exists { + legacy + } else if !settings.exists() && legacy.exists() { + legacy + } else { + settings + }] + } + AgentClient::ClaudeDesktop => claude_desktop_config_paths(home), + AgentClient::Codex => vec![home.join(".codex/config.toml")], + AgentClient::OpenCode => vec![home.join(".config/opencode/opencode.json")], + AgentClient::OpenClaw => vec![home.join(".openclaw/openclaw.json")], + AgentClient::Hermes => vec![hermes_agent_config_path(home)], + } +} + +fn codex_model_catalog_path(home: &Path) -> PathBuf { + home.join(".codex").join(CODEX_MODEL_CATALOG_FILE) +} + +fn expected_agent_record_paths(client: AgentClient, paths: &[PathBuf]) -> Vec { + if client == AgentClient::Codex && paths.len() == 1 { + let mut expected = paths.to_vec(); + expected.push(paths[0].with_file_name(CODEX_MODEL_CATALOG_FILE)); + expected + } else { + paths.to_vec() + } +} + +fn claude_desktop_config_paths(_home: &Path) -> Vec { + #[cfg(target_os = "macos")] + let (normal, threep) = { + let support = _home.join("Library/Application Support"); + (support.join("Claude"), support.join("Claude-3p")) + }; + #[cfg(target_os = "windows")] + let (normal, threep) = { + let local = env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| _home.join("AppData/Local")); + (local.join("Claude"), local.join("Claude-3p")) + }; + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + return Vec::new(); + + #[cfg(any(target_os = "macos", target_os = "windows"))] + { + let library = threep.join("configLibrary"); + vec![ + normal.join("claude_desktop_config.json"), + threep.join("claude_desktop_config.json"), + library.join(format!("{CLAUDE_DESKTOP_PROFILE_ID}.json")), + library.join("_meta.json"), + ] + } +} + +fn hermes_agent_config_path(home: &Path) -> PathBuf { + if let Some(directory) = env::var_os("HERMES_HOME") + .map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) + { + return directory.join("config.yaml"); + } + #[cfg(target_os = "windows")] + { + return env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| home.join("AppData/Local")) + .join("hermes/config.yaml"); + } + #[cfg(not(target_os = "windows"))] + home.join(".hermes/config.yaml") +} + +fn inspect_agent_config(client: AgentClient, home: &Path, port: u16) -> AgentConfigStatus { + let paths = agent_config_paths(client, home); + let config_exists = paths.iter().any(|path| path.is_file()); + let result = inspect_agent_managed_config(client, &paths, port); + let (configured, current_model, config_valid, error) = match result { + Ok((configured, model)) => (configured, model, true, None), + Err(error) => (false, None, false, Some(error)), + }; + let launch_targets = agent_launch_targets(client, home); + let executable = find_agent_executable(client, home); + let installed = !launch_targets.is_empty() || config_exists; + let version = executable + .as_deref() + .filter(|_| client != AgentClient::ClaudeDesktop) + .and_then(read_agent_version); + let mut warnings = Vec::new(); + if !client.supported_platform() { + warnings.push("当前平台不支持 Claude Desktop 3P 配置".to_string()); + } else if launch_targets.is_empty() && config_exists { + warnings.push("只检测到配置文件,未在 PATH 中找到客户端命令".to_string()); + } + if let Some(message) = error.as_ref() { + warnings.push(message.clone()); + } + let modification = inspect_agent_modification( + client, + home, + port, + agent_has_managed_marker(client, &paths).unwrap_or(configured), + current_model.as_deref(), + ); + warnings.extend(modification.warnings.iter().cloned()); + + AgentConfigStatus { + id: client.id().to_string(), + name: client.name().to_string(), + supported_platform: client.supported_platform(), + installed, + executable_path: launch_targets.first().map(|target| target.detail.clone()), + launch_targets, + version, + config_paths: paths.iter().map(|path| path_to_string(path)).collect(), + config_exists, + config_valid, + configured, + current_model, + modification_enabled: modification.enabled, + modification_state: modification.state, + backup_available: modification.backup_available, + applied_model: modification.applied_model, + warnings, + error, + } +} + +fn inspect_agent_managed_config( + client: AgentClient, + paths: &[PathBuf], + port: u16, +) -> Result<(bool, Option), String> { + match client { + AgentClient::ClaudeCode => inspect_claude_agent_config(&paths[0], port), + AgentClient::ClaudeDesktop if client.supported_platform() => { + inspect_claude_desktop_agent_config(paths, port) + } + AgentClient::ClaudeDesktop => Ok((false, None)), + AgentClient::Codex => inspect_codex_agent_config(&paths[0], port), + AgentClient::OpenCode => inspect_opencode_agent_config(&paths[0], port), + AgentClient::OpenClaw => inspect_openclaw_agent_config(&paths[0], port), + AgentClient::Hermes => inspect_hermes_agent_config(&paths[0], port), + } +} + +fn agent_has_managed_marker(client: AgentClient, paths: &[PathBuf]) -> Result { + match client { + AgentClient::ClaudeCode => { + if !paths[0].is_file() { + return Ok(false); + } + let root = read_agent_json_or_empty(&paths[0], "Claude Code 配置")?; + let env = root.get("env"); + Ok(env + .and_then(|value| value.get("ANTHROPIC_BASE_URL")) + .and_then(serde_json::Value::as_str) + .is_some_and(|value| { + value.starts_with("http://127.0.0.1:") || value.starts_with("http://localhost:") + }) + && env + .and_then(|value| value.get("ANTHROPIC_AUTH_TOKEN")) + .and_then(serde_json::Value::as_str) + == Some(DEFAULT_API_KEY)) + } + AgentClient::ClaudeDesktop => { + if paths.len() != 4 || !paths[3].is_file() { + return Ok(false); + } + let meta = read_agent_json_or_empty(&paths[3], "Claude Desktop 配置索引")?; + Ok(meta.get("appliedId").and_then(serde_json::Value::as_str) + == Some(CLAUDE_DESKTOP_PROFILE_ID)) + } + AgentClient::Codex => { + if !paths[0].is_file() { + return Ok(false); + } + let root: toml::Value = toml::from_str( + &fs::read_to_string(&paths[0]) + .map_err(|error| format!("读取 Codex 配置失败: {error}"))?, + ) + .map_err(|error| format!("解析 Codex 配置失败: {error}"))?; + Ok(root.get("model_provider").and_then(toml::Value::as_str) + == Some(MANAGED_AGENT_PROVIDER_ID)) + } + AgentClient::OpenCode => { + if !paths[0].is_file() { + return Ok(false); + } + let root = read_agent_json_or_empty(&paths[0], "OpenCode 配置")?; + Ok(root + .get("provider") + .and_then(|value| value.get(MANAGED_AGENT_PROVIDER_ID)) + .is_some()) + } + AgentClient::OpenClaw => { + if !paths[0].is_file() { + return Ok(false); + } + let root: serde_json::Value = json5::from_str( + &fs::read_to_string(&paths[0]) + .map_err(|error| format!("读取 OpenClaw 配置失败: {error}"))?, + ) + .map_err(|error| format!("解析 OpenClaw 配置失败: {error}"))?; + Ok(root + .get("models") + .and_then(|value| value.get("providers")) + .and_then(|value| value.get(MANAGED_AGENT_PROVIDER_ID)) + .is_some()) + } + AgentClient::Hermes => { + if !paths[0].is_file() { + return Ok(false); + } + let root: serde_yaml::Value = serde_yaml::from_str( + &fs::read_to_string(&paths[0]) + .map_err(|error| format!("读取 Hermes 配置失败: {error}"))?, + ) + .map_err(|error| format!("解析 Hermes 配置失败: {error}"))?; + Ok(root + .get("custom_providers") + .and_then(serde_yaml::Value::as_sequence) + .is_some_and(|providers| { + providers.iter().any(|provider| { + provider.get("name").and_then(serde_yaml::Value::as_str) + == Some(MANAGED_AGENT_PROVIDER_ID) + }) + })) + } + } +} + +fn agent_launch_targets(client: AgentClient, home: &Path) -> Vec { + let mut targets = Vec::new(); + if client == AgentClient::Codex { + if let Some(target) = find_codex_app_target(home) { + targets.push(AgentLaunchTarget { + id: "app".to_string(), + label: "Codex App".to_string(), + detail: codex_app_target_detail(&target), + }); + } + } + + if let Some(executable) = find_agent_executable(client, home) { + targets.push(AgentLaunchTarget { + id: if client == AgentClient::ClaudeDesktop { + "app".to_string() + } else { + "cli".to_string() + }, + label: match client { + AgentClient::ClaudeDesktop => "Claude Desktop".to_string(), + AgentClient::Codex => "Codex CLI".to_string(), + _ => client.name().to_string(), + }, + detail: path_to_string(&executable), + }); + } + targets +} + +fn codex_app_target_detail(target: &CodexAppTarget) -> String { + match target { + CodexAppTarget::Application(path) => path_to_string(path), + #[cfg(target_os = "windows")] + CodexAppTarget::WindowsAppId(app_id) => format!("Microsoft Store · {app_id}"), + } +} + +fn find_codex_app_target(home: &Path) -> Option { + #[cfg(target_os = "macos")] + { + return [PathBuf::from("/Applications"), home.join("Applications")] + .into_iter() + .flat_map(|directory| { + [ + "Codex.app", + "OpenAI Codex.app", + "OpenAI.Codex.app", + "ChatGPT.app", + ] + .into_iter() + .map(move |name| directory.join(name)) + }) + .find(|path| path.is_dir()) + .map(CodexAppTarget::Application); + } + + #[cfg(target_os = "windows")] + { + if let Some(executable) = find_windows_codex_app_executable(home) { + return Some(CodexAppTarget::Application(executable)); + } + return find_windows_codex_app_id().map(CodexAppTarget::WindowsAppId); + } + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + let _ = home; + None + } +} + +#[cfg(target_os = "windows")] +fn find_windows_codex_app_executable(home: &Path) -> Option { + let local = env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| home.join("AppData/Local")); + [ + local.join("OpenAI/Codex/bin"), + local.join("OpenAI/Codex"), + local.join("Programs/OpenAI/Codex"), + local.join("Programs/Codex"), + ] + .into_iter() + .flat_map(|directory| { + ["Codex.exe", "ChatGPT.exe"] + .into_iter() + .map(move |name| directory.join(name)) + }) + .find(|path| path.is_file()) +} + +#[cfg(target_os = "windows")] +fn find_windows_codex_app_id() -> Option { + use std::os::windows::process::CommandExt; + + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let output = Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "$app = Get-StartApps | Where-Object { $_.AppID -like 'OpenAI.Codex*!App' -or $_.AppID -like 'OpenAI.CodexBeta*!App' } | Select-Object -First 1 -ExpandProperty AppID; if ($app) { Write-Output $app }", + ]) + .creation_flags(CREATE_NO_WINDOW) + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(str::to_string) +} + +#[cfg(target_os = "macos")] +fn launch_codex_app(target: &CodexAppTarget) -> Result<(), String> { + let CodexAppTarget::Application(application) = target; + Command::new("open") + .arg(application) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map(|_| ()) + .map_err(|error| format!("启动 Codex App 失败: {error}")) +} + +#[cfg(target_os = "windows")] +fn launch_codex_app(target: &CodexAppTarget) -> Result<(), String> { + use std::os::windows::process::CommandExt; + + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let mut command = match target { + CodexAppTarget::Application(executable) => Command::new(executable), + CodexAppTarget::WindowsAppId(app_id) => { + let mut command = Command::new("explorer.exe"); + command.arg(format!("shell:AppsFolder\\{app_id}")); + command + } + }; + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .creation_flags(CREATE_NO_WINDOW) + .spawn() + .map(|_| ()) + .map_err(|error| format!("启动 Codex App 失败: {error}")) +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +fn launch_codex_app(_target: &CodexAppTarget) -> Result<(), String> { + Err("当前平台不支持 Codex App".to_string()) +} + +fn find_agent_executable(client: AgentClient, home: &Path) -> Option { + if client == AgentClient::ClaudeDesktop { + return find_claude_desktop_executable(home); + } + for directory in agent_executable_directories(home) { + for name in client.executable_names() { + #[cfg(target_os = "windows")] + let candidates = [ + directory.join(format!("{name}.exe")), + directory.join(format!("{name}.cmd")), + directory.join(format!("{name}.bat")), + directory.join(name), + ]; + #[cfg(not(target_os = "windows"))] + let candidates = [directory.join(name)]; + if let Some(candidate) = candidates.into_iter().find(|path| path.is_file()) { + return Some(candidate); + } + } + } + None +} + +fn agent_executable_directories(home: &Path) -> Vec { + let mut directories = Vec::new(); + let mut push = |path: PathBuf| { + if !path.as_os_str().is_empty() && !directories.iter().any(|item| item == &path) { + directories.push(path); + } + }; + + if let Some(path) = env::var_os("PATH") { + env::split_paths(&path).for_each(&mut push); + } + [ + home.join(".local/bin"), + home.join(".npm-global/bin"), + home.join(".bun/bin"), + home.join(".cargo/bin"), + home.join("bin"), + ] + .into_iter() + .for_each(&mut push); + for variable in ["PNPM_HOME", "BUN_INSTALL", "NPM_CONFIG_PREFIX"] { + if let Some(path) = env::var_os(variable) { + let path = PathBuf::from(path); + push( + if variable == "BUN_INSTALL" || variable == "NPM_CONFIG_PREFIX" { + path.join("bin") + } else { + path + }, + ); + } + } + for root in [ + home.join(".nvm/versions/node"), + home.join(".local/state/fnm_multishells"), + ] { + if let Ok(entries) = fs::read_dir(root) { + for entry in entries.flatten() { + push(entry.path().join("bin")); + } + } + } + + #[cfg(unix)] + for path in ["/usr/local/bin", "/usr/bin", "/bin"] { + push(PathBuf::from(path)); + } + + #[cfg(target_os = "macos")] + push(PathBuf::from("/opt/homebrew/bin")); + + #[cfg(target_os = "windows")] + { + if let Some(app_data) = env::var_os("APPDATA") { + push(PathBuf::from(app_data).join("npm")); + } + if let Some(local_app_data) = env::var_os("LOCALAPPDATA") { + push(PathBuf::from(local_app_data).join("Microsoft/WindowsApps")); + } + } + + directories +} + +fn find_claude_desktop_executable(home: &Path) -> Option { + #[cfg(target_os = "macos")] + { + return [ + PathBuf::from("/Applications/Claude.app/Contents/MacOS/Claude"), + home.join("Applications/Claude.app/Contents/MacOS/Claude"), + ] + .into_iter() + .find(|path| path.is_file()); + } + #[cfg(target_os = "windows")] + { + let local = env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| home.join("AppData/Local")); + return [ + local.join("Programs/Claude/Claude.exe"), + local.join("Claude/Claude.exe"), + ] + .into_iter() + .find(|path| path.is_file()); + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + let _ = home; + None + } +} + +fn read_agent_version(path: &Path) -> Option { + let output = Command::new(path).arg("--version").output().ok()?; + let text = if output.stdout.is_empty() { + String::from_utf8_lossy(&output.stderr) + } else { + String::from_utf8_lossy(&output.stdout) + }; + text.lines() + .next() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) +} + +fn launch_desktop_agent(executable: &Path, label: &str) -> Result<(), String> { + #[cfg(target_os = "macos")] + let mut command = { + let application = executable + .ancestors() + .find(|path| path.extension().and_then(|value| value.to_str()) == Some("app")) + .unwrap_or(executable); + let mut command = Command::new("open"); + command.arg(application); + command + }; + + #[cfg(target_os = "windows")] + let mut command = Command::new(executable); + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + let _ = (executable, label); + return Err("当前平台不支持桌面智能体".to_string()); + } + + #[cfg(any(target_os = "macos", target_os = "windows"))] + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map(|_| ()) + .map_err(|error| format!("启动 {label} 失败: {error}")) +} + +#[cfg(target_os = "macos")] +fn shell_single_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + +#[cfg(target_os = "macos")] +fn launch_cli_agent( + executable: &Path, + label: &str, + working_directory: &Path, +) -> Result<(), String> { + let command_line = format!( + "cd {} && exec {}", + shell_single_quote(&path_to_string(working_directory)), + shell_single_quote(&path_to_string(executable)) + ); + let apple_script_command = command_line.replace('\\', "\\\\").replace('"', "\\\""); + let script = format!( + "tell application \"Terminal\"\nactivate\ndo script \"{apple_script_command}\"\nend tell" + ); + let output = Command::new("osascript") + .arg("-e") + .arg(script) + .output() + .map_err(|error| format!("启动 {label} 终端失败: {error}"))?; + if output.status.success() { + return Ok(()); + } + let message = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(format!( + "启动 {label} 终端失败{}", + if message.is_empty() { + String::new() + } else { + format!(": {message}") + } + )) +} + +#[cfg(target_os = "linux")] +fn launch_cli_agent( + executable: &Path, + label: &str, + working_directory: &Path, +) -> Result<(), String> { + let terminals: &[(&str, &[&str])] = &[ + ("x-terminal-emulator", &["-e"]), + ("gnome-terminal", &["--"]), + ("konsole", &["-e"]), + ("xfce4-terminal", &["-e"]), + ("mate-terminal", &["--"]), + ("kitty", &["-e"]), + ("alacritty", &["-e"]), + ("ghostty", &["-e"]), + ("xterm", &["-e"]), + ]; + let path = env::var_os("PATH").unwrap_or_default(); + let mut last_error = None; + for (terminal, arguments) in terminals { + let available = env::split_paths(&path).any(|directory| directory.join(terminal).is_file()); + if !available { + continue; + } + match Command::new(terminal) + .args(*arguments) + .arg(executable) + .current_dir(working_directory) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + Ok(_) => return Ok(()), + Err(error) => last_error = Some(error.to_string()), + } + } + Err(match last_error { + Some(error) => format!("启动 {label} 失败: {error}"), + None => format!("启动 {label} 失败:未找到可用的终端程序"), + }) +} + +#[cfg(target_os = "windows")] +fn launch_cli_agent( + executable: &Path, + label: &str, + working_directory: &Path, +) -> Result<(), String> { + use std::os::windows::process::CommandExt; + + const CREATE_NEW_CONSOLE: u32 = 0x0000_0010; + let command_line = format!("\"{}\"", path_to_string(executable).replace('"', "\"\"")); + Command::new("cmd") + .args(["/D", "/K"]) + .arg(command_line) + .current_dir(working_directory) + .creation_flags(CREATE_NEW_CONSOLE) + .spawn() + .map(|_| ()) + .map_err(|error| format!("启动 {label} 失败: {error}")) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +fn launch_cli_agent( + _executable: &Path, + label: &str, + _working_directory: &Path, +) -> Result<(), String> { + Err(format!("当前平台不支持启动 {label}")) +} + +fn inspect_claude_agent_config(path: &Path, port: u16) -> Result<(bool, Option), String> { + if !path.is_file() { + return Ok((false, None)); + } + let root: serde_json::Value = serde_json::from_str( + &fs::read_to_string(path).map_err(|error| format!("读取 Claude Code 配置失败: {error}"))?, + ) + .map_err(|error| format!("解析 Claude Code 配置失败: {error}"))?; + let env = root.get("env").and_then(serde_json::Value::as_object); + let expected_base = format!("http://127.0.0.1:{port}"); + let configured = env + .and_then(|env| env.get("ANTHROPIC_BASE_URL")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + == Some(expected_base.as_str()) + && env + .and_then(|env| env.get("ANTHROPIC_AUTH_TOKEN")) + .and_then(serde_json::Value::as_str) + == Some(DEFAULT_API_KEY); + let model = env + .and_then(|env| env.get("ANTHROPIC_MODEL")) + .and_then(serde_json::Value::as_str) + .or_else(|| root.get("model").and_then(serde_json::Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + Ok((configured, model)) +} + +fn inspect_claude_desktop_agent_config( + paths: &[PathBuf], + port: u16, +) -> Result<(bool, Option), String> { + if paths.len() != 4 || !paths.iter().any(|path| path.is_file()) { + return Ok((false, None)); + } + let normal = read_agent_json_or_empty(&paths[0], "Claude Desktop 主配置")?; + let threep = read_agent_json_or_empty(&paths[1], "Claude Desktop 3P 配置")?; + let profile = read_agent_json_or_empty(&paths[2], "Claude Desktop 网关配置")?; + let meta = read_agent_json_or_empty(&paths[3], "Claude Desktop 配置索引")?; + let expected_base = format!("http://127.0.0.1:{port}"); + let configured = normal + .get("deploymentMode") + .and_then(serde_json::Value::as_str) + == Some("3p") + && threep + .get("deploymentMode") + .and_then(serde_json::Value::as_str) + == Some("3p") + && profile + .get("inferenceGatewayBaseUrl") + .and_then(serde_json::Value::as_str) + == Some(expected_base.as_str()) + && profile + .get("inferenceGatewayApiKey") + .and_then(serde_json::Value::as_str) + == Some(DEFAULT_API_KEY) + && meta.get("appliedId").and_then(serde_json::Value::as_str) + == Some(CLAUDE_DESKTOP_PROFILE_ID); + let model = profile + .get("inferenceModels") + .and_then(serde_json::Value::as_array) + .and_then(|models| models.first()) + .and_then(|model| { + model + .as_str() + .or_else(|| model.get("name").and_then(serde_json::Value::as_str)) + }) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + Ok((configured, model)) +} + +fn read_agent_json_or_empty(path: &Path, label: &str) -> Result { + if !path.is_file() { + return Ok(serde_json::json!({})); + } + let content = + fs::read_to_string(path).map_err(|error| format!("读取 {label} 失败: {error}"))?; + if content.trim().is_empty() { + return Ok(serde_json::json!({})); + } + let value: serde_json::Value = + serde_json::from_str(&content).map_err(|error| format!("解析 {label} 失败: {error}"))?; + if !value.is_object() { + return Err(format!("{label} 根节点必须是对象")); + } + Ok(value) +} + +fn inspect_codex_agent_config(path: &Path, port: u16) -> Result<(bool, Option), String> { + if !path.is_file() { + return Ok((false, None)); + } + let root: toml::Value = toml::from_str( + &fs::read_to_string(path).map_err(|error| format!("读取 Codex 配置失败: {error}"))?, + ) + .map_err(|error| format!("解析 Codex 配置失败: {error}"))?; + let expected_base = format!("http://127.0.0.1:{port}/v1"); + let provider = root + .get("model_providers") + .and_then(toml::Value::as_table) + .and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID)) + .and_then(toml::Value::as_table); + let configured = root.get("model_provider").and_then(toml::Value::as_str) + == Some(MANAGED_AGENT_PROVIDER_ID) + && provider + .and_then(|provider| provider.get("base_url")) + .and_then(toml::Value::as_str) + == Some(expected_base.as_str()) + && provider + .and_then(|provider| provider.get("experimental_bearer_token")) + .and_then(toml::Value::as_str) + == Some(DEFAULT_API_KEY) + && provider + .and_then(|provider| provider.get("wire_api")) + .and_then(toml::Value::as_str) + == Some("responses"); + let model = root + .get("model") + .and_then(toml::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + Ok((configured, model)) +} + +fn inspect_opencode_agent_config(path: &Path, port: u16) -> Result<(bool, Option), String> { + if !path.is_file() { + return Ok((false, None)); + } + let root: serde_json::Value = serde_json::from_str( + &fs::read_to_string(path).map_err(|error| format!("读取 OpenCode 配置失败: {error}"))?, + ) + .map_err(|error| format!("解析 OpenCode 配置失败: {error}"))?; + let provider = root + .get("provider") + .and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID)); + let expected_base = format!("http://127.0.0.1:{port}/v1"); + let configured = provider + .and_then(|provider| provider.get("options")) + .and_then(|options| options.get("baseURL")) + .and_then(serde_json::Value::as_str) + == Some(expected_base.as_str()) + && provider + .and_then(|provider| provider.get("options")) + .and_then(|options| options.get("apiKey")) + .and_then(serde_json::Value::as_str) + == Some(DEFAULT_API_KEY); + let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); + let model = root + .get("model") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.strip_prefix(&prefix).unwrap_or(value)) + .map(str::to_string); + Ok((configured, model)) +} + +fn inspect_openclaw_agent_config(path: &Path, port: u16) -> Result<(bool, Option), String> { + if !path.is_file() { + return Ok((false, None)); + } + let content = + fs::read_to_string(path).map_err(|error| format!("读取 OpenClaw 配置失败: {error}"))?; + let root: serde_json::Value = json5::from_str(&content) + .map_err(|error| format!("解析 OpenClaw JSON5 配置失败: {error}"))?; + let provider = root + .get("models") + .and_then(|models| models.get("providers")) + .and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID)); + let expected_base = format!("http://127.0.0.1:{port}/v1"); + let configured = provider + .and_then(|provider| provider.get("baseUrl")) + .and_then(serde_json::Value::as_str) + == Some(expected_base.as_str()) + && provider + .and_then(|provider| provider.get("apiKey")) + .and_then(serde_json::Value::as_str) + == Some(DEFAULT_API_KEY); + let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); + let model = root + .get("agents") + .and_then(|agents| agents.get("defaults")) + .and_then(|defaults| defaults.get("model")) + .and_then(|model| model.get("primary")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.strip_prefix(&prefix).unwrap_or(value)) + .map(str::to_string); + Ok((configured, model)) +} + +fn inspect_hermes_agent_config(path: &Path, port: u16) -> Result<(bool, Option), String> { + if !path.is_file() { + return Ok((false, None)); + } + let root: serde_yaml::Value = serde_yaml::from_str( + &fs::read_to_string(path).map_err(|error| format!("读取 Hermes 配置失败: {error}"))?, + ) + .map_err(|error| format!("解析 Hermes YAML 配置失败: {error}"))?; + let provider = root + .get("custom_providers") + .and_then(serde_yaml::Value::as_sequence) + .and_then(|providers| { + providers.iter().find(|provider| { + provider.get("name").and_then(serde_yaml::Value::as_str) + == Some(MANAGED_AGENT_PROVIDER_ID) + }) + }); + let expected_base = format!("http://127.0.0.1:{port}/v1"); + let configured = provider + .and_then(|provider| provider.get("base_url")) + .and_then(serde_yaml::Value::as_str) + == Some(expected_base.as_str()) + && provider + .and_then(|provider| provider.get("api_key")) + .and_then(serde_yaml::Value::as_str) + == Some(DEFAULT_API_KEY) + && root + .get("model") + .and_then(|model| model.get("provider")) + .and_then(serde_yaml::Value::as_str) + == Some(MANAGED_AGENT_PROVIDER_ID); + let model = root + .get("model") + .and_then(|model| model.get("default")) + .and_then(serde_yaml::Value::as_str) + .or_else(|| provider.and_then(|provider| provider.get("model")?.as_str())) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + Ok((configured, model)) +} + +fn build_agent_updates( + client: AgentClient, + home: &Path, + port: u16, + model: &str, + codex_models: Option<&[CodexModelCatalogSpec]>, +) -> Result, String> { + let paths = agent_config_paths(client, home); + let root_base = format!("http://127.0.0.1:{port}"); + let openai_base = format!("{root_base}/v1"); + match client { + AgentClient::ClaudeCode => { + let before = read_optional_text(&paths[0])?; + let after = + build_claude_agent_config(before.as_deref(), &root_base, DEFAULT_API_KEY, model)?; + Ok(vec![AgentFileUpdate { + path: paths[0].clone(), + after, + }]) + } + AgentClient::ClaudeDesktop => { + if paths.len() != 4 { + return Err("Claude Desktop 当前平台配置路径不可用".to_string()); + } + let normal_before = read_optional_text(&paths[0])?; + let threep_before = read_optional_text(&paths[1])?; + let profile_before = read_optional_text(&paths[2])?; + let meta_before = read_optional_text(&paths[3])?; + Ok(vec![ + AgentFileUpdate { + path: paths[0].clone(), + after: build_claude_desktop_deployment_config(normal_before.as_deref())?, + }, + AgentFileUpdate { + path: paths[1].clone(), + after: build_claude_desktop_deployment_config(threep_before.as_deref())?, + }, + AgentFileUpdate { + path: paths[2].clone(), + after: build_claude_desktop_profile( + profile_before.as_deref(), + &root_base, + DEFAULT_API_KEY, + model, + )?, + }, + AgentFileUpdate { + path: paths[3].clone(), + after: build_claude_desktop_meta(meta_before.as_deref())?, + }, + ]) + } + AgentClient::Codex => { + let before = read_optional_text(&paths[0])?; + let after = + build_codex_agent_config(before.as_deref(), &openai_base, DEFAULT_API_KEY, model)?; + let mut updates = vec![AgentFileUpdate { + path: paths[0].clone(), + after, + }]; + if let Some(models) = codex_models { + updates.push(AgentFileUpdate { + path: codex_model_catalog_path(home), + after: build_codex_model_catalog(models)?, + }); + } + Ok(updates) + } + AgentClient::OpenCode => { + let before = read_optional_text(&paths[0])?; + let after = build_opencode_agent_config( + before.as_deref(), + &openai_base, + DEFAULT_API_KEY, + model, + )?; + Ok(vec![AgentFileUpdate { + path: paths[0].clone(), + after, + }]) + } + AgentClient::OpenClaw => { + let before = read_optional_text(&paths[0])?; + let after = build_openclaw_agent_config( + before.as_deref(), + &openai_base, + DEFAULT_API_KEY, + model, + )?; + Ok(vec![AgentFileUpdate { + path: paths[0].clone(), + after, + }]) + } + AgentClient::Hermes => { + let before = read_optional_text(&paths[0])?; + let after = + build_hermes_agent_config(before.as_deref(), &openai_base, DEFAULT_API_KEY, model)?; + Ok(vec![AgentFileUpdate { + path: paths[0].clone(), + after, + }]) + } + } +} + +fn read_optional_text(path: &Path) -> Result, String> { + if !path.is_file() { + return Ok(None); + } + fs::read_to_string(path) + .map(Some) + .map_err(|error| format!("读取配置失败 {}: {error}", path_to_string(path))) +} + +fn build_claude_agent_config( + existing: Option<&str>, + base_url: &str, + api_key: &str, + model: &str, +) -> Result { + let mut root = match existing.map(str::trim).filter(|value| !value.is_empty()) { + Some(value) => serde_json::from_str::(value) + .map_err(|error| format!("Claude Code settings.json 格式无效: {error}"))?, + None => serde_json::json!({}), + }; + let root = root + .as_object_mut() + .ok_or_else(|| "Claude Code settings.json 根节点必须是对象".to_string())?; + let env = root + .entry("env".to_string()) + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + .ok_or_else(|| "Claude Code env 必须是对象".to_string())?; + for (key, value) in [ + ("ANTHROPIC_BASE_URL", base_url), + ("ANTHROPIC_API_KEY", api_key), + ("ANTHROPIC_AUTH_TOKEN", api_key), + ("ANTHROPIC_MODEL", model), + ("ANTHROPIC_DEFAULT_HAIKU_MODEL", model), + ("ANTHROPIC_DEFAULT_SONNET_MODEL", model), + ("ANTHROPIC_DEFAULT_OPUS_MODEL", model), + ] { + env.insert( + key.to_string(), + serde_json::Value::String(value.to_string()), + ); + } + root.insert( + "model".to_string(), + serde_json::Value::String(model.to_string()), + ); + let mut rendered = serde_json::to_string_pretty(&serde_json::Value::Object(root.clone())) + .map_err(|error| format!("生成 Claude Code 配置失败: {error}"))?; + rendered.push('\n'); + Ok(rendered) +} + +fn parse_agent_json_object( + existing: Option<&str>, + label: &str, +) -> Result, String> { + let value = match existing.map(str::trim).filter(|value| !value.is_empty()) { + Some(value) => serde_json::from_str::(value) + .map_err(|error| format!("{label} 格式无效: {error}"))?, + None => serde_json::json!({}), + }; + value + .as_object() + .cloned() + .ok_or_else(|| format!("{label} 根节点必须是对象")) +} + +fn render_agent_json( + root: serde_json::Map, + label: &str, +) -> Result { + let mut rendered = serde_json::to_string_pretty(&serde_json::Value::Object(root)) + .map_err(|error| format!("生成 {label} 失败: {error}"))?; + rendered.push('\n'); + Ok(rendered) +} + +fn build_claude_desktop_deployment_config(existing: Option<&str>) -> Result { + let mut root = parse_agent_json_object(existing, "Claude Desktop 配置")?; + root.insert("deploymentMode".to_string(), serde_json::json!("3p")); + render_agent_json(root, "Claude Desktop 配置") +} + +fn build_claude_desktop_profile( + existing: Option<&str>, + base_url: &str, + api_key: &str, + model: &str, +) -> Result { + let mut root = parse_agent_json_object(existing, "Claude Desktop 网关配置")?; + root.insert( + "coworkEgressAllowedHosts".to_string(), + serde_json::json!(["*"]), + ); + root.insert( + "disableDeploymentModeChooser".to_string(), + serde_json::json!(true), + ); + root.insert( + "inferenceGatewayApiKey".to_string(), + serde_json::json!(api_key), + ); + root.insert( + "inferenceGatewayAuthScheme".to_string(), + serde_json::json!("bearer"), + ); + root.insert( + "inferenceGatewayBaseUrl".to_string(), + serde_json::json!(base_url), + ); + root.insert( + "inferenceProvider".to_string(), + serde_json::json!("gateway"), + ); + root.insert("inferenceModels".to_string(), serde_json::json!([model])); + render_agent_json(root, "Claude Desktop 网关配置") +} + +fn build_claude_desktop_meta(existing: Option<&str>) -> Result { + let mut root = parse_agent_json_object(existing, "Claude Desktop 配置索引")?; + let entries = root + .entry("entries".to_string()) + .or_insert_with(|| serde_json::json!([])) + .as_array_mut() + .ok_or_else(|| "Claude Desktop 配置索引 entries 必须是数组".to_string())?; + entries.retain(|entry| { + entry.get("id").and_then(serde_json::Value::as_str) != Some(CLAUDE_DESKTOP_PROFILE_ID) + }); + entries.push(serde_json::json!({ + "id": CLAUDE_DESKTOP_PROFILE_ID, + "name": "Easy CLIProxyAPI" + })); + root.insert( + "appliedId".to_string(), + serde_json::json!(CLAUDE_DESKTOP_PROFILE_ID), + ); + render_agent_json(root, "Claude Desktop 配置索引") +} + +fn build_codex_agent_config( + existing: Option<&str>, + base_url: &str, + api_key: &str, + model: &str, +) -> Result { + use toml_edit::{value, Document, Item, Table}; + + let mut document = match existing.map(str::trim).filter(|value| !value.is_empty()) { + Some(value) => value + .parse::() + .map_err(|error| format!("Codex config.toml 格式无效: {error}"))?, + None => Document::new(), + }; + document["model_provider"] = value(MANAGED_AGENT_PROVIDER_ID); + document["model"] = value(model); + document["model_catalog_json"] = value(CODEX_MODEL_CATALOG_FILE); + if !document.contains_key("model_providers") { + document["model_providers"] = Item::Table(Table::new()); + } + let providers = document["model_providers"] + .as_table_mut() + .ok_or_else(|| "Codex model_providers 必须是 TOML 表".to_string())?; + let mut provider = providers + .get(MANAGED_AGENT_PROVIDER_ID) + .and_then(Item::as_table) + .cloned() + .unwrap_or_default(); + provider["name"] = value("Easy CLIProxyAPI"); + provider["base_url"] = value(base_url); + provider["wire_api"] = value("responses"); + provider["experimental_bearer_token"] = value(api_key); + providers.insert(MANAGED_AGENT_PROVIDER_ID, Item::Table(provider)); + let rendered = document.to_string(); + toml::from_str::(&rendered) + .map_err(|error| format!("验证 Codex 配置失败: {error}"))?; + Ok(rendered) +} + +fn build_codex_model_catalog(models: &[CodexModelCatalogSpec]) -> Result { + if models.is_empty() { + return Err("当前 CPA 没有可写入 Codex 的模型".to_string()); + } + let entries = models + .iter() + .enumerate() + .map(|(index, model)| { + let reasoning_levels = if model.reasoning_levels.is_empty() { + default_codex_reasoning_levels() + } else { + model.reasoning_levels.clone() + }; + let default_reasoning_level = if reasoning_levels.iter().any(|level| level == "medium") { + "medium".to_string() + } else { + reasoning_levels + .first() + .cloned() + .unwrap_or_else(|| "medium".to_string()) + }; + let supported_reasoning_levels = reasoning_levels + .iter() + .map(|level| { + serde_json::json!({ + "effort": level, + "description": codex_reasoning_level_description(level), + }) + }) + .collect::>(); + serde_json::json!({ + "slug": model.id, + "display_name": model.display_name, + "description": model.description, + "base_instructions": "You are Codex, a coding agent. You and the user share the same workspace and collaborate to achieve the user's goals.", + "default_reasoning_level": default_reasoning_level, + "supported_reasoning_levels": supported_reasoning_levels, + "shell_type": "shell_command", + "visibility": "list", + "supported_in_api": true, + "priority": index + 1, + "include_skills_usage_instructions": true, + "supports_reasoning_summaries": true, + "default_reasoning_summary": "none", + "support_verbosity": false, + "truncation_policy": { + "mode": "bytes", + "limit": 10_000, + }, + "supports_parallel_tool_calls": model.supports_parallel_tool_calls, + "supports_image_detail_original": false, + "context_window": model.context_window, + "max_context_window": model.context_window, + "effective_context_window_percent": 95, + "experimental_supported_tools": [], + "input_modalities": ["text", "image"], + "supports_search_tool": false, + }) + }) + .collect::>(); + let mut rendered = serde_json::to_string_pretty(&serde_json::json!({ "models": entries })) + .map_err(|error| format!("生成 Codex 模型目录失败: {error}"))?; + rendered.push('\n'); + serde_json::from_str::(&rendered) + .map_err(|error| format!("验证 Codex 模型目录失败: {error}"))?; + Ok(rendered) +} + +fn codex_reasoning_level_description(level: &str) -> &'static str { + match level { + "none" => "关闭推理", + "minimal" => "最少推理", + "low" => "较低推理强度", + "medium" => "平衡速度与推理深度", + "high" => "较高推理强度", + "xhigh" => "超高推理强度", + "max" => "最大推理强度", + "ultra" => "最高推理与自动任务委派", + _ => "模型支持的推理强度", + } +} + +fn build_opencode_agent_config( + existing: Option<&str>, + base_url: &str, + api_key: &str, + model: &str, +) -> Result { + let mut root = parse_agent_json_object(existing, "OpenCode opencode.json")?; + root.entry("$schema".to_string()) + .or_insert_with(|| serde_json::json!("https://opencode.ai/config.json")); + let providers = root + .entry("provider".to_string()) + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + .ok_or_else(|| "OpenCode provider 必须是对象".to_string())?; + let mut models = serde_json::Map::new(); + models.insert(model.to_string(), serde_json::json!({ "name": model })); + providers.insert( + MANAGED_AGENT_PROVIDER_ID.to_string(), + serde_json::json!({ + "npm": "@ai-sdk/openai-compatible", + "name": "Easy CLIProxyAPI", + "options": { + "baseURL": base_url, + "apiKey": api_key + }, + "models": models + }), + ); + root.insert( + "model".to_string(), + serde_json::json!(format!("{MANAGED_AGENT_PROVIDER_ID}/{model}")), + ); + render_agent_json(root, "OpenCode 配置") +} + +fn build_openclaw_agent_config( + existing: Option<&str>, + base_url: &str, + api_key: &str, + model: &str, +) -> Result { + let mut root = match existing.map(str::trim).filter(|value| !value.is_empty()) { + Some(value) => json5::from_str::(value) + .map_err(|error| format!("OpenClaw openclaw.json 格式无效: {error}"))?, + None => serde_json::json!({}), + }; + let root = root + .as_object_mut() + .ok_or_else(|| "OpenClaw openclaw.json 根节点必须是对象".to_string())?; + let models = root + .entry("models".to_string()) + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + .ok_or_else(|| "OpenClaw models 必须是对象".to_string())?; + models + .entry("mode".to_string()) + .or_insert_with(|| serde_json::json!("merge")); + let providers = models + .entry("providers".to_string()) + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + .ok_or_else(|| "OpenClaw models.providers 必须是对象".to_string())?; + providers.insert( + MANAGED_AGENT_PROVIDER_ID.to_string(), + serde_json::json!({ + "baseUrl": base_url, + "apiKey": api_key, + "api": "openai-completions", + "models": [{ "id": model, "name": model }] + }), + ); + let agents = root + .entry("agents".to_string()) + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + .ok_or_else(|| "OpenClaw agents 必须是对象".to_string())?; + let defaults = agents + .entry("defaults".to_string()) + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + .ok_or_else(|| "OpenClaw agents.defaults 必须是对象".to_string())?; + let default_model = defaults + .entry("model".to_string()) + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + .ok_or_else(|| "OpenClaw agents.defaults.model 必须是对象".to_string())?; + default_model.insert( + "primary".to_string(), + serde_json::json!(format!("{MANAGED_AGENT_PROVIDER_ID}/{model}")), + ); + render_agent_json(root.clone(), "OpenClaw 配置") +} + +fn build_hermes_agent_config( + existing: Option<&str>, + base_url: &str, + api_key: &str, + model: &str, +) -> Result { + let mut root = match existing.map(str::trim).filter(|value| !value.is_empty()) { + Some(value) => serde_yaml::from_str::(value) + .map_err(|error| format!("Hermes config.yaml 格式无效: {error}"))?, + None => serde_yaml::Value::Mapping(serde_yaml::Mapping::new()), + }; + let mapping = root + .as_mapping_mut() + .ok_or_else(|| "Hermes config.yaml 根节点必须是映射".to_string())?; + let providers = mapping + .entry(serde_yaml::Value::String("custom_providers".to_string())) + .or_insert_with(|| serde_yaml::Value::Sequence(Vec::new())) + .as_sequence_mut() + .ok_or_else(|| "Hermes custom_providers 必须是数组".to_string())?; + providers.retain(|provider| { + provider.get("name").and_then(serde_yaml::Value::as_str) != Some(MANAGED_AGENT_PROVIDER_ID) + }); + let mut provider_models = serde_json::Map::new(); + provider_models.insert(model.to_string(), serde_json::json!({})); + providers.push( + serde_yaml::to_value(serde_json::json!({ + "name": MANAGED_AGENT_PROVIDER_ID, + "base_url": base_url, + "api_key": api_key, + "api_mode": "chat_completions", + "model": model, + "models": provider_models + })) + .map_err(|error| format!("生成 Hermes provider 失败: {error}"))?, + ); + let model_config = mapping + .entry(serde_yaml::Value::String("model".to_string())) + .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new())) + .as_mapping_mut() + .ok_or_else(|| "Hermes model 必须是映射".to_string())?; + model_config.insert( + serde_yaml::Value::String("default".to_string()), + serde_yaml::Value::String(model.to_string()), + ); + model_config.insert( + serde_yaml::Value::String("provider".to_string()), + serde_yaml::Value::String(MANAGED_AGENT_PROVIDER_ID.to_string()), + ); + let rendered = + serde_yaml::to_string(&root).map_err(|error| format!("生成 Hermes 配置失败: {error}"))?; + serde_yaml::from_str::(&rendered) + .map_err(|error| format!("验证 Hermes 配置失败: {error}"))?; + Ok(rendered) +} + +fn agent_backup_path(path: &Path) -> Result { + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| format!("智能体配置文件名无效: {}", path_to_string(path)))?; + Ok(path.with_file_name(format!("{file_name}.cpa-gui.backup"))) +} + +fn agent_state_path(paths: &[PathBuf]) -> Result { + let primary = paths + .first() + .ok_or_else(|| "当前平台没有可用的智能体配置路径".to_string())?; + let file_name = primary + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| format!("智能体配置文件名无效: {}", path_to_string(primary)))?; + Ok(primary.with_file_name(format!("{file_name}.cpa-gui.state.json"))) +} + +fn sha256_bytes(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn read_agent_bytes(path: &Path) -> Result>, String> { + if !path.is_file() { + return Ok(None); + } + fs::read(path) + .map(Some) + .map_err(|error| format!("读取智能体配置失败 {}: {error}", path_to_string(path))) +} + +fn write_agent_state(path: &Path, record: &AgentModificationRecord) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!("创建智能体状态目录失败 {}: {error}", path_to_string(parent)) + })?; + } + let mut content = serde_json::to_string_pretty(record) + .map_err(|error| format!("生成智能体备份状态失败: {error}"))?; + content.push('\n'); + write_yaml_if_changed(path, &content).map(|_| ()) +} + +fn validate_agent_record( + client: AgentClient, + paths: &[PathBuf], + record: &AgentModificationRecord, +) -> Result<(), String> { + if record.version != AGENT_MODIFICATION_STATE_VERSION || record.client != client.id() { + return Err("智能体备份状态版本或客户端不匹配".to_string()); + } + if ![ + AGENT_PHASE_APPLYING, + AGENT_PHASE_ACTIVE, + AGENT_PHASE_RESTORING, + AGENT_PHASE_RECOVERY, + ] + .contains(&record.phase.as_str()) + { + return Err("智能体备份状态阶段无效".to_string()); + } + let expected_paths = expected_agent_record_paths(client, paths); + let record_paths = record + .files + .iter() + .map(|file| file.path.clone()) + .collect::>(); + let valid_paths = record_paths.as_slice() == paths + || (client == AgentClient::Codex && record_paths == expected_paths); + if !valid_paths { + return Err("智能体备份状态文件数量或路径不匹配".to_string()); + } + for file in &record.files { + if file.backup_path != agent_backup_path(&file.path)? { + return Err("智能体备份状态包含非预期路径".to_string()); + } + } + Ok(()) +} + +fn load_agent_record( + client: AgentClient, + paths: &[PathBuf], +) -> Result, String> { + let state_path = agent_state_path(paths)?; + if !state_path.is_file() { + return Ok(None); + } + let record: AgentModificationRecord = serde_json::from_str( + &fs::read_to_string(&state_path) + .map_err(|error| format!("读取智能体备份状态失败: {error}"))?, + ) + .map_err(|error| format!("解析智能体备份状态失败: {error}"))?; + validate_agent_record(client, paths, &record)?; + Ok(Some(record)) +} + +fn record_backup_available(record: &AgentModificationRecord) -> bool { + record + .files + .iter() + .all(|file| !file.existed_before || file.backup_path.is_file()) +} + +fn record_conflict_files(record: &AgentModificationRecord) -> Result, String> { + let mut conflicts = Vec::new(); + for file in &record.files { + let current = read_agent_bytes(&file.path)?; + let matches = + current.as_deref().map(sha256_bytes).as_deref() == Some(file.managed_sha256.as_str()); + if !matches { + conflicts.push(path_to_string(&file.path)); + } + } + Ok(conflicts) +} + +fn record_restore_conflict_files(record: &AgentModificationRecord) -> Result, String> { + let mut conflicts = Vec::new(); + for file in &record.files { + let current = read_agent_bytes(&file.path)?; + let matches_managed = + current.as_deref().map(sha256_bytes).as_deref() == Some(file.managed_sha256.as_str()); + let matches_original = if file.existed_before { + current.as_deref().map(sha256_bytes) == file.original_sha256 + } else { + current.is_none() + }; + if !matches_managed && !matches_original { + conflicts.push(path_to_string(&file.path)); + } + } + Ok(conflicts) +} + +fn record_matches_original(record: &AgentModificationRecord) -> Result { + for file in &record.files { + let current = read_agent_bytes(&file.path)?; + let matches = if file.existed_before { + current.as_deref().map(sha256_bytes) == file.original_sha256 + } else { + current.is_none() + }; + if !matches { + return Ok(false); + } + } + Ok(true) +} + +fn inspect_agent_modification( + client: AgentClient, + home: &Path, + port: u16, + configured: bool, + current_model: Option<&str>, +) -> AgentModificationInspection { + let paths = agent_config_paths(client, home); + let state_path = match agent_state_path(&paths) { + Ok(path) => path, + Err(_) => { + return AgentModificationInspection { + enabled: false, + state: "inactive".to_string(), + backup_available: false, + applied_model: current_model.map(str::to_string), + warnings: Vec::new(), + } + } + }; + + if state_path.is_file() { + return match load_agent_record(client, &paths) { + Ok(Some(record)) => { + let backup_available = record_backup_available(&record); + let conflicts = record_conflict_files(&record); + let state = if record.phase != AGENT_PHASE_ACTIVE { + "recovery" + } else { + match conflicts { + Ok(conflicts) if conflicts.is_empty() => "active", + Ok(_) => "conflict", + Err(_) => "recovery", + } + }; + let mut warnings = Vec::new(); + if !backup_available { + warnings.push("原配置备份不完整,恢复前请勿删除剩余备份文件".to_string()); + } + if state == "conflict" { + warnings.push("配置已被其他程序修改,关闭接管时需要确认强制恢复".to_string()); + } else if state == "recovery" { + warnings.push("上次配置操作未完整结束,可关闭开关恢复原配置".to_string()); + } + AgentModificationInspection { + enabled: true, + state: state.to_string(), + backup_available, + applied_model: Some(record.model), + warnings, + } + } + Ok(None) => AgentModificationInspection { + enabled: false, + state: "inactive".to_string(), + backup_available: false, + applied_model: current_model.map(str::to_string), + warnings: Vec::new(), + }, + Err(error) => AgentModificationInspection { + enabled: true, + state: "recovery".to_string(), + backup_available: expected_agent_record_paths(client, &paths) + .iter() + .filter_map(|path| agent_backup_path(path).ok()) + .any(|path| path.is_file()), + applied_model: current_model.map(str::to_string), + warnings: vec![error], + }, + }; + } + + if configured { + if let Some(model) = current_model { + match build_legacy_agent_record(client, home, port, model) { + Ok(Some(record)) => { + return AgentModificationInspection { + enabled: true, + state: "active".to_string(), + backup_available: true, + applied_model: Some(record.model), + warnings: vec![ + "检测到旧版 CPA 配置和备份,可关闭开关恢复原配置".to_string() + ], + } + } + Ok(None) => { + return AgentModificationInspection { + enabled: false, + state: "inactive".to_string(), + backup_available: false, + applied_model: current_model.map(str::to_string), + warnings: vec!["检测到 CPA 配置,但缺少可安全恢复的原始备份".to_string()], + } + } + Err(error) => { + return AgentModificationInspection { + enabled: false, + state: "inactive".to_string(), + backup_available: false, + applied_model: current_model.map(str::to_string), + warnings: vec![error], + } + } + } + } + } + + AgentModificationInspection { + enabled: false, + state: "inactive".to_string(), + backup_available: false, + applied_model: current_model.map(str::to_string), + warnings: Vec::new(), + } +} + +fn fresh_agent_contents( + client: AgentClient, + port: u16, + model: &str, +) -> Result, String> { + let root_base = format!("http://127.0.0.1:{port}"); + let openai_base = format!("{root_base}/v1"); + match client { + AgentClient::ClaudeCode => Ok(vec![build_claude_agent_config( + None, + &root_base, + DEFAULT_API_KEY, + model, + )?]), + AgentClient::ClaudeDesktop => Ok(vec![ + build_claude_desktop_deployment_config(None)?, + build_claude_desktop_deployment_config(None)?, + build_claude_desktop_profile(None, &root_base, DEFAULT_API_KEY, model)?, + build_claude_desktop_meta(None)?, + ]), + AgentClient::Codex => Ok(vec![build_codex_agent_config( + None, + &openai_base, + DEFAULT_API_KEY, + model, + )?]), + AgentClient::OpenCode => Ok(vec![build_opencode_agent_config( + None, + &openai_base, + DEFAULT_API_KEY, + model, + )?]), + AgentClient::OpenClaw => Ok(vec![build_openclaw_agent_config( + None, + &openai_base, + DEFAULT_API_KEY, + model, + )?]), + AgentClient::Hermes => Ok(vec![build_hermes_agent_config( + None, + &openai_base, + DEFAULT_API_KEY, + model, + )?]), + } +} + +fn agent_contents_equal(client: AgentClient, actual: &str, expected: &str) -> bool { + match client { + AgentClient::Codex => { + normalize_codex_config_for_legacy_compare(actual) + == normalize_codex_config_for_legacy_compare(expected) + } + AgentClient::OpenClaw => { + json5::from_str::(actual).ok() + == json5::from_str::(expected).ok() + } + AgentClient::Hermes => { + serde_yaml::from_str::(actual).ok() + == serde_yaml::from_str::(expected).ok() + } + _ => { + serde_json::from_str::(actual).ok() + == serde_json::from_str::(expected).ok() + } + } +} + +fn normalize_codex_config_for_legacy_compare(content: &str) -> Option { + let mut value = toml::from_str::(content).ok()?; + if value + .get("model_catalog_json") + .and_then(toml::Value::as_str) + == Some(CODEX_MODEL_CATALOG_FILE) + { + value.as_table_mut()?.remove("model_catalog_json"); + } + Some(value) +} + +fn build_legacy_agent_record( + client: AgentClient, + home: &Path, + port: u16, + model: &str, +) -> Result, String> { + let paths = agent_config_paths(client, home); + let generated = fresh_agent_contents(client, port, model)?; + if generated.len() != paths.len() { + return Ok(None); + } + let mut files = Vec::new(); + for (index, path) in paths.iter().enumerate() { + let current = read_agent_bytes(path)?; + let Some(current) = current else { + return Ok(None); + }; + let backup_path = agent_backup_path(path)?; + let (existed_before, original_sha256) = if backup_path.is_file() { + let backup = fs::read(&backup_path).map_err(|error| { + format!( + "读取旧版智能体备份失败 {}: {error}", + path_to_string(&backup_path) + ) + })?; + (true, Some(sha256_bytes(&backup))) + } else { + let actual = String::from_utf8(current.clone()) + .map_err(|_| format!("智能体配置不是 UTF-8 文本: {}", path_to_string(path)))?; + if !agent_contents_equal(client, &actual, &generated[index]) { + return Ok(None); + } + (false, None) + }; + files.push(AgentModificationFile { + path: path.clone(), + backup_path, + existed_before, + original_sha256, + managed_sha256: sha256_bytes(¤t), + }); + } + Ok(Some(AgentModificationRecord { + version: AGENT_MODIFICATION_STATE_VERSION, + client: client.id().to_string(), + phase: AGENT_PHASE_ACTIVE.to_string(), + model: model.to_string(), + files, + })) +} + +fn prepare_agent_record( + client: AgentClient, + paths: &[PathBuf], + model: &str, + updates: &[AgentFileUpdate], +) -> Result { + if paths.len() != updates.len() { + return Err("智能体配置更新文件数量不匹配".to_string()); + } + let mut prepared = Vec::new(); + for (path, update) in paths.iter().zip(updates) { + if path != &update.path { + return Err("智能体配置更新路径不匹配".to_string()); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!("创建智能体配置目录失败 {}: {error}", path_to_string(parent)) + })?; + } + let backup_path = agent_backup_path(path)?; + let current = read_agent_bytes(path)?; + let previous_backup = if backup_path.exists() { + if !backup_path.is_file() { + return Err(format!( + "智能体备份路径不是文件: {}", + path_to_string(&backup_path) + )); + } + Some(fs::read(&backup_path).map_err(|error| { + format!( + "读取原有智能体备份失败 {}: {error}", + path_to_string(&backup_path) + ) + })?) + } else { + None + }; + + prepared.push(( + path.clone(), + backup_path, + current, + previous_backup, + sha256_bytes(update.after.as_bytes()), + )); + } + + let backup_snapshots = prepared + .iter() + .map(|(_, backup_path, _, previous_backup, _)| { + (backup_path.clone(), previous_backup.clone()) + }) + .collect::>(); + let mut files = Vec::new(); + for (path, backup_path, current, _, managed_sha256) in prepared { + let backup_result = if let Some(current) = current.as_deref() { + write_bytes_atomically(&backup_path, current).and_then(|_| { + let copied = fs::read(&backup_path).map_err(|error| { + format!( + "校验智能体备份失败 {}: {error}", + path_to_string(&backup_path) + ) + })?; + if sha256_bytes(&copied) != sha256_bytes(current) { + return Err(format!("智能体备份校验失败: {}", path_to_string(&path))); + } + Ok(()) + }) + } else if backup_path.exists() { + fs::remove_file(&backup_path).map_err(|error| { + format!( + "清理旧智能体备份失败 {}: {error}", + path_to_string(&backup_path) + ) + }) + } else { + Ok(()) + }; + if let Err(error) = backup_result { + let rollback = restore_snapshots(&backup_snapshots); + return Err(match rollback { + Ok(()) => error, + Err(rollback_error) => format!("{error};恢复原有备份失败: {rollback_error}"), + }); + } + + let existed_before = current.is_some(); + let original_sha256 = current.as_deref().map(sha256_bytes); + files.push(AgentModificationFile { + path, + backup_path, + existed_before, + original_sha256, + managed_sha256, + }); + } + Ok(AgentModificationRecord { + version: AGENT_MODIFICATION_STATE_VERSION, + client: client.id().to_string(), + phase: AGENT_PHASE_APPLYING.to_string(), + model: model.to_string(), + files, + }) +} + +fn extend_agent_record_for_updates( + record: &AgentModificationRecord, + updates: &[AgentFileUpdate], +) -> Result<(AgentModificationRecord, Vec<(PathBuf, Option>)>), String> { + if record.files.len() > updates.len() { + return Err("智能体配置更新文件数量不匹配".to_string()); + } + for (file, update) in record.files.iter().zip(updates) { + if file.path != update.path { + return Err("智能体配置更新路径不匹配".to_string()); + } + } + if record.files.len() == updates.len() { + return Ok((record.clone(), Vec::new())); + } + + let mut prepared = Vec::new(); + for update in updates.iter().skip(record.files.len()) { + if let Some(parent) = update.path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!("创建智能体配置目录失败 {}: {error}", path_to_string(parent)) + })?; + } + let backup_path = agent_backup_path(&update.path)?; + let previous_backup = if backup_path.exists() { + if !backup_path.is_file() { + return Err(format!( + "智能体备份路径不是文件: {}", + path_to_string(&backup_path) + )); + } + Some(fs::read(&backup_path).map_err(|error| { + format!( + "读取原有智能体备份失败 {}: {error}", + path_to_string(&backup_path) + ) + })?) + } else { + None + }; + let current = read_agent_bytes(&update.path)?; + prepared.push((update, backup_path, previous_backup, current)); + } + + let backup_snapshots = prepared + .iter() + .map(|(_, backup_path, previous_backup, _)| (backup_path.clone(), previous_backup.clone())) + .collect::>(); + let mut next = record.clone(); + for (update, backup_path, _, current) in prepared { + let backup_result = if let Some(current) = current.as_deref() { + write_bytes_atomically(&backup_path, current).and_then(|_| { + let copied = fs::read(&backup_path).map_err(|error| { + format!( + "校验智能体备份失败 {}: {error}", + path_to_string(&backup_path) + ) + })?; + if sha256_bytes(&copied) != sha256_bytes(current) { + return Err(format!( + "智能体备份校验失败: {}", + path_to_string(&update.path) + )); + } + Ok(()) + }) + } else if backup_path.exists() { + fs::remove_file(&backup_path).map_err(|error| { + format!( + "清理旧智能体备份失败 {}: {error}", + path_to_string(&backup_path) + ) + }) + } else { + Ok(()) + }; + if let Err(error) = backup_result { + let rollback = restore_snapshots(&backup_snapshots); + return Err(match rollback { + Ok(()) => error, + Err(rollback_error) => format!("{error};恢复原有备份失败: {rollback_error}"), + }); + } + next.files.push(AgentModificationFile { + path: update.path.clone(), + backup_path, + existed_before: current.is_some(), + original_sha256: current.as_deref().map(sha256_bytes), + managed_sha256: sha256_bytes(update.after.as_bytes()), + }); + } + Ok((next, backup_snapshots)) +} + +fn restore_snapshots(snapshots: &[(PathBuf, Option>)]) -> Result<(), String> { + let mut errors = Vec::new(); + for (path, bytes) in snapshots.iter().rev() { + let result = match bytes { + Some(bytes) => write_bytes_atomically(path, bytes), + None if path.exists() => fs::remove_file(path) + .map_err(|error| format!("删除配置失败 {}: {error}", path_to_string(path))), + None => Ok(()), + }; + if let Err(error) = result { + errors.push(error); + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join(";")) + } +} + +fn apply_agent_updates(updates: &[AgentFileUpdate]) -> Result, String> { + let snapshots = updates + .iter() + .map(|update| Ok((update.path.clone(), read_agent_bytes(&update.path)?))) + .collect::, String>>()?; + let mut changed = Vec::new(); + for update in updates { + let next = update.after.as_bytes(); + if read_agent_bytes(&update.path)?.as_deref() == Some(next) { + continue; + } + if let Err(error) = write_bytes_atomically(&update.path, next) { + let rollback = restore_snapshots(&snapshots); + return Err(match rollback { + Ok(()) => error, + Err(rollback_error) => format!("{error};回滚失败: {rollback_error}"), + }); + } + changed.push(path_to_string(&update.path)); + } + Ok(changed) +} + +fn restore_agent_record_files(record: &AgentModificationRecord) -> Result, String> { + let snapshots = record + .files + .iter() + .map(|file| Ok((file.path.clone(), read_agent_bytes(&file.path)?))) + .collect::, String>>()?; + let mut changed = Vec::new(); + for file in &record.files { + let result = if file.existed_before { + let backup = fs::read(&file.backup_path).map_err(|error| { + format!( + "读取原配置备份失败 {}: {error}", + path_to_string(&file.backup_path) + ) + })?; + if Some(sha256_bytes(&backup)) != file.original_sha256 { + return Err(format!( + "原配置备份校验失败: {}", + path_to_string(&file.backup_path) + )); + } + if read_agent_bytes(&file.path)?.as_deref() == Some(backup.as_slice()) { + Ok(()) + } else { + changed.push(path_to_string(&file.path)); + write_bytes_atomically(&file.path, &backup) + } + } else if file.path.exists() { + changed.push(path_to_string(&file.path)); + fs::remove_file(&file.path).map_err(|error| { + format!("删除智能体配置失败 {}: {error}", path_to_string(&file.path)) + }) + } else { + Ok(()) + }; + if let Err(error) = result { + let rollback = restore_snapshots(&snapshots); + return Err(match rollback { + Ok(()) => error, + Err(rollback_error) => format!("{error};回滚失败: {rollback_error}"), + }); + } + } + Ok(changed) +} + +fn discard_prepared_agent_backups(record: &AgentModificationRecord) -> Result<(), String> { + let mut errors = Vec::new(); + for file in &record.files { + if file.backup_path.exists() { + if let Err(error) = fs::remove_file(&file.backup_path) { + errors.push(format!( + "删除未启用的智能体备份失败 {}: {error}", + path_to_string(&file.backup_path) + )); + } + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join(";")) + } +} + +fn cleanup_agent_record(state_path: &Path, record: &AgentModificationRecord) -> Result<(), String> { + if state_path.exists() { + fs::remove_file(state_path).map_err(|error| { + format!( + "删除智能体备份状态失败 {}: {error}", + path_to_string(state_path) + ) + })?; + } + for file in &record.files { + if file.backup_path.exists() { + if let Err(error) = fs::remove_file(&file.backup_path) { + eprintln!( + "清理智能体备份失败 {}: {error}", + path_to_string(&file.backup_path) + ); + } + } + } + Ok(()) +} + +fn action_result( + outcome: &str, + enabled: bool, + model: Option, + changed_files: Vec, + conflict_files: Vec, +) -> AgentConfigActionResult { + AgentConfigActionResult { + outcome: outcome.to_string(), + enabled, + model, + changed_files, + conflict_files, + } +} + +fn enable_agent_modification( + client: AgentClient, + home: &Path, + port: u16, + model: &str, + codex_models: Option<&[CodexModelCatalogSpec]>, +) -> Result { + let paths = agent_config_paths(client, home); + let state_path = agent_state_path(&paths)?; + if let Some(record) = load_agent_record(client, &paths)? { + return Ok(action_result( + "enabled", + true, + Some(record.model), + Vec::new(), + Vec::new(), + )); + } + let (_, current_model) = inspect_agent_managed_config(client, &paths, port)?; + if agent_has_managed_marker(client, &paths)? { + if let Some(current_model) = current_model.as_deref() { + if let Some(record) = build_legacy_agent_record(client, home, port, current_model)? { + write_agent_state(&state_path, &record)?; + return Ok(action_result( + "enabled", + true, + Some(record.model), + Vec::new(), + Vec::new(), + )); + } + } + return Err( + "检测到 CPA 配置,但缺少可安全恢复的原始备份;请先手动恢复客户端配置".to_string(), + ); + } + + let updates = build_agent_updates(client, home, port, model, codex_models)?; + let update_paths = updates + .iter() + .map(|update| update.path.clone()) + .collect::>(); + let mut record = prepare_agent_record(client, &update_paths, model, &updates)?; + if let Err(error) = write_agent_state(&state_path, &record) { + let cleanup = discard_prepared_agent_backups(&record); + return Err(match cleanup { + Ok(()) => error, + Err(cleanup_error) => format!("{error};{cleanup_error}"), + }); + } + match apply_agent_updates(&updates) { + Ok(changed) => { + record.phase = AGENT_PHASE_ACTIVE.to_string(); + write_agent_state(&state_path, &record)?; + Ok(action_result( + "enabled", + true, + Some(model.to_string()), + changed, + Vec::new(), + )) + } + Err(error) => match restore_agent_record_files(&record) { + Ok(_) => { + let _ = cleanup_agent_record(&state_path, &record); + Err(error) + } + Err(restore_error) => { + record.phase = AGENT_PHASE_RECOVERY.to_string(); + let _ = write_agent_state(&state_path, &record); + Err(format!("{error};恢复原配置失败: {restore_error}")) + } + }, + } +} + +fn disable_agent_modification( + client: AgentClient, + home: &Path, + port: u16, + force_restore: bool, +) -> Result { + let paths = agent_config_paths(client, home); + let state_path = agent_state_path(&paths)?; + let mut record = match load_agent_record(client, &paths)? { + Some(record) => record, + None => { + let (_, model) = inspect_agent_managed_config(client, &paths, port)?; + if !agent_has_managed_marker(client, &paths)? { + return Ok(action_result( + "disabled", + false, + None, + Vec::new(), + Vec::new(), + )); + } + let model = model.ok_or_else(|| "无法识别当前 CPA 模型".to_string())?; + let record = build_legacy_agent_record(client, home, port, &model)? + .ok_or_else(|| "检测到 CPA 配置,但缺少可安全恢复的原始备份".to_string())?; + write_agent_state(&state_path, &record)?; + record + } + }; + + if record_matches_original(&record)? { + cleanup_agent_record(&state_path, &record)?; + return Ok(action_result( + "disabled", + false, + None, + Vec::new(), + Vec::new(), + )); + } + + let conflicts = record_restore_conflict_files(&record)?; + if !conflicts.is_empty() && !force_restore { + return Ok(action_result( + "restore-conflict", + true, + Some(record.model), + Vec::new(), + conflicts, + )); + } + + record.phase = AGENT_PHASE_RESTORING.to_string(); + write_agent_state(&state_path, &record)?; + match restore_agent_record_files(&record) { + Ok(changed) => { + cleanup_agent_record(&state_path, &record)?; + Ok(action_result("disabled", false, None, changed, Vec::new())) + } + Err(error) => { + record.phase = AGENT_PHASE_RECOVERY.to_string(); + let _ = write_agent_state(&state_path, &record); + Err(format!("恢复原配置失败: {error}")) + } + } +} + +fn update_agent_modification( + client: AgentClient, + home: &Path, + port: u16, + model: &str, + codex_models: Option<&[CodexModelCatalogSpec]>, +) -> Result { + let paths = agent_config_paths(client, home); + let state_path = agent_state_path(&paths)?; + let record = match load_agent_record(client, &paths)? { + Some(record) => record, + None => { + let (_, current_model) = inspect_agent_managed_config(client, &paths, port)?; + if !agent_has_managed_marker(client, &paths)? { + return Err("请先开启修改配置".to_string()); + } + let current_model = current_model.ok_or_else(|| "无法识别当前 CPA 模型".to_string())?; + let record = build_legacy_agent_record(client, home, port, ¤t_model)? + .ok_or_else(|| "缺少原配置备份,无法安全更新".to_string())?; + write_agent_state(&state_path, &record)?; + record + } + }; + if record.phase != AGENT_PHASE_ACTIVE { + return Err("上次配置操作尚未完整结束,请先关闭开关恢复原配置".to_string()); + } + let conflicts = record_conflict_files(&record)?; + if !conflicts.is_empty() { + return Err(format!( + "配置已被其他程序修改,无法更新: {}", + conflicts.join("、") + )); + } + + let updates = build_agent_updates(client, home, port, model, codex_models)?; + let (mut next, backup_snapshots) = extend_agent_record_for_updates(&record, &updates)?; + next.phase = AGENT_PHASE_APPLYING.to_string(); + next.model = model.to_string(); + for (file, update) in next.files.iter_mut().zip(&updates) { + if file.path != update.path { + return Err("智能体配置更新路径不匹配".to_string()); + } + file.managed_sha256 = sha256_bytes(update.after.as_bytes()); + } + if let Err(error) = write_agent_state(&state_path, &next) { + let rollback = restore_snapshots(&backup_snapshots); + return Err(match rollback { + Ok(()) => error, + Err(rollback_error) => format!("{error};恢复模型目录备份失败: {rollback_error}"), + }); + } + match apply_agent_updates(&updates) { + Ok(changed) => { + next.phase = AGENT_PHASE_ACTIVE.to_string(); + write_agent_state(&state_path, &next)?; + Ok(action_result( + "updated", + true, + Some(model.to_string()), + changed, + Vec::new(), + )) + } + Err(error) => { + let state_rollback = write_agent_state(&state_path, &record).err(); + let backup_rollback = restore_snapshots(&backup_snapshots).err(); + let mut errors = vec![error]; + if let Some(rollback_error) = state_rollback { + errors.push(format!("恢复原状态失败: {rollback_error}")); + } + if let Some(rollback_error) = backup_rollback { + errors.push(format!("恢复模型目录备份失败: {rollback_error}")); + } + Err(errors.join(";")) + } + } +} + #[tauri::command] fn get_lan_ipv4() -> Option { detect_lan_ipv4().map(|address| address.to_string()) @@ -1195,64 +4344,21 @@ async fn fetch_release( client: &reqwest::Client, version: Option<&str>, ) -> Result { - let api_result = fetch_release_from_api(client, version).await; - match api_result { + if let Some(version) = version { + return Ok(release_from_tag(version)); + } + let atom_result = fetch_release_from_atom(client).await; + match atom_result { Ok(release) => Ok(release), - Err(api_error) => fetch_release_from_page(client, version) - .await - .map_err(|page_error| { - format!("{api_error};备用 release 页面请求也失败: {page_error}") - }), + Err(atom_error) => fetch_release_from_page(client).await.map_err(|page_error| { + format!("GitHub 发布源请求失败: {atom_error};release 页面请求失败: {page_error}") + }), } } -async fn fetch_release_from_api( - client: &reqwest::Client, - version: Option<&str>, -) -> Result { - let url = version.map_or_else( - || RELEASE_API_URL.to_string(), - |version| { - format!( - "https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/tags/{}", - normalize_version(version) - ) - }, - ); - +async fn fetch_release_from_page(client: &reqwest::Client) -> Result { let response = client - .get(&url) - .header(reqwest::header::ACCEPT, GITHUB_API_ACCEPT) - .header("X-GitHub-Api-Version", GITHUB_API_VERSION) - .header(reqwest::header::USER_AGENT, USER_AGENT) - .send() - .await - .map_err(|err| format!("GitHub API 请求失败: {err}"))?; - let status = response.status(); - let body = response - .text() - .await - .map_err(|err| format!("读取 GitHub API 响应失败: {err}"))?; - if !status.is_success() { - return Err(format_github_error(status.as_u16(), &body)); - } - serde_json::from_str::(&body).map_err(|err| { - format!( - "解析 GitHub release 失败: {err}; body={}", - truncate_for_error(&body) - ) - }) -} - -async fn fetch_release_from_page( - client: &reqwest::Client, - version: Option<&str>, -) -> Result { - let page_url = version - .map(|value| format!("{}{}/", RELEASE_TAG_PAGE_PREFIX, normalize_version(value))) - .unwrap_or_else(|| RELEASE_PAGE_URL.to_string()); - let response = client - .get(&page_url) + .get(RELEASE_PAGE_URL) .header(reqwest::header::ACCEPT, "text/html,application/xhtml+xml") .header(reqwest::header::USER_AGENT, USER_AGENT) .send() @@ -1260,43 +4366,90 @@ async fn fetch_release_from_page( .map_err(|err| format!("GitHub release 页面请求失败: {err}"))?; let status = response.status(); let final_url = response.url().clone(); - let body = response - .text() - .await - .map_err(|err| format!("读取 GitHub release 页面失败: {err}"))?; if !status.is_success() { + let body = response + .text() + .await + .map_err(|err| format!("读取 GitHub release 页面失败: {err}"))?; return Err(format_github_error(status.as_u16(), &body)); } - let tag = version - .map(normalize_version) - .or_else(|| release_tag_from_url(&final_url)) + let tag = release_tag_from_url(&final_url) .ok_or_else(|| "GitHub release 页面没有返回版本标签".to_string())?; - let assets_url = format!("{}{tag}", RELEASE_ASSETS_PAGE_PREFIX); - let assets_response = client - .get(&assets_url) - .header(reqwest::header::ACCEPT, "text/html,application/xhtml+xml") + Ok(release_from_tag(&tag)) +} + +async fn fetch_release_from_atom(client: &reqwest::Client) -> Result { + let response = client + .get(RELEASE_ATOM_URL) + .header( + reqwest::header::ACCEPT, + "application/atom+xml,application/xml,text/xml", + ) .header(reqwest::header::USER_AGENT, USER_AGENT) .send() .await - .map_err(|err| format!("GitHub release 资产页面请求失败: {err}"))?; - let assets_status = assets_response.status(); - let assets_body = assets_response + .map_err(|err| format!("GitHub Atom feed 请求失败: {err}"))?; + let status = response.status(); + let body = response .text() .await - .map_err(|err| format!("读取 GitHub release 资产页面失败: {err}"))?; - if !assets_status.is_success() { - return Err(format_github_error(assets_status.as_u16(), &assets_body)); + .map_err(|err| format!("读取 GitHub Atom feed 失败: {err}"))?; + if !status.is_success() { + return Err(format_github_error(status.as_u16(), &body)); } + let tag = release_tag_from_atom(&body) + .ok_or_else(|| "GitHub Atom feed 没有返回版本标签".to_string())?; + Ok(release_from_tag(&tag)) +} - let assets = parse_release_assets(&assets_body); - if assets.is_empty() { - return Err("GitHub release 页面没有找到可下载资产".to_string()); +fn release_tag_from_atom(xml: &str) -> Option { + let entry = xml.split_once("")?.1; + if let Some(tag_path) = entry.split_once("/releases/tag/").map(|(_, value)| value) { + let tag = tag_path + .split(['\"', '<', '?', '#']) + .next() + .unwrap_or_default() + .trim_matches('/'); + if !tag.is_empty() { + return Some(normalize_version(tag)); + } } - Ok(GithubRelease { + let title = entry + .split_once("")? + .1 + .split_once("")? + .0 + .trim(); + (!title.is_empty()).then(|| normalize_version(title)) +} + +fn release_from_tag(tag: &str) -> GithubRelease { + let tag = normalize_version(tag); + let version = tag.trim_start_matches('v'); + let assets = [ + ("linux", "amd64", "tar.gz"), + ("linux", "aarch64", "tar.gz"), + ("darwin", "amd64", "tar.gz"), + ("darwin", "aarch64", "tar.gz"), + ("windows", "amd64", "zip"), + ("windows", "aarch64", "zip"), + ] + .into_iter() + .map(|(os, arch, extension)| { + let name = format!("CLIProxyAPI_{version}_{os}_{arch}.{extension}"); + GithubAsset { + browser_download_url: format!("{RELEASE_DOWNLOAD_PREFIX}{tag}/{name}"), + name, + size: None, + digest: None, + } + }) + .collect(); + GithubRelease { tag_name: tag, assets, - }) + } } fn release_tag_from_url(url: &reqwest::Url) -> Option { @@ -1309,6 +4462,7 @@ fn release_tag_from_url(url: &reqwest::Url) -> Option { } } +#[cfg(test)] fn parse_release_assets(html: &str) -> Vec { let mut assets = Vec::new(); let mut cursor = 0; @@ -1739,9 +4893,9 @@ fn patch_core_network_settings(config: &GuiConfigFile) -> Result<(), String> { } fn patch_core_auth_dir(auth_dir: &str) -> Result<(), String> { - patch_existing_core_config(|document| { - document.set("auth-dir", auth_dir.to_string()); - Ok(()) + let auth_dir = auth_dir.to_string(); + patch_existing_core_config(move |document| { + set_core_yaml_top_level_value(document, "auth-dir", serde_norway::Value::String(auth_dir)) }) } @@ -1787,29 +4941,41 @@ fn patch_core_management_secret_key(secret_key: &str) -> Result<(), String> { let _ = secret_key; let secret_key = DEFAULT_MANAGEMENT_SECRET_KEY.to_string(); patch_existing_core_config(move |document| { - set_yaml_edit_nested_value(document, "remote-management", "secret-key", secret_key); - Ok(()) + set_core_yaml_nested_value( + document, + "remote-management", + "secret-key", + serde_norway::Value::String(secret_key), + ) }) } fn patch_core_plugins_enabled(enabled: bool) -> Result<(), String> { patch_existing_core_config(|document| { - set_yaml_edit_nested_value(document, "plugins", "enabled", enabled); - Ok(()) + set_core_yaml_nested_value( + document, + "plugins", + "enabled", + serde_norway::Value::Bool(enabled), + ) }) } fn patch_core_routing_strategy(strategy: &str) -> Result<(), String> { let strategy = strategy.to_string(); patch_existing_core_config(move |document| { - set_yaml_edit_nested_value(document, "routing", "strategy", strategy); - Ok(()) + set_core_yaml_nested_value( + document, + "routing", + "strategy", + serde_norway::Value::String(strategy), + ) }) } fn patch_existing_core_config(update: F) -> Result<(), String> where - F: FnOnce(&yaml_edit::Document) -> Result<(), String>, + F: FnOnce(&mut serde_norway::Value) -> Result, { let _config_guard = lock_core_config_file()?; let config_path = core_install_dir()?.join(CORE_CONFIG_FILE); @@ -1819,22 +4985,70 @@ where let content = fs::read_to_string(&config_path) .map_err(|err| format!("读取内核配置失败 {}: {err}", path_to_string(&config_path)))?; - let file = content - .parse::() - .map_err(|err| format!("解析内核配置失败: {err}"))?; - let document = file - .document() - .ok_or_else(|| "内核配置没有 YAML 文档".to_string())?; - update(&document)?; - let updated = file.to_string(); - serde_norway::from_str::(&updated) - .map_err(|err| format!("验证更新后的内核配置失败: {err}"))?; + let Some(updated) = patch_core_yaml_document(&content, update)? else { + return Ok(()); + }; write_yaml_if_changed(&config_path, &updated)?; Ok(()) } +fn patch_core_yaml_document(content: &str, update: F) -> Result, String> +where + F: FnOnce(&mut serde_norway::Value) -> Result, +{ + let mut document = yaml_serde_edit::YamlValue::parse(content) + .map_err(|err| format!("解析内核配置失败: {err}"))?; + let mut updated_value = document.get().clone(); + if !update(&mut updated_value)? { + return Ok(None); + } + document.set(updated_value); + let updated = document.get_string(); + serde_norway::from_str::(&updated) + .map_err(|err| format!("验证更新后的内核配置失败: {err}"))?; + Ok(Some(updated)) +} + +fn set_core_yaml_top_level_value( + document: &mut serde_norway::Value, + key: &str, + value: serde_norway::Value, +) -> Result { + let root = document + .as_mapping_mut() + .ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?; + let key = yaml_key(key); + if root.get(&key) == Some(&value) { + return Ok(false); + } + root.insert(key, value); + Ok(true) +} + +fn set_core_yaml_nested_value( + document: &mut serde_norway::Value, + section: &str, + key: &str, + value: serde_norway::Value, +) -> Result { + let root = document + .as_mapping_mut() + .ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?; + let section = root + .entry(yaml_key(section)) + .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())) + .as_mapping_mut() + .ok_or_else(|| "内核配置区段必须是 YAML 映射".to_string())?; + let key = yaml_key(key); + if section.get(&key) == Some(&value) { + return Ok(false); + } + section.insert(key, value); + Ok(true) +} + fn patch_core_api_keys_yaml(content: &str, api_keys: &[String]) -> Result { let file = content .parse::() @@ -1960,6 +5174,7 @@ fn clear_legacy_api_key_paths(document: &yaml_edit::Document) { document.remove_path("auth.providers.config-api-key.api-keys"); } +#[cfg(test)] fn set_yaml_edit_nested_value( document: &yaml_edit::Document, section: &str, @@ -2189,7 +5404,6 @@ fn yaml_mapping_value<'a>( mapping.get(yaml_key(key)) } -#[cfg(test)] fn yaml_mapping_value_mut<'a>( mapping: &'a mut serde_norway::Mapping, key: &str, @@ -2201,6 +5415,791 @@ fn yaml_key(key: &str) -> serde_norway::Value { serde_norway::Value::String(key.to_string()) } +fn validate_thinking_alias_model_id(value: &str, label: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err(format!("{label}不能为空")); + } + if value.len() > 240 + || value + .chars() + .any(|character| character.is_whitespace() || character.is_control()) + { + return Err(format!("{label}格式无效,不能包含空白字符")); + } + Ok(value.to_string()) +} + +fn validate_thinking_alias_effort(value: &str) -> Result { + let effort = value.trim().to_ascii_lowercase(); + if effort.is_empty() { + return Err("思考强度不能为空".to_string()); + } + if effort.chars().all(|character| character.is_ascii_digit()) { + return Err("固定思考别名不支持纯数字预算,请输入思考等级名称".to_string()); + } + if effort.len() > 64 + || !effort.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') + }) + { + return Err("思考强度格式无效,仅支持字母、数字、短横线、下划线和点".to_string()); + } + Ok(effort) +} + +async fn fetch_management_config_yaml(config: &GuiConfigFile) -> Result { + let client = management_http_client()?; + let response = client + .get(management_endpoint(config, "config.yaml")?) + .header("Authorization", management_authorization(config)?) + .header( + reqwest::header::ACCEPT, + "application/yaml,text/yaml,text/plain", + ) + .send() + .await + .map_err(|error| format!("读取内核 YAML 配置失败: {error}"))?; + read_management_text(response).await +} + +async fn put_management_config_yaml(config: &GuiConfigFile, content: &str) -> Result<(), String> { + let client = management_http_client()?; + let response = client + .put(management_endpoint(config, "config.yaml")?) + .header("Authorization", management_authorization(config)?) + .header(reqwest::header::CONTENT_TYPE, "application/yaml") + .body(content.to_string()) + .send() + .await + .map_err(|error| format!("保存内核 YAML 配置失败: {error}"))?; + read_management_value(response).await.map(|_| ()) +} + +async fn fetch_codex_model_definitions( + config: &GuiConfigFile, +) -> Result, String> { + let client = management_http_client()?; + let response = client + .get(management_endpoint(config, "model-definitions/codex")?) + .header("Authorization", management_authorization(config)?) + .header(reqwest::header::ACCEPT, "application/json") + .send() + .await + .map_err(|error| format!("读取 Codex OAuth 模型定义失败: {error}"))?; + let payload = read_management_value(response).await?; + parse_codex_model_definitions(&payload) +} + +fn resolved_thinking_alias_sources( + content: &str, + definitions: &[CodexModelDefinition], +) -> Result, String> { + let document = serde_norway::from_str::(content) + .map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?; + let root = document + .as_mapping() + .ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?; + let mut sources = definitions + .iter() + .filter(|definition| !definition.reasoning_levels.is_empty()) + .map(|definition| ResolvedThinkingAliasSource { + source: ThinkingAliasSource { + id: format!("codex-oauth:{}", definition.id), + model: definition.id.clone(), + display_name: definition.display_name.clone(), + provider: "Codex OAuth".to_string(), + kind: "codex-oauth".to_string(), + protocol: "codex".to_string(), + }, + location: ThinkingAliasSourceLocation::CodexOauth, + }) + .collect::>(); + collect_config_thinking_alias_sources( + root, + "codex-api-key", + "Codex API", + "codex-api", + "codex", + &mut sources, + )?; + collect_config_thinking_alias_sources( + root, + "openai-compatibility", + "OpenAI 兼容", + "openai-compatible", + "openai", + &mut sources, + )?; + Ok(sources) +} + +fn collect_config_thinking_alias_sources( + root: &serde_norway::Mapping, + section: &'static str, + fallback_provider: &str, + kind: &str, + protocol: &str, + sources: &mut Vec, +) -> Result<(), String> { + let Some(providers) = yaml_mapping_value(root, section) else { + return Ok(()); + }; + let providers = providers + .as_sequence() + .ok_or_else(|| format!("{section} 必须是数组"))?; + for (provider_index, provider) in providers.iter().enumerate() { + let Some(provider) = provider.as_mapping() else { + continue; + }; + if matches!( + yaml_mapping_value(provider, "disabled"), + Some(serde_norway::Value::Bool(true)) + ) { + continue; + } + let provider_name = + thinking_alias_provider_name(provider, fallback_provider, provider_index); + let Some(models) = yaml_mapping_value(provider, "models") else { + continue; + }; + let models = models + .as_sequence() + .ok_or_else(|| format!("{section}.models 必须是数组"))?; + for (model_index, model) in models.iter().enumerate() { + let Some((upstream_model, client_model, display_name)) = + configured_model_identity(model) + else { + continue; + }; + if client_model != upstream_model + && find_thinking_alias_effort(root, &client_model, protocol).is_some() + { + continue; + } + sources.push(ResolvedThinkingAliasSource { + source: ThinkingAliasSource { + id: format!("{section}:{provider_index}:{model_index}"), + model: client_model, + display_name, + provider: provider_name.clone(), + kind: kind.to_string(), + protocol: protocol.to_string(), + }, + location: ThinkingAliasSourceLocation::ConfigModel { + section, + provider_index, + model_index, + }, + }); + } + } + Ok(()) +} + +fn thinking_alias_provider_name( + provider: &serde_norway::Mapping, + fallback: &str, + index: usize, +) -> String { + yaml_mapping_value(provider, "name") + .or_else(|| yaml_mapping_value(provider, "base-url")) + .and_then(serde_norway::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("{fallback} {}", index + 1)) +} + +fn configured_model_identity( + model: &serde_norway::Value, +) -> Option<(String, String, Option)> { + if let Some(name) = model + .as_str() + .map(str::trim) + .filter(|name| !name.is_empty()) + { + return Some((name.to_string(), name.to_string(), None)); + } + let model = model.as_mapping()?; + let name = yaml_mapping_value(model, "name") + .and_then(serde_norway::Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty())?; + let alias = yaml_mapping_value(model, "alias") + .and_then(serde_norway::Value::as_str) + .map(str::trim) + .filter(|alias| !alias.is_empty()); + let display_name = yaml_mapping_value(model, "display-name") + .or_else(|| yaml_mapping_value(model, "display_name")) + .and_then(serde_norway::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + Some(( + name.to_string(), + alias.unwrap_or(name).to_string(), + display_name, + )) +} + +fn thinking_aliases_from_yaml(content: &str) -> Result, String> { + let document = serde_norway::from_str::(content) + .map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?; + thinking_aliases_from_value(&document) +} + +fn thinking_aliases_from_value( + document: &serde_norway::Value, +) -> Result, String> { + let root = document + .as_mapping() + .ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?; + let mut entries = Vec::new(); + if let Some(oauth_aliases) = yaml_mapping_value(root, "oauth-model-alias") { + let oauth_aliases = oauth_aliases + .as_mapping() + .ok_or_else(|| "oauth-model-alias 必须是 YAML 映射".to_string())?; + if let Some(codex_aliases) = yaml_mapping_value(oauth_aliases, "codex") { + let codex_aliases = codex_aliases + .as_sequence() + .ok_or_else(|| "oauth-model-alias.codex 必须是数组".to_string())?; + for entry in codex_aliases { + let Some(mapping) = entry.as_mapping() else { + continue; + }; + if !matches!( + yaml_mapping_value(mapping, "fork"), + Some(serde_norway::Value::Bool(true)) + ) { + continue; + } + let Some(source_model) = yaml_mapping_value(mapping, "name") + .and_then(serde_norway::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + continue; + }; + let Some(alias) = yaml_mapping_value(mapping, "alias") + .and_then(serde_norway::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + continue; + }; + entries.push(ThinkingAliasEntry { + source_model: source_model.to_string(), + alias: alias.to_string(), + effort: find_thinking_alias_effort(root, alias, "codex"), + provider: "Codex OAuth".to_string(), + kind: "codex-oauth".to_string(), + }); + } + } + } + collect_config_thinking_alias_entries( + root, + "codex-api-key", + "Codex API", + "codex-api", + "codex", + &mut entries, + )?; + collect_config_thinking_alias_entries( + root, + "openai-compatibility", + "OpenAI 兼容", + "openai-compatible", + "openai", + &mut entries, + )?; + entries.sort_by(|left, right| { + left.provider + .to_ascii_lowercase() + .cmp(&right.provider.to_ascii_lowercase()) + .then_with(|| { + left.alias + .to_ascii_lowercase() + .cmp(&right.alias.to_ascii_lowercase()) + }) + }); + Ok(entries) +} + +fn collect_config_thinking_alias_entries( + root: &serde_norway::Mapping, + section: &str, + fallback_provider: &str, + kind: &str, + protocol: &str, + entries: &mut Vec, +) -> Result<(), String> { + let Some(providers) = yaml_mapping_value(root, section) else { + return Ok(()); + }; + let providers = providers + .as_sequence() + .ok_or_else(|| format!("{section} 必须是数组"))?; + for (provider_index, provider) in providers.iter().enumerate() { + let Some(provider) = provider.as_mapping() else { + continue; + }; + let provider_name = + thinking_alias_provider_name(provider, fallback_provider, provider_index); + let Some(models) = yaml_mapping_value(provider, "models") else { + continue; + }; + let models = models + .as_sequence() + .ok_or_else(|| format!("{section}.models 必须是数组"))?; + for model in models { + let Some((source_model, alias, _)) = configured_model_identity(model) else { + continue; + }; + if source_model == alias { + continue; + } + let Some(effort) = find_thinking_alias_effort(root, &alias, protocol) else { + continue; + }; + entries.push(ThinkingAliasEntry { + source_model, + alias, + effort: Some(effort), + provider: provider_name.clone(), + kind: kind.to_string(), + }); + } + } + Ok(()) +} + +fn find_thinking_alias_effort( + root: &serde_norway::Mapping, + alias: &str, + protocol: &str, +) -> Option { + let rules = nested_yaml_value(root, &["payload", "override"])?.as_sequence()?; + for rule in rules { + let Some(rule) = rule.as_mapping() else { + continue; + }; + let effort = yaml_mapping_value(rule, "params") + .and_then(serde_norway::Value::as_mapping) + .and_then(|params| { + if protocol.eq_ignore_ascii_case("openai") { + yaml_mapping_value(params, "reasoning_effort") + .or_else(|| yaml_mapping_value(params, "reasoning.effort")) + } else { + yaml_mapping_value(params, "reasoning.effort") + } + }) + .and_then(serde_norway::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + let Some(effort) = effort else { + continue; + }; + let Some(models) = + yaml_mapping_value(rule, "models").and_then(serde_norway::Value::as_sequence) + else { + continue; + }; + if models + .iter() + .any(|model| thinking_payload_model_matches(model, alias, protocol)) + { + return Some(effort.to_ascii_lowercase()); + } + } + None +} + +fn thinking_payload_model_matches( + model: &serde_norway::Value, + alias: &str, + protocol: &str, +) -> bool { + let Some(model) = model.as_mapping() else { + return false; + }; + let name_matches = yaml_mapping_value(model, "name") + .and_then(serde_norway::Value::as_str) + .is_some_and(|name| name.trim().eq_ignore_ascii_case(alias)); + let protocol_matches = yaml_mapping_value(model, "protocol") + .and_then(serde_norway::Value::as_str) + .is_some_and(|value| value.trim().eq_ignore_ascii_case(protocol)); + name_matches && protocol_matches +} + +fn add_thinking_alias_to_yaml( + content: &str, + source: &ResolvedThinkingAliasSource, + alias: &str, + effort: &str, +) -> Result { + let mut document = yaml_serde_edit::YamlValue::parse(content) + .map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?; + let mut updated = document.get().clone(); + let root = updated + .as_mapping_mut() + .ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?; + + if configured_model_alias_exists(root, alias) { + return Err(format!("别名模型 {alias} 已存在")); + } + + match &source.location { + ThinkingAliasSourceLocation::CodexOauth => { + append_codex_oauth_thinking_alias(root, &source.source.model, alias)?; + } + ThinkingAliasSourceLocation::ConfigModel { + section, + provider_index, + model_index, + } => append_config_thinking_alias( + root, + section, + *provider_index, + *model_index, + &source.source.model, + alias, + effort, + )?, + } + + remove_thinking_payload_model(root, alias)?; + let payload = root + .entry(yaml_key("payload")) + .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())) + .as_mapping_mut() + .ok_or_else(|| "payload 必须是 YAML 映射".to_string())?; + let override_rules = payload + .entry(yaml_key("override")) + .or_insert_with(|| serde_norway::Value::Sequence(Vec::new())) + .as_sequence_mut() + .ok_or_else(|| "payload.override 必须是数组".to_string())?; + + let mut model_mapping = serde_norway::Mapping::new(); + model_mapping.insert( + yaml_key("name"), + serde_norway::Value::String(alias.to_string()), + ); + model_mapping.insert( + yaml_key("protocol"), + serde_norway::Value::String(source.source.protocol.clone()), + ); + let mut params_mapping = serde_norway::Mapping::new(); + params_mapping.insert( + yaml_key(if source.source.protocol == "openai" { + "reasoning_effort" + } else { + "reasoning.effort" + }), + serde_norway::Value::String(effort.to_string()), + ); + if source.source.protocol == "openai" + && source + .source + .model + .to_ascii_lowercase() + .starts_with("deepseek") + { + params_mapping.insert( + yaml_key("thinking.type"), + serde_norway::Value::String("enabled".to_string()), + ); + } + let mut rule_mapping = serde_norway::Mapping::new(); + rule_mapping.insert( + yaml_key("models"), + serde_norway::Value::Sequence(vec![serde_norway::Value::Mapping(model_mapping)]), + ); + rule_mapping.insert( + yaml_key("params"), + serde_norway::Value::Mapping(params_mapping), + ); + override_rules.push(serde_norway::Value::Mapping(rule_mapping)); + + render_updated_core_yaml(&mut document, updated) +} + +fn append_codex_oauth_thinking_alias( + root: &mut serde_norway::Mapping, + source_model: &str, + alias: &str, +) -> Result<(), String> { + let oauth_aliases = root + .entry(yaml_key("oauth-model-alias")) + .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())) + .as_mapping_mut() + .ok_or_else(|| "oauth-model-alias 必须是 YAML 映射".to_string())?; + let codex_aliases = oauth_aliases + .entry(yaml_key("codex")) + .or_insert_with(|| serde_norway::Value::Sequence(Vec::new())) + .as_sequence_mut() + .ok_or_else(|| "oauth-model-alias.codex 必须是数组".to_string())?; + let mut alias_mapping = serde_norway::Mapping::new(); + alias_mapping.insert( + yaml_key("name"), + serde_norway::Value::String(source_model.to_string()), + ); + alias_mapping.insert( + yaml_key("alias"), + serde_norway::Value::String(alias.to_string()), + ); + alias_mapping.insert(yaml_key("fork"), serde_norway::Value::Bool(true)); + codex_aliases.push(serde_norway::Value::Mapping(alias_mapping)); + Ok(()) +} + +fn append_config_thinking_alias( + root: &mut serde_norway::Mapping, + section: &str, + provider_index: usize, + model_index: usize, + expected_model: &str, + alias: &str, + effort: &str, +) -> Result<(), String> { + let providers = yaml_mapping_value_mut(root, section) + .and_then(serde_norway::Value::as_sequence_mut) + .ok_or_else(|| format!("{section} 必须是数组"))?; + let provider = providers + .get_mut(provider_index) + .and_then(serde_norway::Value::as_mapping_mut) + .ok_or_else(|| "模型提供商已经变化,请刷新后重试".to_string())?; + let models = yaml_mapping_value_mut(provider, "models") + .and_then(serde_norway::Value::as_sequence_mut) + .ok_or_else(|| format!("{section}.models 必须是数组"))?; + let source = models + .get(model_index) + .cloned() + .ok_or_else(|| "原模型已经变化,请刷新后重试".to_string())?; + let (_, current_model, _) = + configured_model_identity(&source).ok_or_else(|| "原模型配置格式无效".to_string())?; + if !current_model.eq_ignore_ascii_case(expected_model) { + return Err("原模型已经变化,请刷新后重试".to_string()); + } + let mut alias_model = source.as_mapping().cloned().unwrap_or_else(|| { + let mut mapping = serde_norway::Mapping::new(); + if let Some(name) = source.as_str() { + mapping.insert( + yaml_key("name"), + serde_norway::Value::String(name.to_string()), + ); + } + mapping + }); + alias_model.insert( + yaml_key("alias"), + serde_norway::Value::String(alias.to_string()), + ); + if let Some(display_name) = yaml_mapping_value(&alias_model, "display-name") + .and_then(serde_norway::Value::as_str) + .map(str::to_string) + { + alias_model.insert( + yaml_key("display-name"), + serde_norway::Value::String(format!("{display_name} ({effort})")), + ); + } + let thinking = alias_model + .entry(yaml_key("thinking")) + .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())) + .as_mapping_mut() + .ok_or_else(|| "模型 thinking 必须是映射".to_string())?; + thinking.insert( + yaml_key("levels"), + serde_norway::Value::Sequence(vec![serde_norway::Value::String(effort.to_string())]), + ); + models.push(serde_norway::Value::Mapping(alias_model)); + Ok(()) +} + +fn remove_thinking_alias_from_yaml(content: &str, alias: &str) -> Result { + let mut document = yaml_serde_edit::YamlValue::parse(content) + .map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?; + let mut updated = document.get().clone(); + let root = updated + .as_mapping_mut() + .ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?; + let mut removed = false; + let mut remove_oauth_section = false; + if let Some(oauth_aliases) = yaml_mapping_value_mut(root, "oauth-model-alias") { + let oauth_aliases = oauth_aliases + .as_mapping_mut() + .ok_or_else(|| "oauth-model-alias 必须是 YAML 映射".to_string())?; + if let Some(codex_aliases) = yaml_mapping_value_mut(oauth_aliases, "codex") { + let codex_aliases = codex_aliases + .as_sequence_mut() + .ok_or_else(|| "oauth-model-alias.codex 必须是数组".to_string())?; + codex_aliases.retain(|entry| { + let matches = entry + .as_mapping() + .and_then(|mapping| yaml_mapping_value(mapping, "alias")) + .and_then(serde_norway::Value::as_str) + .is_some_and(|value| value.trim().eq_ignore_ascii_case(alias)); + if matches { + removed = true; + } + !matches + }); + if codex_aliases.is_empty() { + oauth_aliases.remove(yaml_key("codex")); + } + } + remove_oauth_section = oauth_aliases.is_empty(); + } + removed |= remove_config_thinking_alias(root, "codex-api-key", "codex", alias)?; + removed |= remove_config_thinking_alias(root, "openai-compatibility", "openai", alias)?; + if !removed { + return Err(format!("别名模型 {alias} 不存在,请刷新后重试")); + } + if remove_oauth_section { + root.remove(yaml_key("oauth-model-alias")); + } + remove_thinking_payload_model(root, alias)?; + render_updated_core_yaml(&mut document, updated) +} + +fn configured_model_alias_exists(root: &serde_norway::Mapping, alias: &str) -> bool { + let oauth_exists = yaml_mapping_value(root, "oauth-model-alias") + .and_then(serde_norway::Value::as_mapping) + .is_some_and(|channels| { + channels.values().any(|entries| { + entries.as_sequence().is_some_and(|entries| { + entries.iter().any(|entry| { + entry + .as_mapping() + .and_then(|mapping| yaml_mapping_value(mapping, "alias")) + .and_then(serde_norway::Value::as_str) + .is_some_and(|value| value.trim().eq_ignore_ascii_case(alias)) + }) + }) + }) + }); + oauth_exists + || ["codex-api-key", "openai-compatibility"] + .into_iter() + .filter_map(|section| yaml_mapping_value(root, section)) + .filter_map(serde_norway::Value::as_sequence) + .flatten() + .filter_map(serde_norway::Value::as_mapping) + .filter_map(|provider| yaml_mapping_value(provider, "models")) + .filter_map(serde_norway::Value::as_sequence) + .flatten() + .filter_map(|model| configured_model_identity(model).map(|(_, alias, _)| alias)) + .any(|value| value.eq_ignore_ascii_case(alias)) +} + +fn remove_config_thinking_alias( + root: &mut serde_norway::Mapping, + section: &str, + protocol: &str, + alias: &str, +) -> Result { + if find_thinking_alias_effort(root, alias, protocol).is_none() { + return Ok(false); + } + let Some(providers) = yaml_mapping_value_mut(root, section) else { + return Ok(false); + }; + let providers = providers + .as_sequence_mut() + .ok_or_else(|| format!("{section} 必须是数组"))?; + let mut removed = false; + for provider in providers { + let Some(provider) = provider.as_mapping_mut() else { + continue; + }; + let Some(models) = yaml_mapping_value_mut(provider, "models") else { + continue; + }; + let models = models + .as_sequence_mut() + .ok_or_else(|| format!("{section}.models 必须是数组"))?; + models.retain(|model| { + let matches = configured_model_identity(model) + .map(|(source, model_alias, _)| { + source != model_alias && model_alias.eq_ignore_ascii_case(alias) + }) + .unwrap_or(false); + removed |= matches; + !matches + }); + } + Ok(removed) +} + +fn remove_thinking_payload_model( + root: &mut serde_norway::Mapping, + alias: &str, +) -> Result<(), String> { + let mut remove_payload_section = false; + if let Some(payload) = yaml_mapping_value_mut(root, "payload") { + let payload = payload + .as_mapping_mut() + .ok_or_else(|| "payload 必须是 YAML 映射".to_string())?; + if let Some(override_rules) = yaml_mapping_value_mut(payload, "override") { + let override_rules = override_rules + .as_sequence_mut() + .ok_or_else(|| "payload.override 必须是数组".to_string())?; + let mut next_rules = Vec::with_capacity(override_rules.len()); + for mut rule in std::mem::take(override_rules) { + let mut removed_from_rule = false; + let mut models_empty = false; + if let Some(rule_mapping) = rule.as_mapping_mut() { + let has_effort = yaml_mapping_value(rule_mapping, "params") + .and_then(serde_norway::Value::as_mapping) + .is_some_and(|params| { + yaml_mapping_value(params, "reasoning.effort").is_some() + || yaml_mapping_value(params, "reasoning_effort").is_some() + }); + if has_effort { + if let Some(models) = yaml_mapping_value_mut(rule_mapping, "models") { + let models = models + .as_sequence_mut() + .ok_or_else(|| "payload.override.models 必须是数组".to_string())?; + let before = models.len(); + models.retain(|model| { + !thinking_payload_model_matches(model, alias, "codex") + && !thinking_payload_model_matches(model, alias, "openai") + }); + removed_from_rule = models.len() != before; + models_empty = models.is_empty(); + } + } + } + if !(removed_from_rule && models_empty) { + next_rules.push(rule); + } + } + *override_rules = next_rules; + if override_rules.is_empty() { + payload.remove(yaml_key("override")); + } + } + remove_payload_section = payload.is_empty(); + } + if remove_payload_section { + root.remove(yaml_key("payload")); + } + Ok(()) +} + +fn render_updated_core_yaml( + document: &mut yaml_serde_edit::YamlValue, + updated: serde_norway::Value, +) -> Result { + document.set(updated); + let rendered = document.get_string(); + serde_norway::from_str::(&rendered) + .map_err(|error| format!("验证更新后的内核配置失败: {error}"))?; + Ok(rendered) +} + fn management_http_client() -> Result { reqwest::Client::builder() .connect_timeout(Duration::from_secs(10)) @@ -2510,50 +6509,65 @@ fn apply_network_settings( } fn apply_gui_managed_settings(content: &str, config: &GuiConfigFile) -> Result { - let file = content - .parse::() + let mut document = yaml_serde_edit::YamlValue::parse(content) .map_err(|err| format!("解析合并后的内核配置失败: {err}"))?; - let document = file - .document() - .ok_or_else(|| "合并后的内核配置没有 YAML 文档".to_string())?; let host = if config.allow_lan { "0.0.0.0" } else { "127.0.0.1" }; - document.set("host", host); - document.set("port", config.port); - document.set("auth-dir", config.auth_dir.clone()); - set_yaml_edit_nested_value( - &document, + let mut updated = document.get().clone(); + set_core_yaml_top_level_value( + &mut updated, + "host", + serde_norway::Value::String(host.to_string()), + )?; + set_core_yaml_top_level_value( + &mut updated, + "port", + serde_norway::to_value(config.port).map_err(|err| format!("序列化内核端口失败: {err}"))?, + )?; + set_core_yaml_top_level_value( + &mut updated, + "auth-dir", + serde_norway::Value::String(config.auth_dir.clone()), + )?; + set_core_yaml_nested_value( + &mut updated, "remote-management", "secret-key", - DEFAULT_MANAGEMENT_SECRET_KEY, - ); - set_yaml_edit_nested_value(&document, "plugins", "enabled", config.plugins_enabled); - set_yaml_edit_nested_value( - &document, + serde_norway::Value::String(DEFAULT_MANAGEMENT_SECRET_KEY.to_string()), + )?; + set_core_yaml_nested_value( + &mut updated, + "plugins", + "enabled", + serde_norway::Value::Bool(config.plugins_enabled), + )?; + set_core_yaml_nested_value( + &mut updated, "routing", "strategy", - config.routing_strategy.clone(), - ); + serde_norway::Value::String(config.routing_strategy.clone()), + )?; + document.set(updated); - let updated = - patch_core_api_keys_yaml(&file.to_string(), &gui_api_key_values(&config.api_keys))?; + let updated = patch_core_api_keys_yaml( + &document.get_string(), + &gui_api_key_values(&config.api_keys), + )?; serde_norway::from_str::(&updated) .map_err(|err| format!("验证启动内核配置失败: {err}"))?; Ok(updated) } -fn write_yaml_if_changed(path: &Path, content: &str) -> Result { - if fs::read_to_string(path).ok().as_deref() == Some(content) { - return Ok(false); - } - +fn write_bytes_atomically(path: &Path, content: &[u8]) -> Result<(), String> { use std::sync::atomic::{AtomicU64, Ordering}; static SEQUENCE: AtomicU64 = AtomicU64::new(0); let directory = path.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(directory) + .map_err(|error| format!("创建配置目录失败 {}: {error}", path_to_string(directory)))?; let file_name = path .file_name() .and_then(|name| name.to_str()) @@ -2566,7 +6580,7 @@ fn write_yaml_if_changed(path: &Path, content: &str) -> Result { let write_result = (|| -> io::Result<()> { let mut file = File::create(&temporary_path)?; - file.write_all(content.as_bytes())?; + file.write_all(content)?; file.sync_all()?; replace_file_atomically(&temporary_path, path) })(); @@ -2579,6 +6593,16 @@ fn write_yaml_if_changed(path: &Path, content: &str) -> Result { )); } + Ok(()) +} + +fn write_yaml_if_changed(path: &Path, content: &str) -> Result { + if fs::read_to_string(path).ok().as_deref() == Some(content) { + return Ok(false); + } + + write_bytes_atomically(path, content.as_bytes())?; + Ok(true) } @@ -3410,6 +7434,15 @@ fn main() { detect_core_platform, get_core_status, get_gui_settings, + get_agent_config_statuses, + get_agent_models, + get_thinking_aliases, + get_thinking_alias_sources, + create_thinking_alias, + delete_thinking_alias, + set_agent_config_enabled, + update_agent_config, + launch_agent, get_lan_ipv4, save_gui_settings, get_core_config_settings, @@ -3450,6 +7483,44 @@ fn main() { mod tests { use super::*; + fn agent_test_home(name: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!( + "cpa-gui-agent-{name}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&path).unwrap(); + path + } + + fn test_codex_models(names: &[&str]) -> Vec { + let models = names + .iter() + .map(|name| AgentModelOption { + name: (*name).to_string(), + alias: None, + }) + .collect::>(); + merge_codex_model_catalog_specs(&models, &[]) + } + + fn test_codex_oauth_thinking_source(model: &str) -> ResolvedThinkingAliasSource { + ResolvedThinkingAliasSource { + source: ThinkingAliasSource { + id: format!("codex-oauth:{model}"), + model: model.to_string(), + display_name: None, + provider: "Codex OAuth".to_string(), + kind: "codex-oauth".to_string(), + protocol: "codex".to_string(), + }, + location: ThinkingAliasSourceLocation::CodexOauth, + } + } + #[test] fn gui_config_defaults_are_stable() { let config = GuiConfigFile::default(); @@ -3467,6 +7538,712 @@ mod tests { assert!(content.contains("routing-strategy = \"round-robin\"")); } + #[test] + fn claude_agent_config_preserves_existing_fields() { + let rendered = build_claude_agent_config( + Some(r#"{"theme":"dark","env":{"KEEP":"yes"}}"#), + "http://127.0.0.1:8317", + DEFAULT_API_KEY, + "claude-test", + ) + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + + assert_eq!(value["theme"], "dark"); + assert_eq!(value["env"]["KEEP"], "yes"); + assert_eq!(value["env"]["ANTHROPIC_BASE_URL"], "http://127.0.0.1:8317"); + assert_eq!(value["env"]["ANTHROPIC_AUTH_TOKEN"], DEFAULT_API_KEY); + assert_eq!(value["env"]["ANTHROPIC_MODEL"], "claude-test"); + assert_eq!(value["model"], "claude-test"); + } + + #[test] + fn codex_agent_config_uses_managed_provider_without_losing_comments() { + let rendered = build_codex_agent_config( + Some("# keep this comment\napproval_policy = \"on-request\"\n"), + "http://127.0.0.1:8317/v1", + DEFAULT_API_KEY, + "gpt-test", + ) + .unwrap(); + let value: toml::Value = toml::from_str(&rendered).unwrap(); + + assert!(rendered.contains("# keep this comment")); + assert_eq!(value["approval_policy"].as_str(), Some("on-request")); + assert_eq!( + value["model_provider"].as_str(), + Some(MANAGED_AGENT_PROVIDER_ID) + ); + assert_eq!(value["model"].as_str(), Some("gpt-test")); + assert_eq!( + value["model_catalog_json"].as_str(), + Some(CODEX_MODEL_CATALOG_FILE) + ); + assert_eq!( + value["model_providers"][MANAGED_AGENT_PROVIDER_ID]["base_url"].as_str(), + Some("http://127.0.0.1:8317/v1") + ); + assert_eq!( + value["model_providers"][MANAGED_AGENT_PROVIDER_ID]["experimental_bearer_token"] + .as_str(), + Some(DEFAULT_API_KEY) + ); + } + + #[test] + fn claude_desktop_config_builds_gateway_profile_and_index() { + let profile = build_claude_desktop_profile( + Some(r#"{"keep":true}"#), + "http://127.0.0.1:8317", + DEFAULT_API_KEY, + "claude-sonnet-test", + ) + .unwrap(); + let meta = + build_claude_desktop_meta(Some(r#"{"entries":[{"id":"other","name":"Other"}]}"#)) + .unwrap(); + let profile: serde_json::Value = serde_json::from_str(&profile).unwrap(); + let meta: serde_json::Value = serde_json::from_str(&meta).unwrap(); + + assert_eq!(profile["keep"], true); + assert_eq!(profile["inferenceGatewayApiKey"], DEFAULT_API_KEY); + assert_eq!(profile["inferenceGatewayBaseUrl"], "http://127.0.0.1:8317"); + assert_eq!(profile["inferenceModels"][0], "claude-sonnet-test"); + assert_eq!(meta["appliedId"], CLAUDE_DESKTOP_PROFILE_ID); + assert_eq!(meta["entries"].as_array().unwrap().len(), 2); + } + + #[test] + fn opencode_agent_config_preserves_other_providers() { + let rendered = build_opencode_agent_config( + Some(r#"{"theme":"dark","provider":{"other":{"npm":"other"}}}"#), + "http://127.0.0.1:8317/v1", + DEFAULT_API_KEY, + "gpt-test", + ) + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + + assert_eq!(value["theme"], "dark"); + assert_eq!(value["provider"]["other"]["npm"], "other"); + assert_eq!( + value["provider"][MANAGED_AGENT_PROVIDER_ID]["options"]["baseURL"], + "http://127.0.0.1:8317/v1" + ); + assert_eq!(value["model"], "cpa-gui/gpt-test"); + } + + #[test] + fn openclaw_agent_config_accepts_json5_and_preserves_unknown_fields() { + let rendered = build_openclaw_agent_config( + Some("{ theme: 'dark', models: { mode: 'merge', providers: {} } }"), + "http://127.0.0.1:8317/v1", + DEFAULT_API_KEY, + "gpt-test", + ) + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + + assert_eq!(value["theme"], "dark"); + assert_eq!( + value["models"]["providers"][MANAGED_AGENT_PROVIDER_ID]["api"], + "openai-completions" + ); + assert_eq!( + value["agents"]["defaults"]["model"]["primary"], + "cpa-gui/gpt-test" + ); + } + + #[test] + fn hermes_agent_config_preserves_unknown_fields_and_uses_current_schema() { + let rendered = build_hermes_agent_config( + Some("theme: dark\ncustom_providers:\n - name: other\n base_url: https://example.com\n"), + "http://127.0.0.1:8317/v1", + DEFAULT_API_KEY, + "gpt-test", + ) + .unwrap(); + let value: serde_yaml::Value = serde_yaml::from_str(&rendered).unwrap(); + let providers = value["custom_providers"].as_sequence().unwrap(); + let managed = providers + .iter() + .find(|provider| provider["name"].as_str() == Some(MANAGED_AGENT_PROVIDER_ID)) + .unwrap(); + + assert_eq!(value["theme"].as_str(), Some("dark")); + assert_eq!(managed["api_mode"].as_str(), Some("chat_completions")); + assert_eq!(managed["model"].as_str(), Some("gpt-test")); + assert!(managed["models"]["gpt-test"].is_mapping()); + assert_eq!( + value["model"]["provider"].as_str(), + Some(MANAGED_AGENT_PROVIDER_ID) + ); + } + + #[test] + fn agent_model_list_parser_accepts_core_response_and_deduplicates_ids() { + let models = parse_agent_model_options(&serde_json::json!({ + "object": "list", + "data": [ + {"id": "gpt-5", "display_name": "GPT 5"}, + {"name": "claude-sonnet", "alias": "Sonnet"}, + "deepseek-chat", + {"id": "GPT-5", "alias": "duplicate"}, + {"id": ""} + ] + })) + .unwrap(); + + assert_eq!( + models, + vec![ + AgentModelOption { + name: "gpt-5".to_string(), + alias: Some("GPT 5".to_string()), + }, + AgentModelOption { + name: "claude-sonnet".to_string(), + alias: Some("Sonnet".to_string()), + }, + AgentModelOption { + name: "deepseek-chat".to_string(), + alias: None, + }, + ] + ); + } + + #[test] + fn agent_model_list_parser_rejects_unexpected_response_shape() { + assert!(parse_agent_model_options(&serde_json::json!({"data": null})).is_err()); + } + + #[test] + fn codex_model_catalog_uses_only_current_cpa_models_and_enriches_matches() { + let models = vec![ + AgentModelOption { + name: "deepseek-chat".to_string(), + alias: None, + }, + AgentModelOption { + name: "gpt-5.4".to_string(), + alias: None, + }, + ]; + let definitions = parse_codex_model_definitions(&serde_json::json!({ + "models": [ + { + "id": "gpt-5.4", + "display_name": "GPT 5.4", + "description": "Stable GPT 5.4", + "context_length": 1_050_000, + "supported_parameters": ["tools"], + "thinking": { "levels": ["low", "medium", "high", "xhigh"] } + }, + { + "id": "not-open-in-cpa", + "context_length": 999_999 + } + ] + })) + .unwrap(); + let specs = merge_codex_model_catalog_specs(&models, &definitions); + + assert_eq!(specs.len(), 2); + assert_eq!(specs[0].id, "deepseek-chat"); + assert_eq!(specs[0].context_window, DEFAULT_CODEX_CONTEXT_WINDOW); + assert_eq!(specs[1].id, "gpt-5.4"); + assert_eq!(specs[1].display_name, "GPT 5.4"); + assert_eq!(specs[1].context_window, 1_050_000); + assert!(!specs.iter().any(|model| model.id == "not-open-in-cpa")); + } + + #[test] + fn codex_model_catalog_renders_codex_supported_fields() { + let rendered = build_codex_model_catalog(&test_codex_models(&["gpt-test"])).unwrap(); + let value: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + let model = &value["models"][0]; + + assert_eq!(model["slug"], "gpt-test"); + assert_eq!(model["context_window"], DEFAULT_CODEX_CONTEXT_WINDOW); + assert_eq!(model["default_reasoning_level"], "medium"); + assert_eq!(model["shell_type"], "shell_command"); + assert_eq!(model["include_skills_usage_instructions"], true); + assert_eq!( + model["supported_reasoning_levels"] + .as_array() + .unwrap() + .len(), + 4 + ); + } + + #[test] + fn agent_model_validation_only_accepts_models_in_current_list() { + let models = vec![AgentModelOption { + name: "gpt-5.4".to_string(), + alias: Some("GPT 5.4".to_string()), + }]; + + assert_eq!( + resolve_available_agent_model(&models, "GPT-5.4").unwrap(), + "gpt-5.4" + ); + assert!(resolve_available_agent_model(&models, "removed-model").is_err()); + assert!(resolve_available_agent_model(&[], "gpt-5.4").is_err()); + } + + #[test] + fn thinking_alias_adds_fork_and_matching_payload_rule() { + let input = "# Keep this comment\ndebug: true\npayload:\n override:\n - models:\n - name: existing-fast\n protocol: codex\n params:\n service_tier: priority\n"; + let source = test_codex_oauth_thinking_source("gpt-5.5"); + let rendered = + add_thinking_alias_to_yaml(input, &source, "gpt-5.5-xhigh", "xhigh").unwrap(); + let aliases = thinking_aliases_from_yaml(&rendered).unwrap(); + + assert!(rendered.contains("# Keep this comment"), "{rendered}"); + assert!(rendered.contains("service_tier: priority"), "{rendered}"); + assert_eq!( + aliases, + vec![ThinkingAliasEntry { + source_model: "gpt-5.5".to_string(), + alias: "gpt-5.5-xhigh".to_string(), + effort: Some("xhigh".to_string()), + provider: "Codex OAuth".to_string(), + kind: "codex-oauth".to_string(), + }] + ); + } + + #[test] + fn thinking_alias_effort_accepts_provider_defined_levels() { + assert_eq!(validate_thinking_alias_effort(" AUTO ").unwrap(), "auto"); + assert_eq!(validate_thinking_alias_effort("ultra").unwrap(), "ultra"); + assert_eq!( + validate_thinking_alias_effort("vendor_level-2.1").unwrap(), + "vendor_level-2.1" + ); + assert!(validate_thinking_alias_effort("").is_err()); + assert!(validate_thinking_alias_effort("high value").is_err()); + assert!(validate_thinking_alias_effort("32768").is_err()); + } + + #[test] + fn thinking_alias_removal_keeps_other_models_in_grouped_rule() { + let input = "oauth-model-alias:\n codex:\n - name: gpt-5.5\n alias: gpt-5.5-xhigh\n fork: true\n - name: gpt-5.4\n alias: gpt-5.4-xhigh\n fork: true\npayload:\n override:\n - models:\n - name: gpt-5.5-xhigh\n protocol: codex\n - name: gpt-5.4-xhigh\n protocol: codex\n params:\n reasoning.effort: xhigh\n"; + let rendered = remove_thinking_alias_from_yaml(input, "gpt-5.5-xhigh").unwrap(); + let aliases = thinking_aliases_from_yaml(&rendered).unwrap(); + + assert_eq!(aliases.len(), 1); + assert_eq!(aliases[0].alias, "gpt-5.4-xhigh"); + assert!(!rendered.contains("gpt-5.5-xhigh"), "{rendered}"); + assert!(rendered.contains("gpt-5.4-xhigh"), "{rendered}"); + assert!(rendered.contains("reasoning.effort: xhigh"), "{rendered}"); + } + + #[test] + fn thinking_alias_rejects_duplicate_client_visible_name() { + let input = "oauth-model-alias:\n codex:\n - name: gpt-5.5\n alias: gpt-5.5-high\n fork: true\n"; + let source = test_codex_oauth_thinking_source("gpt-5.4"); + assert!( + add_thinking_alias_to_yaml(input, &source, "GPT-5.5-HIGH", "high") + .unwrap_err() + .contains("已存在") + ); + } + + #[test] + fn thinking_alias_supports_openai_compatible_model_entries() { + let input = "openai-compatibility:\n - name: DeepSeek\n base-url: https://api.deepseek.com\n api-key-entries:\n - api-key: test\n models:\n - name: deepseek-chat\n display-name: DeepSeek Chat\n thinking:\n levels: [low, medium, high]\n"; + let sources = resolved_thinking_alias_sources(input, &[]).unwrap(); + let source = sources + .iter() + .find(|source| source.source.model == "deepseek-chat") + .unwrap(); + let rendered = + add_thinking_alias_to_yaml(input, source, "deepseek-chat-high", "high").unwrap(); + let value: serde_norway::Value = serde_norway::from_str(&rendered).unwrap(); + let root = value.as_mapping().unwrap(); + let providers = yaml_mapping_value(root, "openai-compatibility") + .and_then(serde_norway::Value::as_sequence) + .unwrap(); + let models = yaml_mapping_value(providers[0].as_mapping().unwrap(), "models") + .and_then(serde_norway::Value::as_sequence) + .unwrap(); + let alias_model = models[1].as_mapping().unwrap(); + + assert_eq!(models.len(), 2); + assert_eq!( + yaml_mapping_value(alias_model, "name").and_then(serde_norway::Value::as_str), + Some("deepseek-chat") + ); + assert_eq!( + yaml_mapping_value(alias_model, "alias").and_then(serde_norway::Value::as_str), + Some("deepseek-chat-high") + ); + assert!(rendered.contains("protocol: openai"), "{rendered}"); + assert!(rendered.contains("reasoning_effort: high"), "{rendered}"); + assert!(rendered.contains("thinking.type: enabled"), "{rendered}"); + assert!(!rendered.contains("oauth-model-alias"), "{rendered}"); + + let entries = thinking_aliases_from_yaml(&rendered).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].provider, "DeepSeek"); + assert_eq!(entries[0].kind, "openai-compatible"); + + let restored = remove_thinking_alias_from_yaml(&rendered, "deepseek-chat-high").unwrap(); + assert!(!restored.contains("deepseek-chat-high"), "{restored}"); + assert!(!restored.contains("reasoning_effort"), "{restored}"); + } + + #[test] + fn thinking_alias_supports_codex_api_model_entries() { + let input = "codex-api-key:\n - api-key: test\n base-url: https://example.com/v1\n models:\n - name: gpt-custom\n"; + let sources = resolved_thinking_alias_sources(input, &[]).unwrap(); + let source = sources + .iter() + .find(|source| source.source.kind == "codex-api") + .unwrap(); + let rendered = + add_thinking_alias_to_yaml(input, source, "gpt-custom-xhigh", "xhigh").unwrap(); + + assert!(rendered.contains("alias: gpt-custom-xhigh"), "{rendered}"); + assert!(rendered.contains("protocol: codex"), "{rendered}"); + assert!(rendered.contains("reasoning.effort: xhigh"), "{rendered}"); + assert!(!rendered.contains("oauth-model-alias"), "{rendered}"); + let entries = thinking_aliases_from_yaml(&rendered).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].kind, "codex-api"); + } + + #[test] + fn agent_modification_backs_up_updates_conflicts_and_restores_exact_bytes() { + let home = agent_test_home("codex-roundtrip"); + let path = home.join(".codex/config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let original = b"# original comment\napproval_policy = \"on-request\"\n"; + fs::write(&path, original).unwrap(); + + let models = test_codex_models(&["gpt-one", "gpt-two", "gpt-three"]); + let enabled = + enable_agent_modification(AgentClient::Codex, &home, 8317, "gpt-one", Some(&models)) + .unwrap(); + assert_eq!(enabled.outcome, "enabled"); + let backup = agent_backup_path(&path).unwrap(); + let state = agent_state_path(&[path.clone()]).unwrap(); + let catalog_path = codex_model_catalog_path(&home); + assert_eq!(fs::read(&backup).unwrap(), original); + assert!(state.is_file()); + assert!(catalog_path.is_file()); + + let updated = + update_agent_modification(AgentClient::Codex, &home, 8317, "gpt-two", Some(&models)) + .unwrap(); + assert_eq!(updated.outcome, "updated"); + assert_eq!(fs::read(&backup).unwrap(), original); + assert!(fs::read_to_string(&path).unwrap().contains("gpt-two")); + + fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap() + .write_all(b"# external change\n") + .unwrap(); + assert!(update_agent_modification( + AgentClient::Codex, + &home, + 8317, + "gpt-three", + Some(&models), + ) + .unwrap_err() + .contains("其他程序修改")); + + let conflict = disable_agent_modification(AgentClient::Codex, &home, 8317, false).unwrap(); + assert_eq!(conflict.outcome, "restore-conflict"); + assert_eq!(conflict.conflict_files, vec![path_to_string(&path)]); + assert!(state.is_file()); + + let restored = disable_agent_modification(AgentClient::Codex, &home, 8317, true).unwrap(); + assert_eq!(restored.outcome, "disabled"); + assert_eq!(fs::read(&path).unwrap(), original); + assert!(!catalog_path.exists()); + assert!(!backup.exists()); + assert!(!state.exists()); + fs::remove_dir_all(home).unwrap(); + } + + #[test] + fn agent_modification_removes_files_that_did_not_exist_before_enable() { + let home = agent_test_home("opencode-absent"); + let path = home.join(".config/opencode/opencode.json"); + + enable_agent_modification(AgentClient::OpenCode, &home, 8317, "gpt-test", None).unwrap(); + assert!(path.is_file()); + assert!(!agent_backup_path(&path).unwrap().exists()); + + disable_agent_modification(AgentClient::OpenCode, &home, 8317, false).unwrap(); + assert!(!path.exists()); + assert!(!agent_state_path(&[path]).unwrap().exists()); + fs::remove_dir_all(home).unwrap(); + } + + #[test] + fn agent_enable_discards_backup_when_state_cannot_be_written() { + let home = agent_test_home("state-write-failure"); + let path = home.join(".codex/config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, b"approval_policy = \"never\"\n").unwrap(); + let state_path = agent_state_path(&[path.clone()]).unwrap(); + fs::create_dir(&state_path).unwrap(); + + let models = test_codex_models(&["gpt-test"]); + assert!(enable_agent_modification( + AgentClient::Codex, + &home, + 8317, + "gpt-test", + Some(&models), + ) + .is_err()); + assert_eq!(fs::read(&path).unwrap(), b"approval_policy = \"never\"\n"); + assert!(!agent_backup_path(&path).unwrap().exists()); + fs::remove_dir_all(home).unwrap(); + } + + #[test] + fn claude_code_keeps_using_the_path_that_owns_active_state() { + let home = agent_test_home("claude-path-state"); + let directory = home.join(".claude"); + let settings = directory.join("settings.json"); + let legacy = directory.join("claude.json"); + fs::create_dir_all(&directory).unwrap(); + fs::write(&settings, b"{}\n").unwrap(); + fs::write(&legacy, b"{}\n").unwrap(); + fs::write(agent_state_path(&[legacy.clone()]).unwrap(), b"{}\n").unwrap(); + + assert_eq!( + agent_config_paths(AgentClient::ClaudeCode, &home), + vec![legacy] + ); + fs::remove_dir_all(home).unwrap(); + } + + #[test] + fn legacy_agent_backup_restores_even_when_gui_port_changed() { + let home = agent_test_home("legacy-port"); + let path = home.join(".codex/config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let original = b"approval_policy = \"never\"\n"; + fs::write(agent_backup_path(&path).unwrap(), original).unwrap(); + fs::write( + &path, + build_codex_agent_config( + None, + "http://127.0.0.1:9999/v1", + DEFAULT_API_KEY, + "gpt-legacy", + ) + .unwrap(), + ) + .unwrap(); + + assert!(agent_has_managed_marker(AgentClient::Codex, &[path.clone()]).unwrap()); + let result = disable_agent_modification(AgentClient::Codex, &home, 8317, false).unwrap(); + assert_eq!(result.outcome, "disabled"); + assert_eq!(fs::read(&path).unwrap(), original); + fs::remove_dir_all(home).unwrap(); + } + + #[test] + fn legacy_generated_only_agent_config_is_removed_without_backup() { + let home = agent_test_home("legacy-generated"); + let path = home.join(".codex/config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + build_codex_agent_config( + None, + "http://127.0.0.1:8317/v1", + DEFAULT_API_KEY, + "gpt-generated", + ) + .unwrap(), + ) + .unwrap(); + + let result = disable_agent_modification(AgentClient::Codex, &home, 8317, false).unwrap(); + assert_eq!(result.outcome, "disabled"); + assert!(!path.exists()); + fs::remove_dir_all(home).unwrap(); + } + + #[test] + fn updating_legacy_codex_state_adds_catalog_without_replacing_original_backup() { + let home = agent_test_home("legacy-catalog-upgrade"); + let path = home.join(".codex/config.toml"); + let catalog_path = codex_model_catalog_path(&home); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let original_config = b"approval_policy = \"never\"\n"; + let original_catalog = b"{\"models\":[{\"slug\":\"user-model\"}]}\n"; + fs::write(agent_backup_path(&path).unwrap(), original_config).unwrap(); + fs::write( + &path, + build_codex_agent_config(None, "http://127.0.0.1:8317/v1", DEFAULT_API_KEY, "gpt-old") + .unwrap(), + ) + .unwrap(); + fs::write(&catalog_path, original_catalog).unwrap(); + let record = build_legacy_agent_record(AgentClient::Codex, &home, 8317, "gpt-old") + .unwrap() + .unwrap(); + assert_eq!(record.files.len(), 1); + write_agent_state(&agent_state_path(&[path.clone()]).unwrap(), &record).unwrap(); + + let models = test_codex_models(&["gpt-new"]); + update_agent_modification(AgentClient::Codex, &home, 8317, "gpt-new", Some(&models)) + .unwrap(); + let upgraded = load_agent_record(AgentClient::Codex, &[path.clone()]) + .unwrap() + .unwrap(); + assert_eq!(upgraded.files.len(), 2); + assert_eq!( + fs::read(agent_backup_path(&path).unwrap()).unwrap(), + original_config + ); + assert_eq!( + fs::read(agent_backup_path(&catalog_path).unwrap()).unwrap(), + original_catalog + ); + + disable_agent_modification(AgentClient::Codex, &home, 8317, false).unwrap(); + assert_eq!(fs::read(&path).unwrap(), original_config); + assert_eq!(fs::read(&catalog_path).unwrap(), original_catalog); + fs::remove_dir_all(home).unwrap(); + } + + #[test] + fn interrupted_agent_state_is_reported_as_recovery() { + let home = agent_test_home("recovery-state"); + let path = home.join(".codex/config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "approval_policy = \"never\"\n").unwrap(); + let models = test_codex_models(&["gpt-test"]); + enable_agent_modification(AgentClient::Codex, &home, 8317, "gpt-test", Some(&models)) + .unwrap(); + + let state_path = agent_state_path(&[path.clone()]).unwrap(); + let mut record = load_agent_record(AgentClient::Codex, &[path]) + .unwrap() + .unwrap(); + record.phase = AGENT_PHASE_APPLYING.to_string(); + write_agent_state(&state_path, &record).unwrap(); + let inspection = + inspect_agent_modification(AgentClient::Codex, &home, 8317, true, Some("gpt-test")); + assert!(inspection.enabled); + assert_eq!(inspection.state, "recovery"); + + disable_agent_modification(AgentClient::Codex, &home, 8317, false).unwrap(); + fs::remove_dir_all(home).unwrap(); + } + + #[test] + fn agent_backup_protocol_restores_multiple_files_and_removes_created_files() { + let home = agent_test_home("multi-file"); + let first = home.join("first.json"); + let second = home.join("second.json"); + fs::write(&first, b"{\"original\":true}\n").unwrap(); + let updates = vec![ + AgentFileUpdate { + path: first.clone(), + after: "{\"managed\":1}\n".to_string(), + }, + AgentFileUpdate { + path: second.clone(), + after: "{\"managed\":2}\n".to_string(), + }, + ]; + let record = prepare_agent_record( + AgentClient::ClaudeDesktop, + &[first.clone(), second.clone()], + "claude-test", + &updates, + ) + .unwrap(); + + apply_agent_updates(&updates).unwrap(); + assert!(first.is_file()); + assert!(second.is_file()); + restore_agent_record_files(&record).unwrap(); + assert_eq!(fs::read_to_string(&first).unwrap(), "{\"original\":true}\n"); + assert!(!second.exists()); + fs::remove_dir_all(home).unwrap(); + } + + #[test] + fn interrupted_multi_file_apply_can_restore_without_false_conflict() { + let home = agent_test_home("partial-recovery"); + let first = home.join("first.json"); + let second = home.join("second.json"); + fs::write(&first, b"{\"original\":1}\n").unwrap(); + fs::write(&second, b"{\"original\":2}\n").unwrap(); + let updates = vec![ + AgentFileUpdate { + path: first.clone(), + after: "{\"managed\":1}\n".to_string(), + }, + AgentFileUpdate { + path: second.clone(), + after: "{\"managed\":2}\n".to_string(), + }, + ]; + let record = prepare_agent_record( + AgentClient::ClaudeDesktop, + &[first.clone(), second.clone()], + "claude-test", + &updates, + ) + .unwrap(); + + write_bytes_atomically(&first, updates[0].after.as_bytes()).unwrap(); + assert_eq!( + record_conflict_files(&record).unwrap(), + vec![path_to_string(&second)] + ); + assert!(record_restore_conflict_files(&record).unwrap().is_empty()); + + fs::write(&second, b"{\"external\":true}\n").unwrap(); + assert_eq!( + record_restore_conflict_files(&record).unwrap(), + vec![path_to_string(&second)] + ); + fs::remove_dir_all(home).unwrap(); + } + + #[test] + fn partial_agent_write_failure_rolls_back_previous_files() { + let home = agent_test_home("partial-write"); + let first = home.join("first.txt"); + let blocked_parent = home.join("blocked"); + fs::write(&first, b"original\n").unwrap(); + fs::write(&blocked_parent, b"not a directory").unwrap(); + let updates = vec![ + AgentFileUpdate { + path: first.clone(), + after: "changed\n".to_string(), + }, + AgentFileUpdate { + path: blocked_parent.join("second.txt"), + after: "never written\n".to_string(), + }, + ]; + + assert!(apply_agent_updates(&updates).is_err()); + assert_eq!(fs::read_to_string(&first).unwrap(), "original\n"); + fs::remove_dir_all(home).unwrap(); + } + #[test] fn legacy_string_api_keys_gain_remarks_and_keep_custom_keys() { let legacy = "port = 8317\nallow-lan = false\nrun-on-startup = false\nauth-dir = \"/tmp/oauth\"\napi-keys = [\"123456\", \"custom-key\"]\nmanagement-secret-key = \"123456\"\nplugins-enabled = false\nrouting-strategy = \"round-robin\"\n"; @@ -3725,6 +8502,34 @@ mod tests { assert_eq!(settings.routing_strategy, "fill-first"); } + #[test] + fn runtime_yaml_ast_patch_handles_core_comments_around_nested_mapping() { + let input = "host: 127.0.0.1\nremote-management:\n# Whether to allow remote access.\n allow-remote: false\n# Management key.\n# All requests require this key.\n secret-key: old\n# Disable panel.\n disable-control-panel: false\nauth-dir: /tmp/old\napi-keys:\n - old-key\n"; + let rendered = patch_core_yaml_document(input, |document| { + let auth_changed = set_core_yaml_top_level_value( + document, + "auth-dir", + serde_norway::Value::String("/tmp/new".to_string()), + )?; + let secret_changed = set_core_yaml_nested_value( + document, + "remote-management", + "secret-key", + serde_norway::Value::String("123456".to_string()), + )?; + Ok(auth_changed || secret_changed) + }) + .unwrap() + .unwrap(); + let parsed = serde_norway::from_str::(&rendered) + .unwrap_or_else(|error| panic!("invalid YAML: {error}\n{rendered}")); + + assert_eq!(parsed["auth-dir"], "/tmp/new"); + assert_eq!(parsed["remote-management"]["secret-key"], "123456"); + assert!(rendered.contains("# All requests require this key.")); + assert!(rendered.contains("disable-control-panel: false")); + } + #[test] fn yaml_edit_runtime_patch_removes_empty_keys_and_skips_unsupported_sections() { let input = "# Client authentication\napi-keys:\n - old-key\nplugins:\n enabled: true\nrouting:\n strategy: fill-first\n"; @@ -3944,6 +8749,41 @@ mod tests { assert_eq!(document["routing"]["strategy"], "round-robin"); } + #[test] + fn release_atom_parser_reads_first_release_tag() { + let xml = r#" + + + + v7.2.80 + + v7.2.79 + + "#; + + assert_eq!(release_tag_from_atom(xml).as_deref(), Some("v7.2.80")); + } + + #[test] + fn synthetic_release_uses_official_asset_names_and_urls() { + let release = release_from_tag("7.2.80"); + let platform = CorePlatform { + os: "linux".to_string(), + arch: "x86_64".to_string(), + asset_os: "linux".to_string(), + asset_arch: "amd64".to_string(), + archive_kind: "tar.gz".to_string(), + }; + let asset = select_release_asset(&release, &platform).unwrap(); + + assert_eq!(release.tag_name, "v7.2.80"); + assert_eq!(asset.name, "CLIProxyAPI_7.2.80_linux_amd64.tar.gz"); + assert_eq!( + asset.browser_download_url, + "https://github.com/router-for-me/CLIProxyAPI/releases/download/v7.2.80/CLIProxyAPI_7.2.80_linux_amd64.tar.gz" + ); + } + #[test] fn release_page_assets_parse_download_links_and_sha256() { let html = r#" diff --git a/src/App.tsx b/src/App.tsx index 171e55c..fc333ae 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { FileKey, Gauge, LogIn, Network, ServerCog, Settings } from 'lucide-react'; +import { Bot, FileKey, Gauge, GitFork, LogIn, Network, ServerCog, Settings } from 'lucide-react'; import appLogo from './assets/logo.jpg'; import { CoreRuntimeProvider, useCoreRuntime } from './coreRuntime'; import { ConfigPanelPage } from './pages/ConfigPanel'; @@ -8,6 +8,8 @@ import { AuthFileManagementPage } from './pages/AuthFileManagementPage'; import { KernelPage } from './pages/Kernel'; import { OAuthLoginPage } from './pages/ManagementPages'; import { QuotaPage } from './pages/QuotaPage'; +import { AgentsPage } from './pages/AgentsPage'; +import { ThinkingAliasesPage } from './pages/ThinkingAliasesPage'; const pages = [ { @@ -22,6 +24,12 @@ const pages = [ icon: Settings, component: ConfigPanelPage, }, + { + id: 'thinking-aliases', + label: '模型别名', + icon: GitFork, + component: ThinkingAliasesPage, + }, { id: 'oauth', label: 'OAuth', @@ -46,6 +54,12 @@ const pages = [ icon: Gauge, component: QuotaPage, }, + { + id: 'agents', + label: '智能体', + icon: Bot, + component: AgentsPage, + }, ] as const; type PageId = (typeof pages)[number]['id']; diff --git a/src/assets/icons/hermes.png b/src/assets/icons/hermes.png new file mode 100644 index 0000000000000000000000000000000000000000..b5141770ea36201c6cbd2e367cda1705ce79a2b9 GIT binary patch literal 39304 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%0AkRTld z_p6@n)4ZRUc_+t_M@8}bO$$x?mjWzu+*-a=7VnfJ9ngrVuIM(J zk5%^pSNt*ns#A$oM z9^rtS;O_q+xbp;u1|f4mx;&XBn&HifG~*P4Q5grj0T&oZD^;R!&>rAzi{0SRoc=AF z=`W5=RKevVM94nux9`5^ntPY3W2W0 zREHUmNhDgp_}V<$IY|3(c3>3FN#S@MsoV>s3xYi>Fq;*J_h-|woGMu{Md#_6?Tx_P zjSi!wxBx#!wSG}?m-qA`x7oO9vsL5814&{kt-90(l&bvZ%PSl=h!(NY`~n|9yP9i4 z%So(>BAgp|tk(b@z;d6+Rih{}2-GFB zxWf=Ta9*MZezJ#AeWOt04ngczw+XEs0nKXszpQ~clRUX5f6g7B%umO@$0`y^fw z#pA5QF@|(sN^CI*SC?TV_Q=~8E0@EiQf+E_zQ(Fbu^!v0fW+>MB;M~1Hwt>*5A}l1 z^;r6TQNI_qh+r+CWue9GO5uAQe0Nf8^Le~Ht>M2KP~?nqqd3bANaUwV$iUU+(Nqro zBWA-H5#Xo7RHN1)gk}YTLik!G((`xY1LAe~OXCLfJpW`%W%F!~ez{t)jB4<7>n-jUaj*DFS2oGstOV4Xfd z=3rKahf2s{u{mULTTIrjC>V|`&t`!g%hY{!VvmW{0Y4w9OsW1#HxEY>(B})u_B{r& z4iu4d8;}w#0uX9b;_o>e88_WE z&achZ!|Nf7D|P?Js?B!_6}tdX80YsFSG|Ax;QPP{SB0FHcC}R9#)1}FXnh3Gv!1!F z|AaQZ%vWkNYZ4;lbPkH4>gNDRIY}{;InxaEqZ$9OqCn{C5kMMgKP@lKQ3H^ynm|Us z$~@4*W{UL(6yIG_kJ`mmg(khI2+~raQ7ttuxxm%P9@E7vYmFqjvhL2jBZLa{g^T2V z8cx6BtPU8+4tk`JI64!W8@QGiaB~ds#p1D#Xx{Dnna7cX)|7*yt5H+JM-sLK-(Gi4 zKZ(5{=ln3h&wiD?GxvTlU~c&FpycROVER*+QRjN9aq<_Y7tqo1Ipcc@`_ZnL1NTWc zR>%k~F>!^f&2Gx9087^d0K%eK>bDXl81DXdy^^(o)`}UoK7#}hL)M;i zKBk#K4G_{fWw4w}-r&wbM9+Xu%=4_`*qwpI)E)_bsN@*GZX%c>~PEyS~Usl7stn3^-PG-vuuEM zCkX3{((m5qf8YE0uB;ej$mP@kLd8(-K{xr8bKMU^GOoyGjq$M^{J2Po^X=RO!Rvx+ zZi(ah#gj0QO~sLwj&7Dan_gU6pZ zWUT$x$r6t0EW;bc{(PNRA1#(;+Jz2o3^COX1d`M_XwgXO9vFKAaDiaXBq@y@{jk9G z8_4QZkzoW8f*L$J+|CXM*W2T7evK~u$Xgf574lmUk4Liv@S|Xe>nA*OYFoO25zdD@ zuw}OLT^F<(ZK%=pu1PhdEy;_q-+xi(7Q2laq3wg@bh zz0t5D-T

`3$$PhJ3@T~S_?sepxsA8!!G|jzS=^>{L<+56b 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 VQmZZ3?=AoU002ovPDHLkV1nw37HI$g literal 0 HcmV?d00001 diff --git a/src/assets/icons/openclaw.svg b/src/assets/icons/openclaw.svg new file mode 100644 index 0000000..4ebcdae --- /dev/null +++ b/src/assets/icons/openclaw.svg @@ -0,0 +1,18 @@ + + OpenClaw + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/opencode.svg b/src/assets/icons/opencode.svg new file mode 100644 index 0000000..5851d28 --- /dev/null +++ b/src/assets/icons/opencode.svg @@ -0,0 +1,17 @@ + + OpenCode + + + + + + + + + + + + + + + diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx new file mode 100644 index 0000000..08921ce --- /dev/null +++ b/src/pages/AgentsPage.tsx @@ -0,0 +1,783 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ComponentType, + type KeyboardEvent, +} from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { + AlertTriangle, + BadgeCheck, + Bot, + Check, + ChevronDown, + LoaderCircle, + Play, + RefreshCw, + Search, + Sparkles, + X, +} from 'lucide-react'; +import claudeIcon from '../assets/icons/claude.svg'; +import codexIcon from '../assets/icons/codex.svg'; +import hermesIcon from '../assets/icons/hermes.png'; +import openclawIcon from '../assets/icons/openclaw.svg'; +import opencodeIcon from '../assets/icons/opencode.svg'; +import { + agentModelAlias, + filterAgentModels, + findAgentModel, + resolveAgentModelSelection, +} from '../services/agentModelPicker'; +import type { ModelOption } from '../services/modelService'; + +type AgentClientId = + | 'claude-code' + | 'claude-desktop' + | 'codex' + | 'opencode' + | 'openclaw' + | 'hermes'; + +type AgentModificationState = 'inactive' | 'active' | 'conflict' | 'recovery'; + +type AgentConfigStatus = { + id: AgentClientId; + name: string; + supportedPlatform: boolean; + installed: boolean; + executablePath: string | null; + launchTargets: AgentLaunchTarget[]; + version: string | null; + configValid: boolean; + configured: boolean; + currentModel: string | null; + modificationEnabled: boolean; + modificationState: AgentModificationState; + backupAvailable: boolean; + appliedModel: string | null; + warnings: string[]; + error: string | null; +}; + +type AgentLaunchTarget = { + id: string; + label: string; + detail: string; +}; + +type AgentConfigActionResult = { + outcome: 'enabled' | 'disabled' | 'updated' | 'restore-conflict'; + enabled: boolean; + model: string | null; + changedFiles: string[]; + conflictFiles: string[]; +}; + +type AgentDefinition = { + id: AgentClientId; + name: string; + icon?: string; + Icon?: ComponentType<{ size?: number; 'aria-hidden'?: boolean }>; + description: string; +}; + +const agentDefinitions: AgentDefinition[] = [ + { + id: 'claude-code', + name: 'Claude Code', + icon: claudeIcon, + description: '使用 CPA 的 Anthropic 兼容接口', + }, + { + id: 'claude-desktop', + name: 'Claude Desktop', + icon: claudeIcon, + description: '使用 CPA 3P 推理网关,仅支持 Windows 和 macOS', + }, + { + id: 'codex', + name: 'Codex', + icon: codexIcon, + description: '使用 CPA Responses 接口', + }, + { + id: 'opencode', + name: 'OpenCode', + icon: opencodeIcon, + description: '使用 CPA OpenAI Compatible provider', + }, + { + id: 'openclaw', + name: 'OpenClaw', + icon: openclawIcon, + description: '使用 CPA 模型供应商和默认模型', + }, + { + id: 'hermes', + name: 'Hermes Agent', + icon: hermesIcon, + description: '使用 CPA custom provider', + }, +]; + +const AGENT_MODEL_SELECTIONS_KEY = 'cpa-gui.agent-model-selections.v1'; + +const readAgentModelSelections = (): Partial> => { + if (typeof window === 'undefined') return {}; + try { + const payload = window.localStorage.getItem(AGENT_MODEL_SELECTIONS_KEY); + if (!payload) return {}; + const parsed = JSON.parse(payload) as Record; + return agentDefinitions.reduce>>((result, agent) => { + const value = parsed[agent.id]; + if (typeof value === 'string' && value.trim()) result[agent.id] = value.trim(); + return result; + }, {}); + } catch { + return {}; + } +}; + +const writeAgentModelSelections = ( + selections: Partial>, +) => { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(AGENT_MODEL_SELECTIONS_KEY, JSON.stringify(selections)); + } catch { + // Local storage can be unavailable in hardened webviews; the in-memory selection still works. + } +}; + +const reconcileAgentModelSelections = ( + current: Partial>, + models: ModelOption[], +) => { + return agentDefinitions.reduce>>((result, agent) => { + const existing = current[agent.id] ?? ''; + result[agent.id] = resolveAgentModelSelection(models, existing); + return result; + }, {}); +}; + +function AgentMark({ definition, size = 26 }: { definition: AgentDefinition; size?: number }) { + if (definition.icon) { + return ; + } + const Icon = definition.Icon ?? Bot; + return ; +} + +const listStatusText = (status: AgentConfigStatus | undefined) => { + if (!status) return '正在检测'; + if (!status.supportedPlatform) return '当前平台不支持'; + if (!status.installed) return '未检测到安装'; + if (status.modificationState === 'conflict') return '配置冲突 · 等待恢复'; + if (status.modificationState === 'recovery') return '操作未完成 · 可恢复'; + if (status.modificationEnabled) return `已修改 · ${status.appliedModel ?? '未记录模型'}`; + return status.version ? `已安装 · ${status.version}` : '已安装 · 保持原配置'; +}; + +type AgentModelPickerProps = { + models: ModelOption[]; + value: string; + loading: boolean; + error: string; + disabled: boolean; + onChange: (value: string) => void; + onRefresh: () => void; +}; + +function AgentModelPicker({ + models, + value, + loading, + error, + disabled, + onChange, + onRefresh, +}: AgentModelPickerProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const [activeIndex, setActiveIndex] = useState(0); + const rootRef = useRef(null); + const searchRef = useRef(null); + const visibleModels = useMemo(() => filterAgentModels(models, search), [models, search]); + const choices = useMemo( + () => visibleModels.map((model) => ({ name: model.name, alias: model.alias ?? '' })), + [visibleModels], + ); + const selectedAlias = agentModelAlias(models, value); + + useEffect(() => { + if (!open) return undefined; + const close = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', close); + return () => document.removeEventListener('mousedown', close); + }, [open]); + + useEffect(() => { + if (!open) return; + setSearch(''); + const selectedIndex = filterAgentModels(models, '').findIndex( + (model) => model.name.toLocaleLowerCase() === value.trim().toLocaleLowerCase(), + ); + setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0); + requestAnimationFrame(() => searchRef.current?.focus()); + }, [open]); + + useEffect(() => { + setActiveIndex((current) => Math.min(current, Math.max(choices.length - 1, 0))); + }, [choices.length]); + + const choose = (name: string) => { + onChange(name); + setOpen(false); + }; + + const moveActive = (offset: number) => { + if (choices.length === 0) return; + setActiveIndex((current) => (current + offset + choices.length) % choices.length); + }; + + const handleSearchKeyDown = (event: KeyboardEvent) => { + if (event.key === 'ArrowDown') { + event.preventDefault(); + moveActive(1); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + moveActive(-1); + } else if (event.key === 'Enter' && choices[activeIndex]) { + event.preventDefault(); + choose(choices[activeIndex].name); + } else if (event.key === 'Escape') { + event.preventDefault(); + setOpen(false); + } + }; + + return ( +

+ + + {open ? ( +
+
+ + { + setSearch(event.currentTarget.value); + setActiveIndex(0); + }} + onKeyDown={handleSearchKeyDown} + placeholder="搜索模型名称或别名" + role="combobox" + aria-controls="agent-model-listbox" + aria-expanded="true" + /> + {search ? ( + + ) : null} + +
+ +
+ {loading && models.length === 0 ? ( +
正在获取模型
+ ) : error && models.length === 0 ? ( +
获取模型失败{error}
+ ) : choices.length === 0 ? ( +
+ {search.trim() ? '没有匹配的模型' : '暂时没有可用模型'} + {search.trim() ? '尝试其他关键词' : '请先接入可用模型后刷新'} +
+ ) : choices.map((choice, index) => { + const selected = choice.name.toLocaleLowerCase() === value.trim().toLocaleLowerCase(); + return ( + + ); + })} +
+
+ {models.length} 个可用模型 + {error && models.length > 0 ? 刷新失败,显示上次结果 : null} +
+
+ ) : null} +
+ ); +} + +export function AgentsPage() { + const [selected, setSelected] = useState('claude-code'); + const [statuses, setStatuses] = useState([]); + const [models, setModels] = useState([]); + const [modelByClient, setModelByClient] = useState>>( + readAgentModelSelections, + ); + const [launchTargetByClient, setLaunchTargetByClient] = useState>>({}); + const [loading, setLoading] = useState(true); + const [modelLoading, setModelLoading] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [modelError, setModelError] = useState(''); + const [notice, setNotice] = useState(''); + const [restoreConflict, setRestoreConflict] = useState(null); + + const loadStatuses = useCallback(async () => { + const nextStatuses = await invoke('get_agent_config_statuses'); + setStatuses(nextStatuses); + }, []); + + const loadModels = useCallback(async () => { + setModelLoading(true); + setModelError(''); + try { + const nextModels = await invoke('get_agent_models'); + setModels(nextModels); + setModelByClient((current) => { + const next = reconcileAgentModelSelections(current, nextModels); + writeAgentModelSelections(next); + return next; + }); + } catch (requestError) { + setModelError(String(requestError)); + } finally { + setModelLoading(false); + } + }, []); + + const refresh = useCallback(async () => { + setLoading(true); + setError(''); + try { + await Promise.all([loadStatuses(), loadModels()]); + } catch (requestError) { + setError(String(requestError)); + } finally { + setLoading(false); + } + }, [loadModels, loadStatuses]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + useEffect(() => { + setLaunchTargetByClient((current) => agentDefinitions.reduce>>( + (next, definition) => { + const targets = statuses.find((status) => status.id === definition.id)?.launchTargets ?? []; + const previous = current[definition.id] ?? ''; + next[definition.id] = targets.some((target) => target.id === previous) + ? previous + : targets[0]?.id ?? ''; + return next; + }, + {}, + )); + }, [statuses]); + + const activeDefinition = agentDefinitions.find((agent) => agent.id === selected) + ?? agentDefinitions[0]; + const activeStatus = statuses.find((status) => status.id === selected) ?? null; + const savedSelectedModel = modelByClient[selected] ?? ''; + const selectedModelOption = findAgentModel(models, savedSelectedModel); + const selectedModel = selectedModelOption?.name ?? ''; + const activeLaunchTargets = activeStatus?.launchTargets ?? []; + const selectedLaunchTargetId = launchTargetByClient[selected] ?? activeLaunchTargets[0]?.id ?? ''; + const selectedLaunchTarget = activeLaunchTargets.find( + (target) => target.id === selectedLaunchTargetId, + ) ?? activeLaunchTargets[0] ?? null; + const appliedModel = activeStatus?.appliedModel ?? activeStatus?.currentModel ?? ''; + const draftChanged = Boolean( + activeStatus?.modificationEnabled + && selectedModel.trim() + && selectedModel.trim() !== appliedModel.trim(), + ); + const canEnable = Boolean( + activeStatus?.supportedPlatform + && activeStatus.installed + && activeStatus.configValid + && !modelLoading + && selectedModelOption, + ); + const canLaunch = Boolean( + activeStatus?.supportedPlatform + && activeStatus.installed + && selectedLaunchTarget, + ); + + const refreshModels = () => { + void loadModels(); + }; + + const selectModel = (value: string) => { + const model = findAgentModel(models, value); + if (!model) return; + setModelByClient((current) => { + const next = { ...current, [selected]: model.name }; + writeAgentModelSelections(next); + return next; + }); + }; + + const requireSelectedModel = () => { + if (modelLoading) { + setError('模型列表仍在加载,请稍后再试'); + return null; + } + if (models.length === 0) { + setError(modelError || '当前没有可选模型,无法修改智能体配置'); + return null; + } + const model = findAgentModel(models, selectedModel); + if (!model) { + setError('当前选择已不在可用模型列表中,请刷新模型后重新选择'); + return null; + } + return model.name; + }; + + const setModificationEnabled = async (enabled: boolean, forceRestore = false) => { + const model = enabled ? requireSelectedModel() : selectedModel.trim(); + if (enabled && !model) return; + setBusy(true); + setError(''); + setNotice(''); + try { + const result = await invoke('set_agent_config_enabled', { + client: selected, + model: model ?? '', + enabled, + forceRestore, + }); + if (result.outcome === 'restore-conflict') { + setRestoreConflict(result.conflictFiles); + return; + } + setRestoreConflict(null); + setNotice(result.enabled + ? `${activeDefinition.name} 已启用配置修改,请重启客户端` + : `${activeDefinition.name} 的原配置已恢复,请重启客户端`); + await loadStatuses(); + } catch (requestError) { + setError(String(requestError)); + } finally { + setBusy(false); + } + }; + + const launchAgent = async () => { + setBusy(true); + setError(''); + setNotice(''); + try { + let appliedBeforeLaunch = false; + const shouldSyncCodexCatalog = Boolean( + selected === 'codex' && activeStatus?.modificationEnabled && selectedModelOption, + ); + if (draftChanged || shouldSyncCodexCatalog) { + if (activeStatus?.modificationState !== 'active') { + throw new Error('当前配置存在冲突或恢复任务,暂时不能应用新模型'); + } + const model = requireSelectedModel(); + if (!model) return; + await invoke('update_agent_config', { + client: selected, + model, + }); + appliedBeforeLaunch = true; + } + await invoke('launch_agent', { client: selected, target: selectedLaunchTarget?.id }); + setNotice(appliedBeforeLaunch + ? draftChanged + ? `${activeDefinition.name} 已应用所选模型并启动` + : `${activeDefinition.name} 已同步模型目录并启动` + : `${activeDefinition.name} 已启动`); + if (appliedBeforeLaunch) await loadStatuses(); + } catch (requestError) { + setError(String(requestError)); + } finally { + setBusy(false); + } + }; + + const statusLabel = loading + ? '检测中' + : !activeStatus?.supportedPlatform + ? '平台不支持' + : !activeStatus.installed + ? '未安装' + : activeStatus.modificationState === 'conflict' + ? '配置冲突' + : activeStatus.modificationState === 'recovery' + ? '需要恢复' + : activeStatus.modificationEnabled + ? '已修改配置' + : '保持原配置'; + const statusTone = activeStatus?.modificationState === 'conflict' + || activeStatus?.modificationState === 'recovery' + || !activeStatus?.supportedPlatform + ? 'error' + : activeStatus?.modificationEnabled + ? 'success' + : ''; + + return ( +
+
+
+ Agent Clients +

智能体配置

+
+ +
+ +
+ {error ?
{error}
: null} + {!error && notice ?
{notice}
: null} +
+ +
+