From 708001ca376ecdc6191b86757c51ebf0be379fc3 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sat, 15 Aug 2026 01:13:03 +0800 Subject: [PATCH 1/8] ci: cache dependencies and parallelize release builds --- .github/workflows/release.yml | 39 +++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) 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 From 11b1ea23a558b5c6c82c2f2a00b973893332a905 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sat, 15 Aug 2026 13:31:58 +0800 Subject: [PATCH 2/8] fix(core): preserve installed plugins during core updates Overlay extracted core files onto the existing installation instead of replacing the entire cpa-core directory, preserving plugins and user data. --- src-tauri/src/core_runtime.rs | 84 +++++++++++++++++++++-------- src-tauri/src/tests/core_runtime.rs | 75 ++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 23 deletions(-) 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/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"); From d093a0122e4bcaa96280a6b70f5ae0fc71417a99 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sat, 15 Aug 2026 14:05:21 +0800 Subject: [PATCH 3/8] feat(agents): add ZCode client integration --- README.ja.md | 1 + README.md | 1 + README.zh-CN.md | 1 + src-tauri/src/agents/commands.rs | 1 + src-tauri/src/agents/configuration.rs | 171 +++++++++++++++++++++ src-tauri/src/agents/discovery.rs | 165 +++++++++++++++++++- src-tauri/src/agents/state.rs | 3 + src-tauri/src/main.rs | 6 + src-tauri/src/tests/agent_configuration.rs | 49 ++++++ src-tauri/src/tests/agent_state.rs | 57 +++++++ src/i18n/ja.ts | 2 + src/i18n/locales/en.ts | 2 + src/i18n/locales/zh-CN.ts | 2 + src/pages/AgentsPage.tsx | 13 +- src/services/agentConfigurationDraft.ts | 1 + tests/agentModelPicker.test.ts | 1 + 16 files changed, 472 insertions(+), 4 deletions(-) diff --git a/README.ja.md b/README.ja.md index 436b0c2..36d24e3 100644 --- a/README.ja.md +++ b/README.ja.md @@ -98,6 +98,7 @@ OpenAI、Claude、Gemini、およびその他の互換形式の間で変換で - OpenClaw - Hermes Agent - Pi(CLIProxyAPI provider 拡張機能) +- ZCode 対応クライアントでは、利用可能なモデルカタログの同期、デフォルトモデルの選択、管理設定を適用する前の 元設定のバックアップ、以前の設定への復元、利用可能なデスクトップまたは CLI エントリーポイントの起動ができます。 diff --git a/README.md b/README.md index 30cae80..f647944 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ proxy. Supported clients include: - OpenClaw - Hermes Agent - Pi (with the CLIProxyAPI provider extension) +- ZCode 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..a4d88fa 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -95,6 +95,7 @@ API 接入页面按照协议或 Provider 管理上游 API 凭证和服务地址 - OpenClaw - Hermes Agent - Pi(通过 CLIProxyAPI provider 插件) +- ZCode 对于受支持的客户端,软件可以同步可用模型目录、选择默认模型、在应用托管配置前备份原始配置, 以及恢复之前的配置。 diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index b02a3e6..1a3e763 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -17,6 +17,7 @@ pub(crate) fn inspect_agent_config_statuses( AgentClient::OpenClaw, AgentClient::Hermes, AgentClient::DeepSeekHarness, + AgentClient::ZCode, ] .into_iter() .map(|client| inspect_agent_config(client, &home, config.port, api_key)) diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index a7ae96a..5e814c0 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -214,6 +214,18 @@ pub(crate) fn build_agent_updates_with_oauth( }, ]) } + AgentClient::ZCode => { + let before = read_optional_text(&paths[0])?; + let after = + build_zcode_agent_config(before.as_deref(), &root_base, api_key, model, models) + .or_else(|_| { + build_zcode_agent_config(None, &root_base, api_key, model, models) + })?; + Ok(vec![AgentFileUpdate { + path: paths[0].clone(), + after, + }]) + } } } @@ -984,6 +996,38 @@ 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> { + let Some(path) = paths.first() else { + return Err("ZCode 当前平台配置路径不可用".to_string()); + }; + let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); + let updated = update_agent_json_file(path, "ZCode 配置", |root| { + let mut changed = false; + if root + .get("model") + .and_then(serde_json::Value::as_str) + .is_some_and(|model| model.starts_with(&prefix)) + { + 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 + })?; + Ok(updated.then(|| path_to_string(path)).into_iter().collect()) +} + pub(crate) fn update_agent_json5_file( path: &Path, label: &str, @@ -1174,6 +1218,7 @@ 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), } } @@ -1476,6 +1521,86 @@ 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_openclaw_config( current: &str, original: Option<&str>, @@ -1906,6 +2031,7 @@ pub(crate) fn build_agent_session_restored_bytes( _ => return Err("DeepSeek Harness 恢复路径索引无效".to_string()), } } + AgentClient::ZCode => build_restored_zcode_config(current, original)?, }; if let (Some(restored), Some(original), Some(original_bytes)) = (restored.as_deref(), original, original_bytes) @@ -2242,6 +2368,51 @@ 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 { + 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(); + ( + model.name, + serde_json::json!({ + "name": display_name, + "kinds": ["anthropic"], + "defaultKind": "anthropic" + }), + ) + }) + .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)); + root.insert( + "model".to_string(), + serde_json::json!(format!("{MANAGED_AGENT_PROVIDER_ID}/{model}")), + ); + render_agent_json(root, "ZCode 配置") +} + 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..3e644d3 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -32,6 +32,7 @@ pub(crate) fn agent_config_paths(client: AgentClient, home: &Path) -> Vec vec![home.join(".zcode/v2").join(ZCODE_CONFIG_FILE)], } } @@ -796,15 +797,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()); @@ -940,6 +952,8 @@ 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 => inspect_zcode_agent_config(&paths[0], port, api_key) + .map(|(configured, model)| (configured, model, false)), } } @@ -1059,6 +1073,22 @@ pub(crate) fn agent_has_managed_marker( Ok(provider_exists && model_selected) } AgentClient::DeepSeekHarness => deepseek_harness_has_managed_marker(paths), + AgentClient::ZCode => { + if !paths[0].is_file() { + return Ok(false); + } + let root = read_agent_json_or_empty(&paths[0], "ZCode 配置")?; + let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); + 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(serde_json::Value::as_str) + .is_some_and(|model| model.starts_with(&prefix)); + Ok(provider_exists && model_selected) + } } } @@ -1135,6 +1165,85 @@ 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"); @@ -1540,6 +1649,9 @@ 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); + } find_named_agent_executable(home, client.executable_names()) } @@ -2232,6 +2344,55 @@ 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 configured = 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("apiFormat")) + .and_then(serde_json::Value::as_str) + == Some("anthropic-messages") + && 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); + let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); + let model = root + .get("model") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.strip_prefix(&prefix).unwrap_or(value)) + .map(str::to_string); + Ok((configured, model)) +} + 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..9fd2509 100644 --- a/src-tauri/src/agents/state.rs +++ b/src-tauri/src/agents/state.rs @@ -728,6 +728,9 @@ 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, + )?]), } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 79078e1..1f8cb25 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -130,6 +130,7 @@ 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 DEEPSEEK_HARNESS_PROVIDER_ID: &str = "easy-cliproxyapi"; const DEEPSEEK_HARNESS_CREDENTIAL: &str = "EASYCLIPROXYAPI_API_KEY"; const DEEPSEEK_HARNESS_SETTINGS_FILE: &str = "settings.yaml"; @@ -972,6 +973,7 @@ enum AgentClient { OpenClaw, Hermes, DeepSeekHarness, + ZCode, } #[derive(Clone, Debug)] @@ -992,6 +994,7 @@ impl AgentClient { "openclaw" => Ok(Self::OpenClaw), "hermes" => Ok(Self::Hermes), "deepseek-harness" => Ok(Self::DeepSeekHarness), + "zcode" => Ok(Self::ZCode), _ => Err(format!("不支持的智能体客户端: {value}")), } } @@ -1005,6 +1008,7 @@ impl AgentClient { Self::OpenClaw => "openclaw", Self::Hermes => "hermes", Self::DeepSeekHarness => "deepseek-harness", + Self::ZCode => "zcode", } } @@ -1017,6 +1021,7 @@ impl AgentClient { Self::OpenClaw => "OpenClaw", Self::Hermes => "Hermes Agent", Self::DeepSeekHarness => "DeepSeek Harness", + Self::ZCode => "ZCode", } } @@ -1038,6 +1043,7 @@ impl AgentClient { Self::OpenClaw => &["openclaw"], Self::Hermes => &["hermes"], Self::DeepSeekHarness => &["dsh"], + Self::ZCode => &["zcode"], } } } diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index 0b39fd8..d411ace 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -612,6 +612,55 @@ 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 models = test_agent_models(&["gpt-test", "deepseek-test"]); + 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!( + inspect_zcode_agent_config(&path, 8317, DEFAULT_API_KEY).unwrap(), + (true, Some("gpt-test".to_string())) + ); + fs::remove_dir_all(home).unwrap(); +} + #[test] fn openclaw_agent_config_accepts_json5_and_preserves_unknown_fields() { let models = test_agent_models(&["gpt-test", "deepseek-test"]); diff --git a/src-tauri/src/tests/agent_state.rs b/src-tauri/src/tests/agent_state.rs index d11c1aa..5739fbd 100644 --- a/src-tauri/src/tests/agent_state.rs +++ b/src-tauri/src/tests/agent_state.rs @@ -367,6 +367,63 @@ 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 session_merge_preserves_runtime_fields_for_other_agent_formats() { let claude_original = r#"{"env":{"KEEP_ENV":"original"},"keep":"claude"}"#; diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index e3c0f66..c6eb236 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -466,6 +466,7 @@ 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.pi': 'CLIProxyAPI プロバイダーを使用して Pi CLI のモデルを動的に読み込みます', 'agents.list.detecting': '検出中', 'agents.list.unsupported': '現在のプラットフォームでは非対応', @@ -617,6 +618,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..5f2f5b6 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -466,6 +466,7 @@ 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.pi': 'Loads models dynamically in Pi through the CLIProxyAPI provider', 'agents.list.detecting': 'Detecting', 'agents.list.unsupported': 'Unsupported on this platform', @@ -617,6 +618,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 so it reloads model providers.', '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..fe8a5f2 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -464,6 +464,7 @@ 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.pi': '使用 Pi CLI 和 CLIProxyAPI provider 动态加载模型', 'agents.list.detecting': '正在检测', 'agents.list.unsupported': '当前平台不支持', @@ -615,6 +616,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..3d0f8ff 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -56,6 +56,7 @@ type AgentClientId = | 'openclaw' | 'hermes' | 'deepseek-harness' + | 'zcode' | 'pi'; type ClaudeModelMappingClientId = 'claude-code' | 'claude-desktop'; @@ -170,7 +171,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.pi'; }; type AgentSubpageId = 'core' | 'sessions'; @@ -224,6 +225,12 @@ const agentDefinitions: AgentDefinition[] = [ icon: deepseekIcon, descriptionKey: 'agents.description.deepseekHarness', }, + { + id: 'zcode', + name: 'ZCode', + Icon: Bot, + descriptionKey: 'agents.description.zcode', + }, { id: 'pi', name: 'Pi', @@ -888,7 +895,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..8085c42 100644 --- a/src/services/agentConfigurationDraft.ts +++ b/src/services/agentConfigurationDraft.ts @@ -6,6 +6,7 @@ export type AgentConfigurationClientId = | 'openclaw' | 'hermes' | 'deepseek-harness' + | 'zcode' | 'pi'; export type AgentConfigurationModificationState = 'unconfigured' | 'applied' | 'invalid'; diff --git a/tests/agentModelPicker.test.ts b/tests/agentModelPicker.test.ts index 8f984ae..706a183 100644 --- a/tests/agentModelPicker.test.ts +++ b/tests/agentModelPicker.test.ts @@ -153,6 +153,7 @@ describe('agent configuration update action', () => { 'opencode', 'openclaw', 'hermes', + 'zcode', ])('%s updates only while its model draft differs', (client) => { expect(appliedConfiguration(client, 'model-b')).toBe('update'); expect(appliedConfiguration(client, ' MODEL-A ')).toBe('close'); From 49893bb32ef5fed240dd934d63e3de43275c0a17 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sat, 15 Aug 2026 14:24:25 +0800 Subject: [PATCH 4/8] feat(agents): add Kimi Code and Grok Build integrations --- README.ja.md | 2 + README.md | 2 + README.zh-CN.md | 2 + src-tauri/src/agents/commands.rs | 2 + src-tauri/src/agents/configuration.rs | 456 ++++++++++++++++++++- src-tauri/src/agents/discovery.rs | 211 ++++++++++ src-tauri/src/agents/state.rs | 23 +- src-tauri/src/configuration_watcher.rs | 4 + src-tauri/src/main.rs | 12 + src-tauri/src/tests/agent_configuration.rs | 198 +++++++++ src/i18n/ja.ts | 2 + src/i18n/locales/en.ts | 2 + src/i18n/locales/zh-CN.ts | 2 + src/pages/AgentsPage.tsx | 18 +- src/services/agentConfigurationDraft.ts | 2 + tests/agentModelPicker.test.ts | 2 + 16 files changed, 937 insertions(+), 3 deletions(-) diff --git a/README.ja.md b/README.ja.md index 36d24e3..ebf9ffd 100644 --- a/README.ja.md +++ b/README.ja.md @@ -99,6 +99,8 @@ OpenAI、Claude、Gemini、およびその他の互換形式の間で変換で - Hermes Agent - Pi(CLIProxyAPI provider 拡張機能) - ZCode +- Kimi Code +- Grok Build 対応クライアントでは、利用可能なモデルカタログの同期、デフォルトモデルの選択、管理設定を適用する前の 元設定のバックアップ、以前の設定への復元、利用可能なデスクトップまたは CLI エントリーポイントの起動ができます。 diff --git a/README.md b/README.md index f647944..fe350c3 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,8 @@ proxy. Supported clients include: - 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 a4d88fa..34745c5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -96,6 +96,8 @@ API 接入页面按照协议或 Provider 管理上游 API 凭证和服务地址 - Hermes Agent - Pi(通过 CLIProxyAPI provider 插件) - ZCode +- Kimi Code +- Grok Build 对于受支持的客户端,软件可以同步可用模型目录、选择默认模型、在应用托管配置前备份原始配置, 以及恢复之前的配置。 diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index 1a3e763..0f49096 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -18,6 +18,8 @@ pub(crate) fn inspect_agent_config_statuses( AgentClient::Hermes, AgentClient::DeepSeekHarness, AgentClient::ZCode, + AgentClient::KimiCode, + AgentClient::GrokBuild, ] .into_iter() .map(|client| inspect_agent_config(client, &home, config.port, api_key)) diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index 5e814c0..d400094 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -226,6 +226,40 @@ pub(crate) fn build_agent_updates_with_oauth( 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, + }]) + } } } @@ -1028,6 +1062,138 @@ pub(crate) fn remove_zcode_managed_configuration(paths: &[PathBuf]) -> Result 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, @@ -1219,6 +1385,8 @@ pub(crate) fn remove_agent_managed_configuration( 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), } } @@ -1601,6 +1769,168 @@ pub(crate) fn build_restored_zcode_config( 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>, @@ -1962,7 +2292,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() } @@ -2032,6 +2362,8 @@ pub(crate) fn build_agent_session_restored_bytes( } } 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) @@ -2413,6 +2745,128 @@ pub(crate) fn build_zcode_agent_config( 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 3e644d3..5e9bacd 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -33,9 +33,25 @@ pub(crate) fn agent_config_paths(client: AgentClient, home: &Path) -> Vec vec![home.join(".zcode/v2").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) @@ -954,6 +970,10 @@ pub(crate) fn inspect_agent_managed_config( .map(|(configured, model)| (configured, model, false)), AgentClient::ZCode => inspect_zcode_agent_config(&paths[0], port, api_key) .map(|(configured, model)| (configured, 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)), } } @@ -1089,6 +1109,22 @@ pub(crate) fn agent_has_managed_marker( .is_some_and(|model| model.starts_with(&prefix)); Ok(provider_exists && model_selected) } + 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", + ), } } @@ -1097,6 +1133,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")] @@ -1652,9 +1726,39 @@ pub(crate) fn find_agent_executable(client: AgentClient, home: &Path) -> Option< 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"]) } @@ -2393,6 +2497,113 @@ pub(crate) fn inspect_zcode_agent_config( 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 9fd2509..b200016 100644 --- a/src-tauri/src/agents/state.rs +++ b/src-tauri/src/agents/state.rs @@ -731,6 +731,20 @@ pub(crate) fn fresh_agent_contents_with_oauth( AgentClient::ZCode => Ok(vec![build_zcode_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, + )?]), } } @@ -741,6 +755,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() @@ -1360,13 +1378,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, ] } 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/main.rs b/src-tauri/src/main.rs index 1f8cb25..bfc43af 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -131,6 +131,8 @@ 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"; @@ -974,6 +976,8 @@ enum AgentClient { Hermes, DeepSeekHarness, ZCode, + KimiCode, + GrokBuild, } #[derive(Clone, Debug)] @@ -995,6 +999,8 @@ impl AgentClient { "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}")), } } @@ -1009,6 +1015,8 @@ impl AgentClient { Self::Hermes => "hermes", Self::DeepSeekHarness => "deepseek-harness", Self::ZCode => "zcode", + Self::KimiCode => "kimi-code", + Self::GrokBuild => "grok-build", } } @@ -1022,6 +1030,8 @@ impl AgentClient { Self::Hermes => "Hermes Agent", Self::DeepSeekHarness => "DeepSeek Harness", Self::ZCode => "ZCode", + Self::KimiCode => "Kimi Code", + Self::GrokBuild => "Grok Build", } } @@ -1044,6 +1054,8 @@ impl AgentClient { 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 d411ace..f607d89 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -661,6 +661,201 @@ fn zcode_agent_config_preserves_other_providers_and_uses_anthropic_messages() { 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 models = test_agent_models(&["gpt-test", "deepseek-test"]); + 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!( + 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 models = test_agent_models(&["gpt-test", "deepseek-test"]); + 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!( + 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"]); @@ -831,6 +1026,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] diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index c6eb236..b3207d9 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -467,6 +467,8 @@ export const jaOverrides = { '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': '現在のプラットフォームでは非対応', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 5f2f5b6..49656ec 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -467,6 +467,8 @@ export const en: Record = { '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', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index fe8a5f2..d5d1c8b 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -465,6 +465,8 @@ export const zhCN = { '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': '当前平台不支持', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 3d0f8ff..3fa4f9b 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -27,6 +27,8 @@ 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'; @@ -57,6 +59,8 @@ type AgentClientId = | 'hermes' | 'deepseek-harness' | 'zcode' + | 'kimi-code' + | 'grok-build' | 'pi'; type ClaudeModelMappingClientId = 'claude-code' | 'claude-desktop'; @@ -171,7 +175,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.zcode' | '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'; @@ -231,6 +235,18 @@ const agentDefinitions: AgentDefinition[] = [ Icon: Bot, descriptionKey: 'agents.description.zcode', }, + { + id: 'kimi-code', + name: 'Kimi Code', + icon: kimiIcon, + descriptionKey: 'agents.description.kimiCode', + }, + { + id: 'grok-build', + name: 'Grok Build', + icon: grokIcon, + descriptionKey: 'agents.description.grokBuild', + }, { id: 'pi', name: 'Pi', diff --git a/src/services/agentConfigurationDraft.ts b/src/services/agentConfigurationDraft.ts index 8085c42..9f78219 100644 --- a/src/services/agentConfigurationDraft.ts +++ b/src/services/agentConfigurationDraft.ts @@ -7,6 +7,8 @@ export type AgentConfigurationClientId = | 'hermes' | 'deepseek-harness' | 'zcode' + | 'kimi-code' + | 'grok-build' | 'pi'; export type AgentConfigurationModificationState = 'unconfigured' | 'applied' | 'invalid'; diff --git a/tests/agentModelPicker.test.ts b/tests/agentModelPicker.test.ts index 706a183..e13c5da 100644 --- a/tests/agentModelPicker.test.ts +++ b/tests/agentModelPicker.test.ts @@ -154,6 +154,8 @@ describe('agent configuration update action', () => { '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'); From b933a0fa994066cb1cf77cd4972b2eee19668471 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sat, 15 Aug 2026 14:34:00 +0800 Subject: [PATCH 5/8] chore(version):change version to v0.2.24 --- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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" From 607c930d7b2f08c145f7430be05b8001535a7db0 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sat, 15 Aug 2026 14:58:56 +0800 Subject: [PATCH 6/8] fix(zcode): sync model context windows from CPA core --- src-tauri/src/agents/commands.rs | 4 ++ src-tauri/src/agents/configuration.rs | 17 +++---- src-tauri/src/agents/discovery.rs | 44 +++++++++++------- src-tauri/src/codex_catalog.rs | 54 ++++++++++++++++++++++ src-tauri/src/tests/agent_configuration.rs | 49 +++++++++++++++++++- src/i18n/ja.ts | 2 +- src/i18n/locales/en.ts | 2 +- src/i18n/locales/zh-CN.ts | 2 +- 8 files changed, 146 insertions(+), 28 deletions(-) diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index 0f49096..5140e85 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -640,6 +640,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 client == AgentClient::ZCode { + 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)?; diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index d400094..cc0f4e6 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -2713,14 +2713,15 @@ pub(crate) fn build_zcode_agent_config( .into_iter() .map(|model| { let display_name = model.alias.as_deref().unwrap_or(&model.name).to_string(); - ( - model.name, - serde_json::json!({ - "name": display_name, - "kinds": ["anthropic"], - "defaultKind": "anthropic" - }), - ) + 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); diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index 5e9bacd..eb1f542 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -2464,18 +2464,38 @@ pub(crate) fn inspect_zcode_agent_config( .get("provider") .and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID)); let expected_base = format!("http://127.0.0.1:{port}"); - let configured = provider - .and_then(|provider| provider.get("enabled")) - .and_then(serde_json::Value::as_bool) - == Some(true) + 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(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("apiFormat")) - .and_then(serde_json::Value::as_str) - == Some("anthropic-messages") && provider .and_then(|provider| provider.get("options")) .and_then(|options| options.get("baseURL")) @@ -2486,14 +2506,6 @@ pub(crate) fn inspect_zcode_agent_config( .and_then(|options| options.get("apiKey")) .and_then(serde_json::Value::as_str) == Some(api_key); - let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); - let model = root - .get("model") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(|value| value.strip_prefix(&prefix).unwrap_or(value)) - .map(str::to_string); Ok((configured, model)) } 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/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index f607d89..40c5908 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -617,7 +617,9 @@ 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 models = test_agent_models(&["gpt-test", "deepseek-test"]); + 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}}}}"#, @@ -654,10 +656,55 @@ fn zcode_agent_config_preserves_other_providers_and_uses_anthropic_messages() { "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_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(); } diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index b3207d9..d328521 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -620,7 +620,7 @@ export const jaOverrides = { 'agents.modify.default': 'デフォルト設定', 'agents.modify.clear': '設定を消去', 'agents.modify.invalid': '現在の設定を解析できません。「設定変更を適用」は最小の有効な設定を再構築し、「デフォルト設定」はファイル全体をリセットします。', - 'agents.modify.zcodeRestart': '設定の適用または終了後、モデルプロバイダーを再読み込みするため ZCode を完全終了して再起動してください。', + '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 49656ec..af1536c 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -620,7 +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 so it reloads model providers.', + '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 d5d1c8b..2e38336 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -618,7 +618,7 @@ export const zhCN = { 'agents.modify.default': '默认配置', 'agents.modify.clear': '清空配置', 'agents.modify.invalid': '当前配置无法解析。点击“应用配置修改”会重建最小合法配置;“默认配置”会重置整个文件。', - 'agents.modify.zcodeRestart': '应用或关闭配置后,请完全退出并重新启动 ZCode 以加载模型供应商。', + 'agents.modify.zcodeRestart': '应用或关闭配置后,请完全退出并重新启动 ZCode;默认模型和上下文长度会在新会话中生效。', 'agents.pi.installTitle': 'Pi CLIProxyAPI provider', 'agents.pi.installDescription': '安装或更新插件,并修复 CPA 凭据、默认 provider 与默认模型', 'agents.pi.install': '安装 Pi 插件', From b312b904a290384ab4d40bd93a455709b61f756d Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sat, 15 Aug 2026 15:28:01 +0800 Subject: [PATCH 7/8] fix(agents): speed up detection and sync ZCode default model --- src-tauri/src/agents/commands.rs | 82 ++++++++--- src-tauri/src/agents/configuration.rs | 137 +++++++++++++----- src-tauri/src/agents/discovery.rs | 155 +++++++++++++++++---- src-tauri/src/agents/state.rs | 59 ++++++-- src-tauri/src/tests/agent_configuration.rs | 105 ++++++++++++++ src-tauri/src/tests/agent_state.rs | 65 +++++++++ src/pages/AgentsPage.tsx | 1 + src/services/agentConfigurationDraft.ts | 3 + tests/agentModelPicker.test.ts | 14 ++ 9 files changed, 525 insertions(+), 96 deletions(-) diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index 5140e85..27a5efd 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,23 +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, - AgentClient::ZCode, - AgentClient::KimiCode, - AgentClient::GrokBuild, - ] - .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( diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index cc0f4e6..c2283a4 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -215,16 +215,31 @@ pub(crate) fn build_agent_updates_with_oauth( ]) } AgentClient::ZCode => { - let before = read_optional_text(&paths[0])?; - let after = - build_zcode_agent_config(before.as_deref(), &root_base, api_key, model, models) + 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) })?; - Ok(vec![AgentFileUpdate { - path: paths[0].clone(), - after, - }]) + 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])?; @@ -1031,35 +1046,59 @@ pub(crate) fn remove_opencode_managed_configuration( } pub(crate) fn remove_zcode_managed_configuration(paths: &[PathBuf]) -> Result, String> { - let Some(path) = paths.first() else { + if paths.is_empty() { return Err("ZCode 当前平台配置路径不可用".to_string()); - }; + } let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); - let updated = update_agent_json_file(path, "ZCode 配置", |root| { - let mut changed = false; - if root - .get("model") - .and_then(serde_json::Value::as_str) - .is_some_and(|model| model.starts_with(&prefix)) - { - root.remove("model"); - changed = true; + 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)); } - 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 - })?; - Ok(updated.then(|| path_to_string(path)).into_iter().collect()) + } + Ok(changed_paths) } pub(crate) fn remove_kimi_code_managed_configuration( @@ -2706,6 +2745,27 @@ pub(crate) fn build_zcode_agent_config( 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"); @@ -2739,10 +2799,13 @@ pub(crate) fn build_zcode_agent_config( 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)); - root.insert( - "model".to_string(), - serde_json::json!(format!("{MANAGED_AGENT_PROVIDER_ID}/{model}")), - ); + 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 配置") } diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index eb1f542..c06b0b3 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,7 +35,10 @@ pub(crate) fn agent_config_paths(client: AgentClient, home: &Path) -> Vec vec![home.join(".zcode/v2").join(ZCODE_CONFIG_FILE)], + AgentClient::ZCode => 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)], } @@ -968,8 +974,19 @@ 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 => inspect_zcode_agent_config(&paths[0], 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) @@ -1094,20 +1111,29 @@ pub(crate) fn agent_has_managed_marker( } AgentClient::DeepSeekHarness => deepseek_harness_has_managed_marker(paths), AgentClient::ZCode => { - if !paths[0].is_file() { - return Ok(false); - } - let root = read_agent_json_or_empty(&paths[0], "ZCode 配置")?; let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); - 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(serde_json::Value::as_str) - .is_some_and(|model| model.starts_with(&prefix)); - Ok(provider_exists && model_selected) + 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], @@ -1322,11 +1348,12 @@ pub(crate) fn find_zcode_desktop_executable(home: &Path) -> Option { 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; } @@ -1420,7 +1447,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; } @@ -1456,7 +1483,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; } @@ -1543,7 +1570,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; } @@ -1596,7 +1623,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; } @@ -1642,7 +1669,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() { @@ -1914,6 +1943,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); @@ -1923,7 +2020,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; } @@ -2471,7 +2568,11 @@ pub(crate) fn inspect_zcode_agent_config( let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); let model = root .get("model") - .and_then(serde_json::Value::as_str) + .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)) diff --git a/src-tauri/src/agents/state.rs b/src-tauri/src/agents/state.rs index b200016..4600f69 100644 --- a/src-tauri/src/agents/state.rs +++ b/src-tauri/src/agents/state.rs @@ -189,7 +189,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()); } @@ -385,7 +389,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,9 +736,10 @@ 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, - )?]), + 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, @@ -1430,14 +1439,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> { @@ -1484,11 +1515,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/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index 40c5908..038e563 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -684,6 +684,55 @@ fn zcode_agent_config_preserves_other_providers_and_uses_anthropic_messages() { 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"); @@ -1583,6 +1632,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 5739fbd..98bbfe1 100644 --- a/src-tauri/src/tests/agent_state.rs +++ b/src-tauri/src/tests/agent_state.rs @@ -424,6 +424,71 @@ fn zcode_runtime_edits_survive_close_and_original_provider_is_restored() { 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(), + 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 session_merge_preserves_runtime_fields_for_other_agent_formats() { let claude_original = r#"{"env":{"KEEP_ENV":"original"},"keep":"claude"}"#; diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 3fa4f9b..659d047 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -885,6 +885,7 @@ export function AgentsPage() { const configurationAction = resolveAgentConfigurationAction({ client: selected, modificationState: activeStatus?.modificationState ?? 'unconfigured', + configurationSynchronized: Boolean(activeStatus?.configured), selectedModel, appliedModel, oauthConfiguration, diff --git a/src/services/agentConfigurationDraft.ts b/src/services/agentConfigurationDraft.ts index 9f78219..a8bf8cb 100644 --- a/src/services/agentConfigurationDraft.ts +++ b/src/services/agentConfigurationDraft.ts @@ -32,6 +32,7 @@ export type AgentModelMappings = { type ResolveAgentConfigurationActionOptions = { client: AgentConfigurationClientId; modificationState: AgentConfigurationModificationState; + configurationSynchronized?: boolean; selectedModel: string; appliedModel: string; oauthConfiguration: boolean; @@ -82,6 +83,7 @@ export const resolveAgentModelMappingsDraftSourceForClient = ( export function resolveAgentConfigurationAction({ client, modificationState, + configurationSynchronized = true, selectedModel, appliedModel, oauthConfiguration, @@ -91,6 +93,7 @@ export function resolveAgentConfigurationAction({ }: ResolveAgentConfigurationActionOptions): AgentConfigurationAction { if (modificationState !== 'applied') return 'apply'; if (client === 'pi') return 'close'; + if (client === 'zcode' && !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 e13c5da..ab709df 100644 --- a/tests/agentModelPicker.test.ts +++ b/tests/agentModelPicker.test.ts @@ -161,6 +161,20 @@ describe('agent configuration update action', () => { 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('Codex also updates when only OAuth configuration changes', () => { expect(resolveAgentConfigurationAction({ client: 'codex', From 51ea7fd69f16b250039d2b53ee4396bf6b597ec6 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sat, 15 Aug 2026 15:51:56 +0800 Subject: [PATCH 8/8] fix(agents): sync Kimi and Grok context windows with CPA core --- src-tauri/src/agents/commands.rs | 9 ++- src-tauri/src/agents/configuration.rs | 6 ++ src-tauri/src/agents/discovery.rs | 26 ++++++- src-tauri/src/agents/state.rs | 3 + src-tauri/src/main.rs | 6 ++ src-tauri/src/tests/agent_configuration.rs | 75 ++++++++++++++++++++- src-tauri/src/tests/agent_state.rs | 44 +++++++++++- src/assets/icons/zcode.png | Bin 0 -> 13843 bytes src/pages/AgentsPage.tsx | 64 +++++++++--------- src/services/agentConfigurationDraft.ts | 5 +- tests/agentModelPicker.test.ts | 17 +++++ 11 files changed, 218 insertions(+), 37 deletions(-) create mode 100644 src/assets/icons/zcode.png diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index 27a5efd..29e0054 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -688,7 +688,7 @@ 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 client == AgentClient::ZCode { + 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); } @@ -714,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 c2283a4..c38d99d 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -525,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()), @@ -922,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, @@ -1582,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, diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index c06b0b3..131d80f 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -368,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, @@ -849,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 { @@ -865,6 +867,7 @@ pub(crate) fn inspect_agent_config( config_exists, config_valid, configured, + configuration_synchronized, current_model, oauth_configuration, modification_enabled: modification.enabled, @@ -880,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, @@ -2063,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) diff --git a/src-tauri/src/agents/state.rs b/src-tauri/src/agents/state.rs index 4600f69..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() @@ -284,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() @@ -1485,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(), diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index bfc43af..5336ec3 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -159,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; @@ -171,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"; @@ -768,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, @@ -951,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")] diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index 038e563..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 = @@ -762,7 +791,9 @@ 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 models = test_agent_models(&["gpt-test", "deepseek-test"]); + 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" @@ -806,6 +837,14 @@ model = "model" .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())) @@ -818,7 +857,9 @@ 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 models = test_agent_models(&["gpt-test", "deepseek-test"]); + 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" @@ -858,6 +899,14 @@ api_key = "other-key" 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())) @@ -1077,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(&[ diff --git a/src-tauri/src/tests/agent_state.rs b/src-tauri/src/tests/agent_state.rs index 98bbfe1..aec01db 100644 --- a/src-tauri/src/tests/agent_state.rs +++ b/src-tauri/src/tests/agent_state.rs @@ -453,6 +453,7 @@ fn zcode_legacy_single_file_state_upgrades_and_restores_cli_config() { 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(), @@ -489,9 +490,37 @@ fn zcode_legacy_single_file_state_upgrades_and_restores_cli_config() { 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", @@ -516,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()); @@ -735,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(), @@ -799,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, @@ -930,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(), @@ -966,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 { @@ -1003,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, @@ -1017,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" @@ -1039,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/assets/icons/zcode.png b/src/assets/icons/zcode.png new file mode 100644 index 0000000000000000000000000000000000000000..366b1faaa83f4fe71da06acbeb1e4fbb67f4718b GIT binary patch literal 13843 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 z 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 KO67B_i2nuo7^Ct4 literal 0 HcmV?d00001 diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 659d047..9a10840 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -32,6 +32,7 @@ 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, @@ -79,6 +80,7 @@ type AgentConfigStatus = { pluginVersion: string | null; configValid: boolean; configured: boolean; + configurationSynchronized: boolean; currentModel: string | null; oauthConfiguration: boolean; modificationEnabled: boolean; @@ -205,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', @@ -223,36 +255,6 @@ const agentDefinitions: AgentDefinition[] = [ icon: hermesIcon, descriptionKey: 'agents.description.hermes', }, - { - id: 'deepseek-harness', - name: 'DeepSeek Harness', - icon: deepseekIcon, - descriptionKey: 'agents.description.deepseekHarness', - }, - { - id: 'zcode', - name: 'ZCode', - Icon: Bot, - descriptionKey: 'agents.description.zcode', - }, - { - id: 'kimi-code', - name: 'Kimi Code', - icon: kimiIcon, - descriptionKey: 'agents.description.kimiCode', - }, - { - id: 'grok-build', - name: 'Grok Build', - icon: grokIcon, - descriptionKey: 'agents.description.grokBuild', - }, - { - id: 'pi', - name: 'Pi', - icon: piIcon, - descriptionKey: 'agents.description.pi', - }, ]; const agentSubpages: AgentSubpageDefinition[] = [ @@ -885,7 +887,7 @@ export function AgentsPage() { const configurationAction = resolveAgentConfigurationAction({ client: selected, modificationState: activeStatus?.modificationState ?? 'unconfigured', - configurationSynchronized: Boolean(activeStatus?.configured), + configurationSynchronized: Boolean(activeStatus?.configurationSynchronized), selectedModel, appliedModel, oauthConfiguration, diff --git a/src/services/agentConfigurationDraft.ts b/src/services/agentConfigurationDraft.ts index a8bf8cb..000330e 100644 --- a/src/services/agentConfigurationDraft.ts +++ b/src/services/agentConfigurationDraft.ts @@ -93,7 +93,10 @@ export function resolveAgentConfigurationAction({ }: ResolveAgentConfigurationActionOptions): AgentConfigurationAction { if (modificationState !== 'applied') return 'apply'; if (client === 'pi') return 'close'; - if (client === 'zcode' && !configurationSynchronized) return 'update'; + 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 ab709df..269f9ad 100644 --- a/tests/agentModelPicker.test.ts +++ b/tests/agentModelPicker.test.ts @@ -175,6 +175,23 @@ describe('agent configuration update action', () => { })).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',