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] 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');