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..60111d9e1 100644 --- a/src-tauri/src/cli/commands/provider_input.rs +++ b/src-tauri/src/cli/commands/provider_input.rs @@ -144,7 +144,7 @@ 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 +209,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 +657,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 +729,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 +3892,52 @@ 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(|v| v.get("api_key").or_else(|| v.get("apiKey"))) + .and_then(Value::as_str) + .unwrap_or_default(); + let default_base_url = current + .and_then(|v| v.get("base_url").or_else(|| v.get("baseUrl"))) + .and_then(Value::as_str) + .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 = serde_json::Map::new(); + map.insert( + "api_key".to_string(), + Value::String(api_key.trim().to_string()), + ); + map.insert( + "base_url".to_string(), + Value::String(base_url.trim().to_string()), + ); + if !model.trim().is_empty() { + map.insert("model".to_string(), Value::String(model.trim().to_string())); + } + Ok(Value::Object(map)) +} + /// 根据应用类型收集 settings_config pub fn prompt_settings_config( app_type: &AppType, @@ -3917,6 +3979,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 +4744,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..1e6103648 100644 --- a/src-tauri/src/cli/tui/form/provider_json.rs +++ b/src-tauri/src/cli/tui/form/provider_json.rs @@ -865,6 +865,19 @@ impl ProviderAddFormState { } } } + AppType::Kimi => { + let base_url = self + .hermes_base_url + .value + .trim() + .trim_end_matches('/') + .to_string(); + set_or_remove_trimmed(settings_obj, "base_url", &base_url); + set_or_remove_trimmed(settings_obj, "api_key", &self.hermes_api_key.value); + if !self.claude_model.value.trim().is_empty() { + set_or_remove_trimmed(settings_obj, "model", &self.claude_model.value); + } + } } Value::Object(provider_obj) @@ -1503,7 +1516,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, 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..dd367f8c2 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,30 @@ 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) = 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()) + { + form.hermes_base_url.set(base_url); + } + if let Some(api_key) = settings + .get("api_key") + .or_else(|| settings.get("apiKey")) + .or_else(|| settings.get("auth_token")) + .and_then(|value| value.as_str()) + { + 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..608ba3188 --- /dev/null +++ b/src-tauri/src/kimi_config.rs @@ -0,0 +1,716 @@ +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"; + +/// 解析 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)] + { + // 单元测试未显式设置 KIMI_CODE_HOME 时,绝不能回退到宿主真实目录,防止测试副作用篡改真实凭据 + std::env::temp_dir().join("cc-switch-kimi-test-isolated") + } + #[cfg(not(test))] + { + dirs::home_dir() + .map(|p| p.join(DEFAULT_KIMI_CONFIG_DIR)) + .unwrap_or_else(|| PathBuf::from(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, 0o644).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 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, + })); + } + } + } + } + + // 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("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()) +} + +/// 准备将 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")))?; + + let p_type = provider_config + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("openai"); + provider_tbl.insert("type", toml_edit::value(p_type)); + + let base_url = provider_config + .get("baseUrl") + .or_else(|| provider_config.get("base_url")) + .or_else(|| provider_config.get("endpoint")) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()); + + if let Some(url) = base_url { + provider_tbl.insert("base_url", toml_edit::value(url)); + } + + let api_key = provider_config + .get("apiKey") + .or_else(|| provider_config.get("api_key")) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()); + + if let Some(key) = api_key { + provider_tbl.insert("api_key", toml_edit::value(key)); + } else if provider_config.get("api_key").is_some() || provider_config.get("apiKey").is_some() { + provider_tbl.remove("api_key"); + } + + // 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.trim(); + 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.trim(); + 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()); + } + if let Some(models) = doc["models"].as_table_like_mut() { + for m in &configured_models { + if models.get(m).is_none() { + let mut tbl = Table::new(); + tbl.insert("provider", toml_edit::value(id)); + tbl.insert("model", toml_edit::value(m.as_str())); + models.insert(m, toml_edit::Item::Table(tbl)); + } else if let Some(tbl) = models.get_mut(m).and_then(|v| v.as_table_like_mut()) { + tbl.insert("provider", toml_edit::value(id)); + tbl.insert("model", toml_edit::value(m.as_str())); + } + } + } + + 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, 0o644).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(), 0o644).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()); + } + if let Some(models) = doc["models"].as_table_like_mut() { + if models.get(&target_model).is_none() { + let mut tbl = Table::new(); + tbl.insert("provider", toml_edit::value(id)); + tbl.insert("model", toml_edit::value(&target_model)); + models.insert(&target_model, toml_edit::Item::Table(tbl)); + } + } + + doc["default_model"] = toml_edit::value(&target_model); + write_file_atomic(&path, &doc.to_string(), 0o644).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}")))? + }; + doc["default_model"] = toml_edit::value(model_name); + write_file_atomic(&path, &doc.to_string(), 0o644).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 servers = val + .get("mcpServers") + .and_then(|v| v.as_object()) + .map(|obj| { + obj.iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::>() + }) + .unwrap_or_default(); + 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() { + fs::read_to_string(&path) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default() + } 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, 0o644).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)] +mod tests { + use super::*; + + #[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..6c4d7c62d 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(), }) } 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..c5d5eabe7 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 @@ -587,6 +589,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); 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, };