diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ccb29d3..1acc080 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,6 +76,18 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: components: clippy, rustfmt + - name: Cache Bun packages + uses: actions/cache@v5 + with: + path: ~/.bun/install/cache + key: bun-${{ runner.os }}-${{ runner.arch }}-1.3.14-${{ hashFiles('bun.lock') }} + restore-keys: | + bun-${{ runner.os }}-${{ runner.arch }}-1.3.14- + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri -> target + cache-on-failure: true - name: Apply release version run: node scripts/set-version.mjs "${{ needs.version.outputs.version }}" - name: Install Linux dependencies @@ -102,9 +114,8 @@ jobs: build: name: Build ${{ matrix.os }}-${{ matrix.arch }} - needs: - - version - - validate + needs: version + if: github.event_name != 'release' || github.event.release.prerelease == true strategy: fail-fast: false matrix: @@ -141,6 +152,8 @@ jobs: suffix: zip runs-on: ${{ matrix.runner }} timeout-minutes: 60 + env: + RUSTFLAGS: -D warnings steps: - uses: actions/checkout@v7 with: @@ -149,6 +162,24 @@ jobs: with: bun-version: 1.3.14 - uses: dtolnay/rust-toolchain@stable + - name: Cache Bun packages + uses: actions/cache@v5 + with: + path: ~/.bun/install/cache + key: bun-${{ runner.os }}-${{ runner.arch }}-1.3.14-${{ hashFiles('bun.lock') }} + restore-keys: | + bun-${{ runner.os }}-${{ runner.arch }}-1.3.14- + - name: Cache Rust dependencies and build outputs + uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri -> target + cache-workspace-crates: true + cache-on-failure: true + - name: Cache bundled core archive + uses: actions/cache@v5 + with: + path: cpa-core + key: core-${{ matrix.os }}-${{ matrix.arch }}-${{ hashFiles('core-version.txt') }} - name: Apply release version run: node scripts/set-version.mjs "${{ needs.version.outputs.version }}" - name: Resolve release package name @@ -168,7 +199,6 @@ jobs: env: GITCODE_GUI_REPOSITORY: ${{ vars.GITCODE_GUI_REPOSITORY }} GITCODE_CORE_REPOSITORY: ${{ vars.GITCODE_CORE_REPOSITORY }} - RUSTFLAGS: -D warnings run: | bun install --frozen-lockfile bun tauri build --no-bundle @@ -425,6 +455,7 @@ jobs: || inputs.release_tag != '' needs: - version + - validate - build runs-on: ubuntu-24.04 timeout-minutes: 15 diff --git a/README.ja.md b/README.ja.md index 436b0c2..ebf9ffd 100644 --- a/README.ja.md +++ b/README.ja.md @@ -98,6 +98,9 @@ OpenAI、Claude、Gemini、およびその他の互換形式の間で変換で - OpenClaw - Hermes Agent - Pi(CLIProxyAPI provider 拡張機能) +- ZCode +- Kimi Code +- Grok Build 対応クライアントでは、利用可能なモデルカタログの同期、デフォルトモデルの選択、管理設定を適用する前の 元設定のバックアップ、以前の設定への復元、利用可能なデスクトップまたは CLI エントリーポイントの起動ができます。 diff --git a/README.md b/README.md index 30cae80..fe350c3 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,9 @@ proxy. Supported clients include: - OpenClaw - Hermes Agent - Pi (with the CLIProxyAPI provider extension) +- ZCode +- Kimi Code +- Grok Build For supported clients, the application can synchronize the available model catalog, select a default model, back up the original configuration before applying managed settings, and restore the diff --git a/README.zh-CN.md b/README.zh-CN.md index 6c3999f..34745c5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -95,6 +95,9 @@ API 接入页面按照协议或 Provider 管理上游 API 凭证和服务地址 - OpenClaw - Hermes Agent - Pi(通过 CLIProxyAPI provider 插件) +- ZCode +- Kimi Code +- Grok Build 对于受支持的客户端,软件可以同步可用模型目录、选择默认模型、在应用托管配置前备份原始配置, 以及恢复之前的配置。 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 74b07e4..cf25c25 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -593,7 +593,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" [[package]] name = "cpa-gui" -version = "0.2.22" +version = "0.2.24" dependencies = [ "base64 0.22.1", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4dbcfef..67ffab7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "cpa-gui" # Single source of truth for the desktop app version. -version = "0.2.22" +version = "0.2.24" description = "CPA desktop management shell" authors = ["lzt404"] edition = "2021" diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index b02a3e6..29e0054 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -1,5 +1,13 @@ use super::*; +const AGENT_STATUS_DETECTION_CONCURRENCY: usize = 4; + +#[derive(Clone, Copy)] +enum AgentStatusDetectionTarget { + Client(AgentClient), + PiProvider, +} + pub(crate) fn inspect_agent_config_statuses( app: &tauri::AppHandle, config: &GuiConfigFile, @@ -9,20 +17,63 @@ pub(crate) fn inspect_agent_config_statuses( .home_dir() .map_err(|error| format!("无法获取用户目录: {error}"))?; let api_key = effective_agent_api_key(config); - let mut statuses = [ - AgentClient::ClaudeCode, - AgentClient::ClaudeDesktop, - AgentClient::Codex, - AgentClient::OpenCode, - AgentClient::OpenClaw, - AgentClient::Hermes, - AgentClient::DeepSeekHarness, - ] - .into_iter() - .map(|client| inspect_agent_config(client, &home, config.port, api_key)) - .collect::>(); - statuses.push(inspect_pi_provider_status(&home, config.port, api_key)); - Ok(statuses) + let targets = [ + AgentStatusDetectionTarget::Client(AgentClient::ClaudeCode), + AgentStatusDetectionTarget::Client(AgentClient::ClaudeDesktop), + AgentStatusDetectionTarget::Client(AgentClient::Codex), + AgentStatusDetectionTarget::Client(AgentClient::OpenCode), + AgentStatusDetectionTarget::Client(AgentClient::OpenClaw), + AgentStatusDetectionTarget::Client(AgentClient::Hermes), + AgentStatusDetectionTarget::Client(AgentClient::DeepSeekHarness), + AgentStatusDetectionTarget::Client(AgentClient::ZCode), + AgentStatusDetectionTarget::Client(AgentClient::KimiCode), + AgentStatusDetectionTarget::Client(AgentClient::GrokBuild), + AgentStatusDetectionTarget::PiProvider, + ]; + let queue = Mutex::new(targets.into_iter().enumerate()); + let results = Mutex::new(vec![None; targets.len()]); + thread::scope(|scope| { + let mut workers = Vec::with_capacity(AGENT_STATUS_DETECTION_CONCURRENCY); + for _ in 0..AGENT_STATUS_DETECTION_CONCURRENCY { + let queue = &queue; + let results = &results; + let home = &home; + workers.push(scope.spawn(move || -> Result<(), String> { + loop { + let next = queue + .lock() + .map_err(|_| "智能体检测任务队列锁已损坏".to_string())? + .next(); + let Some((index, target)) = next else { + return Ok(()); + }; + let status = match target { + AgentStatusDetectionTarget::Client(client) => { + inspect_agent_config(client, home, config.port, api_key) + } + AgentStatusDetectionTarget::PiProvider => { + inspect_pi_provider_status(home, config.port, api_key) + } + }; + results + .lock() + .map_err(|_| "智能体检测结果锁已损坏".to_string())?[index] = Some(status); + } + })); + } + for worker in workers { + worker + .join() + .map_err(|_| "智能体检测工作线程异常退出".to_string())??; + } + Ok::<(), String>(()) + })?; + results + .into_inner() + .map_err(|_| "智能体检测结果锁已损坏".to_string())? + .into_iter() + .map(|status| status.ok_or_else(|| "智能体检测结果不完整".to_string())) + .collect() } pub(crate) fn refresh_agent_config_status_cache( @@ -637,6 +688,10 @@ pub(crate) async fn fetch_prepared_agent_models( prepare_codex_agent_models(&runtime_models) } else { let mut models = fetch_agent_models(config.port, api_key).await?; + if agent_uses_cpa_runtime_context_windows(client) { + let runtime_models = fetch_codex_runtime_models(config.port, api_key).await?; + codex_catalog::merge_runtime_context_windows(&mut models, &runtime_models); + } if matches!(client, AgentClient::ClaudeCode | AgentClient::ClaudeDesktop) { let content = fetch_management_config_yaml(config).await?; mark_configured_agent_model_aliases(&mut models, &content)?; @@ -659,6 +714,13 @@ pub(crate) async fn fetch_prepared_agent_models( } } +pub(crate) fn agent_uses_cpa_runtime_context_windows(client: AgentClient) -> bool { + matches!( + client, + AgentClient::ZCode | AgentClient::KimiCode | AgentClient::GrokBuild + ) +} + pub(crate) fn resolve_claude_desktop_model_mappings( client: AgentClient, models: &[AgentModelOption], diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index a7ae96a..c38d99d 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -214,6 +214,67 @@ pub(crate) fn build_agent_updates_with_oauth( }, ]) } + AgentClient::ZCode => { + let app_before = read_optional_text(&paths[0])?; + let cli_before = read_optional_text(&paths[1])?; + let app_after = + build_zcode_agent_config(app_before.as_deref(), &root_base, api_key, model, models) + .or_else(|_| { + build_zcode_agent_config(None, &root_base, api_key, model, models) + })?; + let cli_after = build_zcode_cli_agent_config( + cli_before.as_deref(), + &root_base, + api_key, + model, + models, + ) + .or_else(|_| build_zcode_cli_agent_config(None, &root_base, api_key, model, models))?; + Ok(vec![ + AgentFileUpdate { + path: paths[0].clone(), + after: app_after, + }, + AgentFileUpdate { + path: paths[1].clone(), + after: cli_after, + }, + ]) + } + AgentClient::KimiCode => { + let before = read_optional_text(&paths[0])?; + let after = build_kimi_code_agent_config( + before.as_deref(), + &openai_base, + api_key, + model, + models, + ) + .or_else(|_| { + build_kimi_code_agent_config(None, &openai_base, api_key, model, models) + })?; + Ok(vec![AgentFileUpdate { + path: paths[0].clone(), + after, + }]) + } + AgentClient::GrokBuild => { + let before = read_optional_text(&paths[0])?; + let after = build_grok_build_agent_config( + before.as_deref(), + &openai_base, + api_key, + model, + models, + ) + .or_else(|_| { + build_grok_build_agent_config(None, &openai_base, api_key, model, models) + })?; + Ok(vec![AgentFileUpdate { + path: paths[0].clone(), + after, + }]) + } } } @@ -464,6 +525,10 @@ pub(crate) fn build_claude_agent_config( CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV.to_string(), serde_json::Value::String(max_context_tokens.to_string()), ); + env.insert( + CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV.to_string(), + serde_json::Value::String("1".to_string()), + ); env.insert( CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV.to_string(), serde_json::Value::String(mappings.auto_compact_pct.to_string()), @@ -861,6 +926,7 @@ pub(crate) fn remove_claude_code_managed_configuration( "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_FABLE_MODEL", + CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV, CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV, DISABLE_AUTO_COMPACT_ENV, @@ -984,6 +1050,194 @@ pub(crate) fn remove_opencode_managed_configuration( Ok(updated.then(|| path_to_string(path)).into_iter().collect()) } +pub(crate) fn remove_zcode_managed_configuration(paths: &[PathBuf]) -> Result, String> { + if paths.is_empty() { + return Err("ZCode 当前平台配置路径不可用".to_string()); + } + let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); + let mut changed_paths = Vec::new(); + for path in paths { + let updated = update_agent_json_file(path, "ZCode 配置", |root| { + let mut changed = false; + let remove_model = if root + .get("model") + .and_then(serde_json::Value::as_str) + .is_some_and(|model| model.starts_with(&prefix)) + { + true + } else if let Some(model) = root + .get_mut("model") + .and_then(serde_json::Value::as_object_mut) + { + if model + .get("main") + .and_then(serde_json::Value::as_str) + .is_some_and(|model| model.starts_with(&prefix)) + { + model.remove("main"); + changed = true; + } + model.is_empty() + } else { + false + }; + if remove_model { + root.remove("model"); + changed = true; + } + let providers_empty = if let Some(providers) = root + .get_mut("provider") + .and_then(serde_json::Value::as_object_mut) + { + changed |= providers.remove(MANAGED_AGENT_PROVIDER_ID).is_some(); + providers.is_empty() + } else { + false + }; + if providers_empty { + root.remove("provider"); + } + changed + })?; + if updated { + changed_paths.push(path_to_string(path)); + } + } + Ok(changed_paths) +} + +pub(crate) fn remove_kimi_code_managed_configuration( + paths: &[PathBuf], +) -> Result, String> { + remove_managed_toml_client_configuration( + paths, + "Kimi Code", + Some("providers"), + "models", + None, + "default_model", + ) +} + +pub(crate) fn remove_grok_build_managed_configuration( + paths: &[PathBuf], +) -> Result, String> { + remove_managed_toml_client_configuration( + paths, + "Grok Build", + None, + "model", + Some("models"), + "default", + ) +} + +pub(crate) fn remove_managed_toml_client_configuration( + paths: &[PathBuf], + label: &str, + provider_section: Option<&str>, + model_entry_section: &str, + default_section: Option<&str>, + default_key: &str, +) -> Result, String> { + use toml_edit::{Document, Item}; + + let Some(path) = paths.first() else { + return Err(format!("{label} 当前平台配置路径不可用")); + }; + if !path.is_file() { + return Ok(Vec::new()); + } + let content = fs::read_to_string(path) + .map_err(|error| format!("读取 {label} 配置失败 {}: {error}", path_to_string(path)))?; + let mut document = content + .parse::() + .map_err(|error| format!("解析 {label} 配置失败: {error}"))?; + let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); + let mut changed = false; + + let default_table = if let Some(section) = default_section { + document + .as_table_mut() + .get_mut(section) + .and_then(Item::as_table_mut) + } else { + Some(document.as_table_mut()) + }; + if let Some(table) = default_table { + if table + .get(default_key) + .and_then(Item::as_str) + .is_some_and(|model| model.starts_with(&prefix)) + { + table.remove(default_key); + changed = true; + } + } + + if let Some(section) = provider_section { + if let Some(providers) = document + .as_table_mut() + .get_mut(section) + .and_then(Item::as_table_mut) + { + changed |= providers.remove(MANAGED_AGENT_PROVIDER_ID).is_some(); + } + } + if let Some(models) = document + .as_table_mut() + .get_mut(model_entry_section) + .and_then(Item::as_table_mut) + { + let managed = models + .iter() + .map(|(key, _)| key.to_string()) + .filter(|key| key.starts_with(&prefix)) + .collect::>(); + changed |= !managed.is_empty(); + for key in managed { + models.remove(&key); + } + } + for section in provider_section.into_iter().chain([model_entry_section]) { + let empty = document + .as_table() + .get(section) + .and_then(Item::as_table) + .is_some_and(toml_edit::Table::is_empty); + if empty { + document.as_table_mut().remove(section); + } + } + if let Some(section) = default_section { + let empty = document + .as_table() + .get(section) + .and_then(Item::as_table) + .is_some_and(toml_edit::Table::is_empty); + if empty { + document.as_table_mut().remove(section); + } + } + if !changed { + return Ok(Vec::new()); + } + let rendered = document.to_string(); + toml::from_str::(&rendered) + .map_err(|error| format!("验证恢复后的 {label} 配置失败: {error}"))?; + if rendered.trim().is_empty() { + fs::remove_file(path).map_err(|error| { + format!( + "删除空的 {label} 配置失败 {}: {error}", + path_to_string(path) + ) + })?; + } else { + write_bytes_directly(path, rendered.as_bytes())?; + } + Ok(vec![path_to_string(path)]) +} + pub(crate) fn update_agent_json5_file( path: &Path, label: &str, @@ -1174,6 +1428,9 @@ pub(crate) fn remove_agent_managed_configuration( AgentClient::OpenClaw => remove_openclaw_managed_configuration(paths), AgentClient::Hermes => remove_hermes_managed_configuration(paths), AgentClient::DeepSeekHarness => remove_deepseek_harness_managed_configuration(paths), + AgentClient::ZCode => remove_zcode_managed_configuration(paths), + AgentClient::KimiCode => remove_kimi_code_managed_configuration(paths), + AgentClient::GrokBuild => remove_grok_build_managed_configuration(paths), } } @@ -1330,6 +1587,7 @@ pub(crate) fn build_restored_claude_code_config( "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_FABLE_MODEL", + CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV, CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV, DISABLE_AUTO_COMPACT_ENV, @@ -1476,6 +1734,248 @@ pub(crate) fn build_restored_opencode_config( render_restored_json(root, original.is_some(), "恢复后的 OpenCode 配置") } +pub(crate) fn build_restored_zcode_config( + current: &str, + original: Option<&str>, +) -> Result, String> { + let mut root = parse_agent_json_object(Some(current), "当前 ZCode 配置")?; + let original_root = parse_restored_json_object(original, "原始 ZCode 配置")?; + restore_json_key(&mut root, original_root.as_ref(), "model"); + let original_provider = original_root + .as_ref() + .and_then(|root| root.get("provider")) + .and_then(serde_json::Value::as_object); + let original_managed = original_provider + .and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID)) + .and_then(serde_json::Value::as_object); + if root + .get("provider") + .is_some_and(serde_json::Value::is_object) + { + let providers = root + .get_mut("provider") + .and_then(serde_json::Value::as_object_mut) + .expect("ZCode provider was checked as an object"); + if providers + .get(MANAGED_AGENT_PROVIDER_ID) + .is_some_and(serde_json::Value::is_object) + { + let managed = providers + .get_mut(MANAGED_AGENT_PROVIDER_ID) + .and_then(serde_json::Value::as_object_mut) + .expect("ZCode managed provider was checked as an object"); + for key in [ + "enabled", + "name", + "source", + "kind", + "defaultKind", + "apiFormat", + "npm", + "models", + ] { + restore_json_key(managed, original_managed, key); + } + let original_options = original_managed + .and_then(|managed| managed.get("options")) + .and_then(serde_json::Value::as_object); + if managed + .get("options") + .is_some_and(serde_json::Value::is_object) + { + let options = managed + .get_mut("options") + .and_then(serde_json::Value::as_object_mut) + .expect("ZCode options were checked as an object"); + for key in ["baseURL", "apiKey"] { + restore_json_key(options, original_options, key); + } + if options.is_empty() && original_options.is_none() { + managed.remove("options"); + } + } else { + restore_json_key(managed, original_managed, "options"); + } + if managed.is_empty() && original_managed.is_none() { + providers.remove(MANAGED_AGENT_PROVIDER_ID); + } + } else if let Some(original_managed) = original_provider + .and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID)) + .cloned() + { + providers.insert(MANAGED_AGENT_PROVIDER_ID.to_string(), original_managed); + } + if providers.is_empty() && original_provider.is_none() { + root.remove("provider"); + } + } else { + restore_json_key(&mut root, original_root.as_ref(), "provider"); + } + render_restored_json(root, original.is_some(), "恢复后的 ZCode 配置") +} + +pub(crate) fn build_restored_kimi_code_config( + current: &str, + original: Option<&str>, +) -> Result, String> { + build_restored_managed_toml_client_config( + current, + original, + "Kimi Code", + Some(("providers", &["type", "base_url", "api_key"])), + "models", + None, + "default_model", + ) +} + +pub(crate) fn build_restored_grok_build_config( + current: &str, + original: Option<&str>, +) -> Result, String> { + build_restored_managed_toml_client_config( + current, + original, + "Grok Build", + None, + "model", + Some("models"), + "default", + ) +} + +pub(crate) fn build_restored_managed_toml_client_config( + current: &str, + original: Option<&str>, + label: &str, + provider: Option<(&str, &[&str])>, + model_entry_section: &str, + default_section: Option<&str>, + default_key: &str, +) -> Result, String> { + use toml_edit::Item; + + let mut current_document = parse_codex_document(Some(current), &format!("当前 {label} 配置"))?; + let original_document = original + .map(|content| parse_codex_document(Some(content), &format!("原始 {label} 配置"))) + .transpose()?; + if let Some(original_document) = original_document.as_ref() { + merge_missing_codex_table_items( + current_document.as_table_mut(), + original_document.as_table(), + ); + } + + if let Some(section) = default_section { + let original_table = original_document + .as_ref() + .and_then(|document| document.as_table().get(section)) + .and_then(Item::as_table); + if let Some(current_table) = current_document + .as_table_mut() + .get_mut(section) + .and_then(Item::as_table_mut) + { + restore_codex_table_item(current_table, original_table, default_key); + } + } else { + restore_codex_table_item( + current_document.as_table_mut(), + original_document + .as_ref() + .map(|document| document.as_table()), + default_key, + ); + } + + if let Some((section, managed_keys)) = provider { + let original_provider = original_document + .as_ref() + .and_then(|document| document.as_table().get(section)) + .and_then(Item::as_table) + .and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID)) + .and_then(Item::as_table) + .cloned(); + if let Some(providers) = current_document + .as_table_mut() + .get_mut(section) + .and_then(Item::as_table_mut) + { + if let Some(current_provider) = providers + .get_mut(MANAGED_AGENT_PROVIDER_ID) + .and_then(Item::as_table_mut) + { + for key in managed_keys { + restore_codex_table_item(current_provider, original_provider.as_ref(), key); + } + if current_provider.is_empty() { + providers.remove(MANAGED_AGENT_PROVIDER_ID); + } + } + } + } + + let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); + let original_models = original_document + .as_ref() + .and_then(|document| document.as_table().get(model_entry_section)) + .and_then(Item::as_table) + .map(|models| { + models + .iter() + .filter(|(key, _)| key.starts_with(&prefix)) + .map(|(key, item)| (key.to_string(), item.clone())) + .collect::>() + }) + .unwrap_or_default(); + if let Some(models) = current_document + .as_table_mut() + .get_mut(model_entry_section) + .and_then(Item::as_table_mut) + { + let managed = models + .iter() + .map(|(key, _)| key.to_string()) + .filter(|key| key.starts_with(&prefix)) + .collect::>(); + for key in managed { + models.remove(&key); + } + for (key, item) in &original_models { + models.insert(key, item.clone()); + } + } else if !original_models.is_empty() { + let models = ensure_toml_child_table(current_document.as_table_mut(), model_entry_section); + for (key, item) in &original_models { + models.insert(key, item.clone()); + } + } + + for section in provider + .map(|(section, _)| section) + .into_iter() + .chain([model_entry_section]) + .chain(default_section) + { + let empty = current_document + .as_table() + .get(section) + .and_then(Item::as_table) + .is_some_and(toml_edit::Table::is_empty); + if empty { + current_document.as_table_mut().remove(section); + } + } + let rendered = current_document.to_string(); + toml::from_str::(&rendered) + .map_err(|error| format!("验证恢复后的 {label} 配置失败: {error}"))?; + if original.is_none() && rendered.trim().is_empty() { + Ok(None) + } else { + Ok(Some(rendered)) + } +} + pub(crate) fn build_restored_openclaw_config( current: &str, original: Option<&str>, @@ -1837,7 +2337,7 @@ pub(crate) fn agent_config_semantically_equal( expected: &str, ) -> bool { match client { - AgentClient::Codex => { + AgentClient::Codex | AgentClient::KimiCode | AgentClient::GrokBuild => { toml::from_str::(actual).ok() == toml::from_str::(expected).ok() } @@ -1906,6 +2406,9 @@ pub(crate) fn build_agent_session_restored_bytes( _ => return Err("DeepSeek Harness 恢复路径索引无效".to_string()), } } + AgentClient::ZCode => build_restored_zcode_config(current, original)?, + AgentClient::KimiCode => build_restored_kimi_code_config(current, original)?, + AgentClient::GrokBuild => build_restored_grok_build_config(current, original)?, }; if let (Some(restored), Some(original), Some(original_bytes)) = (restored.as_deref(), original, original_bytes) @@ -2242,6 +2745,198 @@ pub(crate) fn build_opencode_agent_config( render_agent_json(root, "OpenCode 配置") } +pub(crate) fn build_zcode_agent_config( + existing: Option<&str>, + base_url: &str, + api_key: &str, + model: &str, + available_models: &[AgentModelOption], +) -> Result { + build_zcode_config(existing, base_url, api_key, model, available_models, false) +} + +pub(crate) fn build_zcode_cli_agent_config( + existing: Option<&str>, + base_url: &str, + api_key: &str, + model: &str, + available_models: &[AgentModelOption], +) -> Result { + build_zcode_config(existing, base_url, api_key, model, available_models, true) +} + +fn build_zcode_config( + existing: Option<&str>, + base_url: &str, + api_key: &str, + model: &str, + available_models: &[AgentModelOption], + cli_config: bool, +) -> Result { + let mut root = parse_agent_json_object(existing, "ZCode config.json")?; + let providers = ensure_json_object_entry(&mut root, "provider"); + let models = ordered_agent_models(available_models, model) + .into_iter() + .map(|model| { + let display_name = model.alias.as_deref().unwrap_or(&model.name).to_string(); + let mut entry = serde_json::Map::new(); + entry.insert("name".to_string(), serde_json::json!(display_name)); + if let Some(context_window) = model.context_window { + entry.insert( + "limit".to_string(), + serde_json::json!({ "context": context_window }), + ); + } + (model.name, serde_json::Value::Object(entry)) + }) + .collect::>(); + let managed_provider = ensure_json_object_entry(providers, MANAGED_AGENT_PROVIDER_ID); + managed_provider.insert("enabled".to_string(), serde_json::json!(true)); + managed_provider.insert("name".to_string(), serde_json::json!("EasyCLIProxyAPI")); + managed_provider.insert("source".to_string(), serde_json::json!("custom")); + managed_provider.insert("kind".to_string(), serde_json::json!("anthropic")); + managed_provider.insert("defaultKind".to_string(), serde_json::json!("anthropic")); + managed_provider.insert( + "apiFormat".to_string(), + serde_json::json!("anthropic-messages"), + ); + managed_provider.insert("npm".to_string(), serde_json::json!("@ai-sdk/anthropic")); + let options = ensure_json_object_entry(managed_provider, "options"); + options.insert("baseURL".to_string(), serde_json::json!(base_url)); + options.insert("apiKey".to_string(), serde_json::json!(api_key)); + managed_provider.insert("models".to_string(), serde_json::Value::Object(models)); + let model = format!("{MANAGED_AGENT_PROVIDER_ID}/{model}"); + if cli_config { + ensure_json_object_entry(&mut root, "model") + .insert("main".to_string(), serde_json::json!(model)); + } else { + root.insert("model".to_string(), serde_json::json!(model)); + } + render_agent_json(root, "ZCode 配置") +} + +pub(crate) fn ensure_toml_child_table<'a>( + parent: &'a mut toml_edit::Table, + key: &str, +) -> &'a mut toml_edit::Table { + use toml_edit::{Item, Table}; + + if !parent.get(key).is_some_and(Item::is_table) { + parent.remove(key); + parent.insert(key, Item::Table(Table::new())); + } + parent + .get_mut(key) + .and_then(Item::as_table_mut) + .expect("inserted TOML table must remain a table") +} + +pub(crate) fn managed_model_alias(model: &str) -> String { + format!("{MANAGED_AGENT_PROVIDER_ID}/{model}") +} + +pub(crate) fn build_kimi_code_agent_config( + existing: Option<&str>, + base_url: &str, + api_key: &str, + model: &str, + available_models: &[AgentModelOption], +) -> Result { + use toml_edit::{value, Array, Item, Table}; + + let mut document = parse_codex_document(existing, "Kimi Code config.toml")?; + set_codex_table_item( + document.as_table_mut(), + "default_model", + value(managed_model_alias(model)), + ); + + let providers = ensure_toml_child_table(document.as_table_mut(), "providers"); + let provider = ensure_toml_child_table(providers, MANAGED_AGENT_PROVIDER_ID); + set_codex_table_item(provider, "type", value("openai")); + set_codex_table_item(provider, "base_url", value(base_url)); + set_codex_table_item(provider, "api_key", value(api_key)); + + let models = ensure_toml_child_table(document.as_table_mut(), "models"); + let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); + let stale = models + .iter() + .map(|(key, _)| key.to_string()) + .filter(|key| key.starts_with(&prefix)) + .collect::>(); + for key in stale { + models.remove(&key); + } + for option in ordered_agent_models(available_models, model) { + let mut entry = Table::new(); + entry.insert("provider", value(MANAGED_AGENT_PROVIDER_ID)); + entry.insert("model", value(option.name.clone())); + entry.insert( + "display_name", + value(option.alias.as_deref().unwrap_or(&option.name)), + ); + let context = option + .context_window + .unwrap_or(200_000) + .min(i64::MAX as u64) as i64; + entry.insert("max_context_size", value(context)); + let mut capabilities = Array::new(); + capabilities.push("tool_use"); + entry.insert("capabilities", value(capabilities)); + models.insert(&managed_model_alias(&option.name), Item::Table(entry)); + } + let rendered = document.to_string(); + toml::from_str::(&rendered) + .map_err(|error| format!("验证 Kimi Code 配置失败: {error}"))?; + Ok(rendered) +} + +pub(crate) fn build_grok_build_agent_config( + existing: Option<&str>, + base_url: &str, + api_key: &str, + model: &str, + available_models: &[AgentModelOption], +) -> Result { + use toml_edit::{value, Item, Table}; + + let mut document = parse_codex_document(existing, "Grok Build config.toml")?; + let defaults = ensure_toml_child_table(document.as_table_mut(), "models"); + set_codex_table_item(defaults, "default", value(managed_model_alias(model))); + + let models = ensure_toml_child_table(document.as_table_mut(), "model"); + let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); + let stale = models + .iter() + .map(|(key, _)| key.to_string()) + .filter(|key| key.starts_with(&prefix)) + .collect::>(); + for key in stale { + models.remove(&key); + } + for option in ordered_agent_models(available_models, model) { + let mut entry = Table::new(); + entry.insert("model", value(option.name.clone())); + entry.insert("base_url", value(base_url)); + entry.insert( + "name", + value(option.alias.as_deref().unwrap_or(&option.name)), + ); + entry.insert("api_key", value(api_key)); + entry.insert("api_backend", value("chat_completions")); + let context = option + .context_window + .unwrap_or(200_000) + .min(i64::MAX as u64) as i64; + entry.insert("context_window", value(context)); + models.insert(&managed_model_alias(&option.name), Item::Table(entry)); + } + let rendered = document.to_string(); + toml::from_str::(&rendered) + .map_err(|error| format!("验证 Grok Build 配置失败: {error}"))?; + Ok(rendered) +} + pub(crate) fn build_openclaw_agent_config( existing: Option<&str>, base_url: &str, diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index 2d9023f..131d80f 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -1,5 +1,8 @@ use super::*; +const AGENT_VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5); +const AGENT_VERSION_PROBE_POLL_INTERVAL: Duration = Duration::from_millis(20); + pub(crate) fn agent_config_paths(client: AgentClient, home: &Path) -> Vec { match client { AgentClient::ClaudeCode => { @@ -32,9 +35,29 @@ pub(crate) fn agent_config_paths(client: AgentClient, home: &Path) -> Vec vec![ + home.join(".zcode/v2").join(ZCODE_CONFIG_FILE), + home.join(".zcode/cli").join(ZCODE_CONFIG_FILE), + ], + AgentClient::KimiCode => vec![kimi_code_home(home).join(KIMI_CODE_CONFIG_FILE)], + AgentClient::GrokBuild => vec![grok_build_home(home).join(GROK_BUILD_CONFIG_FILE)], } } +pub(crate) fn kimi_code_home(home: &Path) -> PathBuf { + env::var_os("KIMI_CODE_HOME") + .map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| home.join(".kimi-code")) +} + +pub(crate) fn grok_build_home(home: &Path) -> PathBuf { + env::var_os("GROK_HOME") + .map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| home.join(".grok")) +} + pub(crate) fn deepseek_harness_home(home: &Path) -> PathBuf { env::var_os("DSH_HOME") .map(PathBuf::from) @@ -345,6 +368,7 @@ pub(crate) fn inspect_pi_provider_status( config_exists, config_valid, configured, + configuration_synchronized: configured, current_model: current_model.clone(), oauth_configuration: false, modification_enabled: configured, @@ -796,15 +820,26 @@ pub(crate) fn inspect_agent_config( Err(error) => (false, None, false, false, Some(error)), }; let executable = find_agent_executable(client, home); - let cli_version = executable.as_deref().and_then(read_agent_version); + // ZCode's desktop executable is an Electron app, not a CLI. Invoking it + // with --version can start the GUI and block discovery, so only probe a + // separately installed command-line entry point here. + let cli_version = if client == AgentClient::ZCode { + find_named_agent_executable(home, &["zcode"]) + .filter(|path| executable.as_ref() != Some(path)) + .as_deref() + .and_then(read_agent_version) + } else { + executable.as_deref().and_then(read_agent_version) + }; let app_version = match client { AgentClient::ClaudeDesktop => read_claude_desktop_version(home), AgentClient::Codex => read_codex_app_version(home), AgentClient::DeepSeekHarness => read_deepseek_harness_profile_version(home), + AgentClient::ZCode => read_zcode_app_version(home), _ => None, }; let version = cli_version.clone().or_else(|| app_version.clone()); - let installed = version.is_some(); + let installed = version.is_some() || (client == AgentClient::ZCode && executable.is_some()); let mut warnings = Vec::new(); if !client.supported_platform() { warnings.push("当前平台不支持 Claude Desktop 3P 配置".to_string()); @@ -815,6 +850,7 @@ pub(crate) fn inspect_agent_config( warnings.push(message.clone()); } let modification = inspect_agent_application(client, home); + let configuration_synchronized = agent_configuration_is_synchronized(client, home, configured); warnings.extend(modification.warnings.iter().cloned()); AgentConfigStatus { @@ -831,6 +867,7 @@ pub(crate) fn inspect_agent_config( config_exists, config_valid, configured, + configuration_synchronized, current_model, oauth_configuration, modification_enabled: modification.enabled, @@ -846,6 +883,23 @@ pub(crate) fn inspect_agent_config( } } +pub(crate) fn agent_configuration_is_synchronized( + client: AgentClient, + home: &Path, + configured: bool, +) -> bool { + if !configured { + return false; + } + if !matches!(client, AgentClient::KimiCode | AgentClient::GrokBuild) { + return true; + } + load_agent_applied_state(client, home) + .ok() + .flatten() + .is_some_and(|state| state.configuration_revision >= AGENT_CONFIGURATION_REVISION) +} + pub(crate) fn inspect_agent_application( client: AgentClient, home: &Path, @@ -940,6 +994,23 @@ pub(crate) fn inspect_agent_managed_config( .map(|(configured, model)| (configured, model, false)), AgentClient::DeepSeekHarness => inspect_deepseek_harness_config(paths, port, api_key) .map(|(configured, model)| (configured, model, false)), + AgentClient::ZCode => { + if paths.len() != 2 { + return Err("ZCode 配置路径数量无效".to_string()); + } + let (app_configured, app_model) = inspect_zcode_agent_config(&paths[0], port, api_key)?; + let (cli_configured, cli_model) = inspect_zcode_agent_config(&paths[1], port, api_key)?; + let models_match = app_model.is_some() && app_model == cli_model; + Ok(( + app_configured && cli_configured && models_match, + cli_model.or(app_model), + false, + )) + } + AgentClient::KimiCode => inspect_kimi_code_agent_config(&paths[0], port, api_key) + .map(|(configured, model)| (configured, model, false)), + AgentClient::GrokBuild => inspect_grok_build_agent_config(&paths[0], port, api_key) + .map(|(configured, model)| (configured, model, false)), } } @@ -1059,6 +1130,47 @@ pub(crate) fn agent_has_managed_marker( Ok(provider_exists && model_selected) } AgentClient::DeepSeekHarness => deepseek_harness_has_managed_marker(paths), + AgentClient::ZCode => { + let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); + for path in paths { + if !path.is_file() { + continue; + } + let root = read_agent_json_or_empty(path, "ZCode 配置")?; + let provider_exists = root + .get("provider") + .and_then(|value| value.get(MANAGED_AGENT_PROVIDER_ID)) + .is_some(); + let model_selected = root + .get("model") + .and_then(|model| { + model + .as_str() + .or_else(|| model.get("main").and_then(serde_json::Value::as_str)) + }) + .is_some_and(|model| model.starts_with(&prefix)); + if provider_exists && model_selected { + return Ok(true); + } + } + Ok(false) + } + AgentClient::KimiCode => inspect_managed_toml_model_marker( + &paths[0], + "Kimi Code 配置", + Some("providers"), + "models", + None, + "default_model", + ), + AgentClient::GrokBuild => inspect_managed_toml_model_marker( + &paths[0], + "Grok Build 配置", + None, + "model", + Some("models"), + "default", + ), } } @@ -1067,6 +1179,44 @@ pub(crate) fn is_managed_agent_base_url(value: &str) -> bool { value.starts_with("http://127.0.0.1:") || value.starts_with("http://localhost:") } +pub(crate) fn inspect_managed_toml_model_marker( + path: &Path, + label: &str, + provider_section: Option<&str>, + model_entry_section: &str, + default_section: Option<&str>, + default_key: &str, +) -> Result { + if !path.is_file() { + return Ok(false); + } + let root: toml::Value = toml::from_str( + &fs::read_to_string(path).map_err(|error| format!("读取 {label} 失败: {error}"))?, + ) + .map_err(|error| format!("解析 {label} 失败: {error}"))?; + let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); + let selected = (if let Some(section) = default_section { + root.get(section) + .and_then(toml::Value::as_table) + .and_then(|section| section.get(default_key)) + } else { + root.get(default_key) + }) + .and_then(toml::Value::as_str) + .filter(|model| model.starts_with(&prefix)); + let catalog_has_selected = selected.is_some_and(|model| { + root.get(model_entry_section) + .and_then(toml::Value::as_table) + .is_some_and(|catalog| catalog.contains_key(model)) + }); + let provider_exists = provider_section.is_none_or(|section| { + root.get(section) + .and_then(toml::Value::as_table) + .is_some_and(|providers| providers.contains_key(MANAGED_AGENT_PROVIDER_ID)) + }); + Ok(provider_exists && catalog_has_selected) +} + #[cfg(any(target_os = "macos", target_os = "windows"))] pub(crate) fn find_codex_app_installation(home: &Path) -> Option { #[cfg(target_os = "macos")] @@ -1135,15 +1285,95 @@ pub(crate) fn read_codex_app_version(home: &Path) -> Option { } } +pub(crate) fn read_zcode_app_version(home: &Path) -> Option { + #[cfg(target_os = "windows")] + { + find_zcode_desktop_executable(home).and_then(|path| { + read_windows_executable_version(&path).or_else(|| read_agent_version(&path)) + }) + } + #[cfg(target_os = "macos")] + { + [ + PathBuf::from("/Applications/ZCode.app"), + home.join("Applications/ZCode.app"), + ] + .into_iter() + .find(|path| path.is_dir()) + .and_then(|path| read_macos_app_version(&path)) + .or_else(|| { + find_named_agent_executable(home, &["zcode"]).and_then(|path| read_agent_version(&path)) + }) + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + let _ = home; + None + } +} + +pub(crate) fn find_zcode_desktop_executable(home: &Path) -> Option { + #[cfg(target_os = "windows")] + { + let local = env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| home.join("AppData/Local")); + let mut candidates = vec![ + local.join("Programs/ZCode/ZCode.exe"), + local.join("ZCode/ZCode.exe"), + ]; + for variable in ["ProgramFiles", "ProgramFiles(x86)"] { + if let Some(root) = env::var_os(variable) + .map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) + { + candidates.push(root.join("ZCode/ZCode.exe")); + } + } + candidates + .into_iter() + .find(|path| path.is_file()) + .or_else(|| find_named_agent_executable(home, &["zcode"])) + } + #[cfg(target_os = "macos")] + { + [ + PathBuf::from("/Applications/ZCode.app/Contents/MacOS/ZCode"), + home.join("Applications/ZCode.app/Contents/MacOS/ZCode"), + ] + .into_iter() + .find(|path| path.is_file()) + .or_else(|| find_named_agent_executable(home, &["zcode"])) + } + #[cfg(target_os = "linux")] + { + let mut candidates = agent_executable_directories(home) + .into_iter() + .flat_map(|directory| [directory.join("zcode"), directory.join("ZCode")]) + .collect::>(); + candidates.extend([ + PathBuf::from("/opt/ZCode/zcode"), + PathBuf::from("/opt/ZCode/ZCode"), + PathBuf::from("/opt/zcode/zcode"), + ]); + candidates.into_iter().find(|path| path.is_file()) + } + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + { + find_named_agent_executable(home, &["zcode"]) + } +} + #[cfg(target_os = "macos")] pub(crate) fn read_macos_app_version(application: &Path) -> Option { let info_plist = application.join("Contents/Info.plist"); for key in ["CFBundleShortVersionString", "CFBundleVersion"] { - let output = Command::new("/usr/bin/plutil") + let mut command = Command::new("/usr/bin/plutil"); + command .args(["-extract", key, "raw", "-o", "-"]) - .arg(&info_plist) - .output() - .ok()?; + .arg(&info_plist); + let output = + command_output_with_timeout(&mut command, AGENT_VERSION_PROBE_TIMEOUT).ok()??; if !output.status.success() { continue; } @@ -1237,7 +1467,7 @@ if ($package -and $package.Version) { &encoded_command, ]); configure_background_command(&mut command); - let output = command.output().ok()?; + let output = command_output_with_timeout(&mut command, AGENT_VERSION_PROBE_TIMEOUT).ok()??; if !output.status.success() { return None; } @@ -1273,7 +1503,7 @@ if ($package -and $package.Version) { &encoded_command, ]); configure_background_command(&mut command); - let output = command.output().ok()?; + let output = command_output_with_timeout(&mut command, AGENT_VERSION_PROBE_TIMEOUT).ok()??; if !output.status.success() { return None; } @@ -1360,7 +1590,7 @@ foreach ($shortcutFile in (Get-ChildItem -LiteralPath $shortcutRoots -Filter '*. &encoded_command, ]); configure_background_command(&mut command); - let output = command.output().ok()?; + let output = command_output_with_timeout(&mut command, AGENT_VERSION_PROBE_TIMEOUT).ok()??; if !output.status.success() { return None; } @@ -1413,7 +1643,7 @@ if ($version) {{ &encoded_command, ]); configure_background_command(&mut command); - let output = command.output().ok()?; + let output = command_output_with_timeout(&mut command, AGENT_VERSION_PROBE_TIMEOUT).ok()??; if !output.status.success() { return None; } @@ -1459,7 +1689,9 @@ pub(crate) fn find_windows_codex_app_id_via_registry() -> Option { let mut command = Command::new(windows_registry_executable()); command.args(["query", PACKAGES_KEY, "/f", package_name, "/k", "/s"]); configure_background_command(&mut command); - let Ok(output) = command.output() else { + let Ok(Some(output)) = + command_output_with_timeout(&mut command, AGENT_VERSION_PROBE_TIMEOUT) + else { continue; }; if !output.status.success() { @@ -1540,9 +1772,42 @@ pub(crate) fn find_agent_executable(client: AgentClient, home: &Path) -> Option< if client == AgentClient::ClaudeDesktop { return find_claude_desktop_executable(home); } + if client == AgentClient::ZCode { + return find_zcode_desktop_executable(home); + } + if client == AgentClient::KimiCode { + return find_kimi_code_executable(home); + } + if client == AgentClient::GrokBuild { + return find_grok_build_executable(home); + } find_named_agent_executable(home, client.executable_names()) } +pub(crate) fn find_kimi_code_executable(home: &Path) -> Option { + let directory = kimi_code_home(home).join("bin"); + #[cfg(target_os = "windows")] + let managed = [directory.join("kimi.exe"), directory.join("kimi.cmd")]; + #[cfg(not(target_os = "windows"))] + let managed = [directory.join("kimi")]; + managed + .into_iter() + .find(|path| path.is_file()) + .or_else(|| find_named_agent_executable(home, &["kimi"])) +} + +pub(crate) fn find_grok_build_executable(home: &Path) -> Option { + let directory = grok_build_home(home).join("bin"); + #[cfg(target_os = "windows")] + let managed = [directory.join("grok.exe"), directory.join("grok.cmd")]; + #[cfg(not(target_os = "windows"))] + let managed = [directory.join("grok")]; + managed + .into_iter() + .find(|path| path.is_file()) + .or_else(|| find_named_agent_executable(home, &["grok"])) +} + pub(crate) fn find_pi_executable(home: &Path) -> Option { find_named_agent_executable(home, &["pi"]) } @@ -1698,6 +1963,74 @@ pub(crate) fn windows_batch_executable_argument(executable: &Path) -> String { format!("\"{}\"", path_to_string(executable)) } +pub(crate) fn command_output_with_timeout( + command: &mut Command, + timeout: Duration, +) -> io::Result> { + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command.spawn()?; + let mut stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "无法读取智能体探测标准输出"))?; + let mut stderr = child + .stderr + .take() + .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "无法读取智能体探测错误输出"))?; + thread::scope(|scope| { + let stdout_reader = scope.spawn(move || { + let mut output = Vec::new(); + stdout.read_to_end(&mut output).map(|_| output) + }); + let stderr_reader = scope.spawn(move || { + let mut output = Vec::new(); + stderr.read_to_end(&mut output).map(|_| output) + }); + let deadline = Instant::now() + timeout; + let status = loop { + if let Some(status) = child.try_wait()? { + break Some(status); + } + if Instant::now() >= deadline { + terminate_agent_probe_process(&mut child); + break None; + } + thread::sleep(AGENT_VERSION_PROBE_POLL_INTERVAL); + }; + let stdout = stdout_reader + .join() + .map_err(|_| io::Error::new(io::ErrorKind::Other, "智能体探测输出线程异常退出"))??; + let stderr = stderr_reader + .join() + .map_err(|_| io::Error::new(io::ErrorKind::Other, "智能体探测错误线程异常退出"))??; + Ok(status.map(|status| std::process::Output { + status, + stdout, + stderr, + })) + }) +} + +fn terminate_agent_probe_process(child: &mut Child) { + #[cfg(target_os = "windows")] + { + let process_id = child.id().to_string(); + let mut command = Command::new("taskkill"); + command + .args(["/PID", &process_id, "/T", "/F"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + configure_background_command(&mut command); + let _ = command.status(); + } + let _ = child.kill(); + let _ = child.wait(); +} + pub(crate) fn read_agent_version(path: &Path) -> Option { #[cfg(target_os = "windows")] let mut command = windows_command_for_executable(path, false); @@ -1707,7 +2040,7 @@ pub(crate) fn read_agent_version(path: &Path) -> Option { command.arg("--version"); configure_background_command(&mut command); - let output = command.output().ok()?; + let output = command_output_with_timeout(&mut command, AGENT_VERSION_PROBE_TIMEOUT).ok()??; if !output.status.success() { return None; } @@ -1750,7 +2083,11 @@ pub(crate) fn inspect_claude_agent_config( && env .and_then(|env| env.get("ANTHROPIC_AUTH_TOKEN")) .and_then(serde_json::Value::as_str) - == Some(api_key); + == Some(api_key) + && env + .and_then(|env| env.get(CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV)) + .and_then(serde_json::Value::as_str) + == Some("1"); let model = env .and_then(|env| env.get("ANTHROPIC_MODEL")) .and_then(serde_json::Value::as_str) @@ -2232,6 +2569,178 @@ pub(crate) fn inspect_opencode_agent_config( Ok((configured, model)) } +pub(crate) fn inspect_zcode_agent_config( + path: &Path, + port: u16, + api_key: &str, +) -> 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!("读取 ZCode 配置失败: {error}"))?, + ) + .map_err(|error| format!("解析 ZCode 配置失败: {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}"); + let api_format_matches = provider + .and_then(|provider| provider.get("apiFormat")) + .and_then(serde_json::Value::as_str) + .is_none_or(|api_format| api_format == "anthropic-messages"); + let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); + let model = root + .get("model") + .and_then(|model| { + model + .as_str() + .or_else(|| model.get("main").and_then(serde_json::Value::as_str)) + }) + .map(str::trim) + .filter(|value| !value.is_empty()) + .and_then(|value| value.strip_prefix(&prefix)) + .map(str::to_string); + let selected_model_exists = model.as_deref().is_some_and(|selected_model| { + provider + .and_then(|provider| provider.get("models")) + .and_then(serde_json::Value::as_object) + .is_some_and(|models| { + models + .keys() + .any(|model| model.eq_ignore_ascii_case(selected_model)) + }) + }); + let configured = selected_model_exists + && api_format_matches + && provider + .and_then(|provider| provider.get("enabled")) + .and_then(serde_json::Value::as_bool) + == Some(true) + && provider + .and_then(|provider| provider.get("kind")) + .and_then(serde_json::Value::as_str) + == Some("anthropic") + && 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(api_key); + Ok((configured, model)) +} + +pub(crate) fn inspect_kimi_code_agent_config( + path: &Path, + port: u16, + api_key: &str, +) -> 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!("读取 Kimi Code 配置失败: {error}"))?, + ) + .map_err(|error| format!("解析 Kimi Code 配置失败: {error}"))?; + let expected_base = format!("http://127.0.0.1:{port}/v1"); + let provider = root + .get("providers") + .and_then(toml::Value::as_table) + .and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID)) + .and_then(toml::Value::as_table); + let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); + let selected = root + .get("default_model") + .and_then(toml::Value::as_str) + .map(str::trim) + .filter(|value| value.starts_with(&prefix)); + let model_entry = selected.and_then(|alias| { + root.get("models") + .and_then(toml::Value::as_table) + .and_then(|models| models.get(alias)) + .and_then(toml::Value::as_table) + }); + let model = model_entry + .and_then(|entry| entry.get("model")) + .and_then(toml::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let configured = selected.is_some() + && model.is_some() + && provider + .and_then(|provider| provider.get("type")) + .and_then(toml::Value::as_str) + == Some("openai") + && 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("api_key")) + .and_then(toml::Value::as_str) + == Some(api_key) + && model_entry + .and_then(|entry| entry.get("provider")) + .and_then(toml::Value::as_str) + == Some(MANAGED_AGENT_PROVIDER_ID); + Ok((configured, model)) +} + +pub(crate) fn inspect_grok_build_agent_config( + path: &Path, + port: u16, + api_key: &str, +) -> 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!("读取 Grok Build 配置失败: {error}"))?, + ) + .map_err(|error| format!("解析 Grok Build 配置失败: {error}"))?; + let expected_base = format!("http://127.0.0.1:{port}/v1"); + let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); + let selected = root + .get("models") + .and_then(toml::Value::as_table) + .and_then(|models| models.get("default")) + .and_then(toml::Value::as_str) + .map(str::trim) + .filter(|value| value.starts_with(&prefix)); + let model_entry = selected.and_then(|alias| { + root.get("model") + .and_then(toml::Value::as_table) + .and_then(|models| models.get(alias)) + .and_then(toml::Value::as_table) + }); + let model = model_entry + .and_then(|entry| entry.get("model")) + .and_then(toml::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let configured = selected.is_some() + && model.is_some() + && model_entry + .and_then(|entry| entry.get("base_url")) + .and_then(toml::Value::as_str) + == Some(expected_base.as_str()) + && model_entry + .and_then(|entry| entry.get("api_key")) + .and_then(toml::Value::as_str) + == Some(api_key) + && model_entry + .and_then(|entry| entry.get("api_backend")) + .and_then(toml::Value::as_str) + == Some("chat_completions"); + Ok((configured, model)) +} + pub(crate) fn inspect_openclaw_agent_config( path: &Path, port: u16, diff --git a/src-tauri/src/agents/state.rs b/src-tauri/src/agents/state.rs index 6c613fa..d1eed97 100644 --- a/src-tauri/src/agents/state.rs +++ b/src-tauri/src/agents/state.rs @@ -161,6 +161,7 @@ pub(crate) fn recover_codex_applied_state_from_backups( version: AGENT_APPLIED_STATE_VERSION, client: AgentClient::Codex.id().to_string(), model, + configuration_revision: AGENT_CONFIGURATION_REVISION, claude_desktop_model_mappings: None, backup_files, updated_at_unix: SystemTime::now() @@ -189,7 +190,11 @@ pub(crate) fn validate_agent_applied_state( .map(|file| file.path.clone()) .collect::>(); let valid_paths = state_paths == expected_paths - || (client == AgentClient::Codex && state_paths.as_slice() == paths); + || (client == AgentClient::Codex && state_paths.as_slice() == paths) + || (client == AgentClient::ZCode + && paths + .first() + .is_some_and(|path| state_paths == [path.clone()])); if !valid_paths { return Err("智能体应用状态文件数量或路径不匹配".to_string()); } @@ -280,6 +285,7 @@ pub(crate) fn load_agent_applied_state( version: AGENT_APPLIED_STATE_VERSION, client: client.id().to_string(), model: record.model, + configuration_revision: 0, claude_desktop_model_mappings: None, backup_files, updated_at_unix: SystemTime::now() @@ -385,7 +391,11 @@ pub(crate) fn validate_agent_record( .map(|file| file.path.clone()) .collect::>(); let valid_paths = record_paths.as_slice() == paths - || (client == AgentClient::Codex && record_paths == expected_paths); + || (client == AgentClient::Codex && record_paths == expected_paths) + || (client == AgentClient::ZCode + && paths + .first() + .is_some_and(|path| record_paths == [path.clone()])); if !valid_paths { return Err("智能体备份状态文件数量或路径不匹配".to_string()); } @@ -728,6 +738,24 @@ pub(crate) fn fresh_agent_contents_with_oauth( build_deepseek_harness_settings(None, &openai_base, model, models)?, build_deepseek_harness_credentials(None, api_key)?, ]), + AgentClient::ZCode => Ok(vec![ + build_zcode_agent_config(None, &root_base, api_key, model, models)?, + build_zcode_cli_agent_config(None, &root_base, api_key, model, models)?, + ]), + AgentClient::KimiCode => Ok(vec![build_kimi_code_agent_config( + None, + &openai_base, + api_key, + model, + models, + )?]), + AgentClient::GrokBuild => Ok(vec![build_grok_build_agent_config( + None, + &openai_base, + api_key, + model, + models, + )?]), } } @@ -738,6 +766,10 @@ pub(crate) fn agent_contents_equal(client: AgentClient, actual: &str, expected: normalize_codex_config_for_legacy_compare(actual) == normalize_codex_config_for_legacy_compare(expected) } + AgentClient::KimiCode | AgentClient::GrokBuild => { + toml::from_str::(actual).ok() + == toml::from_str::(expected).ok() + } AgentClient::OpenClaw => { json5::from_str::(actual).ok() == json5::from_str::(expected).ok() @@ -1357,13 +1389,16 @@ pub(crate) fn restore_agent_applied_state_configuration( Ok(()) } -pub(crate) fn agent_clients_restored_on_exit() -> [AgentClient; 5] { +pub(crate) fn agent_clients_restored_on_exit() -> [AgentClient; 8] { [ AgentClient::Codex, AgentClient::OpenCode, AgentClient::OpenClaw, AgentClient::Hermes, AgentClient::DeepSeekHarness, + AgentClient::ZCode, + AgentClient::KimiCode, + AgentClient::GrokBuild, ] } @@ -1406,14 +1441,36 @@ pub(crate) fn commit_agent_configuration( .collect::, String>>()?; snapshots.push((state_path.clone(), previous_state)); - let created_session_backups = existing_state - .as_ref() - .is_none_or(|state| state.backup_files.is_empty()); + let mut created_backup_paths = Vec::new(); let backup_files = if let Some(state) = existing_state.filter(|state| !state.backup_files.is_empty()) { - state.backup_files + let mut backup_files = state.backup_files; + let missing_updates = updates + .iter() + .filter(|update| !backup_files.iter().any(|file| file.path == update.path)) + .map(|update| AgentFileUpdate { + path: update.path.clone(), + after: update.after.clone(), + }) + .collect::>(); + let added_backups = prepare_agent_session_backups(client, &missing_updates)?; + created_backup_paths.extend( + added_backups + .iter() + .filter(|file| file.existed_before) + .map(|file| file.backup_path.clone()), + ); + backup_files.extend(added_backups); + backup_files } else { - prepare_agent_session_backups(client, updates)? + let backup_files = prepare_agent_session_backups(client, updates)?; + created_backup_paths.extend( + backup_files + .iter() + .filter(|file| file.existed_before) + .map(|file| file.backup_path.clone()), + ); + backup_files }; let transaction = (|| -> Result, String> { @@ -1430,6 +1487,7 @@ pub(crate) fn commit_agent_configuration( version: AGENT_APPLIED_STATE_VERSION, client: client.id().to_string(), model: model.to_string(), + configuration_revision: AGENT_CONFIGURATION_REVISION, claude_desktop_model_mappings: (client == AgentClient::ClaudeDesktop) .then(|| claude_desktop_model_mappings.cloned()) .flatten(), @@ -1460,11 +1518,9 @@ pub(crate) fn commit_agent_configuration( } Err(error) => { let rollback = restore_agent_snapshots_direct(&snapshots); - if created_session_backups { - for backup in &backup_files { - if backup.existed_before && backup.backup_path.is_file() { - let _ = fs::remove_file(&backup.backup_path); - } + for backup_path in created_backup_paths { + if backup_path.is_file() { + let _ = fs::remove_file(backup_path); } } match rollback { diff --git a/src-tauri/src/codex_catalog.rs b/src-tauri/src/codex_catalog.rs index 1bfa837..fa6109c 100644 --- a/src-tauri/src/codex_catalog.rs +++ b/src-tauri/src/codex_catalog.rs @@ -154,6 +154,26 @@ pub(crate) fn parse_runtime_models(payload: &Value) -> Result Result { @@ -873,6 +893,40 @@ mod tests { assert_eq!(catalog.models[1].alias.as_deref(), Some("gpt-5.5")); } + #[test] + fn runtime_context_windows_override_generic_model_metadata() { + let mut models = vec![ + AgentModelOption { + name: "GPT-Test".to_string(), + alias: None, + is_alias: false, + context_window: Some(200_000), + }, + AgentModelOption { + name: "unmatched".to_string(), + alias: None, + is_alias: false, + context_window: Some(128_000), + }, + AgentModelOption { + name: "max-only".to_string(), + alias: None, + is_alias: false, + context_window: None, + }, + ]; + let runtime_models = runtime(serde_json::json!({"models":[ + {"id":"gpt-test","context_window":372000}, + {"id":"max-only","max_context_window":272000} + ]})); + + merge_runtime_context_windows(&mut models, &runtime_models); + + assert_eq!(models[0].context_window, Some(372_000)); + assert_eq!(models[1].context_window, Some(128_000)); + assert_eq!(models[2].context_window, Some(272_000)); + } + #[test] fn all_runtime_models_advertise_fast_capabilities() { let runtime = runtime(serde_json::json!({"models":[ diff --git a/src-tauri/src/configuration_watcher.rs b/src-tauri/src/configuration_watcher.rs index 5ffafbf..579bb96 100644 --- a/src-tauri/src/configuration_watcher.rs +++ b/src-tauri/src/configuration_watcher.rs @@ -70,6 +70,10 @@ fn tracked_configuration_paths(app: &tauri::AppHandle) -> Result, S AgentClient::OpenCode, AgentClient::OpenClaw, AgentClient::Hermes, + AgentClient::DeepSeekHarness, + AgentClient::ZCode, + AgentClient::KimiCode, + AgentClient::GrokBuild, ] { paths.extend(agent_managed_paths(client, &home)); } diff --git a/src-tauri/src/core_runtime.rs b/src-tauri/src/core_runtime.rs index fa79a91..54b3274 100644 --- a/src-tauri/src/core_runtime.rs +++ b/src-tauri/src/core_runtime.rs @@ -180,7 +180,6 @@ pub(crate) async fn install_core_version_inner( let install_dir = core_install_dir()?; let base_dir = core_base_dir()?; let staging_dir = base_dir.join("cpa-core.staging"); - let backup_dir = base_dir.join("cpa-core.backup"); let download_dir = base_dir.join("cpa-core.download"); if current_core_status(None, None)?.running { @@ -231,7 +230,7 @@ pub(crate) async fn install_core_version_inner( }, )?; - replace_install_dir(&install_dir, &staging_dir, &backup_dir)?; + overlay_install_dir(&install_dir, &staging_dir)?; let _ = fs::remove_dir_all(&download_dir); Ok(CoreInstallResult { @@ -252,7 +251,6 @@ pub(crate) fn install_bundled_core_inner( let install_dir = core_install_dir()?; let base_dir = core_base_dir()?; let staging_dir = base_dir.join("cpa-core.staging"); - let backup_dir = base_dir.join("cpa-core.backup"); if current_core_status(None, None)?.running { return Err("CPA 内核正在运行,请先停止后再使用内置内核".to_string()); @@ -294,7 +292,7 @@ pub(crate) fn install_bundled_core_inner( installed_at_unix: unix_now(), }, )?; - replace_install_dir(&install_dir, &staging_dir, &backup_dir)?; + overlay_install_dir(&install_dir, &staging_dir)?; Ok(CoreInstallResult { version: info.version.clone(), @@ -1654,30 +1652,70 @@ pub(crate) fn reset_dir(path: &Path) -> Result<(), String> { fs::create_dir_all(path).map_err(|err| format!("创建目录失败 {}: {err}", path_to_string(path))) } -pub(crate) fn replace_install_dir( - install_dir: &Path, - staging_dir: &Path, - backup_dir: &Path, -) -> Result<(), String> { - if backup_dir.exists() { - fs::remove_dir_all(backup_dir).map_err(|err| format!("清理备份目录失败: {err}"))?; +pub(crate) fn overlay_install_dir(install_dir: &Path, staging_dir: &Path) -> Result<(), String> { + if !staging_dir.is_dir() { + return Err(format!( + "内核暂存目录不存在: {}", + path_to_string(staging_dir) + )); } - if install_dir.exists() { - fs::rename(install_dir, backup_dir) - .map_err(|err| format!("备份旧内核目录失败,请确认 CPA 内核未运行: {err}"))?; + if !install_dir.exists() { + return fs::rename(staging_dir, install_dir) + .map_err(|err| format!("安装新内核目录失败: {err}")); + } + if !install_dir.is_dir() { + return Err(format!( + "内核安装路径不是目录: {}", + path_to_string(install_dir) + )); } - if let Err(err) = fs::rename(staging_dir, install_dir) { - if backup_dir.exists() { - let _ = fs::rename(backup_dir, install_dir); + overlay_directory(staging_dir, install_dir)?; + fs::remove_dir_all(staging_dir).map_err(|err| format!("清理内核暂存目录失败: {err}")) +} + +fn overlay_directory(source_dir: &Path, target_dir: &Path) -> Result<(), String> { + for entry in fs::read_dir(source_dir) + .map_err(|err| format!("读取内核暂存目录失败 {}: {err}", path_to_string(source_dir)))? + { + let entry = entry.map_err(|err| format!("读取内核暂存条目失败: {err}"))?; + let source_path = entry.path(); + let target_path = target_dir.join(entry.file_name()); + let file_type = entry + .file_type() + .map_err(|err| format!("读取内核暂存条目类型失败: {err}"))?; + + if file_type.is_dir() { + if target_path.exists() && !target_path.is_dir() { + return Err(format!( + "无法用内核目录覆盖同名文件: {}", + path_to_string(&target_path) + )); + } + fs::create_dir_all(&target_path).map_err(|err| { + format!( + "创建内核安装子目录失败 {}: {err}", + path_to_string(&target_path) + ) + })?; + overlay_directory(&source_path, &target_path)?; + } else if file_type.is_file() { + if target_path.exists() && !target_path.is_file() { + return Err(format!( + "无法用内核文件覆盖同名目录: {}", + path_to_string(&target_path) + )); + } + fs::copy(&source_path, &target_path).map_err(|err| { + format!("覆盖内核文件失败 {}: {err}", path_to_string(&target_path)) + })?; + } else { + return Err(format!( + "内核暂存目录包含不支持的条目: {}", + path_to_string(&source_path) + )); } - - return Err(format!("切换新内核目录失败: {err}")); - } - - if backup_dir.exists() { - fs::remove_dir_all(backup_dir).map_err(|err| format!("删除旧内核备份目录失败: {err}"))?; } Ok(()) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 79078e1..5336ec3 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -130,6 +130,9 @@ const DEFAULT_MAX_RETRY_INTERVAL: u32 = 30; const DEFAULT_STREAMING_BOOTSTRAP_RETRIES: u32 = 0; const LEGACY_DEFAULT_MANAGEMENT_SECRET_KEY: &str = "123456"; const MANAGED_AGENT_PROVIDER_ID: &str = "cpa-gui"; +const ZCODE_CONFIG_FILE: &str = "config.json"; +const KIMI_CODE_CONFIG_FILE: &str = "config.toml"; +const GROK_BUILD_CONFIG_FILE: &str = "config.toml"; const DEEPSEEK_HARNESS_PROVIDER_ID: &str = "easy-cliproxyapi"; const DEEPSEEK_HARNESS_CREDENTIAL: &str = "EASYCLIPROXYAPI_API_KEY"; const DEEPSEEK_HARNESS_SETTINGS_FILE: &str = "settings.yaml"; @@ -156,6 +159,8 @@ const MANAGED_CLAUDE_HAIKU_ALIAS_DISPLAY_NAME: &str = const DEFAULT_CLAUDE_CONTEXT_WINDOW: u64 = 200_000; const CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW: u64 = 1_000_000; const CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV: &str = "CLAUDE_CODE_MAX_CONTEXT_TOKENS"; +const CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV: &str = + "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"; const CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV: &str = "CLAUDE_AUTOCOMPACT_PCT_OVERRIDE"; const DISABLE_AUTO_COMPACT_ENV: &str = "DISABLE_AUTO_COMPACT"; const DEFAULT_CLAUDE_AUTO_COMPACT_PCT: u8 = 90; @@ -168,6 +173,7 @@ const MODEL_ALIAS_CONFIG_SECTIONS: &[&str] = &[ const LEGACY_AGENT_MODIFICATION_STATE_VERSION: u8 = 1; const AGENT_MODIFICATION_STATE_VERSION: u8 = 2; const AGENT_APPLIED_STATE_VERSION: u8 = 4; +const AGENT_CONFIGURATION_REVISION: u8 = 1; const AGENT_PHASE_APPLYING: &str = "applying"; const AGENT_PHASE_ACTIVE: &str = "active"; const AGENT_PHASE_RESTORING: &str = "restoring"; @@ -765,6 +771,7 @@ struct AgentConfigStatus { config_exists: bool, config_valid: bool, configured: bool, + configuration_synchronized: bool, current_model: Option, oauth_configuration: bool, modification_enabled: bool, @@ -948,6 +955,8 @@ struct AgentAppliedState { version: u8, client: String, model: String, + #[serde(default)] + configuration_revision: u8, #[serde(default, skip_serializing_if = "Option::is_none")] claude_desktop_model_mappings: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -972,6 +981,9 @@ enum AgentClient { OpenClaw, Hermes, DeepSeekHarness, + ZCode, + KimiCode, + GrokBuild, } #[derive(Clone, Debug)] @@ -992,6 +1004,9 @@ impl AgentClient { "openclaw" => Ok(Self::OpenClaw), "hermes" => Ok(Self::Hermes), "deepseek-harness" => Ok(Self::DeepSeekHarness), + "zcode" => Ok(Self::ZCode), + "kimi-code" => Ok(Self::KimiCode), + "grok-build" => Ok(Self::GrokBuild), _ => Err(format!("不支持的智能体客户端: {value}")), } } @@ -1005,6 +1020,9 @@ impl AgentClient { Self::OpenClaw => "openclaw", Self::Hermes => "hermes", Self::DeepSeekHarness => "deepseek-harness", + Self::ZCode => "zcode", + Self::KimiCode => "kimi-code", + Self::GrokBuild => "grok-build", } } @@ -1017,6 +1035,9 @@ impl AgentClient { Self::OpenClaw => "OpenClaw", Self::Hermes => "Hermes Agent", Self::DeepSeekHarness => "DeepSeek Harness", + Self::ZCode => "ZCode", + Self::KimiCode => "Kimi Code", + Self::GrokBuild => "Grok Build", } } @@ -1038,6 +1059,9 @@ impl AgentClient { Self::OpenClaw => &["openclaw"], Self::Hermes => &["hermes"], Self::DeepSeekHarness => &["dsh"], + Self::ZCode => &["zcode"], + Self::KimiCode => &["kimi"], + Self::GrokBuild => &["grok"], } } } diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index 0b39fd8..85cfe58 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -22,6 +22,10 @@ fn claude_agent_config_preserves_existing_fields() { 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["env"][CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV], + "1" + ); assert_eq!(value["env"][CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV], "200000"); assert_eq!(value["env"][CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV], "90"); assert!(value["env"].get(DISABLE_AUTO_COMPACT_ENV).is_none()); @@ -44,6 +48,7 @@ fn claude_code_inspection_normalizes_legacy_1m_suffix() { "env": { "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317", "ANTHROPIC_AUTH_TOKEN": "test-key", + "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "1", "ANTHROPIC_MODEL": "deepseek-v4-pro[1m]", "ANTHROPIC_DEFAULT_OPUS_MODEL": "deepseek-v4-pro[1m]", "ANTHROPIC_DEFAULT_SONNET_MODEL": "deepseek-v4-pro[1m]", @@ -71,6 +76,30 @@ fn claude_code_inspection_normalizes_legacy_1m_suffix() { fs::remove_dir_all(directory).unwrap(); } +#[test] +fn claude_code_requires_gateway_model_discovery_to_be_enabled() { + let directory = agent_test_home("claude-code-gateway-model-discovery"); + let path = directory.join("settings.json"); + fs::write( + &path, + r#"{ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317", + "ANTHROPIC_AUTH_TOKEN": "test-key", + "ANTHROPIC_MODEL": "gpt-test", + "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "0" + } + }"#, + ) + .unwrap(); + + let (configured, model) = inspect_claude_agent_config(&path, 8317, "test-key").unwrap(); + + assert!(!configured); + assert_eq!(model.as_deref(), Some("gpt-test")); + fs::remove_dir_all(directory).unwrap(); +} + #[test] fn claude_mapping_legacy_json_defaults_1m_preferences_off() { let mappings: ClaudeDesktopModelMappings = @@ -612,6 +641,366 @@ fn opencode_agent_config_preserves_other_providers() { assert!(value["provider"][MANAGED_AGENT_PROVIDER_ID]["models"]["deepseek-test"].is_object()); } +#[test] +fn zcode_agent_config_preserves_other_providers_and_uses_anthropic_messages() { + let home = agent_test_home("zcode-agent-config"); + let path = home.join(".zcode/v2/config.json"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let mut models = test_agent_models(&["gpt-test", "deepseek-test"]); + models[0].context_window = Some(372_000); + models[1].context_window = Some(272_000); + let rendered = build_zcode_agent_config( + Some( + r#"{"locale":"zh-CN","provider":{"other":{"kind":"openai"},"cpa-gui":{"custom":"keep","options":{"timeout":30}}}}"#, + ), + "http://127.0.0.1:8317", + DEFAULT_API_KEY, + "gpt-test", + &models, + ) + .unwrap(); + fs::write(&path, &rendered).unwrap(); + let value: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + + assert_eq!(value["locale"], "zh-CN"); + assert_eq!(value["provider"]["other"]["kind"], "openai"); + assert_eq!( + value["provider"][MANAGED_AGENT_PROVIDER_ID]["custom"], + "keep" + ); + assert_eq!( + value["provider"][MANAGED_AGENT_PROVIDER_ID]["options"]["timeout"], + 30 + ); + assert_eq!( + value["provider"][MANAGED_AGENT_PROVIDER_ID]["kind"], + "anthropic" + ); + assert_eq!( + value["provider"][MANAGED_AGENT_PROVIDER_ID]["apiFormat"], + "anthropic-messages" + ); + assert_eq!( + value["provider"][MANAGED_AGENT_PROVIDER_ID]["options"]["baseURL"], + "http://127.0.0.1:8317" + ); + assert_eq!(value["model"], "cpa-gui/gpt-test"); + assert_eq!( + value["provider"][MANAGED_AGENT_PROVIDER_ID]["models"]["gpt-test"]["limit"]["context"], + 372_000 + ); + assert_eq!( + value["provider"][MANAGED_AGENT_PROVIDER_ID]["models"]["deepseek-test"]["limit"]["context"], + 272_000 + ); + assert_eq!( + inspect_zcode_agent_config(&path, 8317, DEFAULT_API_KEY).unwrap(), + (true, Some("gpt-test".to_string())) + ); + + let mut normalized = value; + let provider = normalized["provider"][MANAGED_AGENT_PROVIDER_ID] + .as_object_mut() + .unwrap(); + provider.remove("apiFormat"); + provider.remove("defaultKind"); + provider.remove("npm"); + fs::write(&path, serde_json::to_string_pretty(&normalized).unwrap()).unwrap(); + assert_eq!( + inspect_zcode_agent_config(&path, 8317, DEFAULT_API_KEY).unwrap(), + (true, Some("gpt-test".to_string())) + ); + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn zcode_cli_config_sets_main_model_and_both_configs_must_match() { + let home = agent_test_home("zcode-cli-default-model"); + let paths = agent_config_paths(AgentClient::ZCode, &home); + fs::create_dir_all(paths[0].parent().unwrap()).unwrap(); + fs::create_dir_all(paths[1].parent().unwrap()).unwrap(); + let models = test_agent_models(&["gpt-test", "deepseek-test"]); + let app_config = build_zcode_agent_config( + None, + "http://127.0.0.1:8317", + DEFAULT_API_KEY, + "gpt-test", + &models, + ) + .unwrap(); + let cli_config = build_zcode_cli_agent_config( + Some(r#"{"model":{"lite":"other/lite"},"plugins":{"keep":true}}"#), + "http://127.0.0.1:8317", + DEFAULT_API_KEY, + "gpt-test", + &models, + ) + .unwrap(); + let cli_value: serde_json::Value = serde_json::from_str(&cli_config).unwrap(); + + assert_eq!(cli_value["model"]["main"], "cpa-gui/gpt-test"); + assert_eq!(cli_value["model"]["lite"], "other/lite"); + assert_eq!(cli_value["plugins"]["keep"], true); + fs::write(&paths[0], app_config).unwrap(); + fs::write(&paths[1], &cli_config).unwrap(); + assert_eq!( + inspect_agent_managed_config(AgentClient::ZCode, &paths, 8317, DEFAULT_API_KEY).unwrap(), + (true, Some("gpt-test".to_string()), false) + ); + + let mut mismatched = cli_value; + mismatched["model"]["main"] = serde_json::json!("cpa-gui/deepseek-test"); + fs::write( + &paths[1], + serde_json::to_string_pretty(&mismatched).unwrap(), + ) + .unwrap(); + assert_eq!( + inspect_agent_managed_config(AgentClient::ZCode, &paths, 8317, DEFAULT_API_KEY).unwrap(), + (false, Some("deepseek-test".to_string()), false) + ); + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn zcode_inspection_requires_the_default_model_to_belong_to_the_managed_provider() { + let home = agent_test_home("zcode-default-model-inspection"); + let path = home.join(".zcode/v2/config.json"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let rendered = build_zcode_agent_config( + None, + "http://127.0.0.1:8317", + DEFAULT_API_KEY, + "gpt-test", + &test_agent_models(&["gpt-test"]), + ) + .unwrap(); + let mut value: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + value["model"] = serde_json::json!("other/gpt-test"); + fs::write(&path, serde_json::to_string_pretty(&value).unwrap()).unwrap(); + + assert_eq!( + inspect_zcode_agent_config(&path, 8317, DEFAULT_API_KEY).unwrap(), + (false, None) + ); + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn kimi_code_config_preserves_existing_tables_and_uses_openai_provider() { + let home = agent_test_home("kimi-code-agent-config"); + let path = home.join(".kimi-code/config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let mut models = test_agent_models(&["gpt-test", "deepseek-test"]); + models[0].context_window = Some(1_048_576); + models[1].context_window = Some(131_072); + let rendered = build_kimi_code_agent_config( + Some( + r#"theme = "dark" + +[providers.other] +type = "openai" +base_url = "https://example.com/v1" + +[models."other/model"] +provider = "other" +model = "model" +"#, + ), + "http://127.0.0.1:8317/v1", + DEFAULT_API_KEY, + "gpt-test", + &models, + ) + .unwrap(); + fs::write(&path, &rendered).unwrap(); + let value: toml::Value = toml::from_str(&rendered).unwrap(); + + assert_eq!(value["theme"].as_str(), Some("dark")); + assert_eq!( + value["providers"]["other"]["base_url"].as_str(), + Some("https://example.com/v1") + ); + assert_eq!(value["default_model"].as_str(), Some("cpa-gui/gpt-test")); + assert_eq!( + value["providers"][MANAGED_AGENT_PROVIDER_ID]["type"].as_str(), + Some("openai") + ); + assert_eq!( + value["providers"][MANAGED_AGENT_PROVIDER_ID]["base_url"].as_str(), + Some("http://127.0.0.1:8317/v1") + ); + assert_eq!( + value["models"]["cpa-gui/gpt-test"]["capabilities"] + .as_array() + .unwrap()[0] + .as_str(), + Some("tool_use") + ); + assert_eq!( + value["models"]["cpa-gui/gpt-test"]["max_context_size"].as_integer(), + Some(1_048_576) + ); + assert_eq!( + value["models"]["cpa-gui/deepseek-test"]["max_context_size"].as_integer(), + Some(131_072) + ); + assert_eq!( + inspect_kimi_code_agent_config(&path, 8317, DEFAULT_API_KEY).unwrap(), + (true, Some("gpt-test".to_string())) + ); + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn grok_build_config_preserves_existing_models_and_uses_chat_completions() { + let home = agent_test_home("grok-build-agent-config"); + let path = home.join(".grok/config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let mut models = test_agent_models(&["gpt-test", "deepseek-test"]); + models[0].context_window = Some(1_048_576); + models[1].context_window = Some(131_072); + let rendered = build_grok_build_agent_config( + Some( + r#"theme = "dark" + +[models] +default = "other" + +[model.other] +model = "other-model" +base_url = "https://example.com/v1" +api_key = "other-key" +"#, + ), + "http://127.0.0.1:8317/v1", + DEFAULT_API_KEY, + "gpt-test", + &models, + ) + .unwrap(); + fs::write(&path, &rendered).unwrap(); + let value: toml::Value = toml::from_str(&rendered).unwrap(); + + assert_eq!(value["theme"].as_str(), Some("dark")); + assert_eq!( + value["model"]["other"]["model"].as_str(), + Some("other-model") + ); + assert_eq!( + value["models"]["default"].as_str(), + Some("cpa-gui/gpt-test") + ); + assert_eq!( + value["model"]["cpa-gui/gpt-test"]["base_url"].as_str(), + Some("http://127.0.0.1:8317/v1") + ); + assert_eq!( + value["model"]["cpa-gui/gpt-test"]["api_backend"].as_str(), + Some("chat_completions") + ); + assert_eq!( + value["model"]["cpa-gui/gpt-test"]["context_window"].as_integer(), + Some(1_048_576) + ); + assert_eq!( + value["model"]["cpa-gui/deepseek-test"]["context_window"].as_integer(), + Some(131_072) + ); + assert_eq!( + inspect_grok_build_agent_config(&path, 8317, DEFAULT_API_KEY).unwrap(), + (true, Some("gpt-test".to_string())) + ); + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn kimi_and_grok_toml_restore_preserves_runtime_fields_and_original_selection() { + use toml_edit::{value, Document}; + + let models = test_agent_models(&["gpt-test", "deepseek-test"]); + let kimi_original = r#"default_model = "cpa-gui/original" + +[providers.cpa-gui] +type = "openai_responses" +base_url = "https://original.example/v1" +api_key = "original-key" + +[models."cpa-gui/original"] +provider = "cpa-gui" +model = "original" +"#; + let kimi_managed = build_kimi_code_agent_config( + Some(kimi_original), + "http://127.0.0.1:8317/v1", + DEFAULT_API_KEY, + "gpt-test", + &models, + ) + .unwrap(); + let mut kimi_runtime = kimi_managed.parse::().unwrap(); + kimi_runtime + .as_table_mut() + .insert("runtime_added", value(true)); + kimi_runtime["providers"][MANAGED_AGENT_PROVIDER_ID] + .as_table_mut() + .unwrap() + .insert("runtime_custom", value("keep")); + let kimi_restored = + build_restored_kimi_code_config(&kimi_runtime.to_string(), Some(kimi_original)) + .unwrap() + .unwrap(); + let kimi: toml::Value = toml::from_str(&kimi_restored).unwrap(); + assert_eq!(kimi["default_model"].as_str(), Some("cpa-gui/original")); + assert_eq!(kimi["runtime_added"].as_bool(), Some(true)); + assert_eq!( + kimi["providers"][MANAGED_AGENT_PROVIDER_ID]["type"].as_str(), + Some("openai_responses") + ); + assert_eq!( + kimi["providers"][MANAGED_AGENT_PROVIDER_ID]["runtime_custom"].as_str(), + Some("keep") + ); + assert!(kimi["models"].get("cpa-gui/gpt-test").is_none()); + assert_eq!( + kimi["models"]["cpa-gui/original"]["model"].as_str(), + Some("original") + ); + + let grok_original = r#"[models] +default = "cpa-gui/original" + +[model."cpa-gui/original"] +model = "original" +base_url = "https://original.example/v1" +api_key = "original-key" +"#; + let grok_managed = build_grok_build_agent_config( + Some(grok_original), + "http://127.0.0.1:8317/v1", + DEFAULT_API_KEY, + "gpt-test", + &models, + ) + .unwrap(); + let mut grok_runtime = grok_managed.parse::().unwrap(); + grok_runtime + .as_table_mut() + .insert("runtime_added", value(true)); + let grok_restored = + build_restored_grok_build_config(&grok_runtime.to_string(), Some(grok_original)) + .unwrap() + .unwrap(); + let grok: toml::Value = toml::from_str(&grok_restored).unwrap(); + assert_eq!(grok["models"]["default"].as_str(), Some("cpa-gui/original")); + assert_eq!(grok["runtime_added"].as_bool(), Some(true)); + assert!(grok["model"].get("cpa-gui/gpt-test").is_none()); + assert_eq!( + grok["model"]["cpa-gui/original"]["base_url"].as_str(), + Some("https://original.example/v1") + ); +} + #[test] fn openclaw_agent_config_accepts_json5_and_preserves_unknown_fields() { let models = test_agent_models(&["gpt-test", "deepseek-test"]); @@ -737,6 +1126,28 @@ fn agent_model_list_parser_exposes_aliases_as_selectable_model_ids() { ); } +#[test] +fn kimi_grok_and_zcode_use_cpa_runtime_context_windows() { + for client in [ + AgentClient::KimiCode, + AgentClient::GrokBuild, + AgentClient::ZCode, + ] { + assert!(agent_uses_cpa_runtime_context_windows(client)); + } + for client in [ + AgentClient::ClaudeCode, + AgentClient::ClaudeDesktop, + AgentClient::Codex, + AgentClient::OpenCode, + AgentClient::OpenClaw, + AgentClient::Hermes, + AgentClient::DeepSeekHarness, + ] { + assert!(!agent_uses_cpa_runtime_context_windows(client)); + } +} + #[test] fn claude_model_lists_mark_yaml_aliases_when_api_returns_only_model_ids() { let mut models = test_agent_models(&[ @@ -782,6 +1193,9 @@ fn exit_preserves_claude_client_configurations() { assert!(restored.contains(&AgentClient::Codex)); assert!(restored.contains(&AgentClient::OpenCode)); assert!(restored.contains(&AgentClient::DeepSeekHarness)); + assert!(restored.contains(&AgentClient::ZCode)); + assert!(restored.contains(&AgentClient::KimiCode)); + assert!(restored.contains(&AgentClient::GrokBuild)); } #[test] @@ -1289,6 +1703,62 @@ fn detected_agent_version_requires_a_real_version_value() { assert!(normalize_detected_agent_version(&"1".repeat(257)).is_none()); } +#[test] +fn agent_probe_command_captures_output_before_timeout() { + #[cfg(target_os = "windows")] + let mut command = { + let mut command = Command::new(windows_command_processor()); + command.args(["/D", "/C", "echo agent 1.2.3"]); + configure_background_command(&mut command); + command + }; + #[cfg(not(target_os = "windows"))] + let mut command = { + let mut command = Command::new("sh"); + command.args(["-c", "printf 'agent 1.2.3\\n'"]); + command + }; + + let output = command_output_with_timeout(&mut command, Duration::from_secs(2)) + .unwrap() + .unwrap(); + + assert!(output.status.success()); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "agent 1.2.3" + ); +} + +#[test] +fn agent_probe_command_stops_at_timeout() { + #[cfg(target_os = "windows")] + let mut command = { + let mut command = Command::new(windows_powershell_executable()); + command.args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "Start-Sleep -Seconds 5", + ]); + configure_background_command(&mut command); + command + }; + #[cfg(not(target_os = "windows"))] + let mut command = { + let mut command = Command::new("sh"); + command.args(["-c", "sleep 5"]); + command + }; + + let started = Instant::now(); + let output = command_output_with_timeout(&mut command, Duration::from_millis(100)).unwrap(); + + assert!(output.is_none()); + assert!(started.elapsed() < Duration::from_secs(3)); +} + #[test] fn codex_model_list_is_empty_when_cpa_has_no_writable_models() { let prepared = prepare_codex_agent_models(&[]).unwrap(); diff --git a/src-tauri/src/tests/agent_state.rs b/src-tauri/src/tests/agent_state.rs index d11c1aa..aec01db 100644 --- a/src-tauri/src/tests/agent_state.rs +++ b/src-tauri/src/tests/agent_state.rs @@ -367,9 +367,160 @@ fn opencode_runtime_edits_survive_close_and_next_apply_owns_conflicts() { fs::remove_dir_all(home).unwrap(); } +#[test] +fn zcode_runtime_edits_survive_close_and_original_provider_is_restored() { + let home = agent_test_home("zcode-runtime-edit-merge"); + let path = home.join(".zcode/v2/config.json"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + r#"{"model":"other/original","provider":{"other":{"keep":"original"},"cpa-gui":{"name":"Original CPA","custom":"original"}},"keep":"root"}"#, + ) + .unwrap(); + let models = test_agent_models(&["gpt-one", "gpt-two"]); + apply_agent_configuration( + AgentClient::ZCode, + &home, + 8317, + DEFAULT_API_KEY, + "gpt-one", + &models, + None, + ) + .unwrap(); + + let mut runtime: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + runtime["agentAdded"] = serde_json::json!({"enabled": true}); + runtime["provider"]["other"]["runtimeAdded"] = serde_json::json!(42); + runtime["provider"][MANAGED_AGENT_PROVIDER_ID]["customAfterApply"] = + serde_json::json!("keep-me"); + runtime["provider"][MANAGED_AGENT_PROVIDER_ID]["options"]["baseURL"] = + serde_json::json!("https://agent-overwrite.invalid"); + fs::write(&path, serde_json::to_string_pretty(&runtime).unwrap()).unwrap(); + + restore_agent_session_configuration(AgentClient::ZCode, &home).unwrap(); + let closed: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(closed["keep"], "root"); + assert_eq!(closed["model"], "other/original"); + assert_eq!(closed["agentAdded"]["enabled"], true); + assert_eq!(closed["provider"]["other"]["runtimeAdded"], 42); + assert_eq!( + closed["provider"][MANAGED_AGENT_PROVIDER_ID]["name"], + "Original CPA" + ); + assert_eq!( + closed["provider"][MANAGED_AGENT_PROVIDER_ID]["custom"], + "original" + ); + assert_eq!( + closed["provider"][MANAGED_AGENT_PROVIDER_ID]["customAfterApply"], + "keep-me" + ); + assert!(closed["provider"][MANAGED_AGENT_PROVIDER_ID] + .get("options") + .is_none()); + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn zcode_legacy_single_file_state_upgrades_and_restores_cli_config() { + let home = agent_test_home("zcode-legacy-state-upgrade"); + let paths = agent_config_paths(AgentClient::ZCode, &home); + fs::create_dir_all(paths[0].parent().unwrap()).unwrap(); + fs::create_dir_all(paths[1].parent().unwrap()).unwrap(); + let original_app = br#"{"model":"other/original","provider":{"other":{"keep":true}}}"#; + let original_cli = + br#"{"model":{"main":"other/original","lite":"other/lite"},"plugins":{"keep":true}}"#; + let models = test_agent_models(&["gpt-one", "gpt-two"]); + let managed_app = build_zcode_agent_config( + Some(std::str::from_utf8(original_app).unwrap()), + "http://127.0.0.1:8317", + DEFAULT_API_KEY, + "gpt-one", + &models, + ) + .unwrap(); + fs::write(&paths[0], managed_app).unwrap(); + fs::write(&paths[1], original_cli).unwrap(); + let app_backup = dated_agent_backup_path(&paths[0]).unwrap(); + fs::write(&app_backup, original_app).unwrap(); + let state_path = agent_state_path(&paths).unwrap(); + write_agent_applied_state( + &state_path, + &AgentAppliedState { + version: AGENT_APPLIED_STATE_VERSION, + client: AgentClient::ZCode.id().to_string(), + model: "gpt-one".to_string(), + configuration_revision: 0, + claude_desktop_model_mappings: None, + backup_files: vec![AgentAppliedBackupFile { + path: paths[0].clone(), + backup_path: app_backup, + existed_before: true, + }], + updated_at_unix: 1, + }, + ) + .unwrap(); + + apply_agent_configuration( + AgentClient::ZCode, + &home, + 8317, + DEFAULT_API_KEY, + "gpt-two", + &models, + None, + ) + .unwrap(); + let upgraded = load_agent_applied_state(AgentClient::ZCode, &home) + .unwrap() + .unwrap(); + assert_eq!(upgraded.backup_files.len(), 2); + let cli_value: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&paths[1]).unwrap()).unwrap(); + assert_eq!(cli_value["model"]["main"], "cpa-gui/gpt-two"); + + restore_agent_session_configuration(AgentClient::ZCode, &home).unwrap(); + assert_eq!(fs::read(&paths[0]).unwrap(), original_app); + assert_eq!(fs::read(&paths[1]).unwrap(), original_cli); + assert!(!state_path.exists()); + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn kimi_and_grok_legacy_states_require_context_catalog_resynchronization() { + for client in [AgentClient::KimiCode, AgentClient::GrokBuild] { + let home = agent_test_home(&format!("{}-context-revision", client.id())); + let paths = agent_config_paths(client, &home); + let state_path = agent_state_path(&paths).unwrap(); + fs::create_dir_all(state_path.parent().unwrap()).unwrap(); + let mut state = AgentAppliedState { + version: AGENT_APPLIED_STATE_VERSION, + client: client.id().to_string(), + model: "gpt-test".to_string(), + configuration_revision: 0, + claude_desktop_model_mappings: None, + backup_files: Vec::new(), + updated_at_unix: 1, + }; + write_agent_applied_state(&state_path, &state).unwrap(); + + assert!(!agent_configuration_is_synchronized(client, &home, true)); + + state.configuration_revision = AGENT_CONFIGURATION_REVISION; + write_agent_applied_state(&state_path, &state).unwrap(); + assert!(agent_configuration_is_synchronized(client, &home, true)); + assert!(!agent_configuration_is_synchronized(client, &home, false)); + fs::remove_dir_all(home).unwrap(); + } +} + #[test] fn session_merge_preserves_runtime_fields_for_other_agent_formats() { - let claude_original = r#"{"env":{"KEEP_ENV":"original"},"keep":"claude"}"#; + let claude_original = r#"{"env":{"KEEP_ENV":"original","CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY":"0"},"keep":"claude"}"#; let claude_managed = build_claude_agent_config( Some(claude_original), "http://127.0.0.1:8317", @@ -394,6 +545,10 @@ fn session_merge_preserves_runtime_fields_for_other_agent_formats() { assert_eq!(claude["runtimeAdded"], true); assert_eq!(claude["env"]["RUNTIME_ENV"], "keep"); assert_eq!(claude["env"]["KEEP_ENV"], "original"); + assert_eq!( + claude["env"][CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV], + "0" + ); assert!(claude["env"].get("ANTHROPIC_BASE_URL").is_none()); assert!(claude.get("model").is_none()); @@ -613,6 +768,7 @@ fn legacy_codex_single_file_state_remains_restorable() { version: AGENT_APPLIED_STATE_VERSION, client: AgentClient::Codex.id().to_string(), model: "gpt-test".to_string(), + configuration_revision: AGENT_CONFIGURATION_REVISION, claude_desktop_model_mappings: None, backup_files: vec![AgentAppliedBackupFile { path: path.clone(), @@ -677,6 +833,7 @@ fn claude_desktop_version_three_state_can_be_closed_safely() { version: 3, client: AgentClient::ClaudeDesktop.id().to_string(), model: "gpt-test".to_string(), + configuration_revision: 0, claude_desktop_model_mappings: None, backup_files: Vec::new(), updated_at_unix: 1, @@ -808,6 +965,7 @@ fn applied_state_rejects_backup_paths_outside_the_managed_config_set() { version: AGENT_APPLIED_STATE_VERSION, client: AgentClient::OpenCode.id().to_string(), model: "gpt-test".to_string(), + configuration_revision: AGENT_CONFIGURATION_REVISION, claude_desktop_model_mappings: None, backup_files: vec![AgentAppliedBackupFile { path: path.clone(), @@ -844,6 +1002,7 @@ fn missing_session_backup_is_detected_before_any_file_is_restored() { version: AGENT_APPLIED_STATE_VERSION, client: AgentClient::Codex.id().to_string(), model: "gpt-test".to_string(), + configuration_revision: AGENT_CONFIGURATION_REVISION, claude_desktop_model_mappings: None, backup_files: vec![ AgentAppliedBackupFile { @@ -881,6 +1040,7 @@ fn version_three_cleanup_covers_every_non_desktop_agent_format() { version: 3, client: client.id().to_string(), model: "gpt-test".to_string(), + configuration_revision: 0, claude_desktop_model_mappings: None, backup_files: Vec::new(), updated_at_unix: 1, @@ -895,6 +1055,7 @@ fn version_three_cleanup_covers_every_non_desktop_agent_format() { "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317", "ANTHROPIC_API_KEY": "secret", "ANTHROPIC_AUTH_TOKEN": "secret", + "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "1", "ANTHROPIC_MODEL": "gpt-test", "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-test", "KEEP_ENV": "yes" @@ -917,6 +1078,9 @@ fn version_three_cleanup_covers_every_non_desktop_agent_format() { serde_json::from_str(&fs::read_to_string(&claude_path).unwrap()).unwrap(); assert!(claude.get("model").is_none()); assert!(claude["env"].get("ANTHROPIC_BASE_URL").is_none()); + assert!(claude["env"] + .get(CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV) + .is_none()); assert_eq!(claude["env"]["KEEP_ENV"], "yes"); assert_eq!(claude["keep"], "claude"); diff --git a/src-tauri/src/tests/core_runtime.rs b/src-tauri/src/tests/core_runtime.rs index b172615..dc82cad 100644 --- a/src-tauri/src/tests/core_runtime.rs +++ b/src-tauri/src/tests/core_runtime.rs @@ -40,6 +40,81 @@ fn replacing_a_core_preserves_only_regular_bundled_assets() { fs::remove_dir_all(root).unwrap(); } +#[test] +fn overlaying_a_core_updates_packaged_files_and_preserves_plugins() { + let root = agent_test_home("core-overlay-preserves-plugins"); + let install_dir = root.join("cpa-core"); + let staging_dir = root.join("cpa-core.staging"); + fs::create_dir_all(install_dir.join("plugins/custom-router")).unwrap(); + fs::create_dir_all(staging_dir.join("plugins/bundled-router")).unwrap(); + fs::create_dir_all(staging_dir.join("runtime")).unwrap(); + fs::write(install_dir.join(core_binary_name()), b"old core").unwrap(); + fs::write(install_dir.join("README.md"), b"old readme").unwrap(); + fs::write(install_dir.join("user-data.json"), b"user data").unwrap(); + fs::write( + install_dir.join("plugins/custom-router/plugin.js"), + b"custom plugin", + ) + .unwrap(); + fs::write(staging_dir.join(core_binary_name()), b"new core").unwrap(); + fs::write(staging_dir.join("README.md"), b"new readme").unwrap(); + fs::write( + staging_dir.join("plugins/bundled-router/plugin.js"), + b"bundled plugin", + ) + .unwrap(); + fs::write(staging_dir.join("runtime/default.json"), b"new runtime").unwrap(); + + overlay_install_dir(&install_dir, &staging_dir).unwrap(); + + assert_eq!( + fs::read(install_dir.join(core_binary_name())).unwrap(), + b"new core" + ); + assert_eq!( + fs::read(install_dir.join("README.md")).unwrap(), + b"new readme" + ); + assert_eq!( + fs::read(install_dir.join("runtime/default.json")).unwrap(), + b"new runtime" + ); + assert_eq!( + fs::read(install_dir.join("plugins/custom-router/plugin.js")).unwrap(), + b"custom plugin" + ); + assert_eq!( + fs::read(install_dir.join("plugins/bundled-router/plugin.js")).unwrap(), + b"bundled plugin" + ); + assert_eq!( + fs::read(install_dir.join("user-data.json")).unwrap(), + b"user data" + ); + assert!(!staging_dir.exists()); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn installing_a_core_into_a_missing_directory_moves_the_complete_staging_tree() { + let root = agent_test_home("core-overlay-first-install"); + let install_dir = root.join("cpa-core"); + let staging_dir = root.join("cpa-core.staging"); + fs::create_dir_all(&staging_dir).unwrap(); + fs::write(staging_dir.join(core_binary_name()), b"new core").unwrap(); + fs::write(staging_dir.join(CORE_EXAMPLE_CONFIG_FILE), b"port: 8317\n").unwrap(); + + overlay_install_dir(&install_dir, &staging_dir).unwrap(); + + assert_eq!( + fs::read(install_dir.join(core_binary_name())).unwrap(), + b"new core" + ); + assert!(install_dir.join(CORE_EXAMPLE_CONFIG_FILE).is_file()); + assert!(!staging_dir.exists()); + fs::remove_dir_all(root).unwrap(); +} + #[test] fn replacing_a_core_migrates_old_fields_into_the_new_template() { let root = agent_test_home("core-config-migrate"); diff --git a/src/assets/icons/zcode.png b/src/assets/icons/zcode.png new file mode 100644 index 0000000..366b1fa Binary files /dev/null and b/src/assets/icons/zcode.png differ diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index e3c0f66..d328521 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -466,6 +466,9 @@ export const jaOverrides = { 'agents.description.openclaw': 'CPA モデルプロバイダーと既定モデルを使用', 'agents.description.hermes': 'CPA カスタムプロバイダーを使用', 'agents.description.deepseekHarness': 'Harness のネイティブ DeepSeek 設定と分離された OpenAI 互換ルートで CPA を使用', + 'agents.description.zcode': 'CPA の Anthropic Messages API と動的モデルカタログを使用', + 'agents.description.kimiCode': 'CPA の OpenAI Chat Completions API と動的モデルカタログを使用', + 'agents.description.grokBuild': 'CPA の OpenAI Chat Completions API とカスタムモデルカタログを使用', 'agents.description.pi': 'CLIProxyAPI プロバイダーを使用して Pi CLI のモデルを動的に読み込みます', 'agents.list.detecting': '検出中', 'agents.list.unsupported': '現在のプラットフォームでは非対応', @@ -617,6 +620,7 @@ export const jaOverrides = { 'agents.modify.default': 'デフォルト設定', 'agents.modify.clear': '設定を消去', 'agents.modify.invalid': '現在の設定を解析できません。「設定変更を適用」は最小の有効な設定を再構築し、「デフォルト設定」はファイル全体をリセットします。', + 'agents.modify.zcodeRestart': '設定の適用または終了後、ZCode を完全終了して再起動してください。デフォルトモデルとコンテキスト長は新しいセッションで有効になります。', 'agents.pi.installTitle': 'Pi CLIProxyAPI プロバイダー', 'agents.pi.installDescription': 'プラグインをインストールまたは更新し、CPA 認証情報、既定のプロバイダー、既定のモデルを修復します', 'agents.pi.install': 'Pi プラグインをインストール', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 067af82..af1536c 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -466,6 +466,9 @@ export const en: Record = { 'agents.description.openclaw': 'Uses the CPA model provider and default model', 'agents.description.hermes': 'Uses the CPA custom provider', 'agents.description.deepseekHarness': 'Uses CPA through an isolated, OpenAI-compatible Harness route', + 'agents.description.zcode': 'Uses the CPA Anthropic Messages API and dynamic model catalog', + 'agents.description.kimiCode': 'Uses the CPA OpenAI Chat Completions API and dynamic model catalog', + 'agents.description.grokBuild': 'Uses the CPA OpenAI Chat Completions API and custom model catalog', 'agents.description.pi': 'Loads models dynamically in Pi through the CLIProxyAPI provider', 'agents.list.detecting': 'Detecting', 'agents.list.unsupported': 'Unsupported on this platform', @@ -617,6 +620,7 @@ export const en: Record = { 'agents.modify.default': 'Default Configuration', 'agents.modify.clear': 'Clear Configuration', 'agents.modify.invalid': 'The current configuration cannot be parsed. Apply Configuration Changes rebuilds a minimal valid configuration; Default Configuration resets the whole file.', + 'agents.modify.zcodeRestart': 'Fully quit and restart ZCode after applying or closing the configuration; the default model and context length take effect in new sessions.', 'agents.pi.installTitle': 'Pi CLIProxyAPI provider', 'agents.pi.installDescription': 'Install or update the plugin and repair CPA credentials, the default provider, and the default model.', 'agents.pi.install': 'Install Pi Plugin', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 89e90d8..2e38336 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -464,6 +464,9 @@ export const zhCN = { 'agents.description.openclaw': '使用 CPA 模型供应商和默认模型', 'agents.description.hermes': '使用 CPA custom provider', 'agents.description.deepseekHarness': '通过通用 OpenAI 兼容路由使用 CPA,并与 Harness 原生 DeepSeek 配置隔离', + 'agents.description.zcode': '使用 CPA 的 Anthropic Messages 接口和动态模型目录', + 'agents.description.kimiCode': '使用 CPA 的 OpenAI Chat Completions 接口和动态模型目录', + 'agents.description.grokBuild': '使用 CPA 的 OpenAI Chat Completions 接口和自定义模型目录', 'agents.description.pi': '使用 Pi CLI 和 CLIProxyAPI provider 动态加载模型', 'agents.list.detecting': '正在检测', 'agents.list.unsupported': '当前平台不支持', @@ -615,6 +618,7 @@ export const zhCN = { 'agents.modify.default': '默认配置', 'agents.modify.clear': '清空配置', 'agents.modify.invalid': '当前配置无法解析。点击“应用配置修改”会重建最小合法配置;“默认配置”会重置整个文件。', + 'agents.modify.zcodeRestart': '应用或关闭配置后,请完全退出并重新启动 ZCode;默认模型和上下文长度会在新会话中生效。', 'agents.pi.installTitle': 'Pi CLIProxyAPI provider', 'agents.pi.installDescription': '安装或更新插件,并修复 CPA 凭据、默认 provider 与默认模型', 'agents.pi.install': '安装 Pi 插件', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index d76b90a..9a10840 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -27,9 +27,12 @@ import claudeIcon from '../assets/icons/claude.svg'; import codexIcon from '../assets/icons/codex.svg'; import deepseekIcon from '../assets/icons/deepseek.svg'; import hermesIcon from '../assets/icons/hermes.png'; +import grokIcon from '../assets/icons/grok.svg'; +import kimiIcon from '../assets/icons/kimi-light.svg'; import openclawIcon from '../assets/icons/openclaw.svg'; import opencodeIcon from '../assets/icons/opencode.svg'; import piIcon from '../assets/icons/pi-logo-on-light.svg'; +import zcodeIcon from '../assets/icons/zcode.png'; import { agentModelAlias, filterAgentModels, @@ -56,6 +59,9 @@ type AgentClientId = | 'openclaw' | 'hermes' | 'deepseek-harness' + | 'zcode' + | 'kimi-code' + | 'grok-build' | 'pi'; type ClaudeModelMappingClientId = 'claude-code' | 'claude-desktop'; @@ -74,6 +80,7 @@ type AgentConfigStatus = { pluginVersion: string | null; configValid: boolean; configured: boolean; + configurationSynchronized: boolean; currentModel: string | null; oauthConfiguration: boolean; modificationEnabled: boolean; @@ -170,7 +177,7 @@ type AgentDefinition = { name: string; icon?: string; Icon?: ComponentType<{ size?: number; 'aria-hidden'?: boolean }>; - descriptionKey: 'agents.description.claudeCode' | 'agents.description.claudeDesktop' | 'agents.description.codex' | 'agents.description.opencode' | 'agents.description.openclaw' | 'agents.description.hermes' | 'agents.description.deepseekHarness' | 'agents.description.pi'; + descriptionKey: 'agents.description.claudeCode' | 'agents.description.claudeDesktop' | 'agents.description.codex' | 'agents.description.opencode' | 'agents.description.openclaw' | 'agents.description.hermes' | 'agents.description.deepseekHarness' | 'agents.description.zcode' | 'agents.description.kimiCode' | 'agents.description.grokBuild' | 'agents.description.pi'; }; type AgentSubpageId = 'core' | 'sessions'; @@ -200,12 +207,42 @@ const agentDefinitions: AgentDefinition[] = [ icon: codexIcon, descriptionKey: 'agents.description.codex', }, + { + id: 'deepseek-harness', + name: 'DeepSeek Harness', + icon: deepseekIcon, + descriptionKey: 'agents.description.deepseekHarness', + }, { id: 'opencode', name: 'OpenCode', icon: opencodeIcon, descriptionKey: 'agents.description.opencode', }, + { + id: 'pi', + name: 'Pi', + icon: piIcon, + descriptionKey: 'agents.description.pi', + }, + { + id: 'grok-build', + name: 'Grok Build', + icon: grokIcon, + descriptionKey: 'agents.description.grokBuild', + }, + { + id: 'zcode', + name: 'ZCode', + icon: zcodeIcon, + descriptionKey: 'agents.description.zcode', + }, + { + id: 'kimi-code', + name: 'Kimi Code', + icon: kimiIcon, + descriptionKey: 'agents.description.kimiCode', + }, { id: 'openclaw', name: 'OpenClaw', @@ -218,18 +255,6 @@ const agentDefinitions: AgentDefinition[] = [ icon: hermesIcon, descriptionKey: 'agents.description.hermes', }, - { - id: 'deepseek-harness', - name: 'DeepSeek Harness', - icon: deepseekIcon, - descriptionKey: 'agents.description.deepseekHarness', - }, - { - id: 'pi', - name: 'Pi', - icon: piIcon, - descriptionKey: 'agents.description.pi', - }, ]; const agentSubpages: AgentSubpageDefinition[] = [ @@ -862,6 +887,7 @@ export function AgentsPage() { const configurationAction = resolveAgentConfigurationAction({ client: selected, modificationState: activeStatus?.modificationState ?? 'unconfigured', + configurationSynchronized: Boolean(activeStatus?.configurationSynchronized), selectedModel, appliedModel, oauthConfiguration, @@ -888,7 +914,9 @@ export function AgentsPage() { : t('agents.model.firstSelection', { count: models.length })); const modificationDescription = activeStatus?.modificationState === 'invalid' ? t('agents.modify.invalid') - : ''; + : selected === 'zcode' + ? t('agents.modify.zcodeRestart') + : ''; const refreshModels = () => { void loadModels(selected); }; diff --git a/src/services/agentConfigurationDraft.ts b/src/services/agentConfigurationDraft.ts index 116f1d3..000330e 100644 --- a/src/services/agentConfigurationDraft.ts +++ b/src/services/agentConfigurationDraft.ts @@ -6,6 +6,9 @@ export type AgentConfigurationClientId = | 'openclaw' | 'hermes' | 'deepseek-harness' + | 'zcode' + | 'kimi-code' + | 'grok-build' | 'pi'; export type AgentConfigurationModificationState = 'unconfigured' | 'applied' | 'invalid'; @@ -29,6 +32,7 @@ export type AgentModelMappings = { type ResolveAgentConfigurationActionOptions = { client: AgentConfigurationClientId; modificationState: AgentConfigurationModificationState; + configurationSynchronized?: boolean; selectedModel: string; appliedModel: string; oauthConfiguration: boolean; @@ -79,6 +83,7 @@ export const resolveAgentModelMappingsDraftSourceForClient = ( export function resolveAgentConfigurationAction({ client, modificationState, + configurationSynchronized = true, selectedModel, appliedModel, oauthConfiguration, @@ -88,6 +93,10 @@ export function resolveAgentConfigurationAction({ }: ResolveAgentConfigurationActionOptions): AgentConfigurationAction { if (modificationState !== 'applied') return 'apply'; if (client === 'pi') return 'close'; + if ( + (client === 'zcode' || client === 'kimi-code' || client === 'grok-build') + && !configurationSynchronized + ) return 'update'; const modelChanged = client === 'claude-code' || client === 'claude-desktop' ? !sameAgentModelMappings(modelMappings, appliedModelMappings) diff --git a/tests/agentModelPicker.test.ts b/tests/agentModelPicker.test.ts index 8f984ae..269f9ad 100644 --- a/tests/agentModelPicker.test.ts +++ b/tests/agentModelPicker.test.ts @@ -153,11 +153,45 @@ describe('agent configuration update action', () => { 'opencode', 'openclaw', 'hermes', + 'zcode', + 'kimi-code', + 'grok-build', ])('%s updates only while its model draft differs', (client) => { expect(appliedConfiguration(client, 'model-b')).toBe('update'); expect(appliedConfiguration(client, ' MODEL-A ')).toBe('close'); }); + test('ZCode updates an applied legacy configuration even when the model is unchanged', () => { + expect(resolveAgentConfigurationAction({ + client: 'zcode', + modificationState: 'applied', + configurationSynchronized: false, + selectedModel: 'model-a', + appliedModel: 'model-a', + oauthConfiguration: false, + appliedOauthConfiguration: false, + modelMappings: { opus: '', sonnet: '', haiku: '' }, + appliedModelMappings: { opus: '', sonnet: '', haiku: '' }, + })).toBe('update'); + }); + + test.each(['kimi-code', 'grok-build'])( + '%s updates an applied configuration whose CPA context catalog is stale', + (client) => { + expect(resolveAgentConfigurationAction({ + client, + modificationState: 'applied', + configurationSynchronized: false, + selectedModel: 'model-a', + appliedModel: 'model-a', + oauthConfiguration: false, + appliedOauthConfiguration: false, + modelMappings: { opus: '', sonnet: '', haiku: '' }, + appliedModelMappings: { opus: '', sonnet: '', haiku: '' }, + })).toBe('update'); + }, + ); + test('Codex also updates when only OAuth configuration changes', () => { expect(resolveAgentConfigurationAction({ client: 'codex',