diff --git a/docs/kimi-sync-compatibility.md b/docs/kimi-sync-compatibility.md new file mode 100644 index 000000000..255f52bb9 --- /dev/null +++ b/docs/kimi-sync-compatibility.md @@ -0,0 +1,21 @@ +# Kimi configuration and database synchronization + +The Kimi adapter uses the existing `providers` table with `app_type = "kimi"` and JSON `settings_config`. It does not add tables or columns, or change schema version 18. The native provider attributes are retained in `providerConfig`; each model retains its local alias in `id` and its native attributes (including the upstream `model` and context size) in `config`. Existing flat provider input remains accepted. Cross-provider alias collisions are rejected before writing live configuration. + +The database SQL export/import path carries these provider rows unchanged. The regression test covers export for sync, import into another database and reconstruction in an empty Kimi directory. This demonstrates this CLI's round-trip behavior; it does not claim that another upstream client understands Kimi or that all upstream versions retain unknown app rows. + +Kimi MCP/Skills enablement is **local deployment state**. The existing shared database has no Kimi enable columns. As with Pi skills, Kimi skill enablement is reconstructed from the native skill directory. MCP enablement is reconstructed from membership in the local `mcp.json`. Restarting retains these states without a schema fork. SQL/WebDAV synchronization carries the shared MCP definitions and skill catalog, but does **not** copy the Kimi enable flags to another machine. Enable the desired definitions on that machine explicitly. Native OAuth files remain managed by Kimi CLI. + +## 中文说明 + +Kimi Provider 继续使用现有 providers 表和 settings_config JSON,数据库 schema 保持 18,不新增表或列。模型别名与真实模型 ID 分开保存,原始扩展字段随配置保留;跨供应商同名模型冲突会在写入前被拒绝。回归测试覆盖数据库同步导出、导入另一数据库以及空目录恢复,但不代表其他上游客户端已支持 Kimi 或保证保留未知 app 数据。 + +Kimi 的 MCP/Skills 开关属于本机部署状态,从本机 mcp.json 和 skills 目录恢复。重启不会丢失,但数据库/WebDAV 不会将这些开关自动复制到另一机器;目标机器需显式启用。共享定义与 Provider 配置仍走既有数据库同步。Kimi CLI 继续负责原生 OAuth 文件。 + +## Original CC-Switch revision checked + +The upstream named here is **farion1231/cc-switch**, not the CLI fork. At original main `36d950411e622129f285635f43c17e5f35462413`, `src-tauri/src/database/mod.rs:53` declares schema **19**, while this CLI's base declares **18**. The upstream v18→v19 migration adds `enabled_mcode` to MCP/Skills. Its SQL exporter serializes table rows without filtering the provider `app_type`; its AppType enum does not include Kimi. Provider payload storage is opaque JSON, and the providers table has no app-type CHECK constraint. + +This PR does not add a further schema divergence. It does **not** resolve the existing fork-wide v19 compatibility gap: a database migrated by the current original application is rejected as too new by this CLI on return. Aligning the shared schema/migrations requires a separate upstream-coordinated change, not a Kimi-only schema increment. Do not advertise latest-original ↔ CLI bidirectional synchronization as verified by this PR. + +核对的原版为 farion1231/cc-switch,固定到上述完整 SHA:原版 schema 19、CLI 基线 schema 18。原版升版后的整库回传会触发 CLI 的新版本拒绝保护。这是两项目既有的版本差异,本 Kimi 修复不私自改共享 schema,也不能据此宣称与原版最新版双向同步已经通过。原版尚无 Kimi AppType;其通用 SQL 导出没有按 app_type 过滤 Provider 行。 diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index b5eeb47ad..0fa971ac8 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -17,6 +17,8 @@ pub struct McpApps { pub opencode: bool, #[serde(default)] pub hermes: bool, + #[serde(default)] + pub kimi: bool, } impl McpApps { @@ -30,6 +32,7 @@ impl McpApps { AppType::Hermes => self.hermes, AppType::OpenClaw => false, AppType::Pi => false, + AppType::Kimi => self.kimi, } } @@ -43,6 +46,7 @@ impl McpApps { AppType::Hermes => self.hermes = enabled, AppType::OpenClaw => {} AppType::Pi => {} + AppType::Kimi => self.kimi = enabled, } } @@ -64,12 +68,15 @@ impl McpApps { if self.hermes { apps.push(AppType::Hermes); } + if self.kimi { + apps.push(AppType::Kimi); + } apps } /// 检查是否所有应用都未启用 pub fn is_empty(&self) -> bool { - !self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes + !self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes && !self.kimi } } @@ -88,6 +95,8 @@ pub struct SkillApps { pub hermes: bool, #[serde(default)] pub pi: bool, + #[serde(default)] + pub kimi: bool, } impl SkillApps { @@ -100,6 +109,7 @@ impl SkillApps { AppType::Hermes => self.hermes, AppType::OpenClaw => false, AppType::Pi => self.pi, + AppType::Kimi => self.kimi, } } @@ -112,11 +122,12 @@ impl SkillApps { AppType::Hermes => self.hermes = enabled, AppType::OpenClaw => {} AppType::Pi => self.pi = enabled, + AppType::Kimi => self.kimi = enabled, } } pub fn is_empty(&self) -> bool { - !self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes && !self.pi + !self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes && !self.pi && !self.kimi } pub fn only(app: &AppType) -> Self { @@ -142,6 +153,7 @@ impl SkillApps { self.opencode |= other.opencode; self.hermes |= other.hermes; self.pi |= other.pi; + self.kimi |= other.kimi; } } @@ -252,6 +264,8 @@ pub struct McpRoot { pub hermes: McpConfig, #[serde(default, skip_serializing_if = "McpConfig::is_empty")] pub openclaw: McpConfig, + #[serde(default, skip_serializing_if = "McpConfig::is_empty")] + pub kimi: McpConfig, #[serde(skip)] pub pi: McpConfig, } @@ -268,6 +282,7 @@ impl Default for McpRoot { opencode: McpConfig::default(), hermes: McpConfig::default(), openclaw: McpConfig::default(), + kimi: McpConfig::default(), pi: McpConfig::default(), } } @@ -297,6 +312,8 @@ pub struct PromptRoot { pub openclaw: PromptConfig, #[serde(default)] pub pi: PromptConfig, + #[serde(default)] + pub kimi: PromptConfig, } use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file}; @@ -315,6 +332,7 @@ pub enum AppType { Hermes, OpenClaw, Pi, + Kimi, } impl AppType { @@ -327,13 +345,14 @@ impl AppType { AppType::Hermes => "hermes", AppType::OpenClaw => "openclaw", AppType::Pi => "pi", + AppType::Kimi => "kimi", } } pub fn is_additive_mode(&self) -> bool { matches!( self, - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi ) } @@ -350,6 +369,7 @@ impl AppType { AppType::Hermes, AppType::OpenClaw, AppType::Pi, + AppType::Kimi, ] .into_iter() } @@ -374,13 +394,14 @@ impl FromStr for AppType { "hermes" => Ok(AppType::Hermes), "openclaw" => Ok(AppType::OpenClaw), "pi" => Ok(AppType::Pi), + "kimi" => Ok(AppType::Kimi), other => Err(AppError::localized( "unsupported_app", format!( - "不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, hermes, openclaw, pi。" + "不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, hermes, openclaw, pi, kimi。" ), format!( - "Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, hermes, openclaw, pi." + "Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, hermes, openclaw, pi, kimi." ), )), } @@ -407,6 +428,9 @@ pub struct CommonConfigSnippets { #[serde(default, skip_serializing_if = "Option::is_none")] pub openclaw: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kimi: Option, } impl CommonConfigSnippets { @@ -420,6 +444,7 @@ impl CommonConfigSnippets { AppType::Hermes => self.hermes.as_ref(), AppType::OpenClaw => self.openclaw.as_ref(), AppType::Pi => None, + AppType::Kimi => self.kimi.as_ref(), } } @@ -433,6 +458,7 @@ impl CommonConfigSnippets { AppType::Hermes => self.hermes = snippet, AppType::OpenClaw => self.openclaw = snippet, AppType::Pi => {} + AppType::Kimi => self.kimi = snippet, } } } @@ -476,6 +502,7 @@ impl Default for MultiAppConfig { apps.insert("hermes".to_string(), ProviderManager::default()); apps.insert("openclaw".to_string(), ProviderManager::default()); apps.insert("pi".to_string(), ProviderManager::default()); + apps.insert("kimi".to_string(), ProviderManager::default()); Self { version: 2, @@ -590,6 +617,13 @@ impl MultiAppConfig { updated = true; } + if !config.apps.contains_key("kimi") { + config + .apps + .insert("kimi".to_string(), ProviderManager::default()); + updated = true; + } + // 执行 MCP 迁移(v3.6.x → v3.7.0) let migrated = config.migrate_mcp_to_unified()?; if migrated { @@ -657,6 +691,7 @@ impl MultiAppConfig { AppType::Hermes => &self.mcp.hermes, AppType::OpenClaw => &self.mcp.openclaw, AppType::Pi => &self.mcp.pi, + AppType::Kimi => &self.mcp.kimi, } } @@ -670,6 +705,7 @@ impl MultiAppConfig { AppType::Hermes => &mut self.mcp.hermes, AppType::OpenClaw => &mut self.mcp.openclaw, AppType::Pi => &mut self.mcp.pi, + AppType::Kimi => &mut self.mcp.kimi, } } @@ -699,6 +735,7 @@ impl MultiAppConfig { AppType::Gemini, AppType::OpenCode, AppType::Hermes, + AppType::Kimi, ] { let old_servers = match app { AppType::Claude => &self.mcp.claude.servers, @@ -706,6 +743,7 @@ impl MultiAppConfig { AppType::Gemini => &self.mcp.gemini.servers, AppType::OpenCode => &self.mcp.opencode.servers, AppType::Hermes => &self.mcp.hermes.servers, + AppType::Kimi => &self.mcp.kimi.servers, AppType::OpenClaw => continue, AppType::Pi => continue, }; @@ -812,6 +850,7 @@ impl MultiAppConfig { self.mcp.gemini = McpConfig::default(); self.mcp.opencode = McpConfig::default(); self.mcp.hermes = McpConfig::default(); + self.mcp.kimi = McpConfig::default(); Ok(true) } diff --git a/src-tauri/src/cli/commands/config_common.rs b/src-tauri/src/cli/commands/config_common.rs index dc0720ac3..0144e647f 100644 --- a/src-tauri/src/cli/commands/config_common.rs +++ b/src-tauri/src/cli/commands/config_common.rs @@ -194,7 +194,8 @@ fn canonical_common_snippet(app_type: AppType, raw: &str) -> Result { + | AppType::Pi + | AppType::Kimi => { let value: serde_json::Value = serde_json::from_str(raw).map_err(|e| { AppError::InvalidInput(texts::tui_toast_invalid_json(&e.to_string())) })?; diff --git a/src-tauri/src/cli/commands/env.rs b/src-tauri/src/cli/commands/env.rs index e056d5293..fee74b772 100644 --- a/src-tauri/src/cli/commands/env.rs +++ b/src-tauri/src/cli/commands/env.rs @@ -96,7 +96,7 @@ fn run_app_doctor(app_type: &AppType) -> Result<(), AppError> { AppType::Claude => check_claude_doctor(), AppType::Codex => check_codex_doctor(), AppType::Gemini => check_gemini_doctor(), - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => { println!( "{}", info(&format!( diff --git a/src-tauri/src/cli/commands/failover.rs b/src-tauri/src/cli/commands/failover.rs index efc9d246f..4f85ec870 100644 --- a/src-tauri/src/cli/commands/failover.rs +++ b/src-tauri/src/cli/commands/failover.rs @@ -409,7 +409,7 @@ fn takeover_enabled_for(takeovers: &ProxyTakeoverStatus, app_type: &AppType) -> AppType::Claude => takeovers.claude, AppType::Codex => takeovers.codex, AppType::Gemini => takeovers.gemini, - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => false, } } diff --git a/src-tauri/src/cli/commands/provider.rs b/src-tauri/src/cli/commands/provider.rs index 201a48065..915cc9d72 100644 --- a/src-tauri/src/cli/commands/provider.rs +++ b/src-tauri/src/cli/commands/provider.rs @@ -498,7 +498,7 @@ fn prompt_and_apply_provider_api_format( match app_type { AppType::Claude => prompt_and_apply_claude_api_format(app_type, provider), AppType::Codex => prompt_and_apply_codex_api_format(app_type, provider), - AppType::Gemini | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + AppType::Gemini | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => { Ok(()) } } @@ -1441,7 +1441,7 @@ fn build_add_settings_config( } Ok(settings) } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => { + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Kimi => { let current = current.ok_or_else(|| add_additive_requires_config_error(app_type))?; let api_key = non_empty(args.api_key.clone()); let base_url = non_empty(args.base_url.clone()); @@ -1528,7 +1528,7 @@ fn apply_add_provider_api_format( }; apply_codex_api_format(provider, format); } - AppType::Gemini | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + AppType::Gemini | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => { } } Ok(()) @@ -1854,6 +1854,10 @@ fn existing_provider_ids_for_duplicate( .into_iter() .map(|(id, _)| id) .collect::>(), + AppType::Kimi => crate::kimi_config::get_providers()? + .into_iter() + .map(|(id, _)| id) + .collect::>(), _ => Vec::new(), }; ids.extend(live_ids); diff --git a/src-tauri/src/cli/commands/provider_input.rs b/src-tauri/src/cli/commands/provider_input.rs index a61da2022..1e3c75c72 100644 --- a/src-tauri/src/cli/commands/provider_input.rs +++ b/src-tauri/src/cli/commands/provider_input.rs @@ -144,7 +144,9 @@ pub fn common_snippet_has_effective_config( .ok() .and_then(|value| value.as_object().cloned()) .is_some_and(|obj| !obj.is_empty()), - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => { + false + } } } @@ -209,7 +211,7 @@ pub fn provider_add_template_choices(app_type: &AppType) -> Vec { + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => { vec![ProviderAddTemplateChoice { template: ProviderAddTemplate::Custom, label: "Custom", @@ -657,7 +659,7 @@ fn build_sponsor_template_settings_config( }) } } - AppType::Pi => Err(unsupported_template_error(ProviderAddTemplate::Custom)), + AppType::Pi | AppType::Kimi => Err(unsupported_template_error(ProviderAddTemplate::Custom)), } } @@ -729,6 +731,22 @@ pub fn apply_additive_template_field_overrides( } Ok(updated) } + AppType::Kimi => { + let mut updated = current.clone(); + let object = updated.as_object_mut().ok_or_else(|| { + AppError::InvalidInput("Kimi provider configuration must be an object".to_string()) + })?; + if let Some(api_key) = api_key { + object.insert("api_key".to_string(), Value::String(api_key.to_string())); + } + if let Some(base_url) = base_url { + object.insert("base_url".to_string(), Value::String(base_url.to_string())); + } + if let Some(model) = model { + object.insert("model".to_string(), Value::String(model.to_string())); + } + Ok(updated) + } AppType::Claude | AppType::Codex | AppType::Gemini => Err(AppError::InvalidInput(format!( "{} does not use additive provider settings", app_type.as_str() @@ -3876,6 +3894,43 @@ fn validate_pi_prompt_request_url(current: Option<&Value>, edited: &Value) -> Re Ok(()) } +fn prompt_kimi_config(current: Option<&Value>) -> Result { + println!("\n{}", "Kimi Code".bright_cyan().bold()); + + let default_api_key = current + .and_then(crate::kimi_config::provider_api_key) + .unwrap_or_default(); + let default_base_url = current + .and_then(crate::kimi_config::provider_base_url) + .unwrap_or("https://api.moonshot.cn/v1"); + let default_model = current + .and_then(|v| v.get("model")) + .and_then(Value::as_str) + .unwrap_or("kimi-k2.5"); + + let api_key = Text::new(texts::api_key_label()) + .with_initial_value(default_api_key) + .prompt() + .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?; + + let base_url = Text::new(texts::base_url_label()) + .with_initial_value(default_base_url) + .prompt() + .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?; + + let model = Text::new(texts::model_label()) + .with_initial_value(default_model) + .prompt() + .map_err(|e| AppError::Message(texts::input_failed_error(&e.to_string())))?; + + let mut map = current + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + crate::kimi_config::set_editor_fields(&mut map, &base_url, &api_key, &model); + Ok(Value::Object(map)) +} + /// 根据应用类型收集 settings_config pub fn prompt_settings_config( app_type: &AppType, @@ -3917,6 +3972,7 @@ pub fn prompt_settings_config( AppType::OpenCode => prompt_opencode_config(current).map(SettingsConfigPromptResult::new), AppType::Hermes => prompt_hermes_config(current).map(SettingsConfigPromptResult::new), AppType::OpenClaw => prompt_openclaw_config(current).map(SettingsConfigPromptResult::new), + AppType::Kimi => prompt_kimi_config(current).map(SettingsConfigPromptResult::new), AppType::Pi => { let mut config = prompt_pi_config(current)?; if current.is_none() { @@ -4681,6 +4737,26 @@ pub fn display_provider_summary(provider: &Provider, app_type: &AppType) { println!(" {}: {}", texts::model_label(), models.len()); } } + AppType::Kimi => { + if let Some(api_key) = provider.configured_api_key(app_type) { + println!(" {}: {}", texts::api_key_display_label(), api_key); + } + if let Some(base_url) = provider + .settings_config + .get("base_url") + .or_else(|| provider.settings_config.get("baseUrl")) + .and_then(|v| v.as_str()) + { + println!(" {}: {}", texts::base_url_display_label(), base_url); + } + if let Some(model) = provider + .settings_config + .get("model") + .and_then(|v| v.as_str()) + { + println!(" {}: {}", texts::model_label(), model); + } + } } // 可选字段 diff --git a/src-tauri/src/cli/commands/provider_inspect.rs b/src-tauri/src/cli/commands/provider_inspect.rs index 758ef6f2d..a96610470 100644 --- a/src-tauri/src/cli/commands/provider_inspect.rs +++ b/src-tauri/src/cli/commands/provider_inspect.rs @@ -925,6 +925,26 @@ fn model_fetch_target( request_headers: None, strategy: ProviderModelFetchStrategy::Bearer, }), + AppType::Kimi => Ok(ModelFetchTarget { + base_url, + is_full_url, + auth_value: Some( + provider + .settings_config + .get("apiKey") + .or_else(|| provider.settings_config.get("api_key")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| { + AppError::Message(format!("Missing API key for provider '{}'", provider.id)) + })?, + ), + custom_user_agent, + request_headers: None, + strategy: ProviderModelFetchStrategy::Bearer, + }), AppType::OpenClaw => Ok(ModelFetchTarget { base_url, is_full_url, @@ -1028,7 +1048,7 @@ fn default_one_off_model_fetch_strategy(app_type: &AppType) -> ProviderModelFetc match app_type { AppType::Claude => ProviderModelFetchStrategy::Anthropic, AppType::Gemini => ProviderModelFetchStrategy::GoogleApiKey, - AppType::Codex | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + AppType::Codex | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => { ProviderModelFetchStrategy::Bearer } } diff --git a/src-tauri/src/cli/commands/provider_usage_query.rs b/src-tauri/src/cli/commands/provider_usage_query.rs index 95439650e..ab3130621 100644 --- a/src-tauri/src/cli/commands/provider_usage_query.rs +++ b/src-tauri/src/cli/commands/provider_usage_query.rs @@ -735,6 +735,20 @@ fn provider_comment_credentials<'a>( .or_else(|| settings.get("auth_token")) .and_then(|value| value.as_str()), ), + AppType::Kimi => ( + settings + .get("base_url") + .or_else(|| settings.get("baseUrl")) + .or_else(|| settings.get("baseURL")) + .or_else(|| settings.get("endpoint")) + .and_then(|value| value.as_str()) + .map(str::to_string), + settings + .get("api_key") + .or_else(|| settings.get("apiKey")) + .or_else(|| settings.get("auth_token")) + .and_then(|value| value.as_str()), + ), AppType::OpenClaw => ( settings .get("baseUrl") diff --git a/src-tauri/src/cli/commands/settings.rs b/src-tauri/src/cli/commands/settings.rs index dfacaede4..c93a51272 100644 --- a/src-tauri/src/cli/commands/settings.rs +++ b/src-tauri/src/cli/commands/settings.rs @@ -513,6 +513,7 @@ fn set_visible_apps_list(apps: Vec) -> Result<(), AppError> { hermes: false, openclaw: false, pi: false, + kimi: false, }; for app in apps { visible_apps.set_enabled_for(&app, true); @@ -889,6 +890,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }) .expect("save manual visible apps"); @@ -913,6 +915,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }) .expect_err("empty visible apps should be rejected"); diff --git a/src-tauri/src/cli/failover_policy.rs b/src-tauri/src/cli/failover_policy.rs index 1b7edf2c5..ed85a8381 100644 --- a/src-tauri/src/cli/failover_policy.rs +++ b/src-tauri/src/cli/failover_policy.rs @@ -85,6 +85,6 @@ fn takeover_enabled_for(takeover: &ProxyTakeoverStatus, app_type: &AppType) -> b AppType::Claude => takeover.claude, AppType::Codex => takeover.codex, AppType::Gemini => takeover.gemini, - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => false, } } diff --git a/src-tauri/src/cli/tui/app/helpers.rs b/src-tauri/src/cli/tui/app/helpers.rs index da5f05104..94137d333 100644 --- a/src-tauri/src/cli/tui/app/helpers.rs +++ b/src-tauri/src/cli/tui/app/helpers.rs @@ -1761,6 +1761,7 @@ pub(crate) fn app_type_picker_index(app_type: &AppType) -> usize { AppType::Hermes => 4, AppType::OpenClaw => 5, AppType::Pi => 6, + AppType::Kimi => 7, } } diff --git a/src-tauri/src/cli/tui/app/tests.rs b/src-tauri/src/cli/tui/app/tests.rs index 1050e5b00..c25752304 100644 --- a/src-tauri/src/cli/tui/app/tests.rs +++ b/src-tauri/src/cli/tui/app/tests.rs @@ -971,6 +971,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }, }; @@ -1166,6 +1167,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + kimi: false, }) .expect("save visible apps"); let mut app = App::new(Some(AppType::Claude)); @@ -1192,6 +1194,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + kimi: false, }) .expect("save visible apps"); let mut app = App::new(Some(AppType::Claude)); @@ -1226,6 +1229,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + kimi: false, }) .expect("save visible apps"); let mut app = App::new(Some(AppType::Gemini)); @@ -1268,6 +1272,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + kimi: false, }) .expect("save visible apps"); @@ -1292,6 +1297,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }) .expect("save visible apps"); @@ -1320,6 +1326,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + kimi: false, }) .expect("save visible apps"); @@ -1344,6 +1351,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }) .expect("save visible apps"); @@ -11226,6 +11234,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }) .expect("save visible apps"); crate::settings::set_visible_apps_mode(crate::settings::VisibleAppsMode::Manual) @@ -11277,6 +11286,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }) .expect("save visible apps"); @@ -11315,6 +11325,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }; settings.visible_apps_settings.mode = crate::settings::VisibleAppsMode::Auto; settings.visible_apps_settings.auto_prompt_decided = true; @@ -11354,6 +11365,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }; settings.visible_apps_settings.mode = crate::settings::VisibleAppsMode::Auto; settings.visible_apps_settings.auto_prompt_decided = true; @@ -11392,6 +11404,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }; let mut settings = crate::settings::get_settings(); settings.visible_apps = initial.clone(); diff --git a/src-tauri/src/cli/tui/data.rs b/src-tauri/src/cli/tui/data.rs index a6794aef0..39c36f584 100644 --- a/src-tauri/src/cli/tui/data.rs +++ b/src-tauri/src/cli/tui/data.rs @@ -409,6 +409,7 @@ impl ProxySnapshot { AppType::Hermes => None, AppType::OpenClaw => None, AppType::Pi => None, + AppType::Kimi => None, } } @@ -1742,6 +1743,13 @@ fn extract_api_url(settings_config: &Value, app_type: &AppType) -> Option crate::pi_config::provider_base_url(settings_config).ok(), + AppType::Kimi => settings_config + .get("base_url") + .or_else(|| settings_config.get("baseUrl")) + .or_else(|| settings_config.get("baseURL")) + .or_else(|| settings_config.get("endpoint"))? + .as_str() + .map(|s| s.to_string()), } } @@ -1752,6 +1760,11 @@ fn extract_primary_model_id( ) -> Option { match app_type { AppType::Hermes => hermes_primary_model_id(settings_config), + AppType::Kimi => settings_config + .get("model") + .and_then(Value::as_str) + .filter(|v| !v.trim().is_empty()) + .map(ToOwned::to_owned), AppType::OpenClaw => match openclaw_live_provider { Some(live_provider) => openclaw_primary_model_id(live_provider), None => openclaw_primary_model_id(settings_config), diff --git a/src-tauri/src/cli/tui/form/provider_json.rs b/src-tauri/src/cli/tui/form/provider_json.rs index 8c7d22881..7674dadcd 100644 --- a/src-tauri/src/cli/tui/form/provider_json.rs +++ b/src-tauri/src/cli/tui/form/provider_json.rs @@ -865,6 +865,20 @@ impl ProviderAddFormState { } } } + AppType::Kimi => { + let base_url = self + .hermes_base_url + .value + .trim() + .trim_end_matches('/') + .to_string(); + crate::kimi_config::set_editor_fields( + settings_obj, + &base_url, + &self.hermes_api_key.value, + &self.claude_model.value, + ); + } } Value::Object(provider_obj) @@ -1503,7 +1517,7 @@ pub(crate) fn strip_common_config_from_settings( ) .map_err(|e| e.to_string())?; } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => {} + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => {} AppType::Codex => { *settings_value = ProviderService::remove_common_config_from_settings_for_preview( app_type, @@ -1644,3 +1658,68 @@ fn set_or_remove_f64(obj: &mut serde_json::Map, key: &str, raw: & obj.remove(key); } } + +#[cfg(test)] +mod kimi_roundtrip_tests { + use super::*; + #[test] + fn kimi_native_only_noop_edit_keeps_connection_and_clear_is_explicit() { + let provider = crate::provider::Provider::with_id( + "native".into(), + "native".into(), + json!({ + "providerConfig":{"type":"kimi","base_url":"https://example.invalid/v1","api_key":"native-secret"}, + "model":" fast ", "models":[{"id":" fast ","config":{"model":"kimi-k2.5"}}] + }), + None, + ); + let mut form = ProviderAddFormState::from_provider(AppType::Kimi, &provider); + let value = form.to_provider_json_value(); + assert_eq!( + value["settingsConfig"]["base_url"], + "https://example.invalid/v1" + ); + assert_eq!(value["settingsConfig"]["api_key"], "native-secret"); + assert_eq!(value["settingsConfig"]["model"], " fast "); + form.hermes_api_key.set(""); + let cleared = form.to_provider_json_value(); + assert!(!cleared["settingsConfig"] + .to_string() + .contains("native-secret")); + } + + #[test] + fn kimi_form_keeps_native_metadata_and_replaces_imported_alias_fields() { + let provider = crate::provider::Provider::with_id( + "native".into(), + "native".into(), + json!({ + "type":"kimi", "baseUrl":"https://old.invalid", "base_url":"https://old.invalid", + "apiKey":"old", "api_key":"old", "model":" fast ", + "providerConfig":{"type":"kimi","custom":"preserve","api_key":"superseded-secret","base_url":"https://old.invalid"}, + "models":[{"id":" fast ","config":{"model":"kimi-k2.5","max_context_size":262144}}] + }), + None, + ); + let mut form = ProviderAddFormState::from_provider(AppType::Kimi, &provider); + form.hermes_base_url.set("https://new.invalid"); + form.hermes_api_key.set(""); + let value = form.to_provider_json_value(); + let settings = &value["settingsConfig"]; + assert_eq!(settings["base_url"], "https://new.invalid"); + assert_eq!(settings["api_key"], ""); + assert_eq!(settings["model"], " fast "); + assert!(settings.get("apiKey").is_none()); + assert!(settings.get("baseUrl").is_none()); + assert_eq!(settings["models"], provider.settings_config["models"]); + assert_eq!( + settings["providerConfig"], + json!({"type":"kimi","custom":"preserve"}) + ); + assert!(!settings.to_string().contains("superseded-secret")); + form.hermes_api_key.set("replacement-secret"); + let replaced = form.to_provider_json_value(); + assert_eq!(replaced["settingsConfig"]["api_key"], "replacement-secret"); + assert!(!replaced.to_string().contains("superseded-secret")); + } +} diff --git a/src-tauri/src/cli/tui/form/provider_state.rs b/src-tauri/src/cli/tui/form/provider_state.rs index ceb4923cd..f415cb4f4 100644 --- a/src-tauri/src/cli/tui/form/provider_state.rs +++ b/src-tauri/src/cli/tui/form/provider_state.rs @@ -375,7 +375,7 @@ impl ProviderAddFormState { .ok() .and_then(|value| value.as_object().cloned()) .is_some_and(|env| !env.is_empty()), - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => false, } } @@ -431,7 +431,7 @@ impl ProviderAddFormState { app_type, settings, &snippet, ) } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => false, } } @@ -579,6 +579,11 @@ impl ProviderAddFormState { fields.push(ProviderAddField::OpenCodeBaseUrl); fields.push(ProviderAddField::OpenClawModels); } + AppType::Kimi => { + fields.push(ProviderAddField::HermesBaseUrl); + fields.push(ProviderAddField::HermesApiKey); + fields.push(ProviderAddField::ClaudeModelConfig); + } } if Self::supports_common_config(&self.app_type) { @@ -1193,7 +1198,8 @@ impl ProviderAddFormState { | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw - | AppType::Pi => {} + | AppType::Pi + | AppType::Kimi => {} } Ok(()) } @@ -1425,7 +1431,8 @@ impl ProviderAddFormState { | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw - | AppType::Pi => false, + | AppType::Pi + | AppType::Kimi => false, } } @@ -2231,7 +2238,7 @@ impl ProviderAddFormState { AppType::Claude => self.claude_base_url.value.clone(), AppType::Codex => self.codex_base_url.value.clone(), AppType::Gemini => self.gemini_base_url.value.clone(), - AppType::Hermes => self.hermes_base_url.value.clone(), + AppType::Hermes | AppType::Kimi => self.hermes_base_url.value.clone(), AppType::OpenCode | AppType::OpenClaw => self.opencode_base_url.value.clone(), AppType::Pi => { let provider = self.to_provider_json_value(); @@ -2260,7 +2267,7 @@ impl ProviderAddFormState { AppType::Claude => (&self.claude_api_key.value, &self.claude_base_url.value), AppType::Codex => (&self.codex_api_key.value, &self.codex_base_url.value), AppType::Gemini => (&self.gemini_api_key.value, &self.gemini_base_url.value), - AppType::Hermes => (&self.hermes_api_key.value, &self.hermes_base_url.value), + AppType::Hermes | AppType::Kimi => (&self.hermes_api_key.value, &self.hermes_base_url.value), AppType::OpenCode | AppType::OpenClaw => { (&self.opencode_api_key.value, &self.opencode_base_url.value) } @@ -2518,7 +2525,8 @@ impl ProviderAddFormState { | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw - | AppType::Pi => false, + | AppType::Pi + | AppType::Kimi => false, } } diff --git a/src-tauri/src/cli/tui/form/provider_state_loading.rs b/src-tauri/src/cli/tui/form/provider_state_loading.rs index d1706cc4f..efbcb8f4d 100644 --- a/src-tauri/src/cli/tui/form/provider_state_loading.rs +++ b/src-tauri/src/cli/tui/form/provider_state_loading.rs @@ -29,6 +29,7 @@ pub(super) fn populate_form_from_provider( AppType::Hermes => populate_hermes_form(form, provider), AppType::OpenClaw => populate_openclaw_form(form, provider), AppType::Pi => populate_openclaw_form(form, provider), + AppType::Kimi => populate_kimi_form(form, provider), } form.is_full_url = form.supports_full_url_mode() && provider @@ -438,6 +439,19 @@ fn populate_hermes_form(form: &mut ProviderAddFormState, provider: &Provider) { } } +fn populate_kimi_form(form: &mut ProviderAddFormState, provider: &Provider) { + let settings = &provider.settings_config; + if let Some(base_url) = crate::kimi_config::provider_base_url(settings) { + form.hermes_base_url.set(base_url); + } + if let Some(api_key) = crate::kimi_config::provider_api_key(settings) { + form.hermes_api_key.set(api_key); + } + if let Some(model) = settings.get("model").and_then(|value| value.as_str()) { + form.claude_model.set(model); + } +} + fn populate_openclaw_form(form: &mut ProviderAddFormState, provider: &Provider) { if let Some(api_key) = provider .settings_config diff --git a/src-tauri/src/cli/tui/form/provider_templates.rs b/src-tauri/src/cli/tui/form/provider_templates.rs index 7945baae9..2dacb8c5d 100644 --- a/src-tauri/src/cli/tui/form/provider_templates.rs +++ b/src-tauri/src/cli/tui/form/provider_templates.rs @@ -260,6 +260,7 @@ pub(super) fn provider_builtin_template_defs(app_type: &AppType) -> &'static [Pr AppType::Hermes => &PROVIDER_TEMPLATE_DEFS_HERMES, AppType::OpenClaw => &PROVIDER_TEMPLATE_DEFS_OPENCLAW, AppType::Pi => &PROVIDER_TEMPLATE_DEFS_OPENCLAW, + AppType::Kimi => &PROVIDER_TEMPLATE_DEFS_HERMES, } } @@ -281,7 +282,7 @@ pub(super) fn provider_after_sponsor_template_defs( match app_type { AppType::Claude => &PROVIDER_TEMPLATE_DEFS_CLAUDE_AFTER_SPONSORS, AppType::Codex => &PROVIDER_TEMPLATE_DEFS_CODEX_AFTER_SPONSORS, - AppType::Gemini | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + AppType::Gemini | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => { &[] } } @@ -848,7 +849,7 @@ impl ProviderAddFormState { self.opencode_model_original_id = None; } } - AppType::Pi => {} + AppType::Pi | AppType::Kimi => {} } if matches!(self.app_type, AppType::Codex) { diff --git a/src-tauri/src/cli/tui/runtime_actions/helpers.rs b/src-tauri/src/cli/tui/runtime_actions/helpers.rs index 679300648..69ed6a9d3 100644 --- a/src-tauri/src/cli/tui/runtime_actions/helpers.rs +++ b/src-tauri/src/cli/tui/runtime_actions/helpers.rs @@ -68,6 +68,7 @@ pub(crate) fn app_display_name(app_type: &AppType) -> &'static str { AppType::Hermes => "Hermes", AppType::OpenClaw => "OpenClaw", AppType::Pi => "Pi", + AppType::Kimi => "Kimi", } } diff --git a/src-tauri/src/cli/tui/runtime_actions/mod.rs b/src-tauri/src/cli/tui/runtime_actions/mod.rs index 898f8936e..935ffddf6 100644 --- a/src-tauri/src/cli/tui/runtime_actions/mod.rs +++ b/src-tauri/src/cli/tui/runtime_actions/mod.rs @@ -1868,6 +1868,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + kimi: false, }) .expect("save initial visible apps"); @@ -1879,6 +1880,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }; let mut app = App::new(Some(AppType::OpenClaw)); app.route = Route::ConfigOpenClawTools; @@ -1940,6 +1942,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + kimi: false, }; crate::settings::set_visible_apps(initial_visible_apps.clone()) .expect("save initial visible apps"); @@ -1963,6 +1966,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }, }, ) @@ -1992,6 +1996,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + kimi: false, }) .expect("save initial visible apps"); write_invalid_legacy_config(temp_home.path()); @@ -2004,6 +2009,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }; let mut app = App::new(Some(AppType::Claude)); let mut data = UiData::default(); @@ -2042,6 +2048,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + kimi: false, }; crate::settings::set_visible_apps(initial_visible_apps.clone()) .expect("save initial visible apps"); @@ -2062,6 +2069,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }, }, ) @@ -2092,6 +2100,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }; settings.visible_apps_settings.mode = crate::settings::VisibleAppsMode::Auto; settings.visible_apps_settings.auto_prompt_decided = true; @@ -2105,6 +2114,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }; let mut app = App::new(Some(AppType::Claude)); let mut data = UiData::default(); @@ -2152,6 +2162,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + kimi: false, }; let mut settings = crate::settings::get_settings(); settings.visible_apps = initial_visible_apps.clone(); @@ -2179,6 +2190,7 @@ mod tests { hermes: false, openclaw: false, pi: false, + kimi: false, }, selected: 5, }, diff --git a/src-tauri/src/cli/tui/tests.rs b/src-tauri/src/cli/tui/tests.rs index 3682832a2..339f08aa5 100644 --- a/src-tauri/src/cli/tui/tests.rs +++ b/src-tauri/src/cli/tui/tests.rs @@ -5459,6 +5459,7 @@ fn startup_hidden_requested_app_bootstrap_uses_visible_app_normalization_before_ hermes: false, openclaw: true, pi: false, + kimi: false, }) .expect("save visible apps"); diff --git a/src-tauri/src/cli/tui/theme.rs b/src-tauri/src/cli/tui/theme.rs index a1fe08440..bb2ad3f34 100644 --- a/src-tauri/src/cli/tui/theme.rs +++ b/src-tauri/src/cli/tui/theme.rs @@ -253,6 +253,7 @@ fn accent_rgb(app: &AppType, light: bool) -> (u8, u8, u8) { AppType::Hermes => LIGHT_YELLOW, AppType::OpenClaw => LIGHT_CORAL, AppType::Pi => LIGHT_CYAN, + AppType::Kimi => LIGHT_CYAN, }; } @@ -264,6 +265,7 @@ fn accent_rgb(app: &AppType, light: bool) -> (u8, u8, u8) { AppType::Hermes => DRACULA_YELLOW, AppType::OpenClaw => OPENCLAW_CORAL, AppType::Pi => DRACULA_CYAN, + AppType::Kimi => DRACULA_CYAN, } } diff --git a/src-tauri/src/cli/tui/ui/forms/provider.rs b/src-tauri/src/cli/tui/ui/forms/provider.rs index 7fd4c1383..d79298189 100644 --- a/src-tauri/src/cli/tui/ui/forms/provider.rs +++ b/src-tauri/src/cli/tui/ui/forms/provider.rs @@ -236,7 +236,7 @@ fn common_json_preview_value(app_type: &AppType, common_snippet: &str) -> Option AppType::Gemini => serde_json::from_str::(common_snippet) .ok() .map(|env| json!({ "env": env })), - AppType::Codex | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + AppType::Codex | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => { None } } diff --git a/src-tauri/src/cli/tui/ui/header_tests.rs b/src-tauri/src/cli/tui/ui/header_tests.rs index 9f98904dd..4d40451bf 100644 --- a/src-tauri/src/cli/tui/ui/header_tests.rs +++ b/src-tauri/src/cli/tui/ui/header_tests.rs @@ -197,6 +197,7 @@ fn header_openclaw_sacrifices_tabs_before_losing_the_only_status_badge() { hermes: false, openclaw: true, pi: false, + kimi: false, }); let _lang = use_test_language(Language::English); let _no_color = super::tests::EnvGuard::remove("NO_COLOR"); @@ -238,6 +239,7 @@ fn header_openclaw_truncates_long_default_model_without_fake_proxy_gap() { hermes: false, openclaw: true, pi: false, + kimi: false, }); let _lang = use_test_language(Language::English); let _no_color = super::tests::EnvGuard::remove("NO_COLOR"); diff --git a/src-tauri/src/cli/tui/ui/tests.rs b/src-tauri/src/cli/tui/ui/tests.rs index e6938451e..6176211aa 100644 --- a/src-tauri/src/cli/tui/ui/tests.rs +++ b/src-tauri/src/cli/tui/ui/tests.rs @@ -4155,6 +4155,7 @@ fn installed_skill(directory: &str, name: &str) -> InstalledSkill { opencode: false, hermes: false, pi: false, + kimi: false, }, installed_at: 1, content_hash: None, @@ -4654,6 +4655,7 @@ fn header_only_renders_selected_visible_apps() { hermes: false, openclaw: true, pi: false, + kimi: false, }) .expect("save visible apps"); @@ -4684,6 +4686,7 @@ fn header_keeps_all_app_tabs_visible_with_proxy_chip() { hermes: false, openclaw: true, pi: false, + kimi: false, }) .expect("save visible apps"); @@ -4714,6 +4717,7 @@ fn settings_page_shows_visible_apps_row_value() { hermes: false, openclaw: true, pi: false, + kimi: false, }) .expect("save visible apps"); @@ -5388,6 +5392,7 @@ fn zero_selection_warning_toast_renders_after_picker_rejection() { hermes: false, openclaw: false, pi: false, + kimi: false, }, }; app.push_toast( @@ -5428,6 +5433,7 @@ fn visible_apps_picker_uses_space_toggle_key() { hermes: false, openclaw: false, pi: false, + kimi: false, }, }; @@ -5460,6 +5466,7 @@ fn visible_apps_picker_auto_mode_does_not_append_auto_suffix_to_apps() { hermes: true, openclaw: true, pi: false, + kimi: false, }, }; @@ -5618,6 +5625,7 @@ fn header_centers_tabs_when_room_allows() { hermes: true, openclaw: true, pi: false, + kimi: false, }) .expect("save visible apps"); @@ -5664,6 +5672,7 @@ fn header_keeps_title_and_right_badges_visible_without_large_gap_in_chinese() { hermes: true, openclaw: true, pi: false, + kimi: false, }) .expect("save visible apps"); @@ -6183,6 +6192,7 @@ fn home_connection_card_labels_mcp_and_skills_with_active_counts() { opencode: false, hermes: false, pi: false, + kimi: false, }, installed_at: 0, content_hash: None, @@ -7224,6 +7234,7 @@ fn skills_page_shows_opencode_summary() { opencode: true, hermes: false, pi: false, + kimi: false, }; data.skills.installed = vec![skill]; @@ -7251,6 +7262,7 @@ fn skills_page_shows_hermes_column_and_summary() { opencode: false, hermes: true, pi: false, + kimi: false, }; data.skills.installed = vec![skill]; @@ -7281,6 +7293,7 @@ fn skill_detail_page_shows_opencode_enabled_state() { opencode: true, hermes: false, pi: false, + kimi: false, }; data.skills.installed = vec![skill]; @@ -7312,6 +7325,7 @@ fn skill_detail_page_shows_hermes_enabled_state() { opencode: false, hermes: true, pi: false, + kimi: false, }; data.skills.installed = vec![skill]; @@ -7375,6 +7389,7 @@ fn mcp_page_renders_opencode_column() { gemini: false, opencode: true, hermes: false, + kimi: false, }, description: None, homepage: None, @@ -7412,6 +7427,7 @@ fn mcp_page_renders_hermes_column() { gemini: false, opencode: false, hermes: true, + kimi: true, }, description: None, homepage: None, @@ -7468,6 +7484,7 @@ fn mcp_page_uses_space_toggle_key() { gemini: false, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, @@ -7765,6 +7782,7 @@ fn mcp_page_shows_summary_bar() { gemini: false, opencode: true, hermes: false, + kimi: false, }, description: None, homepage: None, @@ -7785,6 +7803,7 @@ fn mcp_page_shows_summary_bar() { gemini: false, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, diff --git a/src-tauri/src/cli/ui/colors.rs b/src-tauri/src/cli/ui/colors.rs index d9c147e51..033b14142 100644 --- a/src-tauri/src/cli/ui/colors.rs +++ b/src-tauri/src/cli/ui/colors.rs @@ -37,6 +37,7 @@ fn inquire_color_for_app(app_type: &AppType) -> InquireColor { AppType::Hermes => InquireColor::LightBlue, AppType::OpenClaw => InquireColor::LightRed, AppType::Pi => InquireColor::LightCyan, + AppType::Kimi => InquireColor::LightCyan, } } @@ -90,6 +91,7 @@ fn highlight_color_for_app(app_type: &AppType) -> Color { AppType::Hermes => Color::BrightBlue, AppType::OpenClaw => Color::BrightRed, AppType::Pi => Color::BrightCyan, + AppType::Kimi => Color::BrightCyan, } } diff --git a/src-tauri/src/database/dao/mcp.rs b/src-tauri/src/database/dao/mcp.rs index 8f5008c5c..39e2f2a19 100644 --- a/src-tauri/src/database/dao/mcp.rs +++ b/src-tauri/src/database/dao/mcp.rs @@ -48,6 +48,7 @@ impl Database { gemini: enabled_gemini, opencode: enabled_opencode, hermes: enabled_hermes, + kimi: false, }, description, homepage, diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs index 7668d9c85..c8a9b95c9 100644 --- a/src-tauri/src/database/dao/skills.rs +++ b/src-tauri/src/database/dao/skills.rs @@ -46,6 +46,7 @@ impl Database { opencode: row.get(11)?, hermes: row.get(12)?, pi: false, + kimi: false, }, installed_at: row.get(13)?, content_hash: row.get(14)?, @@ -91,6 +92,7 @@ impl Database { opencode: row.get(11)?, hermes: row.get(12)?, pi: false, + kimi: false, }, installed_at: row.get(13)?, content_hash: row.get(14)?, diff --git a/src-tauri/src/deeplink/mcp.rs b/src-tauri/src/deeplink/mcp.rs index 27aae7cf4..41f6ce34d 100644 --- a/src-tauri/src/deeplink/mcp.rs +++ b/src-tauri/src/deeplink/mcp.rs @@ -174,6 +174,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result { gemini: false, opencode: false, hermes: false, + kimi: false, }; for app in apps_str.split(',') { @@ -187,6 +188,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result { log::debug!("OpenClaw doesn't support MCP, ignoring in apps parameter"); } "hermes" => apps.hermes = true, + "kimi" => apps.kimi = true, other => { return Err(AppError::InvalidInput(format!( "Invalid app in 'apps': {other}" diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 77621b7c2..8e16cc90d 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -142,6 +142,7 @@ fn build_provider_from_request( AppType::OpenCode => build_opencode_settings(request), AppType::Hermes => build_hermes_settings(request), AppType::OpenClaw => build_openclaw_settings(request), + AppType::Kimi => build_kimi_settings(request), AppType::Pi => { return Err(AppError::InvalidInput( "Pi providers must be added from the Pi provider page".to_string(), @@ -388,6 +389,31 @@ fn build_hermes_settings(request: &DeepLinkImportRequest) -> serde_json::Value { Value::Object(settings) } +fn build_kimi_settings(request: &DeepLinkImportRequest) -> serde_json::Value { + let endpoint = get_primary_endpoint(request); + let mut settings = serde_json::Map::new(); + settings.insert( + "name".to_string(), + json!(request.name.clone().unwrap_or_else(|| "custom".to_string())), + ); + + if !endpoint.is_empty() { + settings.insert("base_url".to_string(), json!(endpoint)); + } + if let Some(api_key) = &request.api_key { + settings.insert("api_key".to_string(), json!(api_key)); + } + if let Some(model) = request + .model + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + settings.insert("model".to_string(), json!(model)); + } + + Value::Object(settings) +} + fn build_openclaw_settings(request: &DeepLinkImportRequest) -> serde_json::Value { if let Some(config) = &request.openclaw_config { let mut settings = match config { diff --git a/src-tauri/src/kimi_config.rs b/src-tauri/src/kimi_config.rs new file mode 100644 index 000000000..cc1d91d38 --- /dev/null +++ b/src-tauri/src/kimi_config.rs @@ -0,0 +1,1142 @@ +use crate::app_config::MultiAppConfig; +use crate::error::AppError; +use anyhow::{Context, Result}; +use indexmap::IndexMap; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use toml_edit::{DocumentMut, Table}; + +pub const DEFAULT_KIMI_CONFIG_DIR: &str = ".kimi-code"; +pub const KIMI_HOME_ENV: &str = "KIMI_CODE_HOME"; +pub const KIMI_CONFIG_FILE: &str = "config.toml"; + +const BASE_URL_FIELDS: &[&str] = &["base_url", "baseUrl", "baseURL", "endpoint"]; +const API_KEY_FIELDS: &[&str] = &["api_key", "apiKey", "auth_token"]; + +fn connection_field<'a>(settings: &'a Value, names: &[&str]) -> Option<&'a str> { + names + .iter() + .find_map(|name| settings.get(*name)) + .or_else(|| { + settings + .get("providerConfig") + .and_then(|native| names.iter().find_map(|name| native.get(*name))) + }) + .and_then(Value::as_str) +} + +pub(crate) fn provider_base_url(settings: &Value) -> Option<&str> { + connection_field(settings, BASE_URL_FIELDS) +} + +pub(crate) fn provider_api_key(settings: &Value) -> Option<&str> { + connection_field(settings, API_KEY_FIELDS) +} + +/// Canonicalize edited connection fields without retaining superseded aliases or secrets. +pub(crate) fn set_editor_fields( + settings: &mut serde_json::Map, + base_url: &str, + api_key: &str, + model: &str, +) { + for name in BASE_URL_FIELDS.iter().chain(API_KEY_FIELDS) { + settings.remove(*name); + } + if let Some(native) = settings + .get_mut("providerConfig") + .and_then(Value::as_object_mut) + { + for name in BASE_URL_FIELDS.iter().chain(API_KEY_FIELDS) { + native.remove(*name); + } + } + settings.insert("base_url".into(), json!(base_url.trim())); + settings.insert("api_key".into(), json!(api_key.trim())); + if !model.is_empty() { + settings.insert("model".into(), json!(model)); + } +} + +/// 解析 Kimi Code 根目录路径(遵循 KIMI_CODE_HOME 环境变量,默认 ~/.kimi-code) +pub fn get_kimi_config_dir() -> PathBuf { + if let Some(override_dir) = crate::settings::get_kimi_override_dir() { + return override_dir; + } + if let Some(env_val) = std::env::var_os(KIMI_HOME_ENV) { + if !env_val.is_empty() { + return PathBuf::from(env_val); + } + } + #[cfg(test)] + if crate::test_support::test_home_override().is_none() + && std::env::var_os("CC_SWITCH_TEST_HOME").is_none() + { + return std::env::temp_dir().join("cc-switch-kimi-test-isolated"); + } + crate::config::get_home_dir().join(DEFAULT_KIMI_CONFIG_DIR) +} + +/// 获取 Kimi Code 的主配置文件路径 (~/.kimi-code/config.toml) +pub fn get_kimi_config_path() -> PathBuf { + get_kimi_config_dir().join(KIMI_CONFIG_FILE) +} + +/// 获取 Kimi Code 的 MCP 配置文件路径 (~/.kimi-code/mcp.json) +pub fn get_kimi_mcp_path() -> PathBuf { + get_kimi_config_dir().join("mcp.json") +} + +/// 获取 Kimi Code 的 Skills 存放目录 (~/.kimi-code/skills) +pub fn get_kimi_skills_dir() -> PathBuf { + get_kimi_config_dir().join("skills") +} + +/// 获取 Kimi Code 的全局指令文件路径 (~/.kimi-code/AGENTS.md) +pub fn get_kimi_agents_md_path() -> PathBuf { + get_kimi_config_dir().join("AGENTS.md") +} + +pub fn kimi_write_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +pub fn read_kimi_config_source() -> Result, AppError> { + let path = get_kimi_config_path(); + if !path.exists() { + return Ok(None); + } + let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; + Ok(Some(content)) +} + +pub fn write_kimi_config_source(source: &str) -> Result<(), AppError> { + let path = get_kimi_config_path(); + write_file_atomic(&path, source, 0o600).map_err(|e| AppError::Message(e.to_string())) +} + +pub fn read_kimi_config_json() -> Result { + let source = read_kimi_config_source()?.unwrap_or_default(); + if source.trim().is_empty() { + return Ok(json!({})); + } + let toml_val: toml::Value = toml::from_str(&source) + .map_err(|e| AppError::Config(format!("Failed to parse Kimi config.toml: {e}")))?; + let json_val = serde_json::to_value(toml_val) + .map_err(|e| AppError::Config(format!("Failed to convert Kimi config to JSON: {e}")))?; + Ok(json_val) +} + +/// 读取 live config.toml 中配置的所有 providers,转化为统一的 Provider settings_config 格式 +pub fn get_providers() -> Result, AppError> { + let path = get_kimi_config_path(); + if !path.exists() { + return Ok(IndexMap::new()); + } + let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; + if content.trim().is_empty() { + return Ok(IndexMap::new()); + } + let doc = content + .parse::() + .map_err(|e| AppError::Config(format!("Failed to parse Kimi config.toml: {e}")))?; + + let Some(providers_tbl) = doc.get("providers").and_then(|v| v.as_table_like()) else { + return Ok(IndexMap::new()); + }; + + let native: toml::Value = toml::from_str(&content) + .map_err(|e| AppError::Config(format!("Failed to parse Kimi config.toml: {e}")))?; + let native = serde_json::to_value(native) + .map_err(|e| AppError::Config(format!("Failed to convert Kimi config: {e}")))?; + + let default_model_opt = doc.get("default_model").and_then(|v| v.as_str()); + + let mut result = IndexMap::new(); + + for (p_id, p_item) in providers_tbl.iter() { + let Some(p_tbl) = p_item.as_table_like() else { + continue; + }; + + let p_type = p_tbl + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("openai"); + let base_url = p_tbl + .get("base_url") + .or_else(|| p_tbl.get("baseUrl")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let api_key = p_tbl + .get("api_key") + .or_else(|| p_tbl.get("apiKey")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + // Find models pointing to this provider + let mut models = Vec::new(); + if let Some(models_tbl) = doc.get("models").and_then(|v| v.as_table_like()) { + for (m_name, m_item) in models_tbl.iter() { + if let Some(m_tbl) = m_item.as_table_like() { + if m_tbl.get("provider").and_then(|p| p.as_str()) == Some(p_id) { + let display_name = m_tbl + .get("display_name") + .and_then(|v| v.as_str()) + .unwrap_or(m_name); + models.push(json!({ + "id": m_name, + "name": display_name, + // The table key is a local alias, not the upstream model ID. + "config": native["models"][m_name].clone(), + })); + } + } + } + } + + // Determine primary model + let primary_model = if let Some(def_m) = default_model_opt { + if models + .iter() + .any(|m| m.get("id").and_then(Value::as_str) == Some(def_m)) + { + Some(def_m.to_string()) + } else { + models + .first() + .and_then(|m| m.get("id")) + .and_then(Value::as_str) + .map(|s| s.to_string()) + } + } else { + models + .first() + .and_then(|m| m.get("id")) + .and_then(Value::as_str) + .map(|s| s.to_string()) + }; + + let mut obj = serde_json::Map::new(); + obj.insert( + "providerConfig".to_string(), + native["providers"][p_id].clone(), + ); + obj.insert("name".to_string(), json!(p_id)); + obj.insert("type".to_string(), json!(p_type)); + if !base_url.is_empty() { + obj.insert("baseUrl".to_string(), json!(base_url)); + obj.insert("base_url".to_string(), json!(base_url)); + } + if !api_key.is_empty() { + obj.insert("apiKey".to_string(), json!(api_key)); + obj.insert("api_key".to_string(), json!(api_key)); + } + if let Some(model) = primary_model { + obj.insert("model".to_string(), json!(model)); + } + if !models.is_empty() { + obj.insert("models".to_string(), Value::Array(models)); + } + + result.insert(p_id.to_string(), Value::Object(obj)); + } + + Ok(result) +} + +pub fn get_provider(id: &str) -> Result, AppError> { + Ok(get_providers()?.get(id).cloned()) +} + +fn validate_native_fields(config: &Value, model: bool) -> Result<(), AppError> { + let invalid = || { + AppError::localized( + "kimi.native.invalid_fields", + "Kimi 原生配置字段类型无效", + "Invalid field types in Kimi native configuration", + ) + }; + let fields = config.as_object().ok_or_else(invalid)?; + let string_fields: &[&str] = if model { + &["model", "provider", "display_name"] + } else { + &[ + "type", + "base_url", + "baseUrl", + "baseURL", + "endpoint", + "api_key", + "apiKey", + "auth_token", + ] + }; + for key in string_fields { + if let Some(value) = fields.get(*key) { + if !value.is_string() || (*key == "model" && value.as_str().unwrap().trim().is_empty()) + { + return Err(invalid()); + } + } + } + if model { + if let Some(size) = fields.get("max_context_size") { + if size.as_u64().is_none_or(|size| size == 0) { + return Err(invalid()); + } + } + if let Some(capabilities) = fields.get("capabilities") { + if capabilities + .as_array() + .is_none_or(|values| values.iter().any(|v| !v.is_string())) + { + return Err(invalid()); + } + } + } + Ok(()) +} + +fn native_document(config: &Value) -> Result { + let source = toml::to_string(config) + .map_err(|e| AppError::Config(format!("Invalid Kimi native config: {e}")))?; + source + .parse::() + .map_err(|e| AppError::Config(format!("Invalid Kimi native config: {e}"))) +} + +/// 准备将 provider 变更写入 Kimi 的 config.toml,返回更新后的 TOML 文本 +pub fn prepare_provider(id: &str, provider_config: Value) -> Result { + let path = get_kimi_config_path(); + let content = if path.exists() { + fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))? + } else { + String::new() + }; + let mut doc = if content.trim().is_empty() { + DocumentMut::new() + } else { + content + .parse::() + .map_err(|e| AppError::Config(format!("Failed to parse Kimi config.toml: {e}")))? + }; + + if doc.get("providers").is_none() { + doc["providers"] = toml_edit::Item::Table(Table::new()); + } + let providers = doc["providers"] + .as_table_like_mut() + .ok_or_else(|| AppError::Config("Kimi config.toml [providers] is not a table".into()))?; + + if providers.get(id).is_none() { + providers.insert(id, toml_edit::Item::Table(Table::new())); + } + let provider_tbl = providers + .get_mut(id) + .and_then(|v| v.as_table_like_mut()) + .ok_or_else(|| AppError::Config(format!("Kimi provider table '{id}' is invalid")))?; + + if let Some(native) = provider_config.get("providerConfig") { + validate_native_fields(native, false)?; + let native_doc = native_document(native)?; + for (key, value) in native_doc.iter() { + provider_tbl.insert(key, value.clone()); + } + } + + let p_type = provider_config + .get("type") + .or_else(|| { + provider_config + .get("providerConfig") + .and_then(|native| native.get("type")) + }) + .and_then(|v| v.as_str()) + .unwrap_or("openai"); + provider_tbl.insert("type", toml_edit::value(p_type)); + + for (field, names, value) in [ + ( + "base_url", + BASE_URL_FIELDS, + provider_base_url(&provider_config), + ), + ( + "api_key", + API_KEY_FIELDS, + provider_api_key(&provider_config), + ), + ] { + if let Some(value) = value { + for name in names { + provider_tbl.remove(name); + } + if !value.trim().is_empty() { + provider_tbl.insert(field, toml_edit::value(value.trim())); + } + } + } + + // Handle models + let mut configured_models = Vec::new(); + if let Some(model_str) = provider_config.get("model").and_then(|v| v.as_str()) { + let m = model_str; + if !m.is_empty() { + configured_models.push(m.to_string()); + } + } + + if let Some(models_arr) = provider_config.get("models").and_then(|v| v.as_array()) { + for item in models_arr { + if let Some(m_id) = item + .get("id") + .and_then(|v| v.as_str()) + .or_else(|| item.as_str()) + { + let m = m_id; + if !m.is_empty() && !configured_models.contains(&m.to_string()) { + configured_models.push(m.to_string()); + } + } + } + } + + if !configured_models.is_empty() { + if doc.get("models").is_none() { + doc["models"] = toml_edit::Item::Table(Table::new()); + } + let models = doc["models"] + .as_table_like_mut() + .ok_or_else(|| AppError::Config("Kimi models must be a table".into()))?; + { + for m in &configured_models { + if let Some(existing) = models.get(m) { + let owner = existing + .as_table_like() + .and_then(|tbl| tbl.get("provider")) + .and_then(|v| v.as_str()); + if owner != Some(id) { + return Err(AppError::localized( + "kimi.model.conflict", + format!("模型别名 '{m}' 已被其他供应商占用,请使用不同的别名"), + format!("Model alias '{m}' belongs to another provider; choose a different alias"), + )); + } + } + if models.get(m).is_none() { + models.insert(m, toml_edit::Item::Table(Table::new())); + } + let tbl = models + .get_mut(m) + .and_then(|v| v.as_table_like_mut()) + .ok_or_else(|| AppError::Config(format!("Invalid Kimi model table '{m}'")))?; + let model_entry = provider_config + .get("models") + .and_then(Value::as_array) + .and_then(|entries| { + entries + .iter() + .find(|entry| entry.get("id").and_then(Value::as_str) == Some(m)) + }); + if let Some(native) = model_entry.and_then(|entry| entry.get("config")) { + validate_native_fields(native, true)?; + let native_doc = native_document(native)?; + for (key, value) in native_doc.iter() { + tbl.insert(key, value.clone()); + } + } + tbl.insert("provider", toml_edit::value(id)); + // Preserve a native model ID when rewriting an existing alias. + if tbl.get("model").is_none() { + tbl.insert("model", toml_edit::value(m.as_str())); + } + if let Some(name) = model_entry + .and_then(|entry| entry.get("name")) + .and_then(Value::as_str) + { + // A missing display_name is distinct from a synthetic alias label. + if model_entry.and_then(|entry| entry.get("config")).is_none() { + tbl.insert("display_name", toml_edit::value(name)); + } + } + } + } + + if doc.get("default_model").is_none() { + doc["default_model"] = toml_edit::value(&configured_models[0]); + } + } + + Ok(doc.to_string()) +} + +/// 将准备好的配置内容原子写入 Kimi 的 config.toml +pub fn write_prepared_config(content: &str) -> Result<(), AppError> { + let _guard = kimi_write_lock() + .lock() + .map_err(|_| AppError::Message("Kimi write lock poisoned".into()))?; + let path = get_kimi_config_path(); + write_file_atomic(&path, content, 0o600).map_err(|e| AppError::Message(e.to_string())) +} + +/// 从 config.toml 中移除指定 provider 及其关联的 models +pub fn remove_provider(id: &str) -> Result<(), AppError> { + let _guard = kimi_write_lock() + .lock() + .map_err(|_| AppError::Message("Kimi write lock poisoned".into()))?; + let path = get_kimi_config_path(); + if !path.exists() { + return Ok(()); + } + let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; + let mut doc = content + .parse::() + .map_err(|e| AppError::Config(format!("Failed to parse Kimi config.toml: {e}")))?; + + if let Some(providers) = doc.get_mut("providers").and_then(|v| v.as_table_like_mut()) { + providers.remove(id); + } + + let mut removed_models = HashSet::new(); + if let Some(models) = doc.get_mut("models").and_then(|v| v.as_table_like_mut()) { + let to_remove: Vec = models + .iter() + .filter_map(|(m_name, m_item)| { + if let Some(tbl) = m_item.as_table_like() { + if tbl.get("provider").and_then(|p| p.as_str()) == Some(id) { + return Some(m_name.to_string()); + } + } + None + }) + .collect(); + for m in to_remove { + removed_models.insert(m.clone()); + models.remove(&m); + } + } + + if let Some(def_m) = doc.get("default_model").and_then(|v| v.as_str()) { + if removed_models.contains(def_m) { + let next_model = doc + .get("models") + .and_then(|m| m.as_table_like()) + .and_then(|m| m.iter().next().map(|(k, _)| k.to_string())); + if let Some(next) = next_model { + doc["default_model"] = toml_edit::value(next); + } else { + doc.as_table_mut().remove("default_model"); + } + } + } + + write_file_atomic(&path, &doc.to_string(), 0o600).map_err(|e| AppError::Message(e.to_string())) +} + +/// 获取当前激活的 provider ID(根据 default_model 追溯) +pub fn get_current_provider_id() -> Result, AppError> { + let path = get_kimi_config_path(); + if !path.exists() { + return Ok(None); + } + let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; + let doc = content + .parse::() + .map_err(|e| AppError::Config(format!("Failed to parse Kimi config.toml: {e}")))?; + let Some(default_model) = doc.get("default_model").and_then(|v| v.as_str()) else { + return Ok(None); + }; + if let Some(models) = doc.get("models").and_then(|m| m.as_table_like()) { + if let Some(target) = models.get(default_model).and_then(|m| m.as_table_like()) { + if let Some(provider_id) = target.get("provider").and_then(|p| p.as_str()) { + return Ok(Some(provider_id.to_string())); + } + } + } + Ok(None) +} + +/// 切换当前激活的 provider,并将其设为 default_model +pub fn set_current_provider(id: &str, provider_config: &Value) -> Result<(), AppError> { + let _guard = kimi_write_lock() + .lock() + .map_err(|_| AppError::Message("Kimi write lock poisoned".into()))?; + let path = get_kimi_config_path(); + let content = if path.exists() { + fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))? + } else { + String::new() + }; + let mut doc = if content.trim().is_empty() { + DocumentMut::new() + } else { + content + .parse::() + .map_err(|e| AppError::Config(format!("Failed to parse Kimi config.toml: {e}")))? + }; + + let target_model = provider_config + .get("model") + .and_then(|m| m.as_str()) + .map(|s| s.to_string()) + .or_else(|| { + doc.get("models") + .and_then(|m| m.as_table_like()) + .and_then(|models| { + models.iter().find_map(|(m_name, m_item)| { + if let Some(tbl) = m_item.as_table_like() { + if tbl.get("provider").and_then(|p| p.as_str()) == Some(id) { + return Some(m_name.to_string()); + } + } + None + }) + }) + }) + .unwrap_or_else(|| id.to_string()); + + if doc.get("models").is_none() { + doc["models"] = toml_edit::Item::Table(Table::new()); + } + let models = doc["models"] + .as_table_like_mut() + .ok_or_else(|| AppError::Config("Kimi models must be a table".into()))?; + { + let owner = models + .get(&target_model) + .and_then(|v| v.as_table_like()) + .and_then(|tbl| tbl.get("provider")) + .and_then(|v| v.as_str()); + if owner != Some(id) { + return Err(AppError::localized( + "kimi.model.invalid_selection", + "所选模型不存在或不属于此供应商", + "Selected model is missing or belongs to another provider", + )); + } + } + + doc["default_model"] = toml_edit::value(&target_model); + write_file_atomic(&path, &doc.to_string(), 0o600).map_err(|e| AppError::Message(e.to_string())) +} + +/// 设置默认模型 +pub fn set_default_model(model_name: &str) -> Result { + let _guard = kimi_write_lock() + .lock() + .map_err(|_| AppError::Message("Kimi write lock poisoned".into()))?; + let path = get_kimi_config_path(); + let content = if path.exists() { + fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))? + } else { + String::new() + }; + let mut doc = if content.trim().is_empty() { + DocumentMut::new() + } else { + content + .parse::() + .map_err(|e| AppError::Config(format!("Failed to parse Kimi config.toml: {e}")))? + }; + if doc + .get("models") + .and_then(|m| m.as_table_like()) + .and_then(|m| m.get(model_name)) + .is_none() + { + return Err(AppError::localized( + "kimi.model.missing", + "所选模型不存在", + "Selected model does not exist", + )); + } + doc["default_model"] = toml_edit::value(model_name); + write_file_atomic(&path, &doc.to_string(), 0o600) + .map_err(|e| AppError::Message(e.to_string()))?; + Ok(model_name.to_string()) +} + +/// 读取 Kimi MCP 服务器字典 (~/.kimi-code/mcp.json) +pub fn read_kimi_mcp_servers_map() -> Result, AppError> { + let path = get_kimi_mcp_path(); + if !path.exists() { + return Ok(HashMap::new()); + } + let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; + let val: Value = serde_json::from_str(&content).map_err(|e| AppError::json(&path, e))?; + let root = val.as_object().ok_or_else(|| { + AppError::localized( + "kimi.mcp.invalid_root", + "Kimi MCP 配置必须是对象", + "Kimi MCP config must be an object", + ) + })?; + let servers = match root.get("mcpServers") { + None => HashMap::new(), + Some(value) => value + .as_object() + .ok_or_else(|| { + AppError::localized( + "kimi.mcp.invalid_servers", + "Kimi mcpServers 必须是对象", + "Kimi mcpServers must be an object", + ) + })? + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + }; + Ok(servers) +} + +/// 写入 Kimi MCP 服务器字典 +pub fn set_kimi_mcp_servers_map(servers: &HashMap) -> Result<(), AppError> { + let path = get_kimi_mcp_path(); + let parent = path + .parent() + .ok_or_else(|| AppError::Message("Invalid mcp path".into()))?; + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + + let mut root_obj = if path.exists() { + let source = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; + serde_json::from_str::(&source) + .map_err(|e| AppError::json(&path, e))? + .as_object() + .cloned() + .ok_or_else(|| AppError::Config("Kimi MCP config must be an object".into()))? + } else { + serde_json::Map::new() + }; + + let servers_val = serde_json::to_value(servers) + .map_err(|e| AppError::Message(format!("Failed to serialize MCP servers: {e}")))?; + root_obj.insert("mcpServers".to_string(), servers_val); + + let formatted = serde_json::to_string_pretty(&Value::Object(root_obj)) + .map_err(|e| AppError::Message(format!("Failed to format MCP JSON: {e}")))?; + write_file_atomic(&path, &formatted, 0o600).map_err(|e| AppError::Message(e.to_string())) +} + +/// 同步单个 MCP 服务器到 Kimi live 配置 +pub fn sync_single_server_to_kimi( + _config: &MultiAppConfig, + id: &str, + server_spec: &Value, +) -> Result<(), AppError> { + if !crate::sync_policy::should_sync_live(&crate::app_config::AppType::Kimi) { + return Ok(()); + } + let mut servers = read_kimi_mcp_servers_map()?; + servers.insert(id.to_string(), server_spec.clone()); + set_kimi_mcp_servers_map(&servers) +} + +/// 从 Kimi live 配置中移除单个 MCP 服务器 +pub fn remove_server_from_kimi(id: &str) -> Result<(), AppError> { + if !crate::sync_policy::should_sync_live(&crate::app_config::AppType::Kimi) { + return Ok(()); + } + let mut servers = read_kimi_mcp_servers_map()?; + servers.remove(id); + set_kimi_mcp_servers_map(&servers) +} + +fn write_file_atomic( + path: &Path, + content: &str, + #[allow(unused_variables)] mode: u32, +) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow::anyhow!("无效的路径: {}", path.display()))?; + + fs::create_dir_all(parent).with_context(|| format!("创建目录失败: {}", parent.display()))?; + + let filename = path + .file_name() + .ok_or_else(|| anyhow::anyhow!("无效的文件名: {}", path.display()))? + .to_string_lossy(); + + let temp_path = parent.join(format!( + ".{filename}.tmp.{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + + let mut file = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&temp_path) + .with_context(|| format!("创建临时文件失败: {}", temp_path.display()))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(fs::Permissions::from_mode(mode))?; + } + + file.write_all(content.as_bytes())?; + file.sync_all()?; + drop(file); + + fs::rename(&temp_path, path).with_context(|| { + let _ = fs::remove_file(&temp_path); + format!("重命名临时文件到目标文件失败: {}", path.display()) + })?; + + Ok(()) +} + +#[cfg(test)] +pub(crate) mod test_support { + pub(crate) struct TestKimiDir { + _dir: tempfile::TempDir, + previous: Option, + } + impl TestKimiDir { + // Caller holds the shared test home/settings lock. + pub(crate) fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let previous = std::env::var_os(super::KIMI_HOME_ENV); + std::env::set_var(super::KIMI_HOME_ENV, dir.path()); + Self { + _dir: dir, + previous, + } + } + } + impl Drop for TestKimiDir { + fn drop(&mut self) { + crate::test_support::restore_env(super::KIMI_HOME_ENV, &self.previous); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn kimi_roundtrip_preserves_native_model_and_provider_attributes() { + let _lock = crate::test_support::lock_test_home_and_settings(); + let _dir = test_support::TestKimiDir::new(); + let original = r#"default_model = "fast" +[providers.native] +type = "kimi" +base_url = "https://example.invalid/v1" +api_key = "fixture-only" +custom = "preserved" +[providers.native.oauth] +storage = "native-only" +[models.fast] +provider = "native" +model = "kimi-k2.5" +max_context_size = 262144 +capabilities = ["thinking", "image_in"] +"#; + write_kimi_config_source(original).unwrap(); + let imported = get_provider("native").unwrap().unwrap(); + // Database serialization followed by same-machine edit and fresh-machine restore. + let imported: Value = + serde_json::from_str(&serde_json::to_string(&imported).unwrap()).unwrap(); + let expected = read_kimi_config_json().unwrap(); + write_prepared_config(&prepare_provider("native", imported.clone()).unwrap()).unwrap(); + assert_eq!(read_kimi_config_json().unwrap(), expected); + fs::remove_file(get_kimi_config_path()).unwrap(); + write_prepared_config(&prepare_provider("native", imported).unwrap()).unwrap(); + assert_eq!(read_kimi_config_json().unwrap(), expected); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(get_kimi_config_path()) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + } + + #[test] + fn kimi_quoted_alias_restores_verbatim_without_colliding_with_trimmed_alias() { + let _lock = crate::test_support::lock_test_home_and_settings(); + let _dir = test_support::TestKimiDir::new(); + let original = r#"default_model = " fast " +[providers.native] +type = "kimi" +[models." fast "] +provider = "native" +model = "kimi-k2.5" +max_context_size = 262144 +capabilities = ["thinking"] +"#; + write_kimi_config_source(original).unwrap(); + let mut imported = get_provider("native").unwrap().unwrap(); + set_editor_fields(imported.as_object_mut().unwrap(), "", "", " fast "); + let expected = read_kimi_config_json().unwrap(); + fs::remove_file(get_kimi_config_path()).unwrap(); + write_prepared_config(&prepare_provider("native", imported.clone()).unwrap()).unwrap(); + assert_eq!(read_kimi_config_json().unwrap(), expected); + write_prepared_config(&prepare_provider("other", json!({"model":"fast"})).unwrap()) + .unwrap(); + set_current_provider("native", &imported).unwrap(); + let current = read_kimi_config_json().unwrap(); + assert_eq!(current["default_model"], " fast "); + assert_eq!(current["models"][" fast "]["model"], "kimi-k2.5"); + assert_eq!(current["models"]["fast"]["provider"], "other"); + } + + #[test] + fn kimi_native_provider_type_survives_without_flat_duplicate() { + let _lock = crate::test_support::lock_test_home_and_settings(); + let _dir = test_support::TestKimiDir::new(); + let native = json!({"providerConfig":{"type":"kimi"},"model":"fast","models":[{"id":"fast","config":{"model":"kimi-k2.5"}}]}); + write_prepared_config(&prepare_provider("native", native.clone()).unwrap()).unwrap(); + assert_eq!( + read_kimi_config_json().unwrap()["providers"]["native"]["type"], + "kimi" + ); + let mut overridden = native; + overridden["type"] = json!("openai"); + write_prepared_config(&prepare_provider("native", overridden).unwrap()).unwrap(); + assert_eq!( + read_kimi_config_json().unwrap()["providers"]["native"]["type"], + "openai" + ); + } + + #[test] + fn kimi_native_connection_fields_survive_noop_edit_and_explicit_clear() { + for api_name in API_KEY_FIELDS { + let mut native = json!({"providerConfig":{"type":"kimi","base_url":"https://example.invalid/v1"},"model":"fast"}); + native["providerConfig"][*api_name] = json!("native-secret"); + let base = provider_base_url(&native).unwrap().to_string(); + let key = provider_api_key(&native).unwrap().to_string(); + assert_eq!(key, "native-secret"); + set_editor_fields(native.as_object_mut().unwrap(), &base, &key, "fast"); + assert_eq!(provider_api_key(&native), Some("native-secret")); + assert_eq!( + provider_base_url(&native), + Some("https://example.invalid/v1") + ); + set_editor_fields(native.as_object_mut().unwrap(), "", "", "fast"); + assert_eq!(provider_api_key(&native), Some("")); + assert!(!native.to_string().contains("native-secret")); + } + } + + #[test] + fn kimi_model_conflict_and_invalid_selection_do_not_change_live_config() { + let _lock = crate::test_support::lock_test_home_and_settings(); + let _dir = test_support::TestKimiDir::new(); + let config = json!({"type":"openai", "model":"shared"}); + write_prepared_config(&prepare_provider("a", config.clone()).unwrap()).unwrap(); + let before = read_kimi_config_source().unwrap(); + assert!(prepare_provider("b", config.clone()).is_err()); + assert!(set_current_provider("b", &config).is_err()); + assert!(set_default_model("missing").is_err()); + assert_eq!(read_kimi_config_source().unwrap(), before); + assert_eq!(get_current_provider_id().unwrap().as_deref(), Some("a")); + } + + #[test] + fn kimi_native_credential_aliases_are_removed_after_import_and_edit() { + let _lock = crate::test_support::lock_test_home_and_settings(); + let _dir = test_support::TestKimiDir::new(); + let original = "[providers.native]\ntype = 'openai'\napiKey = 'superseded-native-secret'\nbaseUrl = 'https://old.invalid'\n"; + for replacement in ["replacement-secret", ""] { + write_kimi_config_source(original).unwrap(); + let mut imported = get_provider("native").unwrap().unwrap(); + set_editor_fields( + imported.as_object_mut().unwrap(), + "https://new.invalid", + replacement, + "", + ); + assert!(!imported.to_string().contains("superseded-native-secret")); + assert!(!imported.to_string().contains("https://old.invalid")); + write_prepared_config(&prepare_provider("native", imported).unwrap()).unwrap(); + let live = read_kimi_config_source().unwrap().unwrap(); + assert!(!live.contains("superseded-native-secret")); + assert!(!live.contains("https://old.invalid")); + let current = get_provider("native").unwrap().unwrap(); + assert_eq!( + current.get("api_key").and_then(Value::as_str).unwrap_or(""), + replacement + ); + } + } + + #[test] + fn kimi_invalid_native_fields_preserve_existing_live_bytes() { + let _lock = crate::test_support::lock_test_home_and_settings(); + let _dir = test_support::TestKimiDir::new(); + write_prepared_config(&prepare_provider("a", json!({"model":"fast"})).unwrap()).unwrap(); + let before = read_kimi_config_source().unwrap(); + for config in [ + json!({"model":123}), + json!({"model":""}), + json!({"max_context_size":"bad"}), + json!({"capabilities":[123]}), + ] { + let invalid = json!({"model":"fast", "models":[{"id":"fast","config":config}]}); + assert!(prepare_provider("a", invalid).is_err()); + assert_eq!(read_kimi_config_source().unwrap(), before); + } + assert!(prepare_provider("a", json!({"providerConfig":{"api_key":123}})).is_err()); + assert_eq!(read_kimi_config_source().unwrap(), before); + } + + #[test] + fn kimi_mcp_write_preserves_malformed_input() { + let _lock = crate::test_support::lock_test_home_and_settings(); + let _dir = test_support::TestKimiDir::new(); + fs::write(get_kimi_mcp_path(), "{broken").unwrap(); + assert!(set_kimi_mcp_servers_map(&HashMap::new()).is_err()); + assert_eq!(fs::read_to_string(get_kimi_mcp_path()).unwrap(), "{broken"); + } + + #[test] + fn test_kimi_provider_crud_and_mcp() { + let _lock = crate::test_support::lock_test_home_and_settings(); + let temp_home = tempfile::tempdir().unwrap(); + let old_home_env = std::env::var_os(KIMI_HOME_ENV); + std::env::set_var(KIMI_HOME_ENV, temp_home.path()); + + // 1. Initial get_providers on empty directory + let providers = get_providers().unwrap(); + assert!(providers.is_empty()); + + // 2. Prepare and write cortex provider + let provider_config = json!({ + "type": "openai", + "baseUrl": "https://cortex.botiverse.dev/v1", + "apiKey": "sk-cortex-secret", + "model": "devin/swe-2", + "models": [ + { "id": "devin/swe-2", "name": "Devin SWE-2" }, + { "id": "k3", "name": "K3" } + ] + }); + let prepared = prepare_provider("cortex", provider_config).unwrap(); + write_prepared_config(&prepared).unwrap(); + + // Verify providers read back + let providers = get_providers().unwrap(); + assert_eq!(providers.len(), 1); + let cortex = providers.get("cortex").unwrap(); + assert_eq!(cortex["name"], "cortex"); + assert_eq!(cortex["type"], "openai"); + assert_eq!(cortex["baseUrl"], "https://cortex.botiverse.dev/v1"); + assert_eq!(cortex["apiKey"], "sk-cortex-secret"); + assert_eq!(cortex["model"], "devin/swe-2"); + + // Verify active provider + let current_id = get_current_provider_id().unwrap(); + assert_eq!(current_id, Some("cortex".to_string())); + + // 3. Add second provider (openrouter) + let or_config = json!({ + "type": "openai", + "baseUrl": "https://openrouter.ai/api/v1", + "apiKey": "sk-or-test", + "model": "anthropic/claude-3.5-sonnet" + }); + let prepared2 = prepare_provider("openrouter", or_config.clone()).unwrap(); + write_prepared_config(&prepared2).unwrap(); + + let providers = get_providers().unwrap(); + assert_eq!(providers.len(), 2); + + // Switch to openrouter + set_current_provider("openrouter", &or_config).unwrap(); + assert_eq!( + get_current_provider_id().unwrap(), + Some("openrouter".to_string()) + ); + + // 4. Remove cortex + remove_provider("cortex").unwrap(); + let providers = get_providers().unwrap(); + assert_eq!(providers.len(), 1); + assert!(!providers.contains_key("cortex")); + assert!(providers.contains_key("openrouter")); + assert_eq!( + get_current_provider_id().unwrap(), + Some("openrouter".to_string()) + ); + + // 5. MCP sync + let dummy_cfg = MultiAppConfig::default(); + let server_spec = json!({ + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem"] + }); + sync_single_server_to_kimi(&dummy_cfg, "filesystem", &server_spec).unwrap(); + let mcp_map = read_kimi_mcp_servers_map().unwrap(); + assert_eq!(mcp_map.len(), 1); + assert!(mcp_map.contains_key("filesystem")); + + remove_server_from_kimi("filesystem").unwrap(); + let mcp_map = read_kimi_mcp_servers_map().unwrap(); + assert!(mcp_map.is_empty()); + + if let Some(val) = old_home_env { + std::env::set_var(KIMI_HOME_ENV, val); + } else { + std::env::remove_var(KIMI_HOME_ENV); + } + } + + #[test] + fn test_kimi_set_default_model() { + let _lock = crate::test_support::lock_test_home_and_settings(); + let temp_home = tempfile::tempdir().unwrap(); + let old_home_env = std::env::var_os(KIMI_HOME_ENV); + std::env::set_var(KIMI_HOME_ENV, temp_home.path()); + + let provider_config = json!({ + "type": "openai", + "baseUrl": "https://api.moonshot.cn/v1", + "apiKey": "sk-test", + "model": "moonshot-v1-8k", + "models": [ + { "id": "moonshot-v1-8k", "name": "Moonshot 8k" }, + { "id": "moonshot-v1-32k", "name": "Moonshot 32k" } + ] + }); + let prepared = prepare_provider("moonshot", provider_config).unwrap(); + write_prepared_config(&prepared).unwrap(); + + assert_eq!( + get_current_provider_id().unwrap(), + Some("moonshot".to_string()) + ); + + set_default_model("moonshot-v1-32k").unwrap(); + assert_eq!( + get_current_provider_id().unwrap(), + Some("moonshot".to_string()) + ); + + let json = read_kimi_config_json().unwrap(); + assert_eq!(json["default_model"], "moonshot-v1-32k"); + + if let Some(val) = old_home_env { + std::env::set_var(KIMI_HOME_ENV, val); + } else { + std::env::remove_var(KIMI_HOME_ENV); + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cbfb91bc4..89d863097 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,6 +23,7 @@ pub mod hermes_config; mod import_export; #[allow(dead_code)] mod init_status; +pub mod kimi_config; mod mcp; mod model_capabilities; mod openclaw_config; diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 833295772..7bd72b544 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -395,6 +395,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result Result gemini: false, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, @@ -786,6 +788,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result Result Result Result { AppType::Hermes => get_hermes_override_dir().unwrap_or_else(default_hermes_dir), AppType::OpenClaw => get_openclaw_override_dir().unwrap_or_else(default_openclaw_dir), AppType::Pi => crate::pi_config::get_pi_agent_dir()?, + AppType::Kimi => crate::kimi_config::get_kimi_config_dir(), }; let filename = match app { @@ -28,6 +29,7 @@ pub fn prompt_file_path(app: &AppType) -> Result { AppType::Hermes => "AGENTS.md", AppType::OpenClaw => "AGENTS.md", AppType::Pi => "AGENTS.md", + AppType::Kimi => "AGENTS.md", }; Ok(base_dir.join(filename)) diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index dcfb496fb..a6384ef70 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -147,6 +147,10 @@ impl Provider { .or_else(|| self.settings_config.get("api_key")), AppType::OpenClaw => self.settings_config.get("apiKey"), AppType::Pi => self.settings_config.get("apiKey"), + AppType::Kimi => self + .settings_config + .get("apiKey") + .or_else(|| self.settings_config.get("api_key")), }; value @@ -253,7 +257,7 @@ impl Provider { AppType::Claude => "claude", AppType::Codex => "codex", AppType::Gemini => "gemini", - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => return None, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => return None, }; if self.category.as_deref() == Some("official") { @@ -299,7 +303,7 @@ impl Provider { .is_none_or(|value| value.trim().is_empty()); api_key_missing && base_url_missing } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => false, }; is_official.then_some(tool) diff --git a/src-tauri/src/provider_preset_sponsors.rs b/src-tauri/src/provider_preset_sponsors.rs index 5d1e828e4..e663e08c5 100644 --- a/src-tauri/src/provider_preset_sponsors.rs +++ b/src-tauri/src/provider_preset_sponsors.rs @@ -250,7 +250,7 @@ pub(crate) fn sponsor_provider_presets_for_app( AppType::Codex => &CODEX_SPONSOR_PRESETS, AppType::Gemini => &GEMINI_SPONSOR_PRESETS, AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => &ADDITIVE_SPONSOR_PRESETS, - AppType::Pi => &[], + AppType::Pi | AppType::Kimi => &[], } } diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index 329d5ef4b..4d300687b 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -146,7 +146,7 @@ impl ProviderType { } ProviderType::Gemini } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => { ProviderType::Codex } } @@ -200,6 +200,7 @@ pub fn get_adapter(app_type: &AppType) -> Option> { AppType::OpenCode => Box::new(CodexAdapter::new()), AppType::Hermes => Box::new(CodexAdapter::new()), AppType::OpenClaw => Box::new(CodexAdapter::new()), + AppType::Kimi => Box::new(CodexAdapter::new()), AppType::Pi => return None, }) } diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index ece915908..2d4b682b5 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -278,6 +278,7 @@ impl ConfigService { Self::sync_current_provider_for_app(config, &AppType::OpenCode)?; Self::sync_current_provider_for_app(config, &AppType::Hermes)?; Self::sync_current_provider_for_app(config, &AppType::OpenClaw)?; + Self::sync_current_provider_for_app(config, &AppType::Kimi)?; Ok(()) } @@ -316,6 +317,7 @@ impl ConfigService { AppType::Hermes => {} AppType::OpenClaw => {} AppType::Pi => {} + AppType::Kimi => {} } Ok(()) diff --git a/src-tauri/src/services/local_env_check.rs b/src-tauri/src/services/local_env_check.rs index bcb520841..c3c116e1d 100644 --- a/src-tauri/src/services/local_env_check.rs +++ b/src-tauri/src/services/local_env_check.rs @@ -29,10 +29,11 @@ pub enum LocalTool { Hermes, OpenClaw, Pi, + Kimi, } impl LocalTool { - pub const ALL: [LocalTool; 7] = [ + pub const ALL: [LocalTool; 8] = [ LocalTool::Claude, LocalTool::Codex, LocalTool::Gemini, @@ -40,6 +41,7 @@ impl LocalTool { LocalTool::Hermes, LocalTool::OpenClaw, LocalTool::Pi, + LocalTool::Kimi, ]; pub fn all() -> &'static [LocalTool] { @@ -55,6 +57,7 @@ impl LocalTool { LocalTool::Hermes => "Hermes", LocalTool::OpenClaw => "OpenClaw", LocalTool::Pi => "Pi", + LocalTool::Kimi => "Kimi", } } @@ -67,6 +70,7 @@ impl LocalTool { LocalTool::Hermes => "hermes", LocalTool::OpenClaw => "openclaw", LocalTool::Pi => "pi", + LocalTool::Kimi => "kimi", } } @@ -79,6 +83,7 @@ impl LocalTool { LocalTool::Hermes => &["--version", "version"], LocalTool::OpenClaw => &["--version", "version"], LocalTool::Pi => &["--version"], + LocalTool::Kimi => &["--version"], } } @@ -106,6 +111,7 @@ impl LocalTool { AppType::Hermes => LocalTool::Hermes, AppType::OpenClaw => LocalTool::OpenClaw, AppType::Pi => LocalTool::Pi, + AppType::Kimi => LocalTool::Kimi, } } } @@ -859,8 +865,11 @@ mod tests { assert_eq!( display_names, - vec!["Claude", "Codex", "Gemini", "OpenCode", "Hermes", "OpenClaw", "Pi"] + vec![ + "Claude", "Codex", "Gemini", "OpenCode", "Hermes", "OpenClaw", "Pi", "Kimi" + ] ); + assert_eq!(LocalTool::Kimi.binary_name(), "kimi"); assert_eq!(LocalTool::Hermes.binary_name(), "hermes"); assert_eq!(LocalTool::OpenClaw.binary_name(), "openclaw"); assert_eq!(LocalTool::Pi.binary_name(), "pi"); diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index b6a6bdf16..332543842 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -16,6 +16,7 @@ impl McpService { AppType::Gemini, AppType::OpenCode, AppType::Hermes, + AppType::Kimi, ] .into_iter() } @@ -216,6 +217,9 @@ impl McpService { AppType::Hermes => { mcp::sync_single_server_to_hermes(cfg, &server.id, &server.server)?; } + AppType::Kimi => { + crate::kimi_config::sync_single_server_to_kimi(cfg, &server.id, &server.server)?; + } AppType::OpenClaw => {} AppType::Pi => {} } @@ -242,6 +246,7 @@ impl McpService { AppType::Gemini => mcp::remove_server_from_gemini(id)?, AppType::OpenCode => mcp::remove_server_from_opencode(id)?, AppType::Hermes => mcp::remove_server_from_hermes(id)?, + AppType::Kimi => crate::kimi_config::remove_server_from_kimi(id)?, AppType::OpenClaw => {} AppType::Pi => {} } diff --git a/src-tauri/src/services/provider/common_config.rs b/src-tauri/src/services/provider/common_config.rs index d8b3733aa..f69bcb9a6 100644 --- a/src-tauri/src/services/provider/common_config.rs +++ b/src-tauri/src/services/provider/common_config.rs @@ -502,7 +502,7 @@ fn parse_json_object_snippet(app_type: &AppType, snippet: &str) -> Result { + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => { AppError::localized( "common_config.opencode.invalid_json", format!("OpenCode 通用配置片段不是有效的 JSON:{e}"), @@ -524,7 +524,7 @@ fn parse_json_object_snippet(app_type: &AppType, snippet: &str) -> Result { + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => { AppError::localized( "common_config.opencode.not_object", "OpenCode 通用配置片段必须是 JSON 对象", @@ -561,7 +561,8 @@ pub(super) fn validate_common_config_snippet( | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw - | AppType::Pi => { + | AppType::Pi + | AppType::Kimi => { parse_json_object_snippet(app_type, snippet)?; } AppType::Codex => { @@ -617,7 +618,7 @@ pub(super) fn settings_contain_common_config( } _ => false, }, - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => false, } } @@ -682,7 +683,7 @@ pub(super) fn apply_common_config_to_settings( } Ok(result) } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => { Ok(settings.clone()) } } @@ -733,7 +734,7 @@ pub(super) fn remove_common_config_from_settings( } Ok(result) } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => { Ok(settings.clone()) } } diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index e1a7da2c6..92f80302c 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -32,6 +32,9 @@ pub(super) enum LiveSnapshot { OpenClaw { config_source: Option, }, + Kimi { + config_source: Option, + }, } impl LiveSnapshot { @@ -107,6 +110,14 @@ impl LiveSnapshot { delete_file(&path)?; } } + LiveSnapshot::Kimi { config_source } => { + let path = crate::kimi_config::get_kimi_config_path(); + if let Some(source) = config_source { + crate::kimi_config::write_kimi_config_source(source)?; + } else if path.exists() { + delete_file(&path)?; + } + } } Ok(()) } @@ -174,6 +185,10 @@ pub(super) fn capture_live_snapshot(app_type: &AppType) -> Result { + let config_source = crate::kimi_config::read_kimi_config_source()?; + Ok(LiveSnapshot::Kimi { config_source }) + } AppType::Pi => Err(AppError::Config( "Pi providers use the Pi provider service".to_string(), )), @@ -228,6 +243,54 @@ pub fn import_hermes_providers_from_live(state: &AppState) -> Result Result { + let providers = crate::kimi_config::get_providers()?; + if providers.is_empty() { + return Ok(0); + } + + let mut imported = 0usize; + let existing_ids = state.db.get_provider_ids("kimi")?; + + for (id, settings_config) in providers { + if id.trim().is_empty() { + log::warn!("Skipping Kimi provider with empty id"); + continue; + } + if existing_ids.contains(&id) { + log::debug!("Kimi provider '{id}' already exists in database, skipping"); + continue; + } + if !settings_config.is_object() { + log::warn!("Skipping Kimi provider '{id}': config is not an object"); + continue; + } + + let display_name = settings_config + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(&id) + .to_string(); + let mut provider = Provider::with_id(id.clone(), display_name, settings_config, None); + provider.meta = Some(ProviderMeta { + live_config_managed: Some(true), + ..Default::default() + }); + + if let Err(err) = state.db.save_provider("kimi", &provider) { + log::warn!("Failed to import Kimi provider '{id}': {err}"); + continue; + } + + imported += 1; + log::info!("Imported Kimi provider '{id}' from live config"); + } + + Ok(imported) +} + pub fn sync_openclaw_providers_from_live(state: &AppState) -> Result { if !crate::openclaw_config::get_openclaw_config_path().exists() { return Ok(0); diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 67a25f74e..49798f472 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -306,6 +306,9 @@ enum PreparedLiveWrite { OpenClaw { models: Value, }, + Kimi { + config: String, + }, } #[derive(Clone)] @@ -317,7 +320,7 @@ enum PreparedCodexAuthWrite { impl ProviderService { pub fn is_provider_key_app(app_type: &AppType) -> bool { - matches!(app_type, AppType::OpenClaw | AppType::Hermes | AppType::Pi) + matches!(app_type, AppType::OpenClaw | AppType::Hermes | AppType::Pi | AppType::Kimi) } pub fn is_valid_provider_key(value: &str) -> bool { @@ -432,6 +435,10 @@ impl ProviderService { .into_iter() .map(|(id, _)| id) .collect(), + AppType::Kimi => crate::kimi_config::get_providers()? + .into_iter() + .map(|(id, _)| id) + .collect(), _ => HashSet::new(), }; Ok(ids) @@ -753,6 +760,8 @@ impl ProviderService { .map(|providers| providers.contains_key(provider_id)), AppType::OpenClaw => Self::valid_openclaw_live_provider_ids() .map(|ids| ids.is_some_and(|ids| ids.contains(provider_id))), + AppType::Kimi => crate::kimi_config::get_providers() + .map(|providers| providers.contains_key(provider_id)), _ => Ok(false), }; @@ -1005,6 +1014,13 @@ impl ProviderService { &prepared.action.provider.id, &prepared.action.provider.settings_config, )?; + } else if prepared.action.activate_provider + && matches!(prepared.action.app_type, AppType::Kimi) + { + crate::kimi_config::set_current_provider( + &prepared.action.provider.id, + &prepared.action.provider.settings_config, + )?; } } PreparedPostCommitEffect::ProxyLiveBackup { @@ -1426,6 +1442,26 @@ impl ProviderService { } state.save()?; } + AppType::Kimi => { + let providers = crate::kimi_config::get_providers()?; + let live_after = providers.get(provider_id).cloned().ok_or_else(|| { + AppError::localized( + "kimi.live.missing_provider", + format!("Kimi live 配置中缺少供应商: {provider_id}"), + format!("Kimi live config missing provider: {provider_id}"), + ) + })?; + + { + let mut guard = state.config.write().map_err(AppError::from)?; + if let Some(manager) = guard.get_manager_mut(app_type) { + if let Some(target) = manager.providers.get_mut(provider_id) { + target.settings_config = live_after; + } + } + } + state.save()?; + } AppType::Pi => {} } Ok(()) @@ -1482,7 +1518,7 @@ impl ProviderService { strict_current_provider_id, old_snippet, ), - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => Ok(()), + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => Ok(()), }; match result { @@ -1612,7 +1648,7 @@ impl ProviderService { } AppType::Gemini => live_settings.get("env") != provider_settings.get("env"), AppType::Claude => live_settings != provider_settings, - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => false, } } @@ -1770,7 +1806,7 @@ impl ProviderService { AppType::OpenCode => Self::extract_opencode_common_config(settings_config), AppType::Hermes => Self::extract_opencode_common_config(settings_config), AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config), - AppType::Pi => Ok(String::new()), + AppType::Pi | AppType::Kimi => Ok(String::new()), } } @@ -2134,6 +2170,10 @@ impl ProviderService { return crate::hermes_config::get_current_provider_id() .map(|opt| opt.unwrap_or_default()); } + if matches!(app_type, AppType::Kimi) { + return crate::kimi_config::get_current_provider_id() + .map(|opt| opt.unwrap_or_default()); + } if app_type.is_additive_mode() { return Ok(String::new()); } @@ -2478,6 +2518,7 @@ impl ProviderService { AppType::Hermes => unreachable!("additive mode apps are handled earlier"), AppType::OpenClaw => unreachable!("additive mode apps are handled earlier"), AppType::Pi => unreachable!("Pi uses native provider import"), + AppType::Kimi => unreachable!("additive mode apps are handled earlier"), }; let mut provider = Provider::with_id( @@ -2617,6 +2658,17 @@ impl ProviderService { } crate::openclaw_config::read_openclaw_config() } + AppType::Kimi => { + let config_path = crate::kimi_config::get_kimi_config_path(); + if !config_path.exists() { + return Err(AppError::localized( + "kimi.config.missing", + "Kimi 配置文件不存在", + "Kimi configuration file not found", + )); + } + crate::kimi_config::read_kimi_config_json() + } AppType::Pi => Err(AppError::InvalidInput( "Pi providers are read from models.json".to_string(), )), @@ -2696,6 +2748,11 @@ impl ProviderService { crate::openclaw_config::remove_provider(provider_id)?; } } + AppType::Kimi => { + if crate::kimi_config::get_kimi_config_dir().exists() { + crate::kimi_config::remove_provider(provider_id)?; + } + } _ => unreachable!("non-additive apps should not enter remove-from-live branch"), } @@ -2744,6 +2801,7 @@ impl ProviderService { AppType::OpenCode => Self::import_opencode_providers_from_live(state), AppType::OpenClaw => Self::import_openclaw_providers_from_live(state), AppType::Hermes => Self::import_hermes_providers_from_live(state), + AppType::Kimi => live::import_kimi_providers_from_live(state), AppType::Pi => Self::import_pi_providers_from_live(state), _ => Self::import_default_config(state, app_type).map(usize::from), } @@ -2760,11 +2818,15 @@ impl ProviderService { Self::switch(state, AppType::Hermes, provider_id)?; Ok(provider_id.to_string()) } + AppType::Kimi => { + Self::switch(state, AppType::Kimi, provider_id)?; + Ok(provider_id.to_string()) + } AppType::OpenClaw => Self::set_openclaw_default_model(provider_id, model_id), _ => Err(AppError::localized( "provider.set_default_model.unsupported", - "只有 Hermes 和 OpenClaw 支持设置默认供应商/模型", - "Only Hermes and OpenClaw support setting a default provider/model", + "只有 Hermes, OpenClaw 和 Kimi 支持设置默认供应商/模型", + "Only Hermes, OpenClaw, and Kimi support setting a default provider/model", )), } } @@ -2849,6 +2911,16 @@ impl ProviderService { "Cannot remove the current default Hermes provider from config", )) } + AppType::Kimi + if crate::kimi_config::get_current_provider_id()?.as_deref() + == Some(provider_id) => + { + Err(AppError::localized( + "provider.remove_from_config.kimi_current", + "不能从配置中移除 Kimi 当前默认供应商", + "Cannot remove the current default Kimi provider from config", + )) + } AppType::OpenClaw if Self::openclaw_default_model_references_provider(provider_id)? => { Err(AppError::localized( "provider.remove_from_config.openclaw_default", @@ -3009,7 +3081,7 @@ impl ProviderService { takeover_active: false, refresh_stale_backup: false, sync_proxy_live: false, - activate_provider: matches!(app_type, AppType::Hermes), + activate_provider: matches!(app_type, AppType::Hermes | AppType::Kimi), }); } @@ -3035,6 +3107,7 @@ impl ProviderService { AppType::OpenCode => unreachable!("additive mode handled above"), AppType::Hermes => unreachable!("additive mode handled above"), AppType::OpenClaw => unreachable!("additive mode handled above"), + AppType::Kimi => unreachable!("additive mode handled above"), AppType::Pi => unreachable!("Pi switch is handled by the native provider service"), }; @@ -3239,6 +3312,20 @@ impl ProviderService { )?; Ok(PreparedLiveWrite::Hermes { providers }) } + AppType::Kimi => { + if !provider.settings_config.is_object() { + return Err(AppError::localized( + "provider.kimi.settings.not_object", + "Kimi 配置必须是 JSON 对象", + "Kimi configuration must be a JSON object", + )); + } + let config = crate::kimi_config::prepare_provider( + &provider.id, + provider.settings_config.clone(), + )?; + Ok(PreparedLiveWrite::Kimi { config }) + } AppType::OpenClaw => { let settings_config = provider.settings_config.clone(); let looks_like_provider = settings_config.get("baseUrl").is_some() @@ -3272,6 +3359,9 @@ impl ProviderService { PreparedLiveWrite::Hermes { providers } => { crate::hermes_config::write_prepared_providers(providers).map(|_| ()) } + PreparedLiveWrite::Kimi { config } => { + crate::kimi_config::write_prepared_config(&config).map(|_| ()) + } PreparedLiveWrite::OpenClaw { models } => { crate::openclaw_config::write_prepared_models(models) .map(|_| ()) @@ -3496,6 +3586,9 @@ impl ProviderService { AppType::Pi => Err(AppError::Config( "Pi does not support proxy takeover backups".into(), )), + AppType::Kimi => Err(AppError::Config( + "Kimi does not support proxy takeover backups".into(), + )), } } @@ -3586,6 +3679,15 @@ impl ProviderService { )); } } + AppType::Kimi => { + if !provider.settings_config.is_object() { + return Err(AppError::localized( + "provider.kimi.settings.not_object", + "Kimi 配置必须是 JSON 对象", + "Kimi configuration must be a JSON object", + )); + } + } AppType::OpenClaw => { let config = Self::parse_openclaw_provider_settings(&provider.settings_config)?; Self::validate_openclaw_provider_models(&provider.id, &config)?; @@ -3752,6 +3854,11 @@ impl ProviderService { crate::openclaw_config::remove_provider(provider_id)?; } } + AppType::Kimi => { + if crate::kimi_config::get_kimi_config_dir().exists() { + crate::kimi_config::remove_provider(provider_id)?; + } + } _ => unreachable!("non-additive apps should not enter additive delete branch"), } @@ -3793,6 +3900,9 @@ impl ProviderService { AppType::OpenClaw => { let _ = provider_snapshot; } + AppType::Kimi => { + let _ = provider_snapshot; + } AppType::Pi => unreachable!("Pi deletion is handled by the native provider service"), } diff --git a/src-tauri/src/services/provider/tests.rs b/src-tauri/src/services/provider/tests.rs index 175b5f044..8de899178 100644 --- a/src-tauri/src/services/provider/tests.rs +++ b/src-tauri/src/services/provider/tests.rs @@ -427,6 +427,7 @@ fn setup_switched_codex_state_with_managed_mcp() -> (TempDir, EnvGuard, AppState gemini: false, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, diff --git a/src-tauri/src/services/provider/usage.rs b/src-tauri/src/services/provider/usage.rs index 9442eaf9d..c92a3c6f9 100644 --- a/src-tauri/src/services/provider/usage.rs +++ b/src-tauri/src/services/provider/usage.rs @@ -482,6 +482,19 @@ impl ProviderService { .and_then(Value::as_str) .ok_or_else(|| AppError::InvalidInput("Pi provider API key is missing".to_string())) .map(str::to_string), + AppType::Kimi => provider + .settings_config + .get("apiKey") + .or_else(|| provider.settings_config.get("api_key")) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + AppError::localized( + "provider.kimi.api_key.missing", + "缺少 API Key", + "API key is missing", + ) + }) + .map(|s| s.to_string()), } } @@ -562,6 +575,15 @@ impl ProviderService { .and_then(|v| v.as_str()) .unwrap_or_default() .to_string()), + AppType::Kimi => Ok(provider + .settings_config + .get("baseUrl") + .or_else(|| provider.settings_config.get("baseURL")) + .or_else(|| provider.settings_config.get("base_url")) + .or_else(|| provider.settings_config.get("endpoint")) + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string()), AppType::Pi => crate::pi_config::provider_base_url(&provider.settings_config), } } diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 700c4f00e..5367db941 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -1755,7 +1755,8 @@ impl ProxyService { | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw - | AppType::Pi => {} + | AppType::Pi + | AppType::Kimi => {} } if cached != original { self.save_failover_live_snapshot(app_type, &provider.id, &cached) @@ -2588,7 +2589,7 @@ impl ProxyService { (None, _) => Ok(incoming_snapshot), } } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi | AppType::Kimi => { Ok(backup_snapshot) } } diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index cb3ac4e49..464a1bcd3 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -640,6 +640,7 @@ impl SkillService { AppType::OpenCode, AppType::Hermes, AppType::Pi, + AppType::Kimi, ] .into_iter() } @@ -724,6 +725,7 @@ impl SkillService { AppType::Hermes => crate::hermes_config::get_hermes_dir().join("skills"), AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir().join("skills"), AppType::Pi => crate::pi_config::get_pi_agent_dir()?.join("skills"), + AppType::Kimi => crate::kimi_config::get_kimi_skills_dir(), }) } @@ -844,6 +846,7 @@ impl SkillService { let mut installed = db.get_all_installed_skills()?; for skill in installed.values_mut() { skill.apps.pi = Self::skill_exists_in_app(&skill.directory, &AppType::Pi); + skill.apps.kimi = Self::skill_exists_in_app(&skill.directory, &AppType::Kimi); } let skills: HashMap = installed .into_values() @@ -2683,6 +2686,7 @@ impl SkillService { .ok_or_else(|| AppError::Message(format!("Skill not found: {skill_id}")))?; let directory = Self::require_valid_directory(&skill.directory)?; skill.apps.pi = Self::skill_exists_in_app(&skill.directory, &AppType::Pi); + skill.apps.kimi = Self::skill_exists_in_app(&skill.directory, &AppType::Kimi); let (owner, name) = match (&skill.repo_owner, &skill.repo_name) { (Some(owner), Some(name)) => (owner.clone(), name.clone()), @@ -2727,6 +2731,7 @@ impl SkillService { AppError::Message(format!("Skill was removed during update: {skill_id}")) })?; current.apps.pi = Self::skill_exists_in_app(¤t.directory, &AppType::Pi); + current.apps.kimi = Self::skill_exists_in_app(¤t.directory, &AppType::Kimi); if current.directory != skill.directory || current.repo_owner != skill.repo_owner || current.repo_name != skill.repo_name @@ -3494,6 +3499,7 @@ impl SkillService { let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name); let mut apps = selection.apps; apps.pi = Self::skill_exists_in_app(&dir_name, &AppType::Pi); + apps.kimi = Self::skill_exists_in_app(&dir_name, &AppType::Kimi); let (id, repo_owner, repo_name, repo_branch, readme_url) = build_repo_info_from_lock(&agents_lock, &dir_name); @@ -3757,6 +3763,29 @@ mod tests { use super::*; use serial_test::serial; + #[test] + #[serial] + fn kimi_skill_enablement_survives_reload_and_disable() { + let home = tempfile::tempdir().unwrap(); + let _env = crate::test_support::TestEnvGuard::isolated(home.path()); + let _kimi = crate::kimi_config::test_support::TestKimiDir::new(); + let destination = SkillService::get_app_skills_dir(&AppType::Kimi) + .unwrap() + .join("demo"); + fs::create_dir_all(&destination).unwrap(); + fs::write(destination.join("SKILL.md"), "fixture").unwrap(); + SkillService::import_from_apps(vec![ImportSkillSelection { + directory: "demo".into(), + apps: SkillApps::only(&AppType::Kimi), + }]) + .unwrap(); + assert!(SkillService::load_index().unwrap().skills["demo"].apps.kimi); + SkillService::toggle_app("demo", &AppType::Kimi, false).unwrap(); + assert!(!SkillService::load_index().unwrap().skills["demo"].apps.kimi); + SkillService::toggle_app("demo", &AppType::Kimi, true).unwrap(); + assert!(SkillService::load_index().unwrap().skills["demo"].apps.kimi); + } + #[test] fn skill_state_lock_allows_snapshots_but_excludes_writers() { let first_reader = skill_state_read_guard(); diff --git a/src-tauri/src/services/stream_check/provider_extract.rs b/src-tauri/src/services/stream_check/provider_extract.rs index d07986cc5..53f2517bc 100644 --- a/src-tauri/src/services/stream_check/provider_extract.rs +++ b/src-tauri/src/services/stream_check/provider_extract.rs @@ -21,6 +21,7 @@ impl StreamCheckService { } AppType::Hermes => Self::extract_hermes_base_url(provider), AppType::OpenClaw => Self::extract_openclaw_base_url(provider), + AppType::Kimi => Self::extract_kimi_base_url(provider), AppType::Pi => crate::pi_config::provider_base_url(&provider.settings_config), AppType::Claude | AppType::Codex | AppType::Gemini => get_adapter(app_type) .expect("proxy-capable app must have an adapter") @@ -97,6 +98,25 @@ impl StreamCheckService { }) } + fn extract_kimi_base_url(provider: &Provider) -> Result { + provider + .settings_config + .get("base_url") + .or_else(|| provider.settings_config.get("baseUrl")) + .or_else(|| provider.settings_config.get("baseURL")) + .or_else(|| provider.settings_config.get("endpoint")) + .and_then(|value| value.as_str()) + .map(|value| value.trim().trim_end_matches('/').to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::localized( + "kimi_base_url_missing", + "Kimi 供应商缺少 base_url", + "Kimi provider is missing `base_url`", + ) + }) + } + fn extract_openclaw_base_url(provider: &Provider) -> Result { provider .settings_config diff --git a/src-tauri/src/services/visible_apps.rs b/src-tauri/src/services/visible_apps.rs index cb2c4f99e..e84c36734 100644 --- a/src-tauri/src/services/visible_apps.rs +++ b/src-tauri/src/services/visible_apps.rs @@ -34,12 +34,13 @@ pub struct VisibleAppsStartupOutcome { pub should_prompt: bool, } -const CONTROLLED_APPS: [AppType; 5] = [ +const CONTROLLED_APPS: [AppType; 6] = [ AppType::Gemini, AppType::OpenCode, AppType::Hermes, AppType::OpenClaw, AppType::Pi, + AppType::Kimi, ]; pub fn detect_visible_app_installation() -> VisibleAppsDetection { @@ -191,6 +192,7 @@ pub fn app_display_name(app: &AppType) -> &'static str { AppType::Hermes => "Hermes", AppType::OpenClaw => "OpenClaw", AppType::Pi => "Pi", + AppType::Kimi => "Kimi", } } @@ -322,6 +324,7 @@ mod tests { hermes: true, openclaw: false, pi: false, + kimi: false, }; settings.visible_apps_settings = VisibleAppsSettings { mode: VisibleAppsMode::Auto, @@ -365,6 +368,7 @@ mod tests { hermes: false, openclaw: true, pi: false, + kimi: false, }; settings.visible_apps_settings = VisibleAppsSettings { mode: VisibleAppsMode::Manual, @@ -433,6 +437,7 @@ mod tests { hermes: true, openclaw: true, pi: false, + kimi: false, }; settings.visible_apps_settings = VisibleAppsSettings { mode: VisibleAppsMode::Auto, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 06fda8899..38de1d2d6 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -24,6 +24,8 @@ pub struct VisibleApps { pub openclaw: bool, #[serde(default = "default_visible_app_pi")] pub pi: bool, + #[serde(default = "default_visible_app_kimi")] + pub kimi: bool, } fn default_visible_app_claude() -> bool { @@ -54,6 +56,10 @@ fn default_visible_app_pi() -> bool { true } +fn default_visible_app_kimi() -> bool { + true +} + pub fn default_visible_apps() -> VisibleApps { VisibleApps { claude: true, @@ -63,6 +69,7 @@ pub fn default_visible_apps() -> VisibleApps { hermes: true, openclaw: true, pi: true, + kimi: true, } } @@ -130,6 +137,7 @@ impl VisibleApps { AppType::Hermes => self.hermes, AppType::OpenClaw => self.openclaw, AppType::Pi => self.pi, + AppType::Kimi => self.kimi, } } @@ -142,6 +150,7 @@ impl VisibleApps { AppType::Hermes => self.hermes = enabled, AppType::OpenClaw => self.openclaw = enabled, AppType::Pi => self.pi = enabled, + AppType::Kimi => self.kimi = enabled, } } @@ -162,7 +171,7 @@ impl VisibleApps { } } -fn app_order() -> [AppType; 7] { +fn app_order() -> [AppType; 8] { [ AppType::Claude, AppType::Codex, @@ -171,6 +180,7 @@ fn app_order() -> [AppType; 7] { AppType::Hermes, AppType::OpenClaw, AppType::Pi, + AppType::Kimi, ] } @@ -543,6 +553,8 @@ pub struct AppSettings { #[serde(default, skip_serializing_if = "Option::is_none")] pub pi_config_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub kimi_config_dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_claude: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_codex: Option, @@ -554,6 +566,8 @@ pub struct AppSettings { pub current_provider_hermes: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_openclaw: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_provider_kimi: Option, #[serde(default = "default_visible_apps")] pub visible_apps: VisibleApps, #[serde(default = "migrated_visible_apps_settings")] @@ -645,12 +659,14 @@ impl Default for AppSettings { hermes_config_dir: None, openclaw_config_dir: None, pi_config_dir: None, + kimi_config_dir: None, current_provider_claude: None, current_provider_codex: None, current_provider_gemini: None, current_provider_opencode: None, current_provider_hermes: None, current_provider_openclaw: None, + current_provider_kimi: None, visible_apps: default_visible_apps(), visible_apps_settings: VisibleAppsSettings::default(), language: None, @@ -733,6 +749,13 @@ impl AppSettings { .filter(|s| !s.is_empty()) .map(|s| s.to_string()); + self.kimi_config_dir = self + .kimi_config_dir + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + self.language = self .language .as_ref() @@ -1132,6 +1155,14 @@ pub fn get_pi_override_dir() -> Option { .map(|path| resolve_override_path(path)) } +pub fn get_kimi_override_dir() -> Option { + let settings = settings_store().read().ok()?; + settings + .kimi_config_dir + .as_ref() + .map(|path| resolve_override_path(path)) +} + pub fn get_current_provider(app_type: &AppType) -> Option { let settings = settings_store().read().ok()?; match app_type { @@ -1142,6 +1173,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option { AppType::Hermes => settings.current_provider_hermes.clone(), AppType::OpenClaw => settings.current_provider_openclaw.clone(), AppType::Pi => None, + AppType::Kimi => settings.current_provider_kimi.clone(), } } @@ -1156,6 +1188,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppType::Hermes => settings.current_provider_hermes = id.map(|value| value.to_string()), AppType::OpenClaw => settings.current_provider_openclaw = id.map(|value| value.to_string()), AppType::Pi => {} + AppType::Kimi => settings.current_provider_kimi = id.map(|value| value.to_string()), } update_settings(settings) diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index c06acd490..2cb9929f6 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -533,6 +533,7 @@ fn export_db_to_multi_app_config(db: &Database) -> Result Result config.prompts.hermes.prompts = prompts.into_iter().collect(), AppType::OpenClaw => config.prompts.openclaw.prompts = prompts.into_iter().collect(), AppType::Pi => config.prompts.pi.prompts = prompts.into_iter().collect(), + AppType::Kimi => config.prompts.kimi.prompts = prompts.into_iter().collect(), } // common snippet @@ -558,7 +560,17 @@ fn export_db_to_multi_app_config(db: &Database) -> Result { + for (id, server) in &mut servers { + server.apps.kimi = kimi_servers.contains_key(id); + } + } + // A malformed unrelated app must not prevent CC-Switch startup. MCP writes still fail closed. + Err(error) => log::warn!("Could not read Kimi MCP enablement: {error}"), + } config.mcp.servers = Some(servers.into_iter().collect()); Ok(config) @@ -587,6 +599,7 @@ fn persist_multi_app_config_to_db_preserving_current_providers( AppType::OpenCode, AppType::Hermes, AppType::OpenClaw, + AppType::Kimi, ] { let app_key = app.as_str(); let manager = config.get_manager(&app); @@ -761,6 +774,7 @@ fn migrate_legacy_codex_configs(db: &Database, config: &mut MultiAppConfig) { #[cfg(test)] mod tests { use super::AppState; + use crate::app_config::AppType; use crate::database::Database; use crate::provider::Provider; use crate::services::skill::{SkillService, SkillStore}; @@ -788,6 +802,79 @@ mod tests { .expect("write json file"); } + #[test] + #[serial(home_settings)] + fn kimi_provider_survives_database_sync_and_fresh_live_restore() { + let home = TempDir::new().unwrap(); + let _env = TestEnvGuard::isolated(home.path()); + let _kimi = crate::kimi_config::test_support::TestKimiDir::new(); + let original = r#"default_model = "fast" +[providers.native] +type = "kimi" +base_url = "https://example.invalid/v1" +[models.fast] +provider = "native" +model = "kimi-k2.5" +max_context_size = 262144 +"#; + crate::kimi_config::write_kimi_config_source(original).unwrap(); + let expected = crate::kimi_config::read_kimi_config_json().unwrap(); + let config = crate::kimi_config::get_provider("native").unwrap().unwrap(); + let db = Database::memory().unwrap(); + db.save_provider( + "kimi", + &Provider::with_id("native".into(), "native".into(), config, None), + ) + .unwrap(); + let sql = db.export_sql_string_for_sync().unwrap(); + let restored = Database::memory().unwrap(); + restored.import_sql_string_for_sync(&sql).unwrap(); + let provider = restored + .get_provider_by_id("native", "kimi") + .unwrap() + .unwrap(); + std::fs::remove_file(crate::kimi_config::get_kimi_config_path()).unwrap(); + crate::kimi_config::write_prepared_config( + &crate::kimi_config::prepare_provider("native", provider.settings_config).unwrap(), + ) + .unwrap(); + assert_eq!( + crate::kimi_config::read_kimi_config_json().unwrap(), + expected + ); + } + + #[test] + #[serial(home_settings)] + fn kimi_mcp_enablement_survives_restart_without_schema_changes() { + let home = TempDir::new().unwrap(); + let _env = TestEnvGuard::isolated(home.path()); + let _kimi = crate::kimi_config::test_support::TestKimiDir::new(); + let state = AppState::try_new().unwrap(); + let server: crate::app_config::McpServer = serde_json::from_value(json!({ + "id":"native-test", "name":"native-test", "server":{"command":"echo"}, "apps":{"kimi":true} + })).unwrap(); + crate::kimi_config::write_kimi_config_source("").unwrap(); + crate::services::McpService::upsert_server(&state, server).unwrap(); + drop(state); + let state = AppState::try_new().unwrap(); + assert!( + crate::services::McpService::get_all_servers(&state).unwrap()["native-test"] + .apps + .kimi + ); + crate::services::McpService::toggle_app(&state, "native-test", AppType::Kimi, false) + .unwrap(); + drop(state); + let state = AppState::try_new().unwrap(); + assert!( + !crate::services::McpService::get_all_servers(&state).unwrap()["native-test"] + .apps + .kimi + ); + assert_eq!(crate::database::SCHEMA_VERSION, 18); + } + #[test] #[serial(home_settings)] fn existing_database_records_default_repo_initialization_before_user_deletes_repos() { diff --git a/src-tauri/src/sync_policy.rs b/src-tauri/src/sync_policy.rs index 490518660..c8756d657 100644 --- a/src-tauri/src/sync_policy.rs +++ b/src-tauri/src/sync_policy.rs @@ -26,6 +26,11 @@ pub(crate) fn should_sync_live(app_type: &AppType) -> bool { AppType::OpenClaw => get_openclaw_dir().exists(), // Pi live provider writes are owned by the revision-aware native service. AppType::Pi => false, + // Kimi is considered initialized if ~/.kimi-code (or override dir) exists or config.toml exists. + AppType::Kimi => { + crate::kimi_config::get_kimi_config_dir().exists() + || crate::kimi_config::get_kimi_config_path().exists() + } } } diff --git a/src-tauri/tests/import_export_sync.rs b/src-tauri/tests/import_export_sync.rs index c0a057c1c..f3f9a624c 100644 --- a/src-tauri/tests/import_export_sync.rs +++ b/src-tauri/tests/import_export_sync.rs @@ -827,6 +827,7 @@ command = "echo" gemini: false, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, @@ -956,6 +957,7 @@ fn import_from_claude_merges_into_config() { gemini: false, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, diff --git a/src-tauri/tests/mcp_commands.rs b/src-tauri/tests/mcp_commands.rs index bf9b31358..c0d3f5248 100644 --- a/src-tauri/tests/mcp_commands.rs +++ b/src-tauri/tests/mcp_commands.rs @@ -331,6 +331,7 @@ fn set_mcp_enabled_for_codex_writes_live_config() { gemini: false, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, @@ -419,6 +420,7 @@ fn set_mcp_enabled_for_codex_writes_remote_headers_once_as_http_headers() { gemini: false, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, @@ -554,6 +556,7 @@ fn upsert_server_skips_live_sync_when_gemini_uninitialized() { gemini: true, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, @@ -623,6 +626,7 @@ fn upsert_server_disables_app_removes_from_gemini_live() { gemini: true, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, @@ -647,6 +651,7 @@ fn upsert_server_disables_app_removes_from_gemini_live() { gemini: false, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, @@ -710,6 +715,7 @@ fn sync_all_enabled_removes_disabled_gemini_server_from_live_config() { gemini: false, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, diff --git a/src-tauri/tests/openclaw_config.rs b/src-tauri/tests/openclaw_config.rs index 9d9d5ca55..9f554db3e 100644 --- a/src-tauri/tests/openclaw_config.rs +++ b/src-tauri/tests/openclaw_config.rs @@ -224,6 +224,8 @@ mod app_config { OpenCode, Hermes, OpenClaw, + Pi, + Kimi, } impl AppType { @@ -235,6 +237,8 @@ mod app_config { Self::OpenCode => "opencode", Self::Hermes => "hermes", Self::OpenClaw => "openclaw", + Self::Pi => "pi", + Self::Kimi => "kimi", } } } diff --git a/src-tauri/tests/provider_commands.rs b/src-tauri/tests/provider_commands.rs index 84b59176b..030bbbfba 100644 --- a/src-tauri/tests/provider_commands.rs +++ b/src-tauri/tests/provider_commands.rs @@ -1431,6 +1431,7 @@ command = "echo" gemini: false, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index dee26abe4..bc988b280 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -99,6 +99,7 @@ fn insert_codex_managed_mcp(config: &mut MultiAppConfig) { gemini: false, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, @@ -313,6 +314,7 @@ command = "echo" gemini: false, opencode: false, hermes: false, + kimi: false, }, description: None, homepage: None, @@ -4737,6 +4739,7 @@ fn provider_service_switch_openclaw_ignores_unrelated_mcp_sync_failures() { gemini: false, opencode: true, hermes: false, + kimi: false, }, description: None, homepage: None, diff --git a/src-tauri/tests/settings_current_provider.rs b/src-tauri/tests/settings_current_provider.rs index ceb71bd89..2ef66de6e 100644 --- a/src-tauri/tests/settings_current_provider.rs +++ b/src-tauri/tests/settings_current_provider.rs @@ -14,6 +14,7 @@ mod app_config { OpenClaw, Hermes, Pi, + Kimi, } impl AppType { @@ -26,6 +27,7 @@ mod app_config { AppType::OpenClaw => "openclaw", AppType::Hermes => "hermes", AppType::Pi => "pi", + AppType::Kimi => "kimi", } } } diff --git a/src-tauri/tests/settings_visible_apps.rs b/src-tauri/tests/settings_visible_apps.rs index 30b18c5f7..c2bc198e3 100644 --- a/src-tauri/tests/settings_visible_apps.rs +++ b/src-tauri/tests/settings_visible_apps.rs @@ -17,6 +17,7 @@ mod app_config { OpenClaw, Hermes, Pi, + Kimi, } impl AppType { @@ -29,6 +30,7 @@ mod app_config { AppType::OpenClaw => "openclaw", AppType::Hermes => "hermes", AppType::Pi => "pi", + AppType::Kimi => "kimi", } } } @@ -414,6 +416,7 @@ fn set_visible_apps_persists_visible_apps_as_camel_case_json() { opencode: false, openclaw: true, pi: false, + kimi: false, hermes: true, }) .expect("persist visible apps"); @@ -467,6 +470,7 @@ fn load_reads_valid_non_default_visible_apps_from_settings_json() { opencode: true, openclaw: false, pi: true, + kimi: true, hermes: true, } ); @@ -506,6 +510,7 @@ fn load_partial_visible_apps_object_uses_defaults_for_missing_keys() { opencode: true, openclaw: true, pi: true, + kimi: true, hermes: true, } ); @@ -582,6 +587,7 @@ fn set_visible_apps_rejects_zero_selection() { opencode: false, openclaw: false, pi: false, + kimi: false, hermes: false, }) .expect_err("zero visible apps should be rejected"); @@ -605,6 +611,7 @@ fn update_settings_rejects_all_false_visible_apps() { opencode: false, openclaw: false, pi: false, + kimi: false, hermes: false, }, ..Default::default() @@ -702,6 +709,7 @@ fn next_visible_app_wraps_and_skips_hidden_entries() { opencode: true, openclaw: true, pi: false, + kimi: false, hermes: true, };