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