From fe8cd293be8ba4266fdf628a362a78a6ae513d5a Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 17 May 2026 22:24:07 +0800 Subject: [PATCH 1/2] feat(hermes): integrate Hermes app support into the application --- src-tauri/src/app_config.rs | 175 +++++++++- src-tauri/src/cli/commands/config_common.rs | 10 +- src-tauri/src/cli/commands/failover.rs | 2 +- src-tauri/src/cli/commands/mcp.rs | 1 + src-tauri/src/cli/commands/provider_input.rs | 45 +++ .../src/cli/commands/provider_inspect.rs | 15 + src-tauri/src/cli/failover_policy.rs | 2 +- src-tauri/src/cli/i18n.rs | 87 +++++ src-tauri/src/cli/tui/app/editor_state.rs | 1 + .../src/cli/tui/app/form_handlers/provider.rs | 30 +- src-tauri/src/cli/tui/app/helpers.rs | 8 +- .../cli/tui/app/overlay_handlers/pickers.rs | 11 +- src-tauri/src/cli/tui/app/tests.rs | 21 +- src-tauri/src/cli/tui/data.rs | 27 ++ src-tauri/src/cli/tui/form.rs | 55 ++++ src-tauri/src/cli/tui/form/provider_json.rs | 49 ++- src-tauri/src/cli/tui/form/provider_state.rs | 90 ++++- .../cli/tui/form/provider_state_loading.rs | 71 ++++ .../src/cli/tui/form/provider_templates.rs | 15 + .../src/cli/tui/runtime_actions/editor.rs | 51 +++ .../src/cli/tui/runtime_actions/helpers.rs | 2 + src-tauri/src/cli/tui/runtime_actions/mod.rs | 8 + src-tauri/src/cli/tui/tests.rs | 1 + src-tauri/src/cli/tui/theme.rs | 17 + src-tauri/src/cli/tui/ui/forms/provider.rs | 22 +- src-tauri/src/cli/tui/ui/forms/shared.rs | 12 +- src-tauri/src/cli/tui/ui/header_tests.rs | 2 + src-tauri/src/cli/tui/ui/overlay/pickers.rs | 3 + src-tauri/src/cli/tui/ui/tests.rs | 10 + src-tauri/src/cli/ui/colors.rs | 2 + src-tauri/src/database/dao/skills.rs | 2 + src-tauri/src/deeplink/provider.rs | 28 ++ src-tauri/src/hermes_config.rs | 307 ++++++++++++++++++ src-tauri/src/lib.rs | 1 + src-tauri/src/prompt_files.rs | 10 +- src-tauri/src/proxy/providers/mod.rs | 3 +- src-tauri/src/services/config.rs | 2 + src-tauri/src/services/mcp.rs | 4 +- .../src/services/provider/common_config.rs | 16 +- src-tauri/src/services/provider/live.rs | 63 ++++ src-tauri/src/services/provider/mod.rs | 92 +++++- src-tauri/src/services/provider/usage.rs | 21 ++ src-tauri/src/services/skill.rs | 8 +- .../services/stream_check/provider_extract.rs | 45 +++ .../src/services/stream_check/service.rs | 5 +- src-tauri/src/settings.rs | 35 +- src-tauri/src/store.rs | 11 + src-tauri/src/sync_policy.rs | 2 + src-tauri/tests/app_config_load.rs | 35 ++ src-tauri/tests/app_type_parse.rs | 11 + src-tauri/tests/settings_current_provider.rs | 42 +++ src-tauri/tests/settings_visible_apps.rs | 29 +- 52 files changed, 1568 insertions(+), 49 deletions(-) create mode 100644 src-tauri/src/hermes_config.rs diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 16d5756c..a80e7871 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::str::FromStr; +use crate::prompt_files::prompt_file_path; use crate::services::skill::SkillStore; /// MCP 服务器应用状态(标记应用到哪些客户端) @@ -27,6 +28,7 @@ impl McpApps { AppType::Codex => self.codex, AppType::Gemini => self.gemini, AppType::OpenCode => self.opencode, + AppType::Hermes => self.hermes, AppType::OpenClaw => false, } } @@ -38,6 +40,7 @@ impl McpApps { AppType::Codex => self.codex = enabled, AppType::Gemini => self.gemini = enabled, AppType::OpenCode => self.opencode = enabled, + AppType::Hermes => self.hermes = enabled, AppType::OpenClaw => {} } } @@ -57,6 +60,9 @@ impl McpApps { if self.opencode { apps.push(AppType::OpenCode); } + if self.hermes { + apps.push(AppType::Hermes); + } apps } @@ -77,6 +83,8 @@ pub struct SkillApps { pub gemini: bool, #[serde(default)] pub opencode: bool, + #[serde(default)] + pub hermes: bool, } impl SkillApps { @@ -86,6 +94,7 @@ impl SkillApps { AppType::Codex => self.codex, AppType::Gemini => self.gemini, AppType::OpenCode => self.opencode, + AppType::Hermes => self.hermes, AppType::OpenClaw => false, } } @@ -96,12 +105,13 @@ impl SkillApps { AppType::Codex => self.codex = enabled, AppType::Gemini => self.gemini = enabled, AppType::OpenCode => self.opencode = enabled, + AppType::Hermes => self.hermes = enabled, AppType::OpenClaw => {} } } pub fn is_empty(&self) -> bool { - !self.claude && !self.codex && !self.gemini && !self.opencode + !self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes } pub fn only(app: &AppType) -> Self { @@ -125,6 +135,7 @@ impl SkillApps { self.codex |= other.codex; self.gemini |= other.gemini; self.opencode |= other.opencode; + self.hermes |= other.hermes; } } @@ -223,6 +234,8 @@ pub struct McpRoot { #[serde(default, skip_serializing_if = "McpConfig::is_empty")] pub opencode: McpConfig, #[serde(default, skip_serializing_if = "McpConfig::is_empty")] + pub hermes: McpConfig, + #[serde(default, skip_serializing_if = "McpConfig::is_empty")] pub openclaw: McpConfig, } @@ -236,6 +249,7 @@ impl Default for McpRoot { codex: McpConfig::default(), gemini: McpConfig::default(), opencode: McpConfig::default(), + hermes: McpConfig::default(), openclaw: McpConfig::default(), } } @@ -260,6 +274,8 @@ pub struct PromptRoot { #[serde(default)] pub opencode: PromptConfig, #[serde(default)] + pub hermes: PromptConfig, + #[serde(default)] pub openclaw: PromptConfig, } @@ -275,6 +291,7 @@ pub enum AppType { Codex, Gemini, OpenCode, + Hermes, OpenClaw, } @@ -285,12 +302,16 @@ impl AppType { AppType::Codex => "codex", AppType::Gemini => "gemini", AppType::OpenCode => "opencode", + AppType::Hermes => "hermes", AppType::OpenClaw => "openclaw", } } pub fn is_additive_mode(&self) -> bool { - matches!(self, AppType::OpenCode | AppType::OpenClaw) + matches!( + self, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw + ) } pub fn supports_failover(&self) -> bool { @@ -303,6 +324,7 @@ impl AppType { AppType::Codex, AppType::Gemini, AppType::OpenCode, + AppType::Hermes, AppType::OpenClaw, ] .into_iter() @@ -325,14 +347,15 @@ impl FromStr for AppType { "codex" => Ok(AppType::Codex), "gemini" => Ok(AppType::Gemini), "opencode" => Ok(AppType::OpenCode), + "hermes" => Ok(AppType::Hermes), "openclaw" => Ok(AppType::OpenClaw), other => Err(AppError::localized( "unsupported_app", format!( - "不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, openclaw。" + "不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, hermes, openclaw。" ), format!( - "Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, openclaw." + "Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, hermes, openclaw." ), )), } @@ -354,6 +377,9 @@ pub struct CommonConfigSnippets { #[serde(default, skip_serializing_if = "Option::is_none")] pub opencode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hermes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub openclaw: Option, } @@ -366,6 +392,7 @@ impl CommonConfigSnippets { AppType::Codex => self.codex.as_ref(), AppType::Gemini => self.gemini.as_ref(), AppType::OpenCode => self.opencode.as_ref(), + AppType::Hermes => self.hermes.as_ref(), AppType::OpenClaw => self.openclaw.as_ref(), } } @@ -377,6 +404,7 @@ impl CommonConfigSnippets { AppType::Codex => self.codex = snippet, AppType::Gemini => self.gemini = snippet, AppType::OpenCode => self.opencode = snippet, + AppType::Hermes => self.hermes = snippet, AppType::OpenClaw => self.openclaw = snippet, } } @@ -418,6 +446,7 @@ impl Default for MultiAppConfig { apps.insert("codex".to_string(), ProviderManager::default()); apps.insert("gemini".to_string(), ProviderManager::default()); apps.insert("opencode".to_string(), ProviderManager::default()); + apps.insert("hermes".to_string(), ProviderManager::default()); apps.insert("openclaw".to_string(), ProviderManager::default()); Self { @@ -512,6 +541,13 @@ impl MultiAppConfig { updated = true; } + if !config.apps.contains_key("hermes") { + config + .apps + .insert("hermes".to_string(), ProviderManager::default()); + updated = true; + } + if !config.apps.contains_key("openclaw") { config .apps @@ -583,6 +619,7 @@ impl MultiAppConfig { AppType::Codex => &self.mcp.codex, AppType::Gemini => &self.mcp.gemini, AppType::OpenCode => &self.mcp.opencode, + AppType::Hermes => &self.mcp.hermes, AppType::OpenClaw => &self.mcp.openclaw, } } @@ -594,10 +631,137 @@ impl MultiAppConfig { AppType::Codex => &mut self.mcp.codex, AppType::Gemini => &mut self.mcp.gemini, AppType::OpenCode => &mut self.mcp.opencode, + AppType::Hermes => &mut self.mcp.hermes, AppType::OpenClaw => &mut self.mcp.openclaw, } } + /// 创建默认配置并自动导入已存在的提示词文件 + fn default_with_auto_import() -> Result { + log::info!("首次启动,创建默认配置并检测提示词文件"); + + let mut config = Self::default(); + + // 为每个应用尝试自动导入提示词 + Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?; + Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?; + Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?; + Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?; + Self::auto_import_prompt_if_exists(&mut config, AppType::Hermes)?; + Self::auto_import_prompt_if_exists(&mut config, AppType::OpenClaw)?; + + Ok(config) + } + + /// 已存在配置文件时的 Prompt 自动导入逻辑 + /// + /// 适用于「老版本已经生成过 config.json,但当时还没有 Prompt 功能」的升级场景。 + /// 判定规则: + /// - 仅当所有应用的 prompts 都为空时才尝试导入(避免打扰已经在使用 Prompt 功能的用户) + /// - 每个应用最多导入一次,对应各自的提示词文件(如 CLAUDE.md/AGENTS.md/GEMINI.md) + /// + /// 返回值: + /// - Ok(true) 表示至少有一个应用成功导入了提示词 + /// - Ok(false) 表示无需导入或未导入任何内容 + fn maybe_auto_import_prompts_for_existing_config(&mut self) -> Result { + // 如果任一应用已经有提示词配置,说明用户已经在使用 Prompt 功能,避免再次自动导入 + if !self.prompts.claude.prompts.is_empty() + || !self.prompts.codex.prompts.is_empty() + || !self.prompts.gemini.prompts.is_empty() + || !self.prompts.opencode.prompts.is_empty() + || !self.prompts.hermes.prompts.is_empty() + || !self.prompts.openclaw.prompts.is_empty() + { + return Ok(false); + } + + log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入"); + + let mut imported = false; + for app in [ + AppType::Claude, + AppType::Codex, + AppType::Gemini, + AppType::OpenCode, + AppType::Hermes, + AppType::OpenClaw, + ] { + // 复用已有的单应用导入逻辑 + if Self::auto_import_prompt_if_exists(self, app)? { + imported = true; + } + } + + Ok(imported) + } + + /// 检查并自动导入单个应用的提示词文件 + /// + /// 返回值: + /// - Ok(true) 表示成功导入了非空文件 + /// - Ok(false) 表示未导入(文件不存在、内容为空或读取失败) + fn auto_import_prompt_if_exists(config: &mut Self, app: AppType) -> Result { + let file_path = prompt_file_path(&app)?; + + // 检查文件是否存在 + if !file_path.exists() { + log::debug!("提示词文件不存在,跳过自动导入: {file_path:?}"); + return Ok(false); + } + + // 读取文件内容 + let content = match std::fs::read_to_string(&file_path) { + Ok(c) => c, + Err(e) => { + log::warn!("读取提示词文件失败: {file_path:?}, 错误: {e}"); + return Ok(false); // 失败时不中断,继续处理其他应用 + } + }; + + // 检查内容是否为空 + if content.trim().is_empty() { + log::debug!("提示词文件内容为空,跳过导入: {file_path:?}"); + return Ok(false); + } + + log::info!("发现提示词文件,自动导入: {file_path:?}"); + + // 创建提示词对象 + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + let id = format!("auto-imported-{timestamp}"); + let prompt = crate::prompt::Prompt { + id: id.clone(), + name: format!( + "Auto-imported Prompt {}", + chrono::Local::now().format("%Y-%m-%d %H:%M") + ), + content, + description: Some("Automatically imported on first launch".to_string()), + enabled: true, // 自动启用 + created_at: Some(timestamp), + updated_at: Some(timestamp), + }; + + // 插入到对应的应用配置中 + let prompts = match app { + AppType::Claude => &mut config.prompts.claude.prompts, + AppType::Codex => &mut config.prompts.codex.prompts, + AppType::Gemini => &mut config.prompts.gemini.prompts, + AppType::OpenCode => &mut config.prompts.opencode.prompts, + AppType::Hermes => &mut config.prompts.hermes.prompts, + AppType::OpenClaw => &mut config.prompts.openclaw.prompts, + }; + + prompts.insert(id, prompt); + + log::info!("自动导入完成: {}", app.as_str()); + Ok(true) + } + /// 将 v3.6.x 的分应用 MCP 结构迁移到 v3.7.0 的统一结构 /// /// 迁移策略: @@ -623,12 +787,14 @@ impl MultiAppConfig { AppType::Codex, AppType::Gemini, AppType::OpenCode, + AppType::Hermes, ] { let old_servers = match app { AppType::Claude => &self.mcp.claude.servers, AppType::Codex => &self.mcp.codex.servers, AppType::Gemini => &self.mcp.gemini.servers, AppType::OpenCode => &self.mcp.opencode.servers, + AppType::Hermes => &self.mcp.hermes.servers, AppType::OpenClaw => continue, }; @@ -733,6 +899,7 @@ impl MultiAppConfig { self.mcp.codex = McpConfig::default(); self.mcp.gemini = McpConfig::default(); self.mcp.opencode = McpConfig::default(); + self.mcp.hermes = 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 d4c923b1..90047682 100644 --- a/src-tauri/src/cli/commands/config_common.rs +++ b/src-tauri/src/cli/commands/config_common.rs @@ -179,9 +179,13 @@ fn canonical_common_snippet(app_type: AppType, raw: &str) -> Result { - let value: serde_json::Value = serde_json::from_str(trimmed).map_err(|e| { + let snippet = match app_type { + AppType::Claude + | AppType::Gemini + | AppType::OpenCode + | AppType::Hermes + | AppType::OpenClaw => { + let value: serde_json::Value = serde_json::from_str(&raw).map_err(|e| { AppError::InvalidInput(texts::tui_toast_invalid_json(&e.to_string())) })?; if !value.is_object() { diff --git a/src-tauri/src/cli/commands/failover.rs b/src-tauri/src/cli/commands/failover.rs index e9ac5bbc..f14cd379 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::OpenClaw => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => false, } } diff --git a/src-tauri/src/cli/commands/mcp.rs b/src-tauri/src/cli/commands/mcp.rs index 34639b3c..578bed8a 100644 --- a/src-tauri/src/cli/commands/mcp.rs +++ b/src-tauri/src/cli/commands/mcp.rs @@ -265,6 +265,7 @@ fn import_servers(app_type: AppType) -> Result<(), AppError> { AppType::Codex => McpService::import_from_codex(&state)?, AppType::Gemini => McpService::import_from_gemini(&state)?, AppType::OpenCode => 0, + AppType::Hermes => 0, AppType::OpenClaw => 0, }; diff --git a/src-tauri/src/cli/commands/provider_input.rs b/src-tauri/src/cli/commands/provider_input.rs index c01c3a6c..1cf962bb 100644 --- a/src-tauri/src/cli/commands/provider_input.rs +++ b/src-tauri/src/cli/commands/provider_input.rs @@ -169,6 +169,7 @@ pub fn prompt_settings_config_for_add( (AppType::Codex, ProviderAddMode::ThirdParty) => prompt_codex_config(None), (AppType::Gemini, _) => prompt_gemini_config(None), (AppType::OpenCode, _) => Ok(json!({})), + (AppType::Hermes, _) => Ok(json!({})), (AppType::OpenClaw, _) => Ok(json!({})), } } @@ -405,6 +406,7 @@ pub fn prompt_settings_config( } AppType::Gemini => prompt_gemini_config(current), AppType::OpenCode => Ok(current.cloned().unwrap_or_else(|| json!({}))), + AppType::Hermes => Ok(current.cloned().unwrap_or_else(|| json!({}))), AppType::OpenClaw => Ok(current.cloned().unwrap_or_else(|| json!({}))), } } @@ -924,6 +926,49 @@ pub fn display_provider_summary(provider: &Provider, app_type: &AppType) { println!(" {}: {}", texts::model_label(), models.len()); } } + AppType::Hermes => { + if let Some(api_key) = provider + .settings_config + .get("apiKey") + .or_else(|| provider.settings_config.get("api_key")) + .and_then(|v| v.as_str()) + { + println!( + " {}: {}", + texts::api_key_display_label(), + mask_api_key(api_key) + ); + } + if let Some(base_url) = 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(|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); + } else if let Some(models) = provider + .settings_config + .get("models") + .and_then(|v| v.as_object()) + { + println!(" {}: {}", texts::model_label(), models.len()); + } else if let Some(models) = provider + .settings_config + .get("models") + .and_then(|v| v.as_array()) + { + println!(" {}: {}", texts::model_label(), models.len()); + } + } AppType::OpenClaw => { if let Some(api_key) = provider .settings_config diff --git a/src-tauri/src/cli/commands/provider_inspect.rs b/src-tauri/src/cli/commands/provider_inspect.rs index 0e8374c2..61ca3ba2 100644 --- a/src-tauri/src/cli/commands/provider_inspect.rs +++ b/src-tauri/src/cli/commands/provider_inspect.rs @@ -341,6 +341,21 @@ fn model_fetch_target( })?, strategy: ProviderModelFetchStrategy::Bearer, }), + AppType::Hermes => Ok(ModelFetchTarget { + base_url, + auth_value: 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)) + })?, + strategy: ProviderModelFetchStrategy::Bearer, + }), AppType::OpenClaw => Ok(ModelFetchTarget { base_url, auth_value: provider diff --git a/src-tauri/src/cli/failover_policy.rs b/src-tauri/src/cli/failover_policy.rs index 198e53a0..21e789ca 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::OpenClaw => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => false, } } diff --git a/src-tauri/src/cli/i18n.rs b/src-tauri/src/cli/i18n.rs index 5180fb3d..848144cd 100644 --- a/src-tauri/src/cli/i18n.rs +++ b/src-tauri/src/cli/i18n.rs @@ -1638,6 +1638,55 @@ pub mod texts { } } + pub fn tui_label_hermes_api_mode() -> &'static str { + if is_chinese() { + "API 模式" + } else { + "API Mode" + } + } + + pub fn tui_label_hermes_models() -> &'static str { + if is_chinese() { + "模型列表" + } else { + "Models" + } + } + + pub fn tui_hermes_api_mode_value(api_mode: &str) -> &'static str { + match api_mode { + "codex_responses" => { + if is_chinese() { + "Codex Responses API" + } else { + "Codex Responses API" + } + } + "anthropic_messages" => { + if is_chinese() { + "Anthropic Messages" + } else { + "Anthropic Messages" + } + } + "bedrock_converse" => { + if is_chinese() { + "Bedrock Converse" + } else { + "Bedrock Converse" + } + } + _ => { + if is_chinese() { + "OpenAI Chat Completions" + } else { + "OpenAI Chat Completions" + } + } + } + } + pub fn tui_label_openclaw_status() -> &'static str { if is_chinese() { "状态" @@ -1778,6 +1827,36 @@ pub mod texts { } } + pub fn tui_hermes_models_summary(total: usize) -> String { + if is_chinese() { + if total == 0 { + "未配置模型".to_string() + } else { + format!("已配置 {total} 个模型") + } + } else if total == 0 { + "No models configured".to_string() + } else { + format!("{total} models configured") + } + } + + pub fn tui_hermes_models_open_hint() -> &'static str { + if is_chinese() { + "按 Enter 编辑 Hermes 模型列表" + } else { + "Press Enter to edit Hermes models" + } + } + + pub fn tui_hermes_models_editor_title() -> &'static str { + if is_chinese() { + "Hermes 模型列表" + } else { + "Hermes Models" + } + } + pub fn tui_toast_json_must_be_array() -> &'static str { if is_chinese() { "JSON 必须是数组" @@ -1786,6 +1865,14 @@ pub mod texts { } } + pub fn tui_toast_json_must_be_object_or_array() -> &'static str { + if is_chinese() { + "JSON 必须是对象或数组" + } else { + "JSON must be an object or array" + } + } + pub fn tui_label_opencode_model_id() -> &'static str { if is_chinese() { "主模型 ID" diff --git a/src-tauri/src/cli/tui/app/editor_state.rs b/src-tauri/src/cli/tui/app/editor_state.rs index 4e9ed3ab..6468c2f6 100644 --- a/src-tauri/src/cli/tui/app/editor_state.rs +++ b/src-tauri/src/cli/tui/app/editor_state.rs @@ -18,6 +18,7 @@ pub enum EditorSubmit { id: String, }, ProviderFormApplyJson, + ProviderFormApplyHermesModels, ProviderFormApplyOpenClawModels, ProviderFormApplyUsageScriptCode, ProviderFormApplyCodexAuth, diff --git a/src-tauri/src/cli/tui/app/form_handlers/provider.rs b/src-tauri/src/cli/tui/app/form_handlers/provider.rs index be858d84..df39c847 100644 --- a/src-tauri/src/cli/tui/app/form_handlers/provider.rs +++ b/src-tauri/src/cli/tui/app/form_handlers/provider.rs @@ -251,6 +251,13 @@ impl App { }; Action::None } + ProviderAddField::HermesApiMode => { + let Some(FormState::ProviderAdd(provider)) = self.form.as_mut() else { + return Action::None; + }; + provider.hermes_api_mode = provider.hermes_api_mode.next(); + Action::None + } ProviderAddField::OpenClawApiProtocol => { let Some(FormState::ProviderAdd(provider)) = self.form.as_mut() else { return Action::None; @@ -300,6 +307,23 @@ impl App { } Action::None } + ProviderAddField::HermesModels => { + if matches!(key.code, KeyCode::Enter) { + let Some(FormState::ProviderAdd(provider)) = self.form.as_ref() else { + return Action::None; + }; + self.open_editor( + texts::tui_hermes_models_editor_title(), + EditorKind::Json, + provider.hermes_models_editor_text(), + EditorSubmit::ProviderFormApplyHermesModels, + ); + if let Some(editor) = self.editor.as_mut() { + editor.mode = EditorMode::Edit; + } + } + Action::None + } ProviderAddField::CommonSnippet => { if matches!(key.code, KeyCode::Enter) { let Some(FormState::ProviderAdd(provider)) = self.form.as_ref() else { @@ -322,7 +346,8 @@ impl App { } ProviderAddField::CodexModel | ProviderAddField::GeminiModel - | ProviderAddField::OpenCodeModelId => { + | ProviderAddField::OpenCodeModelId + | ProviderAddField::HermesModel => { self.handle_provider_model_field_activate(selected, key) } _ => { @@ -557,12 +582,15 @@ impl App { (!provider.opencode_api_key.value.trim().is_empty()) .then(|| provider.opencode_api_key.value.clone()) } + ProviderAddField::HermesModel => (!provider.hermes_api_key.value.trim().is_empty()) + .then(|| provider.hermes_api_key.value.clone()), _ => None, }; let base_url = match selected { ProviderAddField::CodexModel => provider.codex_base_url.value.clone(), ProviderAddField::GeminiModel => provider.gemini_base_url.value.clone(), ProviderAddField::OpenCodeModelId => provider.opencode_base_url.value.clone(), + ProviderAddField::HermesModel => provider.hermes_base_url.value.clone(), _ => String::new(), }; Action::ProviderModelFetch { diff --git a/src-tauri/src/cli/tui/app/helpers.rs b/src-tauri/src/cli/tui/app/helpers.rs index 0c8738c7..5e955e1c 100644 --- a/src-tauri/src/cli/tui/app/helpers.rs +++ b/src-tauri/src/cli/tui/app/helpers.rs @@ -1185,12 +1185,13 @@ pub(crate) fn app_type_picker_index(app_type: &AppType) -> usize { AppType::Codex => 1, AppType::Gemini => 2, AppType::OpenCode => 3, - AppType::OpenClaw => 4, + AppType::Hermes => 4, + AppType::OpenClaw => 5, } } pub(crate) fn four_app_picker_index(app_type: &AppType) -> usize { - app_type_picker_index(app_type).min(3) + app_type_picker_index(app_type).min(4) } pub(crate) fn app_type_for_picker_index(index: usize) -> AppType { @@ -1198,7 +1199,8 @@ pub(crate) fn app_type_for_picker_index(index: usize) -> AppType { 1 => AppType::Codex, 2 => AppType::Gemini, 3 => AppType::OpenCode, - 4 => AppType::OpenClaw, + 4 => AppType::Hermes, + 5 => AppType::OpenClaw, _ => AppType::Claude, } } diff --git a/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs b/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs index d20658ed..5dc66e1e 100644 --- a/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs +++ b/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs @@ -66,7 +66,7 @@ impl App { Action::None } KeyCode::Down => { - *selected = (*selected + 1).min(3); + *selected = (*selected + 1).min(5); Action::None } KeyCode::Enter => { @@ -321,7 +321,7 @@ impl App { Action::None } KeyCode::Down => { - *selected = (*selected + 1).min(4); + *selected = (*selected + 1).min(5); Action::None } KeyCode::Enter => { @@ -419,6 +419,9 @@ impl App { provider.mark_claude_model_config_touched(); } } + } else if field == ProviderAddField::HermesModel { + provider.hermes_model.set(selected_model.clone()); + provider.ensure_hermes_model_entry(&selected_model); } else if let Some(input_field) = provider.input_mut(field) { input_field.set(selected_model); } @@ -574,7 +577,7 @@ impl App { Action::None } KeyCode::Down => { - *selected = (*selected + 1).min(3); + *selected = (*selected + 1).min(app_type_picker_index(&AppType::OpenClaw)); Action::None } KeyCode::Char(' ') => { @@ -655,7 +658,7 @@ impl App { Action::None } KeyCode::Down => { - *selected = (*selected + 1).min(4); + *selected = (*selected + 1).min(app_type_picker_index(&AppType::OpenClaw)); Action::None } KeyCode::Char(' ') => { diff --git a/src-tauri/src/cli/tui/app/tests.rs b/src-tauri/src/cli/tui/app/tests.rs index 4ca580f1..391cb31e 100644 --- a/src-tauri/src/cli/tui/app/tests.rs +++ b/src-tauri/src/cli/tui/app/tests.rs @@ -583,7 +583,7 @@ mod tests { assert!(matches!(action, Action::None)); assert!(matches!( &app.overlay, - Overlay::SkillsAppsPicker { selected, .. } if *selected == 3 + Overlay::SkillsAppsPicker { selected, .. } if *selected == 4 )); let action = app.on_key(key(KeyCode::Char(' ')), &data); @@ -591,11 +591,12 @@ mod tests { assert!(matches!( &app.overlay, Overlay::SkillsAppsPicker { selected, apps, .. } - if *selected == 3 + if *selected == 4 && !apps.claude && !apps.codex && !apps.gemini - && apps.opencode + && !apps.opencode + && apps.hermes )); } @@ -737,6 +738,7 @@ mod tests { codex: true, gemini: true, opencode: true, + hermes: false, openclaw: true, }) .expect("save visible apps"); @@ -761,6 +763,7 @@ mod tests { codex: true, gemini: true, opencode: true, + hermes: false, openclaw: true, }) .expect("save visible apps"); @@ -801,6 +804,7 @@ mod tests { codex: false, gemini: false, opencode: true, + hermes: false, openclaw: true, }) .expect("save visible apps"); @@ -823,6 +827,7 @@ mod tests { codex: true, gemini: false, opencode: false, + hermes: false, openclaw: false, }) .expect("save visible apps"); @@ -849,6 +854,7 @@ mod tests { codex: true, gemini: false, opencode: false, + hermes: false, openclaw: true, }) .expect("save visible apps"); @@ -871,6 +877,7 @@ mod tests { codex: true, gemini: false, opencode: false, + hermes: false, openclaw: false, }) .expect("save visible apps"); @@ -2511,7 +2518,7 @@ mod tests { assert!(matches!(action, Action::None)); assert!(matches!( &app.overlay, - Overlay::McpAppsPicker { selected, .. } if *selected == 3 + Overlay::McpAppsPicker { selected, .. } if *selected == 4 )); let action = app.on_key(key(KeyCode::Char(' ')), &data); @@ -2519,11 +2526,12 @@ mod tests { assert!(matches!( &app.overlay, Overlay::McpAppsPicker { selected, apps, .. } - if *selected == 3 + if *selected == 4 && !apps.claude && !apps.codex && !apps.gemini - && apps.opencode + && !apps.opencode + && apps.hermes )); } @@ -8292,6 +8300,7 @@ mod tests { codex: false, gemini: false, opencode: false, + hermes: false, openclaw: false, }) .expect("save visible apps"); diff --git a/src-tauri/src/cli/tui/data.rs b/src-tauri/src/cli/tui/data.rs index 9e7bbc22..2832aa72 100644 --- a/src-tauri/src/cli/tui/data.rs +++ b/src-tauri/src/cli/tui/data.rs @@ -266,6 +266,7 @@ impl ProxySnapshot { AppType::Codex => Some(self.codex_takeover), AppType::Gemini => Some(self.gemini_takeover), AppType::OpenCode => None, + AppType::Hermes => None, AppType::OpenClaw => None, } } @@ -685,6 +686,13 @@ fn extract_api_url(settings_config: &Value, app_type: &AppType) -> Option 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()), AppType::OpenClaw => settings_config .get("baseUrl") .or_else(|| settings_config.get("base_url"))? @@ -699,6 +707,25 @@ fn extract_primary_model_id( openclaw_live_provider: Option<&Value>, ) -> Option { match app_type { + AppType::Hermes => settings_config + .get("model") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + settings_config + .get("models") + .and_then(Value::as_object) + .and_then(|models| models.keys().next().cloned()) + }) + .or_else(|| { + settings_config + .get("models") + .and_then(Value::as_array) + .and_then(|models| models.first()) + .and_then(|model| model.get("id")) + .and_then(Value::as_str) + .map(str::to_string) + }), 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.rs b/src-tauri/src/cli/tui/form.rs index 8d2f9758..ba703016 100644 --- a/src-tauri/src/cli/tui/form.rs +++ b/src-tauri/src/cli/tui/form.rs @@ -37,6 +37,7 @@ pub const OPENCLAW_API_PROTOCOLS: [&str; 5] = [ "google-generative-ai", "bedrock-converse-stream", ]; +pub const HERMES_DEFAULT_API_MODE: &str = "chat_completions"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GeminiAuthType { @@ -75,6 +76,49 @@ pub enum ClaudeApiFormat { OpenAiResponses, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HermesApiMode { + ChatCompletions, + CodexResponses, + AnthropicMessages, + BedrockConverse, +} + +impl HermesApiMode { + pub const ALL: [Self; 4] = [ + HermesApiMode::ChatCompletions, + HermesApiMode::CodexResponses, + HermesApiMode::AnthropicMessages, + HermesApiMode::BedrockConverse, + ]; + + pub fn as_str(self) -> &'static str { + match self { + HermesApiMode::ChatCompletions => "chat_completions", + HermesApiMode::CodexResponses => "codex_responses", + HermesApiMode::AnthropicMessages => "anthropic_messages", + HermesApiMode::BedrockConverse => "bedrock_converse", + } + } + + pub fn from_raw(value: &str) -> Self { + match value { + "chat_completions" | "openai_chat" | "openai_chat_completions" => { + HermesApiMode::ChatCompletions + } + "codex_responses" | "openai_responses" => HermesApiMode::CodexResponses, + "anthropic_messages" => HermesApiMode::AnthropicMessages, + "bedrock_converse" => HermesApiMode::BedrockConverse, + _ => HermesApiMode::ChatCompletions, + } + } + + pub fn next(self) -> Self { + let index = Self::ALL.iter().position(|item| *item == self).unwrap_or(0); + Self::ALL[(index + 1) % Self::ALL.len()] + } +} + impl ClaudeApiFormat { pub const ALL: [Self; 3] = [ ClaudeApiFormat::Anthropic, @@ -159,6 +203,11 @@ pub enum ProviderAddField { Name, WebsiteUrl, Notes, + HermesApiMode, + HermesBaseUrl, + HermesApiKey, + HermesModel, + HermesModels, ClaudeBaseUrl, ClaudeApiFormat, ClaudeApiKey, @@ -305,6 +354,12 @@ pub struct ProviderAddFormState { pub gemini_base_url: TextInput, pub gemini_model: TextInput, + pub hermes_api_mode: HermesApiMode, + pub hermes_api_key: TextInput, + pub hermes_base_url: TextInput, + pub hermes_model: TextInput, + pub hermes_models: Value, + pub openclaw_user_agent: bool, pub openclaw_models: Vec, pub usage_query_enabled: bool, diff --git a/src-tauri/src/cli/tui/form/provider_json.rs b/src-tauri/src/cli/tui/form/provider_json.rs index 8c875196..e630081b 100644 --- a/src-tauri/src/cli/tui/form/provider_json.rs +++ b/src-tauri/src/cli/tui/form/provider_json.rs @@ -7,7 +7,7 @@ use super::codex_config::{ }; use super::{ ClaudeApiFormat, GeminiAuthType, ProviderAddFormState, UsageQueryTemplate, - OPENCLAW_DEFAULT_API_PROTOCOL, OPENCLAW_DEFAULT_USER_AGENT, + HERMES_DEFAULT_API_MODE, OPENCLAW_DEFAULT_API_PROTOCOL, OPENCLAW_DEFAULT_USER_AGENT, }; impl ProviderAddFormState { @@ -287,6 +287,51 @@ impl ProviderAddFormState { settings_obj.insert("models".to_string(), models_value); } } + AppType::Hermes => { + settings_obj.remove("apiKey"); + settings_obj.remove("baseUrl"); + settings_obj.remove("baseURL"); + settings_obj.remove("endpoint"); + settings_obj.remove("apiMode"); + + let provider_name = self.hermes_provider_name(); + if provider_name.is_empty() { + settings_obj.remove("name"); + } else { + settings_obj.insert("name".to_string(), json!(provider_name)); + } + + let source = settings_obj + .get("_cc_source") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("custom_providers"); + settings_obj.insert("_cc_source".to_string(), json!(source)); + + settings_obj.insert( + "api_mode".to_string(), + json!(if self.hermes_api_mode.as_str().trim().is_empty() { + HERMES_DEFAULT_API_MODE + } else { + self.hermes_api_mode.as_str() + }), + ); + + set_or_remove_trimmed(settings_obj, "api_key", &self.hermes_api_key.value); + set_or_remove_trimmed(settings_obj, "base_url", &self.hermes_base_url.value); + set_or_remove_trimmed(settings_obj, "model", &self.hermes_model.value); + + let has_models = match &self.hermes_models { + Value::Object(map) => !map.is_empty(), + Value::Array(items) => !items.is_empty(), + _ => false, + }; + if has_models { + settings_obj.insert("models".to_string(), self.hermes_models.clone()); + } else { + settings_obj.remove("models"); + } + } AppType::OpenClaw => { settings_obj.remove("npm"); settings_obj.remove("options"); @@ -656,7 +701,7 @@ pub(crate) fn strip_common_config_from_settings( ) .map_err(|e| e.to_string())?; } - AppType::OpenCode | AppType::OpenClaw => {} + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => {} 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 75ae31b4..4912746c 100644 --- a/src-tauri/src/cli/tui/form/provider_state.rs +++ b/src-tauri/src/cli/tui/form/provider_state.rs @@ -11,7 +11,7 @@ use super::provider_state_loading::populate_form_from_provider; use super::{ ClaudeApiFormat, CodexPreviewSection, CodexWireApi, FormFocus, FormMode, GeminiAuthType, ProviderAddField, ProviderAddFormState, ProviderFormPage, TextInput, UsageQueryField, - UsageQueryTemplate, OPENCLAW_DEFAULT_API_PROTOCOL, + UsageQueryTemplate, HERMES_DEFAULT_API_MODE,OPENCLAW_DEFAULT_API_PROTOCOL, }; impl ProviderAddFormState { @@ -136,6 +136,11 @@ impl ProviderAddFormState { gemini_api_key: TextInput::new(""), gemini_base_url: TextInput::new("https://generativelanguage.googleapis.com"), gemini_model: TextInput::new(""), + hermes_api_mode: HermesApiMode::from_raw(HERMES_DEFAULT_API_MODE), + hermes_api_key: TextInput::new(""), + hermes_base_url: TextInput::new(""), + hermes_model: TextInput::new(""), + hermes_models: json!({}), openclaw_user_agent: false, openclaw_models: Vec::new(), usage_query_enabled: false, @@ -285,7 +290,7 @@ impl ProviderAddFormState { ProviderAddField::Notes, ]; - if matches!(self.app_type, AppType::OpenClaw) { + if matches!(self.app_type, AppType::OpenClaw | AppType::Hermes) { fields.insert(0, ProviderAddField::Id); } @@ -323,6 +328,13 @@ impl ProviderAddFormState { fields.push(ProviderAddField::OpenCodeModelContextLimit); fields.push(ProviderAddField::OpenCodeModelOutputLimit); } + AppType::Hermes => { + fields.push(ProviderAddField::HermesApiMode); + fields.push(ProviderAddField::HermesBaseUrl); + fields.push(ProviderAddField::HermesApiKey); + fields.push(ProviderAddField::HermesModel); + fields.push(ProviderAddField::HermesModels); + } AppType::OpenClaw => { fields.push(ProviderAddField::OpenClawApiProtocol); fields.push(ProviderAddField::OpenCodeApiKey); @@ -413,6 +425,9 @@ impl ProviderAddFormState { ProviderAddField::Name => Some(&self.name), ProviderAddField::WebsiteUrl => Some(&self.website_url), ProviderAddField::Notes => Some(&self.notes), + ProviderAddField::HermesBaseUrl => Some(&self.hermes_base_url), + ProviderAddField::HermesApiKey => Some(&self.hermes_api_key), + ProviderAddField::HermesModel => Some(&self.hermes_model), ProviderAddField::ClaudeBaseUrl => Some(&self.claude_base_url), ProviderAddField::ClaudeApiKey => Some(&self.claude_api_key), ProviderAddField::CodexBaseUrl => Some(&self.codex_base_url), @@ -435,6 +450,8 @@ impl ProviderAddFormState { | ProviderAddField::ClaudeModelConfig | ProviderAddField::ClaudeHideAttribution | ProviderAddField::GeminiAuthType + | ProviderAddField::HermesApiMode + | ProviderAddField::HermesModels | ProviderAddField::OpenClawApiProtocol | ProviderAddField::OpenClawUserAgent | ProviderAddField::OpenClawModels @@ -452,6 +469,9 @@ impl ProviderAddFormState { ProviderAddField::Name => Some(&mut self.name), ProviderAddField::WebsiteUrl => Some(&mut self.website_url), ProviderAddField::Notes => Some(&mut self.notes), + ProviderAddField::HermesBaseUrl => Some(&mut self.hermes_base_url), + ProviderAddField::HermesApiKey => Some(&mut self.hermes_api_key), + ProviderAddField::HermesModel => Some(&mut self.hermes_model), ProviderAddField::ClaudeBaseUrl => Some(&mut self.claude_base_url), ProviderAddField::ClaudeApiKey => Some(&mut self.claude_api_key), ProviderAddField::CodexBaseUrl => Some(&mut self.codex_base_url), @@ -478,6 +498,8 @@ impl ProviderAddFormState { | ProviderAddField::ClaudeModelConfig | ProviderAddField::ClaudeHideAttribution | ProviderAddField::GeminiAuthType + | ProviderAddField::HermesApiMode + | ProviderAddField::HermesModels | ProviderAddField::OpenClawApiProtocol | ProviderAddField::OpenClawUserAgent | ProviderAddField::OpenClawModels @@ -817,6 +839,20 @@ impl ProviderAddFormState { self.claude_hide_attribution_touched = true; } + pub(crate) fn hermes_provider_name(&self) -> String { + let id = self.id.value.trim(); + if !id.is_empty() { + return id.to_string(); + } + + let name = self.name.value.trim(); + if !name.is_empty() { + return name.to_string(); + } + + String::new() + } + pub fn is_claude_official_provider(&self) -> bool { if !matches!(self.app_type, AppType::Claude) { return false; @@ -1055,6 +1091,56 @@ impl ProviderAddFormState { } } + pub(crate) fn hermes_models_summary(&self) -> String { + let total = match &self.hermes_models { + Value::Array(items) => items.len(), + Value::Object(items) => items.len(), + _ => 0, + }; + texts::tui_hermes_models_summary(total) + } + + pub(crate) fn hermes_models_editor_text(&self) -> String { + serde_json::to_string_pretty(&self.hermes_models).unwrap_or_else(|_| "{}".to_string()) + } + + pub(crate) fn ensure_hermes_model_entry(&mut self, selected_model: &str) { + let selected_model = selected_model.trim(); + if selected_model.is_empty() { + return; + } + + if !self.hermes_models.is_object() { + self.hermes_models = json!({}); + } + + if let Some(models) = self.hermes_models.as_object_mut() { + models + .entry(selected_model.to_string()) + .or_insert_with(|| json!({ "name": selected_model })); + } + } + + pub fn apply_hermes_models_value(&mut self, models_value: Value) -> Result<(), String> { + if !matches!(self.app_type, AppType::Hermes) { + return Ok(()); + } + if !models_value.is_array() && !models_value.is_object() { + return Err(texts::tui_toast_json_must_be_object_or_array().to_string()); + } + + let mut provider_value = self.to_provider_json_value(); + let settings_value = provider_value + .as_object_mut() + .and_then(|obj| obj.get_mut("settingsConfig")) + .ok_or_else(|| texts::tui_toast_json_must_be_object().to_string())?; + let settings_obj = settings_value + .as_object_mut() + .ok_or_else(|| texts::tui_toast_json_must_be_object().to_string())?; + settings_obj.insert("models".to_string(), models_value); + self.apply_provider_json_value_to_fields(provider_value) + } + pub(crate) fn openclaw_models_summary(&self) -> String { let total = self.openclaw_models.len(); texts::tui_openclaw_models_summary(total) 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 7cc7271b..0cdbfc60 100644 --- a/src-tauri/src/cli/tui/form/provider_state_loading.rs +++ b/src-tauri/src/cli/tui/form/provider_state_loading.rs @@ -8,6 +8,12 @@ use crate::app_config::AppType; use crate::provider::Provider; use serde_json::Value; +use super::codex_config::parse_codex_config_snippet; +use super::{ + claude_hide_attribution_enabled, ClaudeApiFormat, ProviderAddFormState, + HERMES_DEFAULT_API_MODE, OPENCLAW_DEFAULT_API_PROTOCOL, +}; + pub(super) fn populate_form_from_provider( form: &mut ProviderAddFormState, app_type: &AppType, @@ -18,6 +24,7 @@ pub(super) fn populate_form_from_provider( AppType::Codex => populate_codex_form(form, provider), AppType::Gemini => populate_gemini_form(form, provider), AppType::OpenCode => populate_opencode_form(form, provider), + AppType::Hermes => populate_hermes_form(form, provider), AppType::OpenClaw => populate_openclaw_form(form, provider), } populate_usage_query_form(form, provider); @@ -255,6 +262,70 @@ fn populate_opencode_form(form: &mut ProviderAddFormState, provider: &Provider) } } +fn populate_hermes_form(form: &mut ProviderAddFormState, provider: &Provider) { + form.hermes_api_mode = provider + .settings_config + .get("api_mode") + .or_else(|| provider.settings_config.get("apiMode")) + .and_then(|value| value.as_str()) + .map(HermesApiMode::from_raw) + .unwrap_or_else(|| HermesApiMode::from_raw(HERMES_DEFAULT_API_MODE)); + + if let Some(api_key) = provider + .settings_config + .get("api_key") + .or_else(|| provider.settings_config.get("apiKey")) + .and_then(|value| value.as_str()) + { + form.hermes_api_key.set(api_key); + } + + if let Some(base_url) = 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()) + { + form.hermes_base_url.set(base_url); + } + + if let Some(model) = provider + .settings_config + .get("model") + .and_then(|value| value.as_str()) + { + form.hermes_model.set(model); + } + + form.hermes_models = provider + .settings_config + .get("models") + .cloned() + .unwrap_or_else(|| Value::Object(serde_json::Map::new())); + + if form.hermes_model.is_blank() { + match &form.hermes_models { + Value::Object(models) => { + if let Some(model_id) = models.keys().next() { + form.hermes_model.set(model_id); + } + } + Value::Array(models) => { + if let Some(model_id) = models + .first() + .and_then(|model| model.get("id")) + .and_then(|value| value.as_str()) + { + form.hermes_model.set(model_id); + } + } + _ => {} + } + } +} + 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 c3530a0a..a8054876 100644 --- a/src-tauri/src/cli/tui/form/provider_templates.rs +++ b/src-tauri/src/cli/tui/form/provider_templates.rs @@ -157,17 +157,25 @@ static PROVIDER_TEMPLATE_DEFS_OPENCODE: [ProviderTemplateDef; 1] = [ProviderTemp label: "Custom", }]; +static PROVIDER_TEMPLATE_DEFS_HERMES: [ProviderTemplateDef; 1] = [ProviderTemplateDef { + id: ProviderTemplateId::Custom, + label: "Custom", +}]; + static PROVIDER_TEMPLATE_DEFS_OPENCLAW: [ProviderTemplateDef; 1] = [ProviderTemplateDef { id: ProviderTemplateId::Custom, label: "Custom", }]; +static SPONSOR_PROVIDER_PRESETS_HERMES: [SponsorProviderPreset; 0] = []; + pub(super) fn provider_builtin_template_defs(app_type: &AppType) -> &'static [ProviderTemplateDef] { match app_type { AppType::Claude => &PROVIDER_TEMPLATE_DEFS_CLAUDE, AppType::Codex => &PROVIDER_TEMPLATE_DEFS_CODEX, AppType::Gemini => &PROVIDER_TEMPLATE_DEFS_GEMINI, AppType::OpenCode => &PROVIDER_TEMPLATE_DEFS_OPENCODE, + AppType::Hermes => &PROVIDER_TEMPLATE_DEFS_HERMES, AppType::OpenClaw => &PROVIDER_TEMPLATE_DEFS_OPENCLAW, } } @@ -178,6 +186,7 @@ pub(super) fn provider_sponsor_presets(app_type: &AppType) -> &'static [SponsorP AppType::Codex => &SPONSOR_PROVIDER_PRESETS_CODEX, AppType::Gemini => &SPONSOR_PROVIDER_PRESETS_GEMINI, AppType::OpenCode => &SPONSOR_PROVIDER_PRESETS_OPENCODE, + AppType::Hermes => &SPONSOR_PROVIDER_PRESETS_HERMES, AppType::OpenClaw => &SPONSOR_PROVIDER_PRESETS_OPENCLAW, } } @@ -256,6 +265,11 @@ impl ProviderAddFormState { self.gemini_api_key = defaults.gemini_api_key; self.gemini_base_url = defaults.gemini_base_url; self.gemini_model = defaults.gemini_model; + self.hermes_api_mode = defaults.hermes_api_mode; + self.hermes_api_key = defaults.hermes_api_key; + self.hermes_base_url = defaults.hermes_base_url; + self.hermes_model = defaults.hermes_model; + self.hermes_models = defaults.hermes_models; self.openclaw_user_agent = defaults.openclaw_user_agent; self.openclaw_models = defaults.openclaw_models; self.opencode_npm_package = defaults.opencode_npm_package; @@ -381,6 +395,7 @@ impl ProviderAddFormState { self.opencode_model_original_id = Some("claude-opus-4.6".to_string()); } } + AppType::Hermes => {} AppType::OpenClaw => { if preset.id == "aicodemirror" { self.opencode_api_key.set(""); diff --git a/src-tauri/src/cli/tui/runtime_actions/editor.rs b/src-tauri/src/cli/tui/runtime_actions/editor.rs index 47924ddb..f8d78d1c 100644 --- a/src-tauri/src/cli/tui/runtime_actions/editor.rs +++ b/src-tauri/src/cli/tui/runtime_actions/editor.rs @@ -244,6 +244,9 @@ pub(super) fn submit( } => submit_prompt_create(ctx, id, name, description, content), EditorSubmit::PromptEdit { id } => submit_prompt_edit(ctx, id, content), EditorSubmit::ProviderFormApplyJson => submit_provider_form_apply_json(ctx, content), + EditorSubmit::ProviderFormApplyHermesModels => { + submit_provider_form_apply_hermes_models(ctx, content) + } EditorSubmit::ProviderFormApplyOpenClawModels => { submit_provider_form_apply_openclaw_models(ctx, content) } @@ -534,6 +537,17 @@ fn submit_provider_form_apply_json( let mut provider_value = form.to_provider_json_value(); if let Some(obj) = provider_value.as_object_mut() { obj.insert("settingsConfig".to_string(), settings_value.clone()); + if matches!(form.app_type, AppType::Hermes) { + if let Some(name) = settings_value + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + obj.insert("id".to_string(), json!(name)); + obj.insert("name".to_string(), json!(name)); + } + } } Some(provider_value) } @@ -604,6 +618,43 @@ fn submit_provider_form_apply_usage_script_code( Ok(()) } +fn submit_provider_form_apply_hermes_models( + ctx: &mut RuntimeActionContext<'_>, + content: String, +) -> Result<(), AppError> { + let models_value: Value = match serde_json::from_str(&content) { + Ok(value) => value, + Err(e) => { + ctx.app.push_toast( + texts::tui_toast_invalid_json(&e.to_string()), + ToastKind::Error, + ); + return Ok(()); + } + }; + + if !models_value.is_array() && !models_value.is_object() { + ctx.app.push_toast( + texts::tui_toast_json_must_be_object_or_array(), + ToastKind::Error, + ); + return Ok(()); + } + + let apply_result = match ctx.app.form.as_mut() { + Some(FormState::ProviderAdd(form)) => form.apply_hermes_models_value(models_value), + _ => Ok(()), + }; + + if let Err(err) = apply_result { + ctx.app.push_toast(err, ToastKind::Error); + return Ok(()); + } + + ctx.app.editor = None; + Ok(()) +} + fn submit_provider_form_apply_codex_auth( ctx: &mut RuntimeActionContext<'_>, content: String, diff --git a/src-tauri/src/cli/tui/runtime_actions/helpers.rs b/src-tauri/src/cli/tui/runtime_actions/helpers.rs index efc45933..57eef6b5 100644 --- a/src-tauri/src/cli/tui/runtime_actions/helpers.rs +++ b/src-tauri/src/cli/tui/runtime_actions/helpers.rs @@ -42,6 +42,7 @@ pub(crate) fn import_mcp_for_current_app(app: &mut App, data: &mut UiData) -> Re AppType::Codex => McpService::import_from_codex(&state), AppType::Gemini => McpService::import_from_gemini(&state), AppType::OpenCode => McpService::import_from_opencode(&state), + AppType::Hermes => Ok(0), AppType::OpenClaw => Ok(0), } }, @@ -68,6 +69,7 @@ pub(crate) fn app_display_name(app_type: &AppType) -> &'static str { AppType::Codex => "Codex", AppType::Gemini => "Gemini", AppType::OpenCode => "OpenCode", + AppType::Hermes => "Hermes", AppType::OpenClaw => "OpenClaw", } } diff --git a/src-tauri/src/cli/tui/runtime_actions/mod.rs b/src-tauri/src/cli/tui/runtime_actions/mod.rs index 363ea7f6..add5fecc 100644 --- a/src-tauri/src/cli/tui/runtime_actions/mod.rs +++ b/src-tauri/src/cli/tui/runtime_actions/mod.rs @@ -595,6 +595,7 @@ mod tests { codex: true, gemini: true, opencode: true, + hermes: false, openclaw: true, }) .expect("save initial visible apps"); @@ -604,6 +605,7 @@ mod tests { codex: false, gemini: false, opencode: false, + hermes: false, openclaw: false, }; let mut app = App::new(Some(AppType::OpenClaw)); @@ -663,6 +665,7 @@ mod tests { codex: true, gemini: false, opencode: true, + hermes: false, openclaw: true, }; crate::settings::set_visible_apps(initial_visible_apps.clone()) @@ -684,6 +687,7 @@ mod tests { codex: false, gemini: false, opencode: false, + hermes: false, openclaw: false, }, }, @@ -711,6 +715,7 @@ mod tests { codex: true, gemini: false, opencode: true, + hermes: false, openclaw: true, }) .expect("save initial visible apps"); @@ -721,6 +726,7 @@ mod tests { codex: false, gemini: false, opencode: true, + hermes: false, openclaw: false, }; let mut app = App::new(Some(AppType::Claude)); @@ -757,6 +763,7 @@ mod tests { codex: true, gemini: false, opencode: true, + hermes: false, openclaw: true, }; crate::settings::set_visible_apps(initial_visible_apps.clone()) @@ -775,6 +782,7 @@ mod tests { codex: false, gemini: false, opencode: false, + hermes: false, openclaw: false, }, }, diff --git a/src-tauri/src/cli/tui/tests.rs b/src-tauri/src/cli/tui/tests.rs index cee2266e..bcd8c390 100644 --- a/src-tauri/src/cli/tui/tests.rs +++ b/src-tauri/src/cli/tui/tests.rs @@ -643,6 +643,7 @@ fn startup_hidden_requested_app_bootstrap_uses_visible_app_normalization_before_ codex: true, gemini: false, opencode: true, + hermes: false, openclaw: true, }) .expect("save visible apps"); diff --git a/src-tauri/src/cli/tui/theme.rs b/src-tauri/src/cli/tui/theme.rs index 69131619..79b5c7a9 100644 --- a/src-tauri/src/cli/tui/theme.rs +++ b/src-tauri/src/cli/tui/theme.rs @@ -10,6 +10,7 @@ const DRACULA_PINK: (u8, u8, u8) = (255, 121, 198); const DRACULA_ORANGE: (u8, u8, u8) = (255, 184, 108); const DRACULA_YELLOW: (u8, u8, u8) = (241, 250, 140); const DRACULA_RED: (u8, u8, u8) = (255, 85, 85); +const HERMES_TEAL: (u8, u8, u8) = (0, 109, 112); const OPENCLAW_CORAL: (u8, u8, u8) = (255, 79, 64); const DRACULA_COMMENT: (u8, u8, u8) = (98, 114, 164); const DRACULA_SURFACE: (u8, u8, u8) = (68, 71, 90); @@ -183,6 +184,7 @@ fn accent_rgb(app: &AppType) -> (u8, u8, u8) { AppType::Claude => DRACULA_CYAN, AppType::Gemini => DRACULA_PINK, AppType::OpenCode => DRACULA_ORANGE, + AppType::Hermes => HERMES_TEAL, AppType::OpenClaw => OPENCLAW_CORAL, } } @@ -275,6 +277,21 @@ mod tests { assert_ne!(openclaw.accent, codex.accent); } + #[test] + fn hermes_theme_uses_teal_accent() { + let _lock = env_lock().lock().expect("env lock poisoned"); + let _no_color = EnvGuard::remove("NO_COLOR"); + let _color_mode = EnvGuard::remove(COLOR_MODE_ENV); + let _colorterm = EnvGuard::set("COLORTERM", "truecolor"); + let _term = EnvGuard::set("TERM", "xterm-256color"); + + let hermes = theme_for(&AppType::Hermes); + let opencode = theme_for(&AppType::OpenCode); + + assert_eq!(hermes.accent, Color::Rgb(0, 109, 112)); + assert_ne!(hermes.accent, opencode.accent); + } + #[test] fn theme_keeps_rgb_colors_when_truecolor_is_available() { let _lock = env_lock().lock().expect("env lock poisoned"); diff --git a/src-tauri/src/cli/tui/ui/forms/provider.rs b/src-tauri/src/cli/tui/ui/forms/provider.rs index 84c95b74..50be0ca9 100644 --- a/src-tauri/src/cli/tui/ui/forms/provider.rs +++ b/src-tauri/src/cli/tui/ui/forms/provider.rs @@ -6,12 +6,19 @@ fn claude_api_format_label(api_format: crate::cli::tui::form::ClaudeApiFormat) - texts::tui_claude_api_format_value(api_format.as_str()).to_string() } +fn hermes_api_mode_label(api_mode: crate::cli::tui::form::HermesApiMode) -> String { + texts::tui_hermes_api_mode_value(api_mode.as_str()).to_string() +} + fn should_redact_provider_field( provider: &super::form::ProviderAddFormState, field: ProviderAddField, ) -> bool { - matches!(provider.app_type, AppType::OpenClaw) - && matches!(field, ProviderAddField::OpenCodeApiKey) + matches!( + (&provider.app_type, field), + (&AppType::OpenClaw, ProviderAddField::OpenCodeApiKey) + | (&AppType::Hermes, ProviderAddField::HermesApiKey) + ) } fn common_json_preview_value(app_type: &AppType, common_snippet: &str) -> Option { @@ -839,6 +846,11 @@ pub(crate) fn provider_field_label_and_value( strip_trailing_colon(texts::website_url_label()).to_string() } ProviderAddField::Notes => strip_trailing_colon(texts::notes_label()).to_string(), + ProviderAddField::HermesApiMode => texts::tui_label_hermes_api_mode().to_string(), + ProviderAddField::HermesBaseUrl => texts::tui_label_base_url().to_string(), + ProviderAddField::HermesApiKey => texts::tui_label_api_key().to_string(), + ProviderAddField::HermesModel => texts::model_label().to_string(), + ProviderAddField::HermesModels => texts::tui_label_hermes_models().to_string(), ProviderAddField::ClaudeBaseUrl => texts::tui_label_base_url().to_string(), ProviderAddField::ClaudeApiFormat => texts::tui_label_claude_api_format().to_string(), ProviderAddField::ClaudeApiKey => texts::tui_label_api_key().to_string(), @@ -888,6 +900,8 @@ pub(crate) fn provider_field_label_and_value( }; let value = match field { + ProviderAddField::HermesApiMode => hermes_api_mode_label(provider.hermes_api_mode), + ProviderAddField::HermesModels => provider.hermes_models_summary(), ProviderAddField::ClaudeApiFormat => claude_api_format_label(provider.claude_api_format), ProviderAddField::CodexWireApi => provider.codex_wire_api.as_str().to_string(), ProviderAddField::CodexRequiresOpenaiAuth => { @@ -1012,6 +1026,10 @@ pub(crate) fn provider_field_editor_line( ProviderAddField::GeminiAuthType => { format!("auth_type = {}", provider.gemini_auth_type.as_str()) } + ProviderAddField::HermesApiMode => { + format!("api_mode = {}", provider.hermes_api_mode.as_str()) + } + ProviderAddField::HermesModels => texts::tui_hermes_models_open_hint().to_string(), ProviderAddField::OpenClawApiProtocol => { format!("api = {}", provider.opencode_npm_package.value.trim()) } diff --git a/src-tauri/src/cli/tui/ui/forms/shared.rs b/src-tauri/src/cli/tui/ui/forms/shared.rs index f00cea31..89bd92c5 100644 --- a/src-tauri/src/cli/tui/ui/forms/shared.rs +++ b/src-tauri/src/cli/tui/ui/forms/shared.rs @@ -35,20 +35,24 @@ pub(crate) fn add_form_key_items( ]); } else { let enter_action = match selected_field { - Some(ProviderAddField::CodexModel | ProviderAddField::GeminiModel) => { - texts::tui_key_fetch_model() - } + Some( + ProviderAddField::CodexModel + | ProviderAddField::GeminiModel + | ProviderAddField::HermesModel, + ) => texts::tui_key_fetch_model(), Some( ProviderAddField::ClaudeModelConfig | ProviderAddField::CommonSnippet | ProviderAddField::UsageQuery | ProviderAddField::OpenClawModels, + | ProviderAddField::HermesModels, ) => texts::tui_key_open(), Some( ProviderAddField::GeminiAuthType | ProviderAddField::ClaudeHideAttribution | ProviderAddField::OpenClawApiProtocol - | ProviderAddField::OpenClawUserAgent, + | ProviderAddField::OpenClawUserAgent + | ProviderAddField::HermesApiMode, ) => texts::tui_key_toggle(), _ => texts::tui_key_edit_mode(), }; diff --git a/src-tauri/src/cli/tui/ui/header_tests.rs b/src-tauri/src/cli/tui/ui/header_tests.rs index 60c5685b..c40ef21b 100644 --- a/src-tauri/src/cli/tui/ui/header_tests.rs +++ b/src-tauri/src/cli/tui/ui/header_tests.rs @@ -194,6 +194,7 @@ fn header_openclaw_sacrifices_tabs_before_losing_the_only_status_badge() { codex: true, gemini: true, opencode: true, + hermes: false, openclaw: true, }); let _lang = use_test_language(Language::English); @@ -233,6 +234,7 @@ fn header_openclaw_truncates_long_default_model_without_fake_proxy_gap() { codex: true, gemini: true, opencode: true, + hermes: false, openclaw: true, }); let _lang = use_test_language(Language::English); diff --git a/src-tauri/src/cli/tui/ui/overlay/pickers.rs b/src-tauri/src/cli/tui/ui/overlay/pickers.rs index 2d41a25e..cb48fea3 100644 --- a/src-tauri/src/cli/tui/ui/overlay/pickers.rs +++ b/src-tauri/src/cli/tui/ui/overlay/pickers.rs @@ -773,6 +773,7 @@ pub(super) fn render_mcp_apps_picker_overlay( crate::app_config::AppType::Codex, crate::app_config::AppType::Gemini, crate::app_config::AppType::OpenCode, + crate::app_config::AppType::Hermes, ], ); } @@ -848,6 +849,7 @@ pub(super) fn render_visible_apps_picker_overlay( crate::app_config::AppType::Codex, crate::app_config::AppType::Gemini, crate::app_config::AppType::OpenCode, + crate::app_config::AppType::Hermes, crate::app_config::AppType::OpenClaw, ], ); @@ -874,6 +876,7 @@ pub(super) fn render_skills_apps_picker_overlay( crate::app_config::AppType::Codex, crate::app_config::AppType::Gemini, crate::app_config::AppType::OpenCode, + crate::app_config::AppType::Hermes, ], ); } diff --git a/src-tauri/src/cli/tui/ui/tests.rs b/src-tauri/src/cli/tui/ui/tests.rs index 9d8fbd4d..63b3b618 100644 --- a/src-tauri/src/cli/tui/ui/tests.rs +++ b/src-tauri/src/cli/tui/ui/tests.rs @@ -622,6 +622,7 @@ fn installed_skill(directory: &str, name: &str) -> InstalledSkill { codex: false, gemini: false, opencode: false, + hermes: false, }, installed_at: 1, } @@ -845,6 +846,7 @@ fn header_only_renders_selected_visible_apps() { codex: true, gemini: false, opencode: false, + hermes: false, openclaw: true, }) .expect("save visible apps"); @@ -873,6 +875,7 @@ fn header_keeps_all_app_tabs_visible_with_proxy_chip() { codex: true, gemini: true, opencode: true, + hermes: false, openclaw: true, }) .expect("save visible apps"); @@ -901,6 +904,7 @@ fn settings_page_shows_visible_apps_row_value() { codex: false, gemini: true, opencode: false, + hermes: false, openclaw: true, }) .expect("save visible apps"); @@ -981,6 +985,7 @@ fn zero_selection_warning_toast_renders_after_picker_rejection() { codex: false, gemini: false, opencode: false, + hermes: false, openclaw: false, }, }; @@ -1093,6 +1098,8 @@ fn openclaw_agents_picker_overlay_marks_current_option_when_editing_existing_fal fn header_centers_tabs_when_room_allows() { let _lock = lock_env(); let _no_color = EnvGuard::remove("NO_COLOR"); + let temp_home = TempDir::new().expect("create temp home"); + let _home = SettingsEnvGuard::set_home(temp_home.path()); let app = App::new(Some(AppType::Claude)); let buf = render_with_size(&app, &minimal_data(&app.app_type), 140, 40); @@ -1634,6 +1641,7 @@ fn home_connection_card_labels_mcp_and_skills_with_active_counts() { codex: false, gemini: false, opencode: false, + hermes: false, }, installed_at: 0, }, @@ -2444,6 +2452,7 @@ fn skills_page_shows_opencode_summary() { codex: false, gemini: false, opencode: true, + hermes: false, }; data.skills.installed = vec![skill]; @@ -2471,6 +2480,7 @@ fn skill_detail_page_shows_opencode_enabled_state() { codex: false, gemini: false, opencode: true, + hermes: false, }; data.skills.installed = vec![skill]; diff --git a/src-tauri/src/cli/ui/colors.rs b/src-tauri/src/cli/ui/colors.rs index 81d6d692..9a0744a5 100644 --- a/src-tauri/src/cli/ui/colors.rs +++ b/src-tauri/src/cli/ui/colors.rs @@ -34,6 +34,7 @@ fn inquire_color_for_app(app_type: &AppType) -> InquireColor { AppType::Claude => InquireColor::LightCyan, AppType::Gemini => InquireColor::LightMagenta, AppType::OpenCode => InquireColor::LightGreen, + AppType::Hermes => InquireColor::LightYellow, AppType::OpenClaw => InquireColor::LightRed, } } @@ -85,6 +86,7 @@ fn highlight_color_for_app(app_type: &AppType) -> Color { AppType::Claude => Color::BrightCyan, AppType::Gemini => Color::BrightMagenta, AppType::OpenCode => Color::BrightGreen, + AppType::Hermes => Color::BrightYellow, AppType::OpenClaw => Color::BrightRed, } } diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs index 2254cb06..69a5e7ea 100644 --- a/src-tauri/src/database/dao/skills.rs +++ b/src-tauri/src/database/dao/skills.rs @@ -43,6 +43,7 @@ impl Database { codex: row.get(9)?, gemini: row.get(10)?, opencode: row.get(11)?, + hermes: false, }, installed_at: row.get(12)?, }) @@ -83,6 +84,7 @@ impl Database { codex: row.get(9)?, gemini: row.get(10)?, opencode: row.get(11)?, + hermes: false, }, installed_at: row.get(12)?, }) diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index c6867050..2f53392f 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -67,6 +67,7 @@ pub fn import_provider_from_deeplink( Some("codex") => Some("https://openai.com".to_string()), Some("gemini") => Some("https://ai.google.dev".to_string()), Some("opencode") => Some("https://opencode.ai".to_string()), + Some("hermes") => Some("https://hermes.sh".to_string()), _ => None, }; } @@ -139,6 +140,7 @@ fn build_provider_from_request( AppType::Codex => build_codex_settings(request), AppType::Gemini => build_gemini_settings(request), AppType::OpenCode => build_opencode_settings(request), + AppType::Hermes => build_hermes_settings(request), AppType::OpenClaw => build_openclaw_settings(request), }; @@ -329,6 +331,31 @@ fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value }) } +fn build_hermes_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("baseUrl".to_string(), json!(endpoint)); + } + if let Some(api_key) = &request.api_key { + settings.insert("apiKey".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 { @@ -434,6 +461,7 @@ pub fn parse_and_merge_config( "codex" => merge_codex_config(&mut merged, &config_value)?, "gemini" => merge_gemini_config(&mut merged, &config_value)?, "opencode" => merge_additive_config(&mut merged, &config_value)?, + "hermes" => merge_additive_config(&mut merged, &config_value)?, "openclaw" => merge_openclaw_config(&mut merged, &config_value)?, "" => return Ok(merged), other => return Err(AppError::InvalidInput(format!("Invalid app type: {other}"))), diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs new file mode 100644 index 00000000..554cd55b --- /dev/null +++ b/src-tauri/src/hermes_config.rs @@ -0,0 +1,307 @@ +use crate::config::{atomic_write, home_dir}; +use crate::error::AppError; +use crate::settings::get_hermes_override_dir; +use indexmap::IndexMap; +use serde_json::{json, Map, Value}; +use std::fs; +use std::path::PathBuf; + +fn default_config() -> Value { + json!({ + "custom_providers": [] + }) +} + +pub fn get_hermes_dir() -> PathBuf { + if let Some(override_dir) = get_hermes_override_dir() { + return override_dir; + } + + home_dir() + .map(|home| home.join(".hermes")) + .unwrap_or_else(|| PathBuf::from(".hermes")) +} + +pub fn get_hermes_config_path() -> PathBuf { + get_hermes_dir().join("config.yaml") +} + +pub fn read_hermes_config_source() -> Result, AppError> { + let path = get_hermes_config_path(); + if !path.exists() { + return Ok(None); + } + + fs::read_to_string(&path) + .map(Some) + .map_err(|e| AppError::io(&path, e)) +} + +pub fn write_hermes_config_source(source: &str) -> Result<(), AppError> { + let path = get_hermes_config_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + + atomic_write(&path, source.as_bytes()) +} + +pub fn read_hermes_config() -> Result { + let path = get_hermes_config_path(); + if !path.exists() { + return Ok(default_config()); + } + + let source = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; + let yaml_value: serde_yaml::Value = serde_yaml::from_str(&source) + .map_err(|e| AppError::Config(format!("Failed to parse Hermes config as YAML: {e}")))?; + serde_json::to_value(yaml_value).map_err(|e| AppError::JsonSerialize { source: e }) +} + +fn write_hermes_config(config: &Value) -> Result<(), AppError> { + let yaml_value = serde_yaml::to_value(config) + .map_err(|e| AppError::Config(format!("Failed to convert Hermes config to YAML: {e}")))?; + let yaml = serde_yaml::to_string(&yaml_value) + .map_err(|e| AppError::Config(format!("Failed to serialize Hermes config as YAML: {e}")))?; + write_hermes_config_source(&yaml) +} + +fn ensure_object(value: &mut Value) -> &mut Map { + if !value.is_object() { + *value = Value::Object(Map::new()); + } + value + .as_object_mut() + .expect("value should be object after normalization") +} + +fn provider_id_from_value(value: &Value) -> Option { + let object = value.as_object()?; + for key in ["name", "id", "provider"] { + let candidate = object + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + if let Some(id) = candidate { + return Some(id.to_string()); + } + } + None +} + +fn primary_model_id_from_value(value: &Value) -> Option { + value + .get("model") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| { + value + .get("models") + .and_then(Value::as_object) + .and_then(|models| models.keys().next().cloned()) + }) + .or_else(|| { + value + .get("models") + .and_then(Value::as_array) + .and_then(|models| models.first()) + .and_then(|model| model.get("id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }) +} + +fn provider_matches_model(provider: &Value, model_id: &str) -> bool { + let model_id = model_id.trim(); + if model_id.is_empty() { + return false; + } + + provider + .get("model") + .and_then(Value::as_str) + .map(str::trim) + .is_some_and(|value| value == model_id) + || provider + .get("models") + .and_then(Value::as_object) + .is_some_and(|models| models.contains_key(model_id)) + || provider + .get("models") + .and_then(Value::as_array) + .is_some_and(|models| { + models.iter().any(|model| { + model + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .is_some_and(|value| value == model_id) + }) + }) +} + +fn normalize_provider_value(id: &str, provider: Value) -> Result { + let mut provider = provider; + let object = provider.as_object_mut().ok_or_else(|| { + AppError::localized( + "provider.hermes.settings.not_object", + "Hermes 配置必须是 JSON 对象", + "Hermes configuration must be a JSON object", + ) + })?; + + let has_identifier = ["name", "id", "provider"].iter().any(|key| { + object + .get(*key) + .and_then(Value::as_str) + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + }); + if !has_identifier { + object.insert("name".to_string(), Value::String(id.to_string())); + } + + Ok(provider) +} + +pub fn get_current_provider_id() -> Result, AppError> { + let config = read_hermes_config()?; + let Some(model) = config.get("model").and_then(Value::as_object) else { + return Ok(None); + }; + + let provider_ref = model + .get("provider") + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or_default(); + + if let Some(custom_id) = provider_ref.strip_prefix("custom:") { + let custom_id = custom_id.trim(); + if !custom_id.is_empty() { + return Ok(Some(custom_id.to_string())); + } + } + + if provider_ref == "custom" { + let default_model = model + .get("default") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + if let Some(default_model) = default_model { + for (id, provider) in get_providers()? { + if provider_matches_model(&provider, default_model) { + return Ok(Some(id)); + } + } + } + } + + Ok(None) +} + +pub fn get_providers() -> Result, AppError> { + let config = read_hermes_config()?; + let custom_providers = config + .get("custom_providers") + .cloned() + .unwrap_or_else(|| Value::Array(Vec::new())); + + let mut providers = IndexMap::new(); + match custom_providers { + Value::Array(entries) => { + for entry in entries { + if let Some(id) = provider_id_from_value(&entry) { + providers.insert(id, entry); + } + } + } + Value::Object(entries) => { + for (id, entry) in entries { + providers.insert(id, entry); + } + } + _ => {} + } + + Ok(providers) +} + +pub fn set_current_provider(id: &str, provider: &Value) -> Result<(), AppError> { + let mut config = read_hermes_config()?; + let root = ensure_object(&mut config); + let model = root + .entry("model".to_string()) + .or_insert_with(|| Value::Object(Map::new())); + let model = ensure_object(model); + + model.insert( + "provider".to_string(), + Value::String(format!("custom:{id}")), + ); + if let Some(model_id) = primary_model_id_from_value(provider) { + model.insert("default".to_string(), Value::String(model_id)); + } + + write_hermes_config(&config) +} + +pub fn set_provider(id: &str, provider: Value) -> Result<(), AppError> { + let mut config = read_hermes_config()?; + let root = ensure_object(&mut config); + let normalized = normalize_provider_value(id, provider)?; + + match root + .entry("custom_providers".to_string()) + .or_insert_with(|| Value::Array(Vec::new())) + { + Value::Array(entries) => { + let mut replaced = false; + for entry in entries.iter_mut() { + if provider_id_from_value(entry).as_deref() == Some(id) { + *entry = normalized.clone(); + replaced = true; + break; + } + } + if !replaced { + entries.push(normalized); + } + } + Value::Object(entries) => { + entries.insert(id.to_string(), normalized); + } + slot => { + *slot = Value::Array(vec![normalized]); + } + } + + write_hermes_config(&config) +} + +pub fn remove_provider(id: &str) -> Result<(), AppError> { + let mut config = read_hermes_config()?; + let root = ensure_object(&mut config); + + if let Some(custom_providers) = root.get_mut("custom_providers") { + match custom_providers { + Value::Array(entries) => { + entries.retain(|entry| provider_id_from_value(entry).as_deref() != Some(id)); + } + Value::Object(entries) => { + entries.remove(id); + } + _ => { + *custom_providers = Value::Array(Vec::new()); + } + } + } + + write_hermes_config(&config) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0a7c4f64..3b3f6e6c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,6 +10,7 @@ mod deeplink; mod error; mod gemini_config; mod gemini_mcp; +pub mod hermes_config; mod import_export; mod init_status; mod mcp; diff --git a/src-tauri/src/prompt_files.rs b/src-tauri/src/prompt_files.rs index 018dee81..20552b79 100644 --- a/src-tauri/src/prompt_files.rs +++ b/src-tauri/src/prompt_files.rs @@ -6,7 +6,7 @@ use crate::config::get_claude_settings_path; use crate::error::AppError; use crate::gemini_config::get_gemini_dir; use crate::opencode_config::get_opencode_dir; -use crate::settings::get_openclaw_override_dir; +use crate::settings::{get_hermes_override_dir, get_openclaw_override_dir}; /// 返回指定应用所使用的提示词文件路径。 pub fn prompt_file_path(app: &AppType) -> Result { @@ -15,6 +15,7 @@ pub fn prompt_file_path(app: &AppType) -> Result { AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?, AppType::Gemini => get_gemini_dir(), AppType::OpenCode => get_opencode_dir(), + AppType::Hermes => get_hermes_override_dir().unwrap_or_else(default_hermes_dir), AppType::OpenClaw => get_openclaw_override_dir().unwrap_or_else(default_openclaw_dir), }; @@ -23,6 +24,7 @@ pub fn prompt_file_path(app: &AppType) -> Result { AppType::Codex => "AGENTS.md", AppType::Gemini => "GEMINI.md", AppType::OpenCode => "AGENTS.md", + AppType::Hermes => "AGENTS.md", AppType::OpenClaw => "AGENTS.md", }; @@ -35,6 +37,12 @@ fn default_openclaw_dir() -> PathBuf { .unwrap_or_else(|| PathBuf::from(".openclaw")) } +fn default_hermes_dir() -> PathBuf { + dirs::home_dir() + .map(|home| home.join(".hermes")) + .unwrap_or_else(|| PathBuf::from(".hermes")) +} + fn get_base_dir_with_fallback( primary_path: PathBuf, fallback_dir: &str, diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index a24348c6..fb03b307 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -115,7 +115,7 @@ impl ProviderType { } ProviderType::Gemini } - AppType::OpenCode | AppType::OpenClaw => ProviderType::Codex, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => ProviderType::Codex, } } @@ -165,6 +165,7 @@ pub fn get_adapter(app_type: &AppType) -> Box { AppType::Codex => Box::new(CodexAdapter::new()), AppType::Gemini => Box::new(GeminiAdapter::new()), AppType::OpenCode => Box::new(CodexAdapter::new()), + AppType::Hermes => Box::new(CodexAdapter::new()), AppType::OpenClaw => Box::new(CodexAdapter::new()), } } diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index 50531e19..18d07c26 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -252,6 +252,7 @@ impl ConfigService { Self::sync_current_provider_for_app(config, &AppType::Codex)?; Self::sync_current_provider_for_app(config, &AppType::Gemini)?; 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)?; Ok(()) } @@ -288,6 +289,7 @@ impl ConfigService { AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?, AppType::Gemini => Self::sync_gemini_live(config, ¤t_id, &provider)?, AppType::OpenCode => {} + AppType::Hermes => {} AppType::OpenClaw => {} } diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index ae4d465f..6af30cca 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -163,6 +163,7 @@ impl McpService { AppType::OpenCode => { mcp::sync_single_server_to_opencode(cfg, &server.id, &server.server)?; } + AppType::Hermes => {} AppType::OpenClaw => {} } Ok(()) @@ -187,6 +188,7 @@ impl McpService { AppType::Codex => mcp::remove_server_from_codex(id)?, AppType::Gemini => mcp::remove_server_from_gemini(id)?, AppType::OpenCode => mcp::remove_server_from_opencode(id)?, + AppType::Hermes => {} AppType::OpenClaw => {} } Ok(()) @@ -197,7 +199,7 @@ impl McpService { let servers = Self::get_all_servers(state)?; for app in AppType::all() { - if matches!(app, AppType::OpenClaw) { + if matches!(app, AppType::Hermes | AppType::OpenClaw) { continue; } diff --git a/src-tauri/src/services/provider/common_config.rs b/src-tauri/src/services/provider/common_config.rs index e8ee01b3..42b3fd08 100644 --- a/src-tauri/src/services/provider/common_config.rs +++ b/src-tauri/src/services/provider/common_config.rs @@ -284,7 +284,7 @@ fn parse_json_object_snippet( format!("Gemini 通用配置片段不是有效的 JSON:{e}"), format!("Gemini common config snippet is not valid JSON: {e}"), ), - AppType::OpenCode | AppType::OpenClaw => AppError::localized( + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => AppError::localized( "common_config.opencode.invalid_json", format!("OpenCode 通用配置片段不是有效的 JSON:{e}"), format!("OpenCode common config snippet is not valid JSON: {e}"), @@ -304,7 +304,7 @@ fn parse_json_object_snippet( "Gemini 通用配置片段必须是 JSON 对象", "Gemini common config snippet must be a JSON object", ), - AppType::OpenCode | AppType::OpenClaw => AppError::localized( + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => AppError::localized( "common_config.opencode.not_object", "OpenCode 通用配置片段必须是 JSON 对象", "OpenCode common config snippet must be a JSON object", @@ -339,7 +339,11 @@ pub(super) fn validate_common_config_snippet( } match app_type { - AppType::Claude | AppType::Gemini | AppType::OpenCode | AppType::OpenClaw => { + AppType::Claude + | AppType::Gemini + | AppType::OpenCode + | AppType::Hermes + | AppType::OpenClaw => { parse_json_object_snippet(app_type, snippet, false)?; } AppType::Codex => { @@ -395,7 +399,7 @@ pub(super) fn settings_contain_common_config( } _ => false, }, - AppType::OpenCode | AppType::OpenClaw => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => false, } } @@ -460,7 +464,7 @@ pub(super) fn apply_common_config_to_settings( } Ok(result) } - AppType::OpenCode | AppType::OpenClaw => Ok(settings.clone()), + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => Ok(settings.clone()), } } @@ -509,7 +513,7 @@ pub(super) fn remove_common_config_from_settings( } Ok(result) } - AppType::OpenCode | AppType::OpenClaw => Ok(settings.clone()), + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => Ok(settings.clone()), } } diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index d6d8c839..6767ee48 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -26,6 +26,9 @@ pub(super) enum LiveSnapshot { OpenCode { config: Option, }, + Hermes { + config_source: Option, + }, OpenClaw { config_source: Option, }, @@ -88,6 +91,14 @@ impl LiveSnapshot { delete_file(&path)?; } } + LiveSnapshot::Hermes { config_source } => { + let path = crate::hermes_config::get_hermes_config_path(); + if let Some(source) = config_source { + crate::hermes_config::write_hermes_config_source(source)?; + } else if path.exists() { + delete_file(&path)?; + } + } LiveSnapshot::OpenClaw { config_source } => { let path = crate::openclaw_config::get_openclaw_config_path(); if let Some(source) = config_source { @@ -155,6 +166,10 @@ pub(super) fn capture_live_snapshot(app_type: &AppType) -> Result { + let config_source = crate::hermes_config::read_hermes_config_source()?; + Ok(LiveSnapshot::Hermes { config_source }) + } AppType::OpenClaw => { let config_source = crate::openclaw_config::read_openclaw_config_source()?; Ok(LiveSnapshot::OpenClaw { config_source }) @@ -162,6 +177,54 @@ pub(super) fn capture_live_snapshot(app_type: &AppType) -> Result Result { + let providers = crate::hermes_config::get_providers()?; + if providers.is_empty() { + return Ok(0); + } + + let mut imported = 0usize; + let existing_ids = state.db.get_provider_ids("hermes")?; + + for (id, settings_config) in providers { + if id.trim().is_empty() { + log::warn!("Skipping Hermes provider with empty id"); + continue; + } + if existing_ids.contains(&id) { + log::debug!("Hermes provider '{id}' already exists in database, skipping"); + continue; + } + if !settings_config.is_object() { + log::warn!("Skipping Hermes 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("hermes", &provider) { + log::warn!("Failed to import Hermes provider '{id}': {err}"); + continue; + } + + imported += 1; + log::info!("Imported Hermes 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 04b9e2a9..bf6ec8a2 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -94,6 +94,7 @@ struct PostCommitAction { refresh_snapshot: bool, common_config_snippet: Option, takeover_active: bool, + activate_provider: bool, } impl ProviderService { @@ -257,6 +258,8 @@ impl ProviderService { let read_presence = || match app_type { AppType::OpenCode => crate::opencode_config::get_providers() .map(|providers| providers.contains_key(provider_id)), + AppType::Hermes => crate::hermes_config::get_providers() + .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))), _ => Ok(false), @@ -450,6 +453,12 @@ impl ProviderService { action.common_config_snippet.as_deref(), apply_common_config, )?; + if action.activate_provider && matches!(action.app_type, AppType::Hermes) { + crate::hermes_config::set_current_provider( + &action.provider.id, + &action.provider.settings_config, + )?; + } } if action.sync_mcp { // 使用 v3.7.0 统一的 MCP 同步机制,支持所有应用 @@ -701,6 +710,26 @@ impl ProviderService { } state.save()?; } + AppType::Hermes => { + let providers = crate::hermes_config::get_providers()?; + let live_after = providers.get(provider_id).cloned().ok_or_else(|| { + AppError::localized( + "hermes.live.missing_provider", + format!("Hermes live 配置中缺少供应商: {provider_id}"), + format!("Hermes 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::OpenClaw => { let providers = crate::openclaw_config::get_providers()?; let live_after = providers.get(provider_id).cloned().ok_or_else(|| { @@ -776,7 +805,7 @@ impl ProviderService { strict_current_provider_id, old_snippet, ), - AppType::OpenCode | AppType::OpenClaw => Ok(()), + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => Ok(()), }; match result { @@ -843,6 +872,7 @@ impl ProviderService { refresh_snapshot: false, common_config_snippet: config.common_config_snippets.get(app_type).cloned(), takeover_active, + activate_provider: false, })) } @@ -1405,6 +1435,10 @@ impl ProviderService { /// 获取当前供应商 ID pub fn current(state: &AppState, app_type: AppType) -> Result { + if matches!(app_type, AppType::Hermes) { + return crate::hermes_config::get_current_provider_id() + .map(|opt| opt.unwrap_or_default()); + } if app_type.is_additive_mode() { return Ok(String::new()); } @@ -1482,6 +1516,7 @@ impl ProviderService { refresh_snapshot: false, common_config_snippet, takeover_active: false, + activate_provider: false, }) } else { None @@ -1626,6 +1661,7 @@ impl ProviderService { refresh_snapshot: false, common_config_snippet, takeover_active: false, + activate_provider: false, }) } else { None @@ -1708,6 +1744,7 @@ impl ProviderService { }) } AppType::OpenCode => unreachable!("additive mode apps are handled earlier"), + AppType::Hermes => unreachable!("additive mode apps are handled earlier"), AppType::OpenClaw => unreachable!("additive mode apps are handled earlier"), }; @@ -1817,6 +1854,17 @@ impl ProviderService { } crate::opencode_config::read_opencode_config() } + AppType::Hermes => { + let config_path = crate::hermes_config::get_hermes_config_path(); + if !config_path.exists() { + return Err(AppError::localized( + "hermes.config.missing", + "Hermes 配置文件不存在", + "Hermes configuration file not found", + )); + } + crate::hermes_config::read_hermes_config() + } AppType::OpenClaw => { let config_path = crate::openclaw_config::get_openclaw_config_path(); if !config_path.exists() { @@ -1889,6 +1937,11 @@ impl ProviderService { crate::opencode_config::remove_provider(provider_id)?; } } + AppType::Hermes => { + if crate::hermes_config::get_hermes_dir().exists() { + crate::hermes_config::remove_provider(provider_id)?; + } + } AppType::OpenClaw => { if crate::openclaw_config::get_openclaw_dir().exists() { crate::openclaw_config::remove_provider(provider_id)?; @@ -2107,6 +2160,7 @@ impl ProviderService { .get(&app_type_clone) .cloned(), takeover_active: false, + activate_provider: matches!(&app_type_clone, AppType::Hermes), }; return Ok(((), Some(action))); @@ -2130,6 +2184,7 @@ impl ProviderService { effective_current_provider.as_deref(), )?, AppType::OpenCode => unreachable!("additive mode handled above"), + AppType::Hermes => unreachable!("additive mode handled above"), AppType::OpenClaw => unreachable!("additive mode handled above"), }; @@ -2141,6 +2196,7 @@ impl ProviderService { refresh_snapshot: true, common_config_snippet: config.common_config_snippets.get(&app_type_clone).cloned(), takeover_active: false, + activate_provider: false, }; Ok(((), Some(action))) @@ -2202,6 +2258,16 @@ impl ProviderService { Err(_) => crate::opencode_config::set_provider(&provider.id, config_to_write), } } + AppType::Hermes => { + if !provider.settings_config.is_object() { + return Err(AppError::localized( + "provider.hermes.settings.not_object", + "Hermes 配置必须是 JSON 对象", + "Hermes configuration must be a JSON object", + )); + } + crate::hermes_config::set_provider(&provider.id, provider.settings_config.clone()) + } AppType::OpenClaw => { let settings_config = provider.settings_config.clone(); let looks_like_provider = settings_config.get("baseUrl").is_some() @@ -2428,6 +2494,9 @@ impl ProviderService { AppType::OpenCode => Err(AppError::Config( "OpenCode does not support proxy takeover backups".into(), )), + AppType::Hermes => Err(AppError::Config( + "Hermes does not support proxy takeover backups".into(), + )), AppType::OpenClaw => Err(AppError::Config( "OpenClaw does not support proxy takeover backups".into(), )), @@ -2512,6 +2581,15 @@ impl ProviderService { )); } } + AppType::Hermes => { + if !provider.settings_config.is_object() { + return Err(AppError::localized( + "provider.hermes.settings.not_object", + "Hermes 配置必须是 JSON 对象", + "Hermes 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)?; @@ -2650,6 +2728,11 @@ impl ProviderService { crate::opencode_config::remove_provider(provider_id)?; } } + AppType::Hermes => { + if crate::hermes_config::get_hermes_dir().exists() { + crate::hermes_config::remove_provider(provider_id)?; + } + } AppType::OpenClaw => { if crate::openclaw_config::get_openclaw_dir().exists() { crate::openclaw_config::remove_provider(provider_id)?; @@ -2690,6 +2773,9 @@ impl ProviderService { AppType::OpenCode => { let _ = provider_snapshot; } + AppType::Hermes => { + let _ = provider_snapshot; + } AppType::OpenClaw => { let _ = provider_snapshot; } @@ -2726,6 +2812,10 @@ impl ProviderService { live::import_openclaw_providers_from_live(state) } + pub fn import_hermes_providers_from_live(state: &AppState) -> Result { + live::import_hermes_providers_from_live(state) + } + pub fn import_opencode_providers_from_live(state: &AppState) -> Result { live::import_opencode_providers_from_live(state) } diff --git a/src-tauri/src/services/provider/usage.rs b/src-tauri/src/services/provider/usage.rs index 38008c1e..fa14b3a0 100644 --- a/src-tauri/src/services/provider/usage.rs +++ b/src-tauri/src/services/provider/usage.rs @@ -404,6 +404,19 @@ impl ProviderService { ) }) .map(|s| s.to_string()), + AppType::Hermes => 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.hermes.api_key.missing", + "缺少 API Key", + "API key is missing", + ) + }) + .map(|s| s.to_string()), AppType::OpenClaw => provider .settings_config .get("apiKey") @@ -493,6 +506,14 @@ impl ProviderService { .and_then(|v| v.as_str()) .unwrap_or_default() .to_string()), + AppType::Hermes => Ok(provider + .settings_config + .get("baseUrl") + .or_else(|| provider.settings_config.get("baseURL")) + .or_else(|| provider.settings_config.get("endpoint")) + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string()), AppType::OpenClaw => Ok(provider .settings_config .get("baseUrl") diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index f90df1a1..07072b02 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -382,7 +382,7 @@ pub struct SkillService { impl SkillService { fn app_supports_skills(app: &AppType) -> bool { - !matches!(app, AppType::OpenClaw) + !matches!(app, AppType::Hermes | AppType::OpenClaw) } fn supported_skill_apps() -> impl Iterator { @@ -444,6 +444,11 @@ impl SkillService { return Ok(custom.join("skills")); } } + AppType::Hermes => { + if let Some(custom) = crate::settings::get_hermes_override_dir() { + return Ok(custom.join("skills")); + } + } AppType::OpenClaw => { if let Some(custom) = crate::settings::get_openclaw_override_dir() { return Ok(custom.join("skills")); @@ -464,6 +469,7 @@ impl SkillService { AppType::Codex => home.join(".codex").join("skills"), AppType::Gemini => home.join(".gemini").join("skills"), AppType::OpenCode => home.join(".config").join("opencode").join("skills"), + AppType::Hermes => home.join(".hermes").join("skills"), AppType::OpenClaw => home.join(".openclaw").join("skills"), }) } diff --git a/src-tauri/src/services/stream_check/provider_extract.rs b/src-tauri/src/services/stream_check/provider_extract.rs index 8b56de99..717b4355 100644 --- a/src-tauri/src/services/stream_check/provider_extract.rs +++ b/src-tauri/src/services/stream_check/provider_extract.rs @@ -30,6 +30,28 @@ impl StreamCheckService { .and_then(|value| value.as_object()) .and_then(|models| models.keys().next().cloned()) .unwrap_or_else(|| config.codex_model.clone()), + AppType::Hermes => provider + .settings_config + .get("model") + .and_then(|value| value.as_str()) + .map(str::to_string) + .or_else(|| { + provider + .settings_config + .get("models") + .and_then(|value| value.as_object()) + .and_then(|models| models.keys().next().cloned()) + }) + .or_else(|| { + provider + .settings_config + .get("models") + .and_then(|value| value.as_array()) + .and_then(|models| models.first()) + .and_then(|model| model.get("id").and_then(|value| value.as_str())) + .map(str::to_string) + }) + .unwrap_or_else(|| config.codex_model.clone()), AppType::OpenClaw => provider .settings_config .get("models") @@ -168,6 +190,16 @@ impl StreamCheckService { .unwrap_or_default() .trim_end_matches('/') .to_string()), + AppType::Hermes => Ok(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()) + .unwrap_or_default() + .trim_end_matches('/') + .to_string()), AppType::OpenClaw => Ok(provider .settings_config .get("baseUrl") @@ -218,6 +250,19 @@ impl StreamCheckService { "API key is missing", ) }), + AppType::Hermes => provider + .settings_config + .get("apiKey") + .or_else(|| provider.settings_config.get("api_key")) + .and_then(|value| value.as_str()) + .map(|key| AuthInfo::new(key.to_string(), AuthStrategy::Bearer)) + .ok_or_else(|| { + AppError::localized( + "provider.hermes.api_key.missing", + "缺少 API Key", + "API key is missing", + ) + }), AppType::OpenClaw => provider .settings_config .get("apiKey") diff --git a/src-tauri/src/services/stream_check/service.rs b/src-tauri/src/services/stream_check/service.rs index 7f9314a1..4bc2d8f4 100644 --- a/src-tauri/src/services/stream_check/service.rs +++ b/src-tauri/src/services/stream_check/service.rs @@ -103,8 +103,8 @@ impl StreamCheckService { provider: &Provider, config: &StreamCheckConfig, ) -> Result { - if matches!(app_type, AppType::OpenClaw) { - return Err(AppError::Message("OpenClaw 暂不支持流式检查".to_string())); + if matches!(app_type, AppType::Hermes | AppType::OpenClaw) { + return Err(AppError::Message(format!("{} 暂不支持流式检查", app_type))); } let start = Instant::now(); @@ -161,6 +161,7 @@ impl StreamCheckService { ) .await } + AppType::Hermes => unreachable!("Hermes should return unsupported earlier"), AppType::OpenClaw => unreachable!("OpenClaw should return unsupported earlier"), }; diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 4eef98db..4df3b84d 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -18,6 +18,8 @@ pub struct VisibleApps { pub gemini: bool, #[serde(default = "default_visible_app_opencode")] pub opencode: bool, + #[serde(default = "default_visible_app_hermes")] + pub hermes: bool, #[serde(default = "default_visible_app_openclaw")] pub openclaw: bool, } @@ -38,6 +40,10 @@ fn default_visible_app_opencode() -> bool { true } +fn default_visible_app_hermes() -> bool { + true +} + fn default_visible_app_openclaw() -> bool { true } @@ -48,6 +54,7 @@ pub fn default_visible_apps() -> VisibleApps { codex: true, gemini: false, opencode: true, + hermes: true, openclaw: true, } } @@ -72,6 +79,7 @@ impl VisibleApps { AppType::Codex => self.codex, AppType::Gemini => self.gemini, AppType::OpenCode => self.opencode, + AppType::Hermes => self.hermes, AppType::OpenClaw => self.openclaw, } } @@ -82,6 +90,7 @@ impl VisibleApps { AppType::Codex => self.codex = enabled, AppType::Gemini => self.gemini = enabled, AppType::OpenCode => self.opencode = enabled, + AppType::Hermes => self.hermes = enabled, AppType::OpenClaw => self.openclaw = enabled, } } @@ -103,12 +112,13 @@ impl VisibleApps { } } -fn app_order() -> [AppType; 5] { +fn app_order() -> [AppType; 6] { [ AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode, + AppType::Hermes, AppType::OpenClaw, ] } @@ -313,6 +323,8 @@ pub struct AppSettings { #[serde(default, skip_serializing_if = "Option::is_none")] pub opencode_config_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub hermes_config_dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub openclaw_config_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_claude: Option, @@ -323,6 +335,8 @@ pub struct AppSettings { #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_opencode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_provider_hermes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_openclaw: Option, #[serde(default = "default_visible_apps")] pub visible_apps: VisibleApps, @@ -369,11 +383,13 @@ impl Default for AppSettings { codex_config_dir: None, gemini_config_dir: None, opencode_config_dir: None, + hermes_config_dir: None, openclaw_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, visible_apps: default_visible_apps(), language: None, @@ -424,6 +440,13 @@ impl AppSettings { .filter(|s| !s.is_empty()) .map(|s| s.to_string()); + self.hermes_config_dir = self + .hermes_config_dir + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + self.openclaw_config_dir = self .openclaw_config_dir .as_ref() @@ -594,6 +617,14 @@ pub fn get_opencode_override_dir() -> Option { .map(|p| resolve_override_path(p)) } +pub fn get_hermes_override_dir() -> Option { + let settings = settings_store().read().ok()?; + settings + .hermes_config_dir + .as_ref() + .map(|p| resolve_override_path(p)) +} + pub fn get_openclaw_override_dir() -> Option { let settings = settings_store().read().ok()?; settings @@ -609,6 +640,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option { AppType::Codex => settings.current_provider_codex.clone(), AppType::Gemini => settings.current_provider_gemini.clone(), AppType::OpenCode => settings.current_provider_opencode.clone(), + AppType::Hermes => settings.current_provider_hermes.clone(), AppType::OpenClaw => settings.current_provider_openclaw.clone(), } } @@ -621,6 +653,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppType::Codex => settings.current_provider_codex = id.map(|value| value.to_string()), AppType::Gemini => settings.current_provider_gemini = id.map(|value| value.to_string()), AppType::OpenCode => settings.current_provider_opencode = id.map(|value| value.to_string()), + AppType::Hermes => settings.current_provider_hermes = id.map(|value| value.to_string()), AppType::OpenClaw => settings.current_provider_openclaw = id.map(|value| value.to_string()), } diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index db557b02..7ee148a4 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -127,6 +127,14 @@ impl AppState { Err(error) => log::warn!("✗ Failed to import OpenCode providers: {error}"), } + match crate::services::provider::ProviderService::import_hermes_providers_from_live(self) { + Ok(count) if count > 0 => { + log::info!("✓ Imported {count} Hermes provider(s) from live config"); + } + Ok(_) => log::debug!("○ No new Hermes providers to import"), + Err(error) => log::warn!("✗ Failed to import Hermes providers: {error}"), + } + match crate::services::provider::ProviderService::import_openclaw_providers_from_live(self) { Ok(count) if count > 0 => { @@ -225,6 +233,7 @@ fn export_db_to_multi_app_config(db: &Database) -> Result Result config.prompts.codex.prompts = prompts.into_iter().collect(), AppType::Gemini => config.prompts.gemini.prompts = prompts.into_iter().collect(), AppType::OpenCode => config.prompts.opencode.prompts = prompts.into_iter().collect(), + AppType::Hermes => config.prompts.hermes.prompts = prompts.into_iter().collect(), AppType::OpenClaw => config.prompts.openclaw.prompts = prompts.into_iter().collect(), } @@ -276,6 +286,7 @@ fn persist_multi_app_config_to_db_preserving_current_providers( AppType::Codex, AppType::Gemini, AppType::OpenCode, + AppType::Hermes, AppType::OpenClaw, ] { let app_key = app.as_str(); diff --git a/src-tauri/src/sync_policy.rs b/src-tauri/src/sync_policy.rs index 7eb92699..a744d11f 100644 --- a/src-tauri/src/sync_policy.rs +++ b/src-tauri/src/sync_policy.rs @@ -20,6 +20,8 @@ pub(crate) fn should_sync_live(app_type: &AppType) -> bool { AppType::Gemini => crate::gemini_config::get_gemini_dir().exists(), // OpenCode is considered initialized if ~/.config/opencode (or override dir) exists. AppType::OpenCode => crate::opencode_config::get_opencode_dir().exists(), + // Hermes is considered initialized if ~/.hermes (or override dir) exists. + AppType::Hermes => crate::hermes_config::get_hermes_dir().exists(), // OpenClaw is considered initialized if ~/.openclaw (or override dir) exists. AppType::OpenClaw => get_openclaw_dir().exists(), } diff --git a/src-tauri/tests/app_config_load.rs b/src-tauri/tests/app_config_load.rs index 7a33410d..1099b222 100644 --- a/src-tauri/tests/app_config_load.rs +++ b/src-tauri/tests/app_config_load.rs @@ -146,6 +146,19 @@ fn default_config_contains_openclaw_prompt_root_and_manager() { ); } +#[test] +fn default_config_contains_hermes_prompt_root_and_manager() { + let config = MultiAppConfig::default(); + + assert!(config + .get_manager(&cc_switch_lib::AppType::Hermes) + .is_some()); + assert!( + config.prompts.hermes.prompts.is_empty(), + "default Hermes prompt store should exist" + ); +} + #[test] fn update_settings_persists_openclaw_override_dir() { let _guard = lock_test_mutex(); @@ -168,6 +181,28 @@ fn update_settings_persists_openclaw_override_dir() { ); } +#[test] +fn update_settings_persists_hermes_override_dir() { + let _guard = lock_test_mutex(); + reset_test_fs(); + let home = ensure_test_home(); + let _config_dir = ConfigDirEnvGuard::set(None); + + let mut settings = AppSettings::default(); + settings.hermes_config_dir = Some("~/custom-hermes".to_string()); + update_settings(settings).expect("save settings with hermes override"); + + let path = home.join(".cc-switch").join("settings.json"); + let raw = fs::read_to_string(&path).expect("read settings.json"); + let value: serde_json::Value = serde_json::from_str(&raw).expect("parse settings.json"); + assert_eq!( + value + .get("hermesConfigDir") + .and_then(|entry| entry.as_str()), + Some("~/custom-hermes") + ); +} + #[test] fn update_settings_uses_cc_switch_config_dir_override_for_settings_path() { let _guard = lock_test_mutex(); diff --git a/src-tauri/tests/app_type_parse.rs b/src-tauri/tests/app_type_parse.rs index d86943ac..0571116f 100644 --- a/src-tauri/tests/app_type_parse.rs +++ b/src-tauri/tests/app_type_parse.rs @@ -6,6 +6,7 @@ use cc_switch_lib::AppType; fn parse_known_apps_case_insensitive_and_trim() { assert!(matches!(AppType::from_str("claude"), Ok(AppType::Claude))); assert!(matches!(AppType::from_str("codex"), Ok(AppType::Codex))); + assert!(matches!(AppType::from_str("hermes"), Ok(AppType::Hermes))); assert!(matches!( AppType::from_str("openclaw"), Ok(AppType::OpenClaw) @@ -15,6 +16,10 @@ fn parse_known_apps_case_insensitive_and_trim() { Ok(AppType::Claude) )); assert!(matches!(AppType::from_str("\tcoDeX\t"), Ok(AppType::Codex))); + assert!(matches!( + AppType::from_str(" HeRmEs\t"), + Ok(AppType::Hermes) + )); assert!(matches!( AppType::from_str("\nOpenClaw\t"), Ok(AppType::OpenClaw) @@ -27,6 +32,12 @@ fn openclaw_is_listed_and_uses_additive_mode() { assert!(AppType::OpenClaw.is_additive_mode()); } +#[test] +fn hermes_is_listed_and_uses_additive_mode() { + assert!(AppType::all().any(|app| app == AppType::Hermes)); + assert!(AppType::Hermes.is_additive_mode()); +} + #[test] fn parse_unknown_app_returns_localized_error_message() { let err = AppType::from_str("unknown").unwrap_err(); diff --git a/src-tauri/tests/settings_current_provider.rs b/src-tauri/tests/settings_current_provider.rs index 8e0df568..5d5db97a 100644 --- a/src-tauri/tests/settings_current_provider.rs +++ b/src-tauri/tests/settings_current_provider.rs @@ -10,6 +10,7 @@ mod app_config { Gemini, OpenCode, OpenClaw, + Hermes, } impl AppType { @@ -20,6 +21,7 @@ mod app_config { AppType::Gemini => "gemini", AppType::OpenCode => "opencode", AppType::OpenClaw => "openclaw", + AppType::Hermes => "hermes", } } } @@ -316,3 +318,43 @@ fn settings_current_provider_openclaw_falls_back_to_db_when_cleanup_fails() { Some("db-openclaw") ); } + +#[test] +#[serial] +fn settings_current_provider_hermes_matches_upstream_placeholder_behavior() { + let _home = HomeGuard::new(); + + set_current_provider(&AppType::Hermes, Some("local-hermes")) + .expect("store local hermes provider placeholder"); + assert_eq!( + get_current_provider(&AppType::Hermes).as_deref(), + Some("local-hermes") + ); + + let mut db = Database::default(); + db.insert_provider("hermes", "local-hermes"); + db.set_db_current("hermes", "db-hermes"); + + assert_eq!( + get_effective_current_provider(&db, &AppType::Hermes) + .expect("resolve effective hermes provider") + .as_deref(), + Some("local-hermes"), + "existing local placeholder should win while it still exists in the database" + ); + + set_current_provider(&AppType::Hermes, Some("missing-hermes")) + .expect("overwrite local hermes placeholder"); + assert_eq!( + get_effective_current_provider(&db, &AppType::Hermes) + .expect("fallback to database current provider") + .as_deref(), + Some("db-hermes"), + "missing local placeholder should be cleared and fall back to database current" + ); + assert_eq!( + get_current_provider(&AppType::Hermes), + None, + "invalid local placeholder should be removed after fallback" + ); +} diff --git a/src-tauri/tests/settings_visible_apps.rs b/src-tauri/tests/settings_visible_apps.rs index e7784e84..f5f0f80e 100644 --- a/src-tauri/tests/settings_visible_apps.rs +++ b/src-tauri/tests/settings_visible_apps.rs @@ -13,6 +13,7 @@ mod app_config { Gemini, OpenCode, OpenClaw, + Hermes, } impl AppType { @@ -23,6 +24,7 @@ mod app_config { AppType::Gemini => "gemini", AppType::OpenCode => "opencode", AppType::OpenClaw => "openclaw", + AppType::Hermes => "hermes", } } } @@ -292,6 +294,7 @@ fn default_visible_apps_hide_gemini() { AppType::Codex, AppType::OpenCode, AppType::OpenClaw, + AppType::Hermes, ] ); assert!(!visible.is_enabled_for(&AppType::Gemini)); @@ -308,6 +311,7 @@ fn set_visible_apps_persists_visible_apps_as_camel_case_json() { gemini: true, opencode: false, openclaw: true, + hermes: true, }) .expect("persist visible apps"); @@ -324,6 +328,7 @@ fn set_visible_apps_persists_visible_apps_as_camel_case_json() { "gemini": true, "opencode": false, "openclaw": true, + "hermes": true, }) ); } @@ -341,6 +346,7 @@ fn load_reads_valid_non_default_visible_apps_from_settings_json() { "gemini": true, "opencode": true, "openclaw": false, + "hermes": true, } }), ); @@ -356,11 +362,17 @@ fn load_reads_valid_non_default_visible_apps_from_settings_json() { gemini: true, opencode: true, openclaw: false, + hermes: true, } ); assert_eq!( visible.ordered_enabled(), - vec![AppType::Codex, AppType::Gemini, AppType::OpenCode] + vec![ + AppType::Codex, + AppType::Gemini, + AppType::OpenCode, + AppType::Hermes + ] ); } @@ -387,6 +399,7 @@ fn load_partial_visible_apps_object_uses_defaults_for_missing_keys() { gemini: false, opencode: true, openclaw: true, + hermes: true, } ); } @@ -423,6 +436,7 @@ fn set_visible_apps_rejects_zero_selection() { gemini: false, opencode: false, openclaw: false, + hermes: false, }) .expect_err("zero visible apps should be rejected"); @@ -444,6 +458,7 @@ fn update_settings_rejects_all_false_visible_apps() { gemini: false, opencode: false, openclaw: false, + hermes: false, }; let err = @@ -491,7 +506,8 @@ fn load_normalizes_all_false_visible_apps_to_defaults() { "codex": false, "gemini": false, "opencode": false, - "openclaw": false + "openclaw": false, + "hermes": false } }), ); @@ -535,6 +551,7 @@ fn next_visible_app_wraps_and_skips_hidden_entries() { gemini: false, opencode: true, openclaw: true, + hermes: true, }; assert_eq!( @@ -543,10 +560,18 @@ fn next_visible_app_wraps_and_skips_hidden_entries() { ); assert_eq!( next_visible_app(&visible, &AppType::OpenClaw, 1), + Some(AppType::Hermes) + ); + assert_eq!( + next_visible_app(&visible, &AppType::Hermes, 1), Some(AppType::Claude) ); assert_eq!( next_visible_app(&visible, &AppType::Claude, -1), + Some(AppType::Hermes) + ); + assert_eq!( + next_visible_app(&visible, &AppType::Hermes, -1), Some(AppType::OpenClaw) ); assert_eq!( From 4fd1368c52c2fb0191844aede1f4d7edbcd27b6a Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 17 May 2026 22:36:42 +0800 Subject: [PATCH 2/2] feat(hermes): enhance Hermes configuration, TUI/CLI support, and memory management - Enhance Hermes configuration and CLI commands - Add Hermes app support in TUI and CLI - Implement memory management features in TUI and CLI - Update app type handling and test adjustments fix(tests): update key handling and remove redundant serial attributes - Change key handling in tests from 'x' to ' ' (space) for better action validation - Remove redundant #[serial(home_settings)] attributes from tests to simplify test execution --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 2 +- src-tauri/src/cli/commands/config_common.rs | 2 +- src-tauri/src/cli/commands/hermes.rs | 213 +++ src-tauri/src/cli/commands/mcp.rs | 4 +- src-tauri/src/cli/commands/mod.rs | 1 + src-tauri/src/cli/commands/provider_input.rs | 2 +- src-tauri/src/cli/i18n.rs | 59 +- .../src/cli/i18n/texts/config_actions.rs | 14 +- src-tauri/src/cli/i18n/texts/providers.rs | 8 + src-tauri/src/cli/mod.rs | 4 + src-tauri/src/cli/tui/app.rs | 1 + src-tauri/src/cli/tui/app/app_state.rs | 8 + src-tauri/src/cli/tui/app/editor_state.rs | 3 + .../src/cli/tui/app/form_handlers/mcp.rs | 1 + .../src/cli/tui/app/form_handlers/provider.rs | 19 + src-tauri/src/cli/tui/app/menu.rs | 45 + .../cli/tui/app/overlay_handlers/pickers.rs | 4 +- src-tauri/src/cli/tui/app/tests.rs | 173 ++- src-tauri/src/cli/tui/data.rs | 43 + src-tauri/src/cli/tui/form.rs | 9 +- src-tauri/src/cli/tui/form/mcp.rs | 8 +- src-tauri/src/cli/tui/form/provider_json.rs | 48 +- src-tauri/src/cli/tui/form/provider_state.rs | 176 ++- .../cli/tui/form/provider_state_loading.rs | 73 +- .../src/cli/tui/form/provider_templates.rs | 1 + src-tauri/src/cli/tui/form/tests.rs | 141 ++ src-tauri/src/cli/tui/route.rs | 16 + .../src/cli/tui/runtime_actions/config.rs | 104 +- .../src/cli/tui/runtime_actions/editor.rs | 43 +- .../src/cli/tui/runtime_actions/helpers.rs | 2 +- src-tauri/src/cli/tui/runtime_actions/mod.rs | 4 + .../src/cli/tui/runtime_systems/handlers.rs | 57 + src-tauri/src/cli/tui/ui.rs | 9 + src-tauri/src/cli/tui/ui/chrome.rs | 3 + src-tauri/src/cli/tui/ui/forms/mcp.rs | 15 +- src-tauri/src/cli/tui/ui/forms/provider.rs | 5 +- src-tauri/src/cli/tui/ui/forms/shared.rs | 2 +- src-tauri/src/cli/tui/ui/hermes_memory.rs | 107 ++ src-tauri/src/cli/tui/ui/mcp.rs | 12 + src-tauri/src/cli/tui/ui/skills/helpers.rs | 3 + src-tauri/src/cli/tui/ui/skills/installed.rs | 10 + src-tauri/src/cli/tui/ui/tests.rs | 159 ++- src-tauri/src/database/dao/skills.rs | 21 +- src-tauri/src/hermes_config.rs | 1258 +++++++++++++++-- src-tauri/src/main.rs | 1 + src-tauri/src/mcp.rs | 427 ++++++ src-tauri/src/services/mcp.rs | 17 +- src-tauri/src/services/provider/mod.rs | 3 +- src-tauri/src/services/skill.rs | 4 +- 50 files changed, 3081 insertions(+), 264 deletions(-) create mode 100644 src-tauri/src/cli/commands/hermes.rs create mode 100644 src-tauri/src/cli/tui/ui/hermes_memory.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c3a1ed04..f8aa24c3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3008,6 +3008,7 @@ version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ + "indexmap", "itoa", "memchr", "ryu", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f6733acc..9f91c41d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,7 +24,7 @@ test-hooks = [] [dependencies] # Core dependencies -serde_json = "1.0" +serde_json = { version = "1.0", features = ["preserve_order"] } serde = { version = "1.0", features = ["derive"] } log = "0.4" chrono = { version = "0.4", features = ["serde"] } diff --git a/src-tauri/src/cli/commands/config_common.rs b/src-tauri/src/cli/commands/config_common.rs index 90047682..82d3738b 100644 --- a/src-tauri/src/cli/commands/config_common.rs +++ b/src-tauri/src/cli/commands/config_common.rs @@ -179,7 +179,7 @@ fn canonical_common_snippet(app_type: AppType, raw: &str) -> Result, + }, + /// Clear a memory file (writes empty content) + Clear { + /// Memory kind to clear + #[arg(value_enum)] + kind: MemoryKindArg, + /// Confirm the destructive operation + #[arg(long)] + yes: bool, + }, + /// Enable a memory blob (writes `memory_enabled` / `user_profile_enabled = true`) + Enable { + /// Memory kind to enable + #[arg(value_enum)] + kind: MemoryKindArg, + }, + /// Disable a memory blob (writes the corresponding `*_enabled = false`) + Disable { + /// Memory kind to disable + #[arg(value_enum)] + kind: MemoryKindArg, + }, + /// Show character limits and enable flags for both memory blobs + Limits, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum MemoryKindArg { + Memory, + User, +} + +impl From for MemoryKind { + fn from(value: MemoryKindArg) -> Self { + match value { + MemoryKindArg::Memory => MemoryKind::Memory, + MemoryKindArg::User => MemoryKind::User, + } + } +} + +fn ensure_hermes_dir_exists() -> Result<(), AppError> { + if get_hermes_dir().exists() { + return Ok(()); + } + Err(AppError::localized( + "hermes.dir.missing", + format!("Hermes 配置目录不存在:{}", get_hermes_dir().display()), + format!( + "Hermes config dir not found: {}", + get_hermes_dir().display() + ), + )) +} + +pub fn execute(cmd: HermesCommand) -> Result<(), AppError> { + match cmd { + HermesCommand::Memory(memory_cmd) => execute_memory(memory_cmd), + } +} + +fn execute_memory(cmd: MemoryCommand) -> Result<(), AppError> { + ensure_hermes_dir_exists()?; + match cmd { + MemoryCommand::Show { kind } => show_memory(kind.into()), + MemoryCommand::Set { kind, content } => set_memory_cmd(kind.into(), content), + MemoryCommand::Clear { kind, yes } => clear_memory(kind.into(), yes), + MemoryCommand::Enable { kind } => toggle_memory(kind.into(), true), + MemoryCommand::Disable { kind } => toggle_memory(kind.into(), false), + MemoryCommand::Limits => print_limits(), + } +} + +fn show_memory(kind: MemoryKind) -> Result<(), AppError> { + let content = read_memory(kind)?; + if content.is_empty() { + println!( + "{}", + info(&format!( + "Hermes {} memory is empty (file not created yet)", + kind.as_str() + )) + ); + } else { + print!("{content}"); + if !content.ends_with('\n') { + println!(); + } + } + Ok(()) +} + +fn set_memory_cmd(kind: MemoryKind, content: Option) -> Result<(), AppError> { + let content = match content { + Some(value) => value, + None => { + let mut buf = String::new(); + io::stdin() + .read_to_string(&mut buf) + .map_err(|e| AppError::Config(format!("Failed to read stdin: {e}")))?; + buf + } + }; + + write_memory(kind, &content)?; + println!( + "{}", + success(&format!( + "✓ Wrote {} bytes to Hermes {} memory", + content.len(), + kind.as_str() + )) + ); + Ok(()) +} + +fn clear_memory(kind: MemoryKind, yes: bool) -> Result<(), AppError> { + if !yes { + println!( + "{}", + warning(&format!( + "Refusing to clear Hermes {} memory without --yes", + kind.as_str() + )) + ); + return Ok(()); + } + write_memory(kind, "")?; + println!( + "{}", + success(&format!("✓ Cleared Hermes {} memory", kind.as_str())) + ); + Ok(()) +} + +fn toggle_memory(kind: MemoryKind, enabled: bool) -> Result<(), AppError> { + set_memory_enabled(kind, enabled)?; + let verb = if enabled { "Enabled" } else { "Disabled" }; + println!( + "{}", + success(&format!("✓ {verb} Hermes {} memory", kind.as_str())) + ); + Ok(()) +} + +fn print_limits() -> Result<(), AppError> { + let limits = read_memory_limits()?; + println!("Hermes memory limits:"); + println!( + " memory: {} chars (enabled: {})", + limits.memory, limits.memory_enabled + ); + println!( + " user: {} chars (enabled: {})", + limits.user, limits.user_enabled + ); + + let memory_len = read_memory(MemoryKind::Memory) + .map(|s| s.len()) + .unwrap_or(0); + let user_len = read_memory(MemoryKind::User).map(|s| s.len()).unwrap_or(0); + + println!(); + println!("Current usage (file size in bytes; Hermes truncates at character budget on load):"); + println!(" memory: {memory_len} bytes"); + println!(" user: {user_len} bytes"); + + // Avoid `unused import` if the helper isn't used elsewhere in this file. + let _ = hermes_config::get_hermes_config_path(); + Ok(()) +} diff --git a/src-tauri/src/cli/commands/mcp.rs b/src-tauri/src/cli/commands/mcp.rs index 578bed8a..e7dcfa5c 100644 --- a/src-tauri/src/cli/commands/mcp.rs +++ b/src-tauri/src/cli/commands/mcp.rs @@ -264,8 +264,8 @@ fn import_servers(app_type: AppType) -> Result<(), AppError> { AppType::Claude => McpService::import_from_claude(&state)?, AppType::Codex => McpService::import_from_codex(&state)?, AppType::Gemini => McpService::import_from_gemini(&state)?, - AppType::OpenCode => 0, - AppType::Hermes => 0, + AppType::OpenCode => McpService::import_from_opencode(&state)?, + AppType::Hermes => McpService::import_from_hermes(&state)?, AppType::OpenClaw => 0, }; diff --git a/src-tauri/src/cli/commands/mod.rs b/src-tauri/src/cli/commands/mod.rs index d958aeef..33595c80 100644 --- a/src-tauri/src/cli/commands/mod.rs +++ b/src-tauri/src/cli/commands/mod.rs @@ -4,6 +4,7 @@ mod config_common; pub mod config_webdav; pub mod env; pub mod failover; +pub mod hermes; pub mod internal; pub mod mcp; pub mod prompts; diff --git a/src-tauri/src/cli/commands/provider_input.rs b/src-tauri/src/cli/commands/provider_input.rs index 1cf962bb..3f6ac1f6 100644 --- a/src-tauri/src/cli/commands/provider_input.rs +++ b/src-tauri/src/cli/commands/provider_input.rs @@ -44,7 +44,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::OpenClaw => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => false, } } diff --git a/src-tauri/src/cli/i18n.rs b/src-tauri/src/cli/i18n.rs index 848144cd..e508d83e 100644 --- a/src-tauri/src/cli/i18n.rs +++ b/src-tauri/src/cli/i18n.rs @@ -1654,6 +1654,22 @@ pub mod texts { } } + pub fn tui_label_hermes_rate_limit_delay() -> &'static str { + if is_chinese() { + "限流间隔(秒)" + } else { + "Rate Limit Delay (s)" + } + } + + pub fn tui_hint_hermes_rate_limit_delay() -> &'static str { + if is_chinese() { + "供应商最低请求间隔,可填小数(例如 0.5),留空关闭。" + } else { + "Minimum delay between requests for this provider; decimals allowed (e.g. 0.5). Leave blank to disable." + } + } + pub fn tui_hermes_api_mode_value(api_mode: &str) -> &'static str { match api_mode { "codex_responses" => { @@ -1843,9 +1859,17 @@ pub mod texts { pub fn tui_hermes_models_open_hint() -> &'static str { if is_chinese() { - "按 Enter 编辑 Hermes 模型列表" + "Enter 编辑 / F 从 API 拉取模型" + } else { + "Enter to edit · F to fetch from API" + } + } + + pub fn tui_toast_hermes_models_fetched(added: usize, fetched: usize, total: usize) -> String { + if is_chinese() { + format!("已获取 {fetched} 个模型(新增 {added},当前共 {total})") } else { - "Press Enter to edit Hermes models" + format!("Fetched {fetched} model(s) ({added} new, {total} total)") } } @@ -2015,6 +2039,14 @@ pub mod texts { } } + pub fn tui_label_app_hermes() -> &'static str { + if is_chinese() { + "应用: Hermes" + } else { + "App: Hermes" + } + } + pub fn tui_form_templates_title() -> &'static str { if is_chinese() { "模板" @@ -3689,14 +3721,15 @@ pub mod texts { codex: usize, gemini: usize, opencode: usize, + hermes: usize, ) -> String { if is_chinese() { format!( - "已安装 · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode}" + "已安装 · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · Hermes: {hermes}" ) } else { format!( - "Installed · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode}" + "Installed · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · Hermes: {hermes}" ) } } @@ -3706,14 +3739,15 @@ pub mod texts { codex: usize, gemini: usize, opencode: usize, + hermes: usize, ) -> String { if is_chinese() { format!( - "已安装 · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode}" + "已安装 · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · Hermes: {hermes}" ) } else { format!( - "Installed · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode}" + "Installed · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · Hermes: {hermes}" ) } } @@ -6491,6 +6525,19 @@ pub mod texts { ("🤖 Agents Config", "🤖 Agents 配置") } + pub fn menu_hermes_memory() -> &'static str { + let (en, zh) = menu_hermes_memory_variants(); + if is_chinese() { + zh + } else { + en + } + } + + pub fn menu_hermes_memory_variants() -> (&'static str, &'static str) { + ("🧠 Memory", "🧠 记忆管理") + } + pub fn menu_settings() -> &'static str { let (en, zh) = menu_settings_variants(); if is_chinese() { diff --git a/src-tauri/src/cli/i18n/texts/config_actions.rs b/src-tauri/src/cli/i18n/texts/config_actions.rs index 1ae3e7e3..ecaa99f3 100644 --- a/src-tauri/src/cli/i18n/texts/config_actions.rs +++ b/src-tauri/src/cli/i18n/texts/config_actions.rs @@ -315,14 +315,15 @@ pub fn tui_skills_installed_counts( codex: usize, gemini: usize, opencode: usize, + hermes: usize, ) -> String { if is_chinese() { format!( - "已安装 · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode}" + "已安装 · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · Hermes: {hermes}" ) } else { format!( - "Installed · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode}" + "Installed · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · Hermes: {hermes}" ) } } @@ -332,14 +333,15 @@ pub fn tui_mcp_server_counts( codex: usize, gemini: usize, opencode: usize, + hermes: usize, ) -> String { if is_chinese() { format!( - "已安装 · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode}" + "已安装 · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · Hermes: {hermes}" ) } else { format!( - "Installed · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode}" + "Installed · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · Hermes: {hermes}" ) } } @@ -898,9 +900,7 @@ pub fn tui_confirm_import_prompt_title() -> &'static str { pub fn tui_confirm_import_prompt_message(filename: &str) -> String { if is_chinese() { - format!( - "当前提示词列表为空,检测到已有 {filename}。是否把它作为新提示词打开编辑?" - ) + format!("当前提示词列表为空,检测到已有 {filename}。是否把它作为新提示词打开编辑?") } else { format!( "The prompt list is empty and {filename} already exists. Open it as a new editable prompt?" diff --git a/src-tauri/src/cli/i18n/texts/providers.rs b/src-tauri/src/cli/i18n/texts/providers.rs index aa99b09f..8b0d6348 100644 --- a/src-tauri/src/cli/i18n/texts/providers.rs +++ b/src-tauri/src/cli/i18n/texts/providers.rs @@ -178,6 +178,14 @@ pub fn tui_label_app_opencode() -> &'static str { } } +pub fn tui_label_app_hermes() -> &'static str { + if is_chinese() { + "应用: Hermes" + } else { + "App: Hermes" + } +} + pub fn tui_form_templates_title() -> &'static str { if is_chinese() { "模板" diff --git a/src-tauri/src/cli/mod.rs b/src-tauri/src/cli/mod.rs index c66572bb..a5b61811 100644 --- a/src-tauri/src/cli/mod.rs +++ b/src-tauri/src/cli/mod.rs @@ -65,6 +65,10 @@ pub enum Commands { #[command(subcommand)] Failover(commands::failover::FailoverCommand), + /// Hermes-specific commands (memory blobs etc.) + #[command(subcommand)] + Hermes(commands::hermes::HermesCommand), + /// Start an app with a provider selector without switching the global current provider #[cfg(unix)] #[command(subcommand)] diff --git a/src-tauri/src/cli/tui/app.rs b/src-tauri/src/cli/tui/app.rs index f12e40b6..7d8bb97b 100644 --- a/src-tauri/src/cli/tui/app.rs +++ b/src-tauri/src/cli/tui/app.rs @@ -7,6 +7,7 @@ use crate::app_config::AppType; use crate::cli::i18n::current_language; use crate::cli::i18n::texts; use crate::cli::i18n::Language; +use crate::hermes_config::MemoryKind; use crate::services::skill::SyncMethod; use super::data::UiData; diff --git a/src-tauri/src/cli/tui/app/app_state.rs b/src-tauri/src/cli/tui/app/app_state.rs index 5f73008c..f0cf3ecd 100644 --- a/src-tauri/src/cli/tui/app/app_state.rs +++ b/src-tauri/src/cli/tui/app/app_state.rs @@ -164,6 +164,13 @@ pub enum Action { OpenClawDailyMemoryOpenFile { filename: String, }, + HermesMemoryOpen { + kind: MemoryKind, + }, + HermesMemorySetEnabled { + kind: MemoryKind, + enabled: bool, + }, OpenClawDailyMemorySearch { query: String, }, @@ -505,6 +512,7 @@ pub struct App { pub config_idx: usize, pub workspace_idx: usize, pub daily_memory_idx: usize, + pub hermes_memory_idx: usize, pub openclaw_tools_form: Option, pub openclaw_agents_form: Option, pub openclaw_daily_memory_search_query: String, diff --git a/src-tauri/src/cli/tui/app/editor_state.rs b/src-tauri/src/cli/tui/app/editor_state.rs index 6468c2f6..224890b9 100644 --- a/src-tauri/src/cli/tui/app/editor_state.rs +++ b/src-tauri/src/cli/tui/app/editor_state.rs @@ -41,6 +41,9 @@ pub enum EditorSubmit { OpenClawDailyMemoryFile { filename: String, }, + HermesMemory { + kind: MemoryKind, + }, ConfigOpenClawEnv, ConfigOpenClawTools, ConfigOpenClawAgents, diff --git a/src-tauri/src/cli/tui/app/form_handlers/mcp.rs b/src-tauri/src/cli/tui/app/form_handlers/mcp.rs index 890ca444..5c3e11dd 100644 --- a/src-tauri/src/cli/tui/app/form_handlers/mcp.rs +++ b/src-tauri/src/cli/tui/app/form_handlers/mcp.rs @@ -146,6 +146,7 @@ impl App { McpAddField::AppCodex => mcp.apps.codex = !mcp.apps.codex, McpAddField::AppGemini => mcp.apps.gemini = !mcp.apps.gemini, McpAddField::AppOpenCode => mcp.apps.opencode = !mcp.apps.opencode, + McpAddField::AppHermes => mcp.apps.hermes = !mcp.apps.hermes, _ => { if selected == McpAddField::Id && mcp.locked_id().is_some() { return Some(Action::None); diff --git a/src-tauri/src/cli/tui/app/form_handlers/provider.rs b/src-tauri/src/cli/tui/app/form_handlers/provider.rs index df39c847..2987a386 100644 --- a/src-tauri/src/cli/tui/app/form_handlers/provider.rs +++ b/src-tauri/src/cli/tui/app/form_handlers/provider.rs @@ -321,6 +321,25 @@ impl App { if let Some(editor) = self.editor.as_mut() { editor.mode = EditorMode::Edit; } + return Action::None; + } + // `f` / `F` triggers "Fetch models from API", equivalent to + // upstream `HermesFormFields`' Fetch Models button. All + // fetched model ids are merged into `hermes_models`; + // existing ids are not duplicated. + if matches!(key.code, KeyCode::Char('f') | KeyCode::Char('F')) { + let Some(FormState::ProviderAdd(provider)) = self.form.as_ref() else { + return Action::None; + }; + let api_key = (!provider.hermes_api_key.value.trim().is_empty()) + .then(|| provider.hermes_api_key.value.clone()); + let base_url = provider.hermes_base_url.value.clone(); + return Action::ProviderModelFetch { + base_url, + api_key, + field: ProviderAddField::HermesModels, + claude_idx: None, + }; } Action::None } diff --git a/src-tauri/src/cli/tui/app/menu.rs b/src-tauri/src/cli/tui/app/menu.rs index 159211d9..f40cda62 100644 --- a/src-tauri/src/cli/tui/app/menu.rs +++ b/src-tauri/src/cli/tui/app/menu.rs @@ -56,6 +56,7 @@ impl App { config_idx: 0, workspace_idx: 0, daily_memory_idx: 0, + hermes_memory_idx: 0, openclaw_tools_form: None, openclaw_agents_form: None, openclaw_daily_memory_search_query: String::new(), @@ -114,6 +115,13 @@ impl App { NavItem::Config } } + Route::HermesMemory => { + if matches!(app_type, AppType::Hermes) { + NavItem::HermesMemory + } else { + NavItem::Config + } + } Route::ConfigWebDav => NavItem::Config, Route::Skills | Route::SkillsDiscover @@ -494,6 +502,7 @@ impl App { Route::ConfigOpenClawEnv => self.on_config_openclaw_env_key(key, data), Route::ConfigOpenClawTools => self.on_config_openclaw_tools_key(key, data), Route::ConfigOpenClawAgents => self.on_config_openclaw_agents_key(key, data), + Route::HermesMemory => self.on_hermes_memory_key(key, data), Route::ConfigWebDav => self.on_config_webdav_key(key, data), Route::Skills => self.on_skills_installed_key(key, data), Route::SkillsDiscover => self.on_skills_discover_key(key), @@ -508,6 +517,40 @@ impl App { }, } } + + fn on_hermes_memory_key(&mut self, key: KeyEvent, data: &UiData) -> Action { + match key.code { + KeyCode::Up => { + self.hermes_memory_idx = self.hermes_memory_idx.saturating_sub(1); + Action::None + } + KeyCode::Down => { + self.hermes_memory_idx = (self.hermes_memory_idx + 1).min(1); + Action::None + } + KeyCode::Enter | KeyCode::Char('e') | KeyCode::Char('E') => Action::HermesMemoryOpen { + kind: self.selected_hermes_memory_kind(), + }, + KeyCode::Char(' ') => { + let kind = self.selected_hermes_memory_kind(); + let enabled = match kind { + MemoryKind::Memory => !data.config.hermes_memory.memory_enabled, + MemoryKind::User => !data.config.hermes_memory.user_enabled, + }; + Action::HermesMemorySetEnabled { kind, enabled } + } + _ => Action::None, + } + } + + pub(crate) fn selected_hermes_memory_kind(&self) -> MemoryKind { + if self.hermes_memory_idx == 0 { + MemoryKind::Memory + } else { + MemoryKind::User + } + } + pub(crate) fn clamp_selections(&mut self, data: &UiData) { let providers_len = visible_providers(&self.app_type, &self.filter, data).len(); if providers_len == 0 { @@ -581,6 +624,8 @@ impl App { self.daily_memory_idx = self.daily_memory_idx.min(daily_memory_len - 1); } + self.hermes_memory_idx = self.hermes_memory_idx.min(1); + let config_webdav_len = visible_webdav_config_items(&self.filter).len(); if config_webdav_len == 0 { self.config_webdav_idx = 0; diff --git a/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs b/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs index 5dc66e1e..aed20553 100644 --- a/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs +++ b/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs @@ -577,7 +577,7 @@ impl App { Action::None } KeyCode::Down => { - *selected = (*selected + 1).min(app_type_picker_index(&AppType::OpenClaw)); + *selected = (*selected + 1).min(app_type_picker_index(&AppType::Hermes)); Action::None } KeyCode::Char(' ') => { @@ -710,7 +710,7 @@ impl App { Action::None } KeyCode::Down => { - *selected = (*selected + 1).min(3); + *selected = (*selected + 1).min(app_type_picker_index(&AppType::Hermes)); Action::None } KeyCode::Char(' ') => { diff --git a/src-tauri/src/cli/tui/app/tests.rs b/src-tauri/src/cli/tui/app/tests.rs index 391cb31e..18574349 100644 --- a/src-tauri/src/cli/tui/app/tests.rs +++ b/src-tauri/src/cli/tui/app/tests.rs @@ -21,6 +21,7 @@ mod tests { use crate::cli::tui::terminal::TuiTerminal; use crate::commands::workspace::{DailyMemoryFileInfo, DailyMemorySearchResult, ALLOWED_FILES}; use crate::error::AppError; + use crate::hermes_config::{read_memory, read_memory_limits, MemoryKind}; use crate::prompt::Prompt; use crate::provider::Provider; use crate::services::PromptService; @@ -310,6 +311,10 @@ mod tests { .expect("workspace row should exist") } + fn hermes_memory_nav_index(app: &App) -> usize { + nav_index(app, NavItem::HermesMemory) + } + fn run_runtime_action( app: &mut App, data: &mut UiData, @@ -475,7 +480,7 @@ mod tests { .installed .push(installed_skill("hello-skill", "Hello Skill")); - let action = app.on_key(key(KeyCode::Char('x')), &data); + let action = app.on_key(key(KeyCode::Char(' ')), &data); assert!(matches!(action, Action::None)); } @@ -569,7 +574,7 @@ mod tests { } #[test] - fn skills_apps_picker_from_openclaw_targets_opencode_last_visible_row() { + fn skills_apps_picker_from_openclaw_targets_hermes_last_visible_row() { let mut app = App::new(Some(AppType::OpenClaw)); app.route = Route::Skills; app.focus = Focus::Content; @@ -600,6 +605,47 @@ mod tests { )); } + #[test] + fn skills_apps_picker_keyboard_can_select_hermes() { + let mut app = App::new(Some(AppType::Claude)); + app.route = Route::Skills; + app.focus = Focus::Content; + + let mut data = UiData::default(); + data.skills + .installed + .push(crate::services::skill::InstalledSkill { + id: "local:hello-skill".to_string(), + name: "Hello Skill".to_string(), + description: None, + directory: "hello-skill".to_string(), + repo_owner: None, + repo_name: None, + repo_branch: None, + readme_url: None, + apps: crate::app_config::SkillApps::default(), + installed_at: 0, + }); + + app.on_key(key(KeyCode::Char('m')), &data); + for _ in 0..4 { + app.on_key(key(KeyCode::Down), &data); + } + + let action = app.on_key(key(KeyCode::Char(' ')), &data); + assert!(matches!(action, Action::None)); + assert!(matches!( + &app.overlay, + Overlay::SkillsAppsPicker { selected, apps, .. } + if *selected == 4 + && !apps.claude + && !apps.codex + && !apps.gemini + && !apps.opencode + && apps.hermes + )); + } + #[test] fn skills_d_opens_uninstall_confirm_from_list() { let mut app = App::new(Some(AppType::Claude)); @@ -3826,6 +3872,126 @@ mod tests { assert_eq!(app.route_stack, vec![Route::Main]); } + #[test] + fn hermes_nav_memory_enter_opens_dedicated_subroute() { + let mut app = App::new(Some(AppType::Hermes)); + app.focus = Focus::Nav; + app.nav_idx = hermes_memory_nav_index(&app); + + let action = app.on_key(key(KeyCode::Enter), &UiData::default()); + + assert!(matches!(action, Action::SwitchRoute(Route::HermesMemory))); + assert!(matches!(app.route, Route::HermesMemory)); + assert_eq!(app.route_stack, vec![Route::Main]); + } + + #[test] + fn hermes_memory_enter_opens_selected_blob_editor() { + let mut app = App::new(Some(AppType::Hermes)); + app.route = Route::HermesMemory; + app.focus = Focus::Content; + app.hermes_memory_idx = 1; + + let action = app.on_key(key(KeyCode::Enter), &UiData::default()); + + assert!(matches!( + action, + Action::HermesMemoryOpen { + kind: MemoryKind::User + } + )); + } + + #[test] + fn hermes_memory_space_toggles_selected_blob() { + let mut app = App::new(Some(AppType::Hermes)); + app.route = Route::HermesMemory; + app.focus = Focus::Content; + app.hermes_memory_idx = 0; + let mut data = UiData::default(); + data.config.hermes_memory.memory_enabled = true; + + let action = app.on_key(key(KeyCode::Char(' ')), &data); + + assert!(matches!( + action, + Action::HermesMemorySetEnabled { + kind: MemoryKind::Memory, + enabled: false + } + )); + } + + #[test] + #[serial] + fn hermes_memory_runtime_open_and_submit_writes_file() { + let temp = TempDir::new().expect("temp home"); + let _env = EnvGuard::set_home(temp.path()); + let mut app = App::new(Some(AppType::Hermes)); + let mut data = UiData::default(); + data.config.hermes_memory.memory_content = "old memory".to_string(); + + run_runtime_action( + &mut app, + &mut data, + Action::HermesMemoryOpen { + kind: MemoryKind::Memory, + }, + ) + .expect("open memory editor"); + + let editor = app.editor.as_ref().expect("editor should open"); + assert_eq!( + editor.submit, + EditorSubmit::HermesMemory { + kind: MemoryKind::Memory + } + ); + assert_eq!(editor.initial_text, "old memory"); + + run_runtime_action( + &mut app, + &mut data, + Action::EditorSubmit { + submit: EditorSubmit::HermesMemory { + kind: MemoryKind::Memory, + }, + content: "new memory".to_string(), + }, + ) + .expect("submit memory editor"); + + assert_eq!( + read_memory(MemoryKind::Memory).expect("read memory"), + "new memory" + ); + assert_eq!(data.config.hermes_memory.memory_content, "new memory"); + assert!(app.editor.is_none()); + } + + #[test] + #[serial] + fn hermes_memory_runtime_toggle_writes_config() { + let temp = TempDir::new().expect("temp home"); + let _env = EnvGuard::set_home(temp.path()); + let mut app = App::new(Some(AppType::Hermes)); + let mut data = UiData::default(); + + run_runtime_action( + &mut app, + &mut data, + Action::HermesMemorySetEnabled { + kind: MemoryKind::User, + enabled: false, + }, + ) + .expect("toggle user memory"); + + let limits = read_memory_limits().expect("read memory limits"); + assert!(!limits.user_enabled); + assert!(!data.config.hermes_memory.user_enabled); + } + #[test] fn openclaw_nav_split_keeps_non_openclaw_generic_routes() { let cases = [ @@ -4235,6 +4401,7 @@ mod tests { ); } + #[cfg(unix)] #[test] #[serial(home_settings)] fn openclaw_workspace_open_failure_is_localized() { @@ -4360,6 +4527,7 @@ mod tests { assert_eq!(editor.text(), "late content"); } + #[cfg(unix)] #[test] #[serial(home_settings)] fn openclaw_daily_memory_save_failure_is_localized() { @@ -8348,6 +8516,7 @@ mod tests { codex: false, gemini: false, opencode: false, + hermes: false, openclaw: false, }) .expect("save visible apps"); diff --git a/src-tauri/src/cli/tui/data.rs b/src-tauri/src/cli/tui/data.rs index 2832aa72..2a6c11e2 100644 --- a/src-tauri/src/cli/tui/data.rs +++ b/src-tauri/src/cli/tui/data.rs @@ -7,6 +7,7 @@ use serde_json::Value; use crate::app_config::{AppType, CommonConfigSnippets, McpServer}; use crate::commands::workspace::{self, DailyMemoryFileInfo, ALLOWED_FILES}; use crate::error::AppError; +use crate::hermes_config::{read_memory, read_memory_limits, MemoryKind}; use crate::openclaw_config::{ OpenClawAgentsDefaults, OpenClawEnvConfig, OpenClawHealthWarning, OpenClawToolsConfig, }; @@ -214,6 +215,30 @@ pub struct ConfigSnapshot { pub openclaw_agents_defaults: Option, pub openclaw_warnings: Option>, pub openclaw_workspace: OpenClawWorkspaceSnapshot, + pub hermes_memory: HermesMemorySnapshot, +} + +#[derive(Debug, Clone)] +pub struct HermesMemorySnapshot { + pub memory_content: String, + pub user_content: String, + pub memory_limit: usize, + pub user_limit: usize, + pub memory_enabled: bool, + pub user_enabled: bool, +} + +impl Default for HermesMemorySnapshot { + fn default() -> Self { + Self { + memory_content: String::new(), + user_content: String::new(), + memory_limit: 2200, + user_limit: 1375, + memory_enabled: true, + user_enabled: true, + } + } } #[derive(Debug, Clone, Default)] @@ -859,6 +884,7 @@ fn load_config_snapshot(state: &AppState, app_type: &AppType) -> Result Result Result { + if !matches!(app_type, AppType::Hermes) { + return Ok(HermesMemorySnapshot::default()); + } + + let limits = read_memory_limits()?; + Ok(HermesMemorySnapshot { + memory_content: read_memory(MemoryKind::Memory)?, + user_content: read_memory(MemoryKind::User)?, + memory_limit: limits.memory, + user_limit: limits.user, + memory_enabled: limits.memory_enabled, + user_enabled: limits.user_enabled, }) } diff --git a/src-tauri/src/cli/tui/form.rs b/src-tauri/src/cli/tui/form.rs index ba703016..cf51dd34 100644 --- a/src-tauri/src/cli/tui/form.rs +++ b/src-tauri/src/cli/tui/form.rs @@ -208,6 +208,7 @@ pub enum ProviderAddField { HermesApiKey, HermesModel, HermesModels, + HermesRateLimitDelay, ClaudeBaseUrl, ClaudeApiFormat, ClaudeApiKey, @@ -283,6 +284,7 @@ pub enum McpAddField { AppCodex, AppGemini, AppOpenCode, + AppHermes, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -358,7 +360,12 @@ pub struct ProviderAddFormState { pub hermes_api_key: TextInput, pub hermes_base_url: TextInput, pub hermes_model: TextInput, - pub hermes_models: Value, + /// Hermes model list, stored as an array-of-objects: + /// `[{ id, name, context_length, ... }]` (matches upstream + /// `HermesFormFields`). On YAML write, `hermes_config`'s + /// `normalize_provider_models_for_write` converts this to a dict. + pub hermes_models: Vec, + pub hermes_rate_limit_delay: TextInput, pub openclaw_user_agent: bool, pub openclaw_models: Vec, diff --git a/src-tauri/src/cli/tui/form/mcp.rs b/src-tauri/src/cli/tui/form/mcp.rs index c6fcd7ae..f4dfd50c 100644 --- a/src-tauri/src/cli/tui/form/mcp.rs +++ b/src-tauri/src/cli/tui/form/mcp.rs @@ -181,6 +181,7 @@ impl McpAddFormState { McpAddField::AppCodex, McpAddField::AppGemini, McpAddField::AppOpenCode, + McpAddField::AppHermes, ]); fields @@ -198,7 +199,8 @@ impl McpAddFormState { | McpAddField::AppClaude | McpAddField::AppCodex | McpAddField::AppGemini - | McpAddField::AppOpenCode => None, + | McpAddField::AppOpenCode + | McpAddField::AppHermes => None, } } @@ -214,7 +216,8 @@ impl McpAddFormState { | McpAddField::AppClaude | McpAddField::AppCodex | McpAddField::AppGemini - | McpAddField::AppOpenCode => None, + | McpAddField::AppOpenCode + | McpAddField::AppHermes => None, } } @@ -306,6 +309,7 @@ impl McpAddFormState { "codex": self.apps.codex, "gemini": self.apps.gemini, "opencode": self.apps.opencode, + "hermes": self.apps.hermes, }), ); diff --git a/src-tauri/src/cli/tui/form/provider_json.rs b/src-tauri/src/cli/tui/form/provider_json.rs index e630081b..97ca6b04 100644 --- a/src-tauri/src/cli/tui/form/provider_json.rs +++ b/src-tauri/src/cli/tui/form/provider_json.rs @@ -321,16 +321,50 @@ impl ProviderAddFormState { set_or_remove_trimmed(settings_obj, "base_url", &self.hermes_base_url.value); set_or_remove_trimmed(settings_obj, "model", &self.hermes_model.value); - let has_models = match &self.hermes_models { - Value::Object(map) => !map.is_empty(), - Value::Array(items) => !items.is_empty(), - _ => false, - }; - if has_models { - settings_obj.insert("models".to_string(), self.hermes_models.clone()); + // Always write `settings_config.models` as an + // array-of-objects (`hermes_config::set_provider` converts + // it to a dict before writing YAML). Skip placeholder + // entries whose `id` is blank. + let normalized_models: Vec = self + .hermes_models + .iter() + .filter(|item| { + item.get("id") + .and_then(Value::as_str) + .map(|s| !s.trim().is_empty()) + .unwrap_or(false) + }) + .cloned() + .collect(); + + if !normalized_models.is_empty() { + settings_obj.insert("models".to_string(), Value::Array(normalized_models)); } else { settings_obj.remove("models"); } + + // 供应商级速率限制(单位:秒)。空串或非法数字时移除字段。 + let raw_delay = self.hermes_rate_limit_delay.value.trim(); + if raw_delay.is_empty() { + settings_obj.remove("rate_limit_delay"); + } else { + match raw_delay.parse::() { + Ok(value) if value.is_finite() && value >= 0.0 => { + settings_obj.insert( + "rate_limit_delay".to_string(), + serde_json::Number::from_f64(value) + .map(Value::Number) + .unwrap_or_else(|| json!(raw_delay)), + ); + } + _ => { + // Fall back to writing the raw string so user input + // round-trips and an obvious error surfaces in Hermes + // rather than being silently dropped. + settings_obj.insert("rate_limit_delay".to_string(), json!(raw_delay)); + } + } + } } AppType::OpenClaw => { settings_obj.remove("npm"); diff --git a/src-tauri/src/cli/tui/form/provider_state.rs b/src-tauri/src/cli/tui/form/provider_state.rs index 4912746c..02be8060 100644 --- a/src-tauri/src/cli/tui/form/provider_state.rs +++ b/src-tauri/src/cli/tui/form/provider_state.rs @@ -10,8 +10,8 @@ use super::provider_json::{ use super::provider_state_loading::populate_form_from_provider; use super::{ ClaudeApiFormat, CodexPreviewSection, CodexWireApi, FormFocus, FormMode, GeminiAuthType, - ProviderAddField, ProviderAddFormState, ProviderFormPage, TextInput, UsageQueryField, - UsageQueryTemplate, HERMES_DEFAULT_API_MODE,OPENCLAW_DEFAULT_API_PROTOCOL, + HermesApiMode, ProviderAddField, ProviderAddFormState, ProviderFormPage, TextInput, + UsageQueryField, UsageQueryTemplate, HERMES_DEFAULT_API_MODE, OPENCLAW_DEFAULT_API_PROTOCOL, }; impl ProviderAddFormState { @@ -140,7 +140,8 @@ impl ProviderAddFormState { hermes_api_key: TextInput::new(""), hermes_base_url: TextInput::new(""), hermes_model: TextInput::new(""), - hermes_models: json!({}), + hermes_models: Vec::new(), + hermes_rate_limit_delay: TextInput::new(""), openclaw_user_agent: false, openclaw_models: Vec::new(), usage_query_enabled: false, @@ -235,7 +236,7 @@ impl ProviderAddFormState { .ok() .and_then(|value| value.as_object().cloned()) .is_some_and(|obj| !obj.is_empty()), - AppType::OpenCode | AppType::OpenClaw => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => false, } } @@ -334,6 +335,7 @@ impl ProviderAddFormState { fields.push(ProviderAddField::HermesApiKey); fields.push(ProviderAddField::HermesModel); fields.push(ProviderAddField::HermesModels); + fields.push(ProviderAddField::HermesRateLimitDelay); } AppType::OpenClaw => { fields.push(ProviderAddField::OpenClawApiProtocol); @@ -428,6 +430,7 @@ impl ProviderAddFormState { ProviderAddField::HermesBaseUrl => Some(&self.hermes_base_url), ProviderAddField::HermesApiKey => Some(&self.hermes_api_key), ProviderAddField::HermesModel => Some(&self.hermes_model), + ProviderAddField::HermesRateLimitDelay => Some(&self.hermes_rate_limit_delay), ProviderAddField::ClaudeBaseUrl => Some(&self.claude_base_url), ProviderAddField::ClaudeApiKey => Some(&self.claude_api_key), ProviderAddField::CodexBaseUrl => Some(&self.codex_base_url), @@ -472,6 +475,7 @@ impl ProviderAddFormState { ProviderAddField::HermesBaseUrl => Some(&mut self.hermes_base_url), ProviderAddField::HermesApiKey => Some(&mut self.hermes_api_key), ProviderAddField::HermesModel => Some(&mut self.hermes_model), + ProviderAddField::HermesRateLimitDelay => Some(&mut self.hermes_rate_limit_delay), ProviderAddField::ClaudeBaseUrl => Some(&mut self.claude_base_url), ProviderAddField::ClaudeApiKey => Some(&mut self.claude_api_key), ProviderAddField::CodexBaseUrl => Some(&mut self.codex_base_url), @@ -755,6 +759,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::OpenCode | AppType::OpenClaw => self.opencode_base_url.value.clone(), } } @@ -764,6 +769,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::OpenCode | AppType::OpenClaw => { (&self.opencode_api_key.value, &self.opencode_base_url.value) } @@ -1092,16 +1098,22 @@ impl ProviderAddFormState { } pub(crate) fn hermes_models_summary(&self) -> String { - let total = match &self.hermes_models { - Value::Array(items) => items.len(), - Value::Object(items) => items.len(), - _ => 0, - }; - texts::tui_hermes_models_summary(total) + texts::tui_hermes_models_summary(self.hermes_models.len()) } pub(crate) fn hermes_models_editor_text(&self) -> String { - serde_json::to_string_pretty(&self.hermes_models).unwrap_or_else(|_| "{}".to_string()) + // 始终以 array-of-objects 形式呈现,便于用户在编辑器中按行修改: + // [{ "id": "...", "name": "...", "context_length": 200000 }, ...] + // + // 这与 cc-switch 上游 `HermesFormFields` 的内部表示一致;写入 YAML 时 + // 由 `hermes_config::set_provider` 调用 `normalize_provider_models_for_write` + // 转换为 dict 形式。 + if self.hermes_models.is_empty() { + // 提供一个最小骨架,让首次进入编辑器的用户能直接照样填写 + return "[\n {\n \"id\": \"\",\n \"name\": \"\",\n \"context_length\": null\n }\n]\n".to_string(); + } + serde_json::to_string_pretty(&Value::Array(self.hermes_models.clone())) + .unwrap_or_else(|_| "[]".to_string()) } pub(crate) fn ensure_hermes_model_entry(&mut self, selected_model: &str) { @@ -1109,15 +1121,18 @@ impl ProviderAddFormState { if selected_model.is_empty() { return; } - - if !self.hermes_models.is_object() { - self.hermes_models = json!({}); - } - - if let Some(models) = self.hermes_models.as_object_mut() { - models - .entry(selected_model.to_string()) - .or_insert_with(|| json!({ "name": selected_model })); + let already_present = self.hermes_models.iter().any(|entry| { + entry + .get("id") + .and_then(Value::as_str) + .map(|id| id == selected_model) + .unwrap_or(false) + }); + if !already_present { + self.hermes_models.push(json!({ + "id": selected_model, + "name": selected_model, + })); } } @@ -1125,20 +1140,117 @@ impl ProviderAddFormState { if !matches!(self.app_type, AppType::Hermes) { return Ok(()); } - if !models_value.is_array() && !models_value.is_object() { - return Err(texts::tui_toast_json_must_be_object_or_array().to_string()); + // 接受两种输入形态,最终内部规范为 array: + // 1) `[{ id, name?, context_length? }, ...]` —— 上游 UI 内部表示 + // 2) `{ id: { name?, context_length?, ... }, ... }` —— Hermes YAML 表示 + let normalized: Vec = match models_value { + Value::Array(items) => { + let mut out: Vec = Vec::with_capacity(items.len()); + for item in items { + let Value::Object(mut obj) = item else { + return Err(texts::tui_toast_json_must_be_object_or_array().to_string()); + }; + let id_ok = obj + .get("id") + .and_then(Value::as_str) + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + if !id_ok { + // 跳过没有 id 的空骨架行(编辑器初次进入时的占位会被丢弃) + if obj.get("id").is_none() { + continue; + } + let id_blank = obj + .get("id") + .and_then(Value::as_str) + .map(|s| s.trim().is_empty()) + .unwrap_or(false); + if id_blank { + continue; + } + } + if let Some(id_val) = obj.get("id").cloned() { + if let Some(s) = id_val.as_str() { + obj.insert("id".to_string(), Value::String(s.trim().to_string())); + } + } + out.push(Value::Object(obj)); + } + out + } + Value::Object(map) => { + let mut out: Vec = Vec::with_capacity(map.len()); + for (id, value) in map { + let mut obj = match value { + Value::Object(obj) => obj, + Value::Null => serde_json::Map::new(), + _ => { + return Err(texts::tui_toast_json_must_be_object_or_array().to_string()) + } + }; + if id.trim().is_empty() { + continue; + } + obj.insert("id".to_string(), Value::String(id)); + out.push(Value::Object(obj)); + } + out + } + _ => return Err(texts::tui_toast_json_must_be_object_or_array().to_string()), + }; + + self.hermes_models = normalized; + // 若主模型字段为空,且新列表非空,则用第一个条目的 id 回填主模型 + if self.hermes_model.is_blank() { + if let Some(first_id) = self + .hermes_models + .first() + .and_then(|m| m.get("id")) + .and_then(Value::as_str) + { + self.hermes_model.set(first_id.to_string()); + } } + Ok(()) + } - let mut provider_value = self.to_provider_json_value(); - let settings_value = provider_value - .as_object_mut() - .and_then(|obj| obj.get_mut("settingsConfig")) - .ok_or_else(|| texts::tui_toast_json_must_be_object().to_string())?; - let settings_obj = settings_value - .as_object_mut() - .ok_or_else(|| texts::tui_toast_json_must_be_object().to_string())?; - settings_obj.insert("models".to_string(), models_value); - self.apply_provider_json_value_to_fields(provider_value) + /// 用从 `/v1/models` 拉到的模型 ID 列表合并到现有 `hermes_models`: + /// 已存在的 id 保持原条目不变;缺失的 id 追加为 `{ id, name: id }`。 + /// 返回 `(added, total)`,供 UI toast 反馈。 + pub fn merge_fetched_hermes_models(&mut self, fetched_ids: &[String]) -> (usize, usize) { + let mut added = 0usize; + for raw_id in fetched_ids { + let id = raw_id.trim(); + if id.is_empty() { + continue; + } + let exists = self.hermes_models.iter().any(|entry| { + entry + .get("id") + .and_then(Value::as_str) + .map(|existing| existing == id) + .unwrap_or(false) + }); + if !exists { + self.hermes_models.push(json!({ + "id": id, + "name": id, + })); + added += 1; + } + } + // 若主模型字段仍为空且我们至少新增了一个条目,使用第一个 fetched id 回填 + if self.hermes_model.is_blank() { + if let Some(first_id) = self + .hermes_models + .first() + .and_then(|m| m.get("id")) + .and_then(Value::as_str) + { + self.hermes_model.set(first_id.to_string()); + } + } + (added, self.hermes_models.len()) } pub(crate) fn openclaw_models_summary(&self) -> String { 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 0cdbfc60..9856ffee 100644 --- a/src-tauri/src/cli/tui/form/provider_state_loading.rs +++ b/src-tauri/src/cli/tui/form/provider_state_loading.rs @@ -1,19 +1,14 @@ use super::codex_config::parse_codex_config_snippet; use super::{ claude_hide_attribution_enabled, detect_balance_provider_for_usage_query, - detect_coding_plan_provider_for_usage_query, ClaudeApiFormat, ProviderAddFormState, - UsageQueryTemplate, OPENCLAW_DEFAULT_API_PROTOCOL, + detect_coding_plan_provider_for_usage_query, ClaudeApiFormat, HermesApiMode, + ProviderAddFormState, UsageQueryTemplate, HERMES_DEFAULT_API_MODE, + OPENCLAW_DEFAULT_API_PROTOCOL, }; use crate::app_config::AppType; use crate::provider::Provider; use serde_json::Value; -use super::codex_config::parse_codex_config_snippet; -use super::{ - claude_hide_attribution_enabled, ClaudeApiFormat, ProviderAddFormState, - HERMES_DEFAULT_API_MODE, OPENCLAW_DEFAULT_API_PROTOCOL, -}; - pub(super) fn populate_form_from_provider( form: &mut ProviderAddFormState, app_type: &AppType, @@ -299,29 +294,49 @@ fn populate_hermes_form(form: &mut ProviderAddFormState, provider: &Provider) { form.hermes_model.set(model); } - form.hermes_models = provider - .settings_config - .get("models") - .cloned() - .unwrap_or_else(|| Value::Object(serde_json::Map::new())); + // Hermes 在 settings_config 中的 models 可能是 array 或 dict, + // 统一转换为 array-of-objects 在表单内部使用: + // - array: `[{ id, name?, context_length?, ... }]` 保持原样 + // - dict: `{ id: { name?, ... } }` 转为 array 并注入 id + form.hermes_models = match provider.settings_config.get("models") { + Some(Value::Array(arr)) => arr.clone(), + Some(Value::Object(map)) => map + .iter() + .filter_map(|(id, value)| { + if id.trim().is_empty() { + return None; + } + let mut obj = match value { + Value::Object(obj) => obj.clone(), + Value::Null => serde_json::Map::new(), + _ => return None, + }; + obj.insert("id".to_string(), Value::String(id.clone())); + Some(Value::Object(obj)) + }) + .collect(), + _ => Vec::new(), + }; + + if let Some(delay) = provider.settings_config.get("rate_limit_delay") { + let rendered = match delay { + Value::String(s) => s.trim().to_string(), + Value::Number(n) => n.to_string(), + _ => String::new(), + }; + if !rendered.is_empty() { + form.hermes_rate_limit_delay.set(rendered); + } + } if form.hermes_model.is_blank() { - match &form.hermes_models { - Value::Object(models) => { - if let Some(model_id) = models.keys().next() { - form.hermes_model.set(model_id); - } - } - Value::Array(models) => { - if let Some(model_id) = models - .first() - .and_then(|model| model.get("id")) - .and_then(|value| value.as_str()) - { - form.hermes_model.set(model_id); - } - } - _ => {} + if let Some(model_id) = form + .hermes_models + .first() + .and_then(|model| model.get("id")) + .and_then(|value| value.as_str()) + { + form.hermes_model.set(model_id); } } } diff --git a/src-tauri/src/cli/tui/form/provider_templates.rs b/src-tauri/src/cli/tui/form/provider_templates.rs index a8054876..4d3edb00 100644 --- a/src-tauri/src/cli/tui/form/provider_templates.rs +++ b/src-tauri/src/cli/tui/form/provider_templates.rs @@ -270,6 +270,7 @@ impl ProviderAddFormState { self.hermes_base_url = defaults.hermes_base_url; self.hermes_model = defaults.hermes_model; self.hermes_models = defaults.hermes_models; + self.hermes_rate_limit_delay = defaults.hermes_rate_limit_delay; self.openclaw_user_agent = defaults.openclaw_user_agent; self.openclaw_models = defaults.openclaw_models; self.opencode_npm_package = defaults.opencode_npm_package; diff --git a/src-tauri/src/cli/tui/form/tests.rs b/src-tauri/src/cli/tui/form/tests.rs index 7818184e..4151f2de 100644 --- a/src-tauri/src/cli/tui/form/tests.rs +++ b/src-tauri/src/cli/tui/form/tests.rs @@ -1176,6 +1176,7 @@ fn mcp_add_form_builds_server_and_apps() { form.apps.claude = true; form.apps.codex = false; form.apps.gemini = true; + form.apps.hermes = true; let server = form.to_mcp_server_json_value(); assert_eq!(server["id"], "m1"); @@ -1187,6 +1188,7 @@ fn mcp_add_form_builds_server_and_apps() { assert_eq!(server["apps"]["codex"], false); assert_eq!(server["apps"]["gemini"], true); assert_eq!(server["apps"]["opencode"], false); + assert_eq!(server["apps"]["hermes"], true); } #[test] @@ -1358,6 +1360,28 @@ fn mcp_http_form_replaces_stdio_fields_with_url() { assert!(!fields.contains(&McpAddField::Args)); assert!(!fields.contains(&McpAddField::Env)); assert!(fields.contains(&McpAddField::AppOpenCode)); + assert!(fields.contains(&McpAddField::AppHermes)); +} + +#[test] +fn mcp_form_includes_hermes_app_after_opencode() { + let form = McpAddFormState::new(); + let fields = form.fields(); + + let opencode_idx = fields + .iter() + .position(|field| *field == McpAddField::AppOpenCode) + .expect("MCP AppOpenCode field should exist"); + let hermes_idx = fields + .iter() + .position(|field| *field == McpAddField::AppHermes) + .expect("MCP AppHermes field should exist"); + + assert!( + opencode_idx < hermes_idx, + "MCP AppHermes field should appear after AppOpenCode" + ); + assert!(form.input(McpAddField::AppHermes).is_none()); } #[test] @@ -3056,3 +3080,120 @@ fn provider_add_form_usage_query_numeric_fields_match_upstream_normalization() { assert_eq!(script["timeout"], 10); assert_eq!(script["autoQueryInterval"], 0); } + +// ============================================================================ +// Hermes provider form: models list (array-of-objects representation) +// ============================================================================ + +#[test] +fn hermes_apply_models_value_accepts_array_form() { + let mut form = ProviderAddFormState::new(AppType::Hermes); + let value = json!([ + { "id": "claude-opus-4-7", "name": "Claude Opus 4.7", "context_length": 200000 }, + { "id": "claude-sonnet-4", "name": "Claude Sonnet 4" }, + ]); + + form.apply_hermes_models_value(value).unwrap(); + + assert_eq!(form.hermes_models.len(), 2); + assert_eq!(form.hermes_models[0]["id"], "claude-opus-4-7"); + assert_eq!(form.hermes_models[0]["context_length"], 200000); + assert_eq!(form.hermes_models[1]["id"], "claude-sonnet-4"); + // The first id should back-fill the primary model field when blank. + assert_eq!(form.hermes_model.value, "claude-opus-4-7"); +} + +#[test] +fn hermes_apply_models_value_accepts_dict_form_and_injects_id() { + let mut form = ProviderAddFormState::new(AppType::Hermes); + let value = json!({ + "claude-opus-4-7": { "name": "Claude Opus 4.7", "context_length": 200000 }, + "claude-sonnet-4": {}, + }); + + form.apply_hermes_models_value(value).unwrap(); + + assert_eq!(form.hermes_models.len(), 2); + let ids: Vec<&str> = form + .hermes_models + .iter() + .filter_map(|m| m.get("id").and_then(|v| v.as_str())) + .collect(); + assert!(ids.contains(&"claude-opus-4-7")); + assert!(ids.contains(&"claude-sonnet-4")); +} + +#[test] +fn hermes_apply_models_value_drops_blank_id_placeholders() { + let mut form = ProviderAddFormState::new(AppType::Hermes); + // Editor skeleton: a single blank-id object — should be ignored. + let value = json!([ + { "id": "", "name": "", "context_length": null }, + ]); + + form.apply_hermes_models_value(value).unwrap(); + + assert!(form.hermes_models.is_empty()); +} + +#[test] +fn hermes_merge_fetched_models_skips_existing_and_backfills_primary() { + let mut form = ProviderAddFormState::new(AppType::Hermes); + form.hermes_models = vec![json!({ "id": "claude-opus-4-7", "name": "Existing" })]; + + let fetched = vec![ + "claude-opus-4-7".to_string(), + "claude-sonnet-4".to_string(), + " kimi-k2 ".to_string(), + "".to_string(), + ]; + + let (added, total) = form.merge_fetched_hermes_models(&fetched); + + assert_eq!(added, 2); + assert_eq!(total, 3); + assert_eq!(form.hermes_model.value, "claude-opus-4-7"); + + let ids: Vec<&str> = form + .hermes_models + .iter() + .filter_map(|m| m.get("id").and_then(|v| v.as_str())) + .collect(); + assert_eq!(ids, vec!["claude-opus-4-7", "claude-sonnet-4", "kimi-k2"]); +} + +#[test] +fn hermes_models_round_trip_settings_config_array_form() { + let mut form = ProviderAddFormState::new(AppType::Hermes); + form.name.set("my-provider"); + form.hermes_api_key.set("sk-test"); + form.hermes_base_url.set("https://api.example.com/v1"); + form.hermes_model.set("claude-opus-4-7"); + form.hermes_models = vec![ + json!({ "id": "claude-opus-4-7", "name": "Opus", "context_length": 200000 }), + json!({ "id": "claude-sonnet-4", "name": "Sonnet" }), + ]; + + let provider_value = form.to_provider_json_value(); + let models = provider_value + .get("settingsConfig") + .and_then(|sc| sc.get("models")) + .and_then(|v| v.as_array()) + .expect("models should be an array in settings_config"); + assert_eq!(models.len(), 2); + assert_eq!(models[0]["id"], "claude-opus-4-7"); + assert_eq!(models[0]["context_length"], 200000); + assert_eq!(models[1]["id"], "claude-sonnet-4"); +} + +#[test] +fn hermes_models_editor_text_shows_skeleton_when_empty() { + let form = ProviderAddFormState::new(AppType::Hermes); + let text = form.hermes_models_editor_text(); + // Skeleton must round-trip through serde_json to a JSON array + let value: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert!(value.is_array()); + let arr = value.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["id"], ""); +} diff --git a/src-tauri/src/cli/tui/route.rs b/src-tauri/src/cli/tui/route.rs index 07d0082f..b4e2794e 100644 --- a/src-tauri/src/cli/tui/route.rs +++ b/src-tauri/src/cli/tui/route.rs @@ -13,6 +13,7 @@ pub enum Route { ConfigOpenClawEnv, ConfigOpenClawTools, ConfigOpenClawAgents, + HermesMemory, ConfigWebDav, Skills, SkillsDiscover, @@ -34,6 +35,7 @@ pub enum NavItem { OpenClawEnv, OpenClawTools, OpenClawAgents, + HermesMemory, Settings, Exit, } @@ -61,9 +63,22 @@ impl NavItem { NavItem::Exit, ]; + pub const HERMES_ALL: [NavItem; 8] = [ + NavItem::Main, + NavItem::Providers, + NavItem::Mcp, + NavItem::Skills, + NavItem::HermesMemory, + NavItem::Config, + NavItem::Settings, + NavItem::Exit, + ]; + pub fn all_for_app(app_type: &AppType) -> &'static [NavItem] { if matches!(app_type, AppType::OpenClaw) { &Self::OPENCLAW_ALL + } else if matches!(app_type, AppType::Hermes) { + &Self::HERMES_ALL } else { &Self::ALL } @@ -81,6 +96,7 @@ impl NavItem { NavItem::OpenClawEnv => Some(Route::ConfigOpenClawEnv), NavItem::OpenClawTools => Some(Route::ConfigOpenClawTools), NavItem::OpenClawAgents => Some(Route::ConfigOpenClawAgents), + NavItem::HermesMemory => Some(Route::HermesMemory), NavItem::Settings => Some(Route::Settings), NavItem::Exit => None, } diff --git a/src-tauri/src/cli/tui/runtime_actions/config.rs b/src-tauri/src/cli/tui/runtime_actions/config.rs index add21ced..886b5fcc 100644 --- a/src-tauri/src/cli/tui/runtime_actions/config.rs +++ b/src-tauri/src/cli/tui/runtime_actions/config.rs @@ -2,10 +2,11 @@ use crate::app_config::AppType; use crate::cli::i18n::texts; use crate::commands::workspace; use crate::error::AppError; -use crate::services::ConfigService; +use crate::hermes_config::{read_memory, set_memory_enabled, MemoryKind}; +use crate::services::{ConfigService, ProviderService}; use crate::settings::set_webdav_sync_settings; -use super::super::app::{LoadingKind, Overlay, TextViewState, ToastKind}; +use super::super::app::{EditorKind, EditorSubmit, LoadingKind, Overlay, TextViewState, ToastKind}; use super::super::data::{load_state, UiData}; use super::super::runtime_systems::{WebDavReq, WebDavReqKind}; use super::helpers::{ @@ -149,6 +150,105 @@ pub(super) fn open_proxy_help(ctx: &mut RuntimeActionContext<'_>) -> Result<(), open_proxy_help_overlay(ctx.app, ctx.data) } +pub(super) fn open_hermes_memory( + ctx: &mut RuntimeActionContext<'_>, + kind: MemoryKind, +) -> Result<(), AppError> { + let content = match kind { + MemoryKind::Memory => { + if ctx.data.config.hermes_memory.memory_content.is_empty() { + read_memory(kind)? + } else { + ctx.data.config.hermes_memory.memory_content.clone() + } + } + MemoryKind::User => { + if ctx.data.config.hermes_memory.user_content.is_empty() { + read_memory(kind)? + } else { + ctx.data.config.hermes_memory.user_content.clone() + } + } + }; + ctx.app.open_editor( + hermes_memory_editor_title(kind), + EditorKind::Plain, + content, + EditorSubmit::HermesMemory { kind }, + ); + Ok(()) +} + +pub(super) fn set_hermes_memory_enabled( + ctx: &mut RuntimeActionContext<'_>, + kind: MemoryKind, + enabled: bool, +) -> Result<(), AppError> { + set_memory_enabled(kind, enabled)?; + match kind { + MemoryKind::Memory => ctx.data.config.hermes_memory.memory_enabled = enabled, + MemoryKind::User => ctx.data.config.hermes_memory.user_enabled = enabled, + } + ctx.app.push_toast( + if enabled { + crate::t!("Hermes memory enabled", "Hermes 记忆已启用") + } else { + crate::t!("Hermes memory disabled", "Hermes 记忆已禁用") + }, + ToastKind::Success, + ); + Ok(()) +} + +pub(super) fn hermes_memory_editor_title(kind: MemoryKind) -> String { + match kind { + MemoryKind::Memory => crate::t!("Agent Memory (MEMORY.md)", "Agent Memory (MEMORY.md)"), + MemoryKind::User => crate::t!("User Profile (USER.md)", "User Profile (USER.md)"), + } + .to_string() +} + +pub(super) fn clear_common_snippet( + ctx: &mut RuntimeActionContext<'_>, + app_type: AppType, +) -> Result<(), AppError> { + let state = load_state()?; + ProviderService::clear_common_config_snippet(&state, app_type)?; + + ctx.app + .push_toast(texts::common_config_snippet_cleared(), ToastKind::Success); + *ctx.data = UiData::load(&ctx.app.app_type)?; + Ok(()) +} + +pub(super) fn apply_common_snippet( + ctx: &mut RuntimeActionContext<'_>, + app_type: AppType, +) -> Result<(), AppError> { + if app_type.is_additive_mode() { + ctx.app.push_toast( + texts::common_config_snippet_apply_not_needed(), + ToastKind::Info, + ); + return Ok(()); + } + + let state = load_state()?; + let current_id = ProviderService::current(&state, app_type.clone())?; + if current_id.trim().is_empty() { + ctx.app.push_toast( + texts::common_config_snippet_no_current_provider(), + ToastKind::Info, + ); + return Ok(()); + } + ProviderService::switch(&state, app_type.clone(), ¤t_id)?; + ctx.app + .push_toast(texts::common_config_snippet_applied(), ToastKind::Success); + *ctx.data = UiData::load(&ctx.app.app_type)?; + Ok(()) +} + pub(super) fn webdav_check_connection(ctx: &mut RuntimeActionContext<'_>) -> Result<(), AppError> { queue_webdav_request( ctx, diff --git a/src-tauri/src/cli/tui/runtime_actions/editor.rs b/src-tauri/src/cli/tui/runtime_actions/editor.rs index f8d78d1c..be67f671 100644 --- a/src-tauri/src/cli/tui/runtime_actions/editor.rs +++ b/src-tauri/src/cli/tui/runtime_actions/editor.rs @@ -5,6 +5,7 @@ use crate::cli::i18n::texts; use crate::cli::tui::form::strip_common_config_from_settings; use crate::commands::workspace; use crate::error::AppError; +use crate::hermes_config::{write_memory, MemoryKind}; use crate::openclaw_config::{ set_agents_defaults, set_env_config, set_tools_config, OpenClawAgentsDefaults, OpenClawEnvConfig, OpenClawToolsConfig, @@ -272,6 +273,7 @@ pub(super) fn submit( EditorSubmit::OpenClawDailyMemoryFile { filename } => { submit_openclaw_daily_memory_file(ctx, filename, content) } + EditorSubmit::HermesMemory { kind } => submit_hermes_memory(ctx, kind, content), EditorSubmit::ConfigOpenClawEnv => submit_openclaw_env(ctx, content), EditorSubmit::ConfigOpenClawTools => submit_openclaw_tools(ctx, content), EditorSubmit::ConfigOpenClawAgents => submit_openclaw_agents(ctx, content), @@ -279,6 +281,24 @@ pub(super) fn submit( } } +fn submit_hermes_memory( + ctx: &mut RuntimeActionContext<'_>, + kind: MemoryKind, + content: String, +) -> Result<(), AppError> { + write_memory(kind, &content)?; + match kind { + MemoryKind::Memory => ctx.data.config.hermes_memory.memory_content = content, + MemoryKind::User => ctx.data.config.hermes_memory.user_content = content, + } + ctx.app.editor = None; + ctx.app.push_toast( + crate::t!("Hermes memory saved", "Hermes 记忆已保存"), + ToastKind::Success, + ); + Ok(()) +} + fn submit_prompt_create( ctx: &mut RuntimeActionContext<'_>, id: String, @@ -622,14 +642,21 @@ fn submit_provider_form_apply_hermes_models( ctx: &mut RuntimeActionContext<'_>, content: String, ) -> Result<(), AppError> { - let models_value: Value = match serde_json::from_str(&content) { - Ok(value) => value, - Err(e) => { - ctx.app.push_toast( - texts::tui_toast_invalid_json(&e.to_string()), - ToastKind::Error, - ); - return Ok(()); + // 允许用户提交空文本来清空模型列表(apply_hermes_models_value 期望 + // array / object 中的一个,因此空字符串需在此处转换为空数组)。 + let trimmed = content.trim(); + let models_value: Value = if trimmed.is_empty() { + Value::Array(Vec::new()) + } else { + match serde_json::from_str(trimmed) { + Ok(value) => value, + Err(e) => { + ctx.app.push_toast( + texts::tui_toast_invalid_json(&e.to_string()), + ToastKind::Error, + ); + return Ok(()); + } } }; diff --git a/src-tauri/src/cli/tui/runtime_actions/helpers.rs b/src-tauri/src/cli/tui/runtime_actions/helpers.rs index 57eef6b5..f224985a 100644 --- a/src-tauri/src/cli/tui/runtime_actions/helpers.rs +++ b/src-tauri/src/cli/tui/runtime_actions/helpers.rs @@ -42,7 +42,7 @@ pub(crate) fn import_mcp_for_current_app(app: &mut App, data: &mut UiData) -> Re AppType::Codex => McpService::import_from_codex(&state), AppType::Gemini => McpService::import_from_gemini(&state), AppType::OpenCode => McpService::import_from_opencode(&state), - AppType::Hermes => Ok(0), + AppType::Hermes => McpService::import_from_hermes(&state), AppType::OpenClaw => Ok(0), } }, diff --git a/src-tauri/src/cli/tui/runtime_actions/mod.rs b/src-tauri/src/cli/tui/runtime_actions/mod.rs index add5fecc..3aa8897b 100644 --- a/src-tauri/src/cli/tui/runtime_actions/mod.rs +++ b/src-tauri/src/cli/tui/runtime_actions/mod.rs @@ -213,6 +213,10 @@ pub(crate) fn handle_action( editor::extract_common_snippet_into_editor(&mut ctx, app_type) } Action::EditorSubmit { submit, content } => editor::submit(&mut ctx, submit, content), + Action::HermesMemoryOpen { kind } => config::open_hermes_memory(&mut ctx, kind), + Action::HermesMemorySetEnabled { kind, enabled } => { + config::set_hermes_memory_enabled(&mut ctx, kind, enabled) + } Action::ProviderSwitch { id } => providers::switch(&mut ctx, id), Action::ProviderRemoveFromConfig { id } => providers::remove_from_config(&mut ctx, id), Action::ProviderSetDefaultModel { diff --git a/src-tauri/src/cli/tui/runtime_systems/handlers.rs b/src-tauri/src/cli/tui/runtime_systems/handlers.rs index 88fc2a4a..2a9bf82f 100644 --- a/src-tauri/src/cli/tui/runtime_systems/handlers.rs +++ b/src-tauri/src/cli/tui/runtime_systems/handlers.rs @@ -8,6 +8,7 @@ use crate::settings::{ use super::super::app::{App, ConfirmAction, ConfirmOverlay, LoadingKind, Overlay, ToastKind}; use super::super::data::{load_state, UiData}; +use super::super::form::FormState; use super::super::runtime_actions::app_display_name; use super::types::{ build_stream_check_result_lines, LocalEnvMsg, ModelFetchMsg, ProxyMsg, QuotaMsg, @@ -144,6 +145,62 @@ pub(crate) fn handle_model_fetch_msg(app: &mut App, msg: ModelFetchMsg) { claude_idx, result, } => { + // Special case: when the user invoked "Fetch Models" from the + // Hermes provider form's `HermesModels` row, we don't want the + // single-pick picker UX. Instead, merge all returned model IDs + // directly into the form's `hermes_models` list (the upstream + // `HermesFormFields.handleFetchModels` shape). + if matches!(field, crate::cli::tui::form::ProviderAddField::HermesModels) { + if let Overlay::ModelFetchPicker { + request_id: current_request_id, + field: ref current_field, + .. + } = app.overlay + { + if current_request_id != request_id { + return; + } + if !matches!( + current_field, + crate::cli::tui::form::ProviderAddField::HermesModels + ) { + return; + } + } else { + // Picker was already dismissed; nothing to do. + return; + } + + match result { + Ok(fetched_models) => { + if fetched_models.is_empty() { + app.overlay = Overlay::None; + app.push_toast(texts::tui_model_fetch_no_models(), ToastKind::Warning); + return; + } + let total_fetched = fetched_models.len(); + let merge_result = + if let Some(FormState::ProviderAdd(provider)) = app.form.as_mut() { + Some(provider.merge_fetched_hermes_models(&fetched_models)) + } else { + None + }; + app.overlay = Overlay::None; + if let Some((added, total)) = merge_result { + app.push_toast( + texts::tui_toast_hermes_models_fetched(added, total_fetched, total), + ToastKind::Success, + ); + } + } + Err(err) => { + app.overlay = Overlay::None; + app.push_toast(texts::tui_model_fetch_error_hint(&err), ToastKind::Error); + } + } + return; + } + if let Overlay::ModelFetchPicker { request_id: current_request_id, fetching: ref mut f, diff --git a/src-tauri/src/cli/tui/ui.rs b/src-tauri/src/cli/tui/ui.rs index 4808edfb..36348b29 100644 --- a/src-tauri/src/cli/tui/ui.rs +++ b/src-tauri/src/cli/tui/ui.rs @@ -35,6 +35,7 @@ mod chrome; mod config; mod editor; mod forms; +mod hermes_memory; mod main_page; mod mcp; mod overlay; @@ -54,6 +55,7 @@ use chrome::*; use config::*; use editor::*; use forms::*; +use hermes_memory::*; use main_page::*; use mcp::*; use overlay::*; @@ -155,6 +157,13 @@ fn render_content( render_config(frame, app, data, content_area, theme) } } + Route::HermesMemory => { + if matches!(app.app_type, AppType::Hermes) { + render_hermes_memory(frame, app, data, content_area, theme) + } else { + render_config(frame, app, data, content_area, theme) + } + } Route::ConfigWebDav => render_config_webdav(frame, app, data, content_area, theme), Route::Skills => render_skills_installed(frame, app, data, content_area, theme), Route::SkillsDiscover => render_skills_discover(frame, app, data, content_area, theme), diff --git a/src-tauri/src/cli/tui/ui/chrome.rs b/src-tauri/src/cli/tui/ui/chrome.rs index cb3f7cef..6650309d 100644 --- a/src-tauri/src/cli/tui/ui/chrome.rs +++ b/src-tauri/src/cli/tui/ui/chrome.rs @@ -224,6 +224,7 @@ pub(super) fn nav_label(item: NavItem) -> &'static str { NavItem::OpenClawEnv => texts::menu_openclaw_env(), NavItem::OpenClawTools => texts::menu_openclaw_tools(), NavItem::OpenClawAgents => texts::menu_openclaw_agents(), + NavItem::HermesMemory => texts::menu_hermes_memory(), NavItem::Settings => texts::menu_settings(), NavItem::Exit => texts::menu_exit(), } @@ -241,6 +242,7 @@ pub(super) fn nav_label_variants(item: NavItem) -> (&'static str, &'static str) NavItem::OpenClawEnv => texts::menu_openclaw_env_variants(), NavItem::OpenClawTools => texts::menu_openclaw_tools_variants(), NavItem::OpenClawAgents => texts::menu_openclaw_agents_variants(), + NavItem::HermesMemory => texts::menu_hermes_memory_variants(), NavItem::Settings => texts::menu_settings_variants(), NavItem::Exit => texts::menu_exit_variants(), } @@ -257,6 +259,7 @@ pub(super) fn nav_pane_width(theme: &super::theme::Theme) -> u16 { let max_text_width = NavItem::ALL .iter() .chain(NavItem::OPENCLAW_ALL.iter()) + .chain(NavItem::HERMES_ALL.iter()) .flat_map(|item| { let (en, zh) = nav_label_variants(*item); [en, zh] diff --git a/src-tauri/src/cli/tui/ui/forms/mcp.rs b/src-tauri/src/cli/tui/ui/forms/mcp.rs index 11556935..780a3987 100644 --- a/src-tauri/src/cli/tui/ui/forms/mcp.rs +++ b/src-tauri/src/cli/tui/ui/forms/mcp.rs @@ -186,6 +186,7 @@ pub(crate) fn mcp_field_label_and_value( McpAddField::AppCodex => texts::tui_label_app_codex().to_string(), McpAddField::AppGemini => texts::tui_label_app_gemini().to_string(), McpAddField::AppOpenCode => texts::tui_label_app_opencode().to_string(), + McpAddField::AppHermes => texts::tui_label_app_hermes().to_string(), }; let value = match field { @@ -219,6 +220,13 @@ pub(crate) fn mcp_field_label_and_value( "[ ]".to_string() } } + McpAddField::AppHermes => { + if mcp.apps.hermes { + format!("[{}]", texts::tui_marker_active()) + } else { + "[ ]".to_string() + } + } _ => mcp .input(field) .map(|v| v.value.trim().to_string()) @@ -251,6 +259,7 @@ pub(crate) fn mcp_field_editor_line( McpAddField::AppCodex => format!("codex = {}", mcp.apps.codex), McpAddField::AppGemini => format!("gemini = {}", mcp.apps.gemini), McpAddField::AppOpenCode => format!("opencode = {}", mcp.apps.opencode), + McpAddField::AppHermes => format!("hermes = {}", mcp.apps.hermes), _ => String::new(), }; @@ -286,7 +295,8 @@ fn mcp_add_form_key_items( McpAddField::AppClaude | McpAddField::AppCodex | McpAddField::AppGemini - | McpAddField::AppOpenCode, + | McpAddField::AppOpenCode + | McpAddField::AppHermes, ) => texts::tui_key_toggle(), _ => texts::tui_key_edit_mode(), }; @@ -299,7 +309,8 @@ fn mcp_add_form_key_items( McpAddField::AppClaude | McpAddField::AppCodex | McpAddField::AppGemini - | McpAddField::AppOpenCode, + | McpAddField::AppOpenCode + | McpAddField::AppHermes, ) => { keys.push(("Space", texts::tui_key_toggle())); } diff --git a/src-tauri/src/cli/tui/ui/forms/provider.rs b/src-tauri/src/cli/tui/ui/forms/provider.rs index 50be0ca9..114e9481 100644 --- a/src-tauri/src/cli/tui/ui/forms/provider.rs +++ b/src-tauri/src/cli/tui/ui/forms/provider.rs @@ -31,7 +31,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::OpenClaw => None, + AppType::Codex | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => None, } .filter(Value::is_object) } @@ -851,6 +851,9 @@ pub(crate) fn provider_field_label_and_value( ProviderAddField::HermesApiKey => texts::tui_label_api_key().to_string(), ProviderAddField::HermesModel => texts::model_label().to_string(), ProviderAddField::HermesModels => texts::tui_label_hermes_models().to_string(), + ProviderAddField::HermesRateLimitDelay => { + texts::tui_label_hermes_rate_limit_delay().to_string() + } ProviderAddField::ClaudeBaseUrl => texts::tui_label_base_url().to_string(), ProviderAddField::ClaudeApiFormat => texts::tui_label_claude_api_format().to_string(), ProviderAddField::ClaudeApiKey => texts::tui_label_api_key().to_string(), diff --git a/src-tauri/src/cli/tui/ui/forms/shared.rs b/src-tauri/src/cli/tui/ui/forms/shared.rs index 89bd92c5..3f7faef6 100644 --- a/src-tauri/src/cli/tui/ui/forms/shared.rs +++ b/src-tauri/src/cli/tui/ui/forms/shared.rs @@ -44,7 +44,7 @@ pub(crate) fn add_form_key_items( ProviderAddField::ClaudeModelConfig | ProviderAddField::CommonSnippet | ProviderAddField::UsageQuery - | ProviderAddField::OpenClawModels, + | ProviderAddField::OpenClawModels | ProviderAddField::HermesModels, ) => texts::tui_key_open(), Some( diff --git a/src-tauri/src/cli/tui/ui/hermes_memory.rs b/src-tauri/src/cli/tui/ui/hermes_memory.rs new file mode 100644 index 00000000..20511869 --- /dev/null +++ b/src-tauri/src/cli/tui/ui/hermes_memory.rs @@ -0,0 +1,107 @@ +use super::*; + +pub(super) fn render_hermes_memory( + frame: &mut Frame<'_>, + app: &App, + data: &UiData, + area: Rect, + theme: &super::theme::Theme, +) { + let outer = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_style(pane_border_style(app, Focus::Content, theme)) + .title(crate::t!("Memory", "记忆管理")); + frame.render_widget(outer.clone(), area); + let inner = outer.inner(area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(0)]) + .split(inner); + + if app.focus == Focus::Content { + render_key_bar_center( + frame, + chunks[0], + theme, + &[ + ("Enter/e", texts::tui_key_edit()), + ("Space", crate::t!("Toggle", "切换")), + ], + ); + } + + let memory = &data.config.hermes_memory; + let rows = vec![ + hermes_memory_row( + "Agent Memory (MEMORY.md)", + memory.memory_enabled, + memory.memory_content.len(), + memory.memory_limit, + &memory.memory_content, + ), + hermes_memory_row( + "User Profile (USER.md)", + memory.user_enabled, + memory.user_content.len(), + memory.user_limit, + &memory.user_content, + ), + ]; + + let table = Table::new( + rows, + [ + Constraint::Length(28), + Constraint::Length(10), + Constraint::Length(14), + Constraint::Min(16), + ], + ) + .block(Block::default().borders(Borders::NONE)) + .header(Row::new(vec![ + Cell::from(crate::t!("File", "文件")), + Cell::from(crate::t!("Status", "状态")), + Cell::from(crate::t!("Usage", "用量")), + Cell::from(crate::t!("Preview", "预览")), + ])) + .row_highlight_style(selection_style(theme)) + .highlight_symbol(highlight_symbol(theme)); + + let mut state = TableState::default(); + state.select(Some(app.hermes_memory_idx.min(1))); + frame.render_stateful_widget(table, inset_left(chunks[1], CONTENT_INSET_LEFT), &mut state); +} + +fn hermes_memory_row( + label: &'static str, + enabled: bool, + current: usize, + limit: usize, + content: &str, +) -> Row<'static> { + let status = if enabled { + crate::t!("enabled", "enabled") + } else { + crate::t!("disabled", "disabled") + }; + Row::new(vec![ + Cell::from(label), + Cell::from(status), + Cell::from(format!("{current} / {limit}")), + Cell::from(memory_preview(content)), + ]) +} + +fn memory_preview(content: &str) -> String { + let preview = content + .lines() + .find(|line| !line.trim().is_empty()) + .unwrap_or(""); + if preview.is_empty() { + crate::t!("empty", "empty").to_string() + } else { + preview.chars().take(80).collect() + } +} diff --git a/src-tauri/src/cli/tui/ui/mcp.rs b/src-tauri/src/cli/tui/ui/mcp.rs index 99fea8cc..696ba5c2 100644 --- a/src-tauri/src/cli/tui/ui/mcp.rs +++ b/src-tauri/src/cli/tui/ui/mcp.rs @@ -29,6 +29,7 @@ pub(super) fn render_mcp( Cell::from(crate::app_config::AppType::Codex.as_str()), Cell::from(crate::app_config::AppType::Gemini.as_str()), Cell::from(crate::app_config::AppType::OpenCode.as_str()), + Cell::from(crate::app_config::AppType::Hermes.as_str()), ]) .style(Style::default().fg(theme.dim).add_modifier(Modifier::BOLD)); @@ -55,6 +56,11 @@ pub(super) fn render_mcp( } else { texts::tui_marker_inactive() }), + Cell::from(if row.server.apps.hermes { + texts::tui_marker_active() + } else { + texts::tui_marker_inactive() + }), ]) }); @@ -112,6 +118,11 @@ pub(super) fn render_mcp( .iter() .filter(|row| row.server.apps.opencode) .count(), + data.mcp + .rows + .iter() + .filter(|row| row.server.apps.hermes) + .count(), ); render_summary_bar(frame, chunks[1], theme, summary); @@ -123,6 +134,7 @@ pub(super) fn render_mcp( Constraint::Length(8), Constraint::Length(8), Constraint::Length(10), + Constraint::Length(8), ], ) .header(header) diff --git a/src-tauri/src/cli/tui/ui/skills/helpers.rs b/src-tauri/src/cli/tui/ui/skills/helpers.rs index 6d27a757..408aee25 100644 --- a/src-tauri/src/cli/tui/ui/skills/helpers.rs +++ b/src-tauri/src/cli/tui/ui/skills/helpers.rs @@ -42,6 +42,9 @@ pub(super) fn enabled_skill_apps_text(apps: &crate::app_config::SkillApps) -> St if apps.opencode { enabled.push("OpenCode"); } + if apps.hermes { + enabled.push("Hermes"); + } if enabled.is_empty() { texts::none().to_string() diff --git a/src-tauri/src/cli/tui/ui/skills/installed.rs b/src-tauri/src/cli/tui/ui/skills/installed.rs index b167a8f1..37024e97 100644 --- a/src-tauri/src/cli/tui/ui/skills/installed.rs +++ b/src-tauri/src/cli/tui/ui/skills/installed.rs @@ -50,6 +50,7 @@ pub(super) fn render_skills_installed( Cell::from(crate::app_config::AppType::Codex.as_str()), Cell::from(crate::app_config::AppType::Gemini.as_str()), Cell::from(crate::app_config::AppType::OpenCode.as_str()), + Cell::from(crate::app_config::AppType::Hermes.as_str()), ]) .style(Style::default().fg(theme.dim).add_modifier(Modifier::BOLD)); @@ -60,6 +61,7 @@ pub(super) fn render_skills_installed( Cell::from(skill_marker(skill.apps.codex)), Cell::from(skill_marker(skill.apps.gemini)), Cell::from(skill_marker(skill.apps.opencode)), + Cell::from(skill_marker(skill.apps.hermes)), ]) }); @@ -71,6 +73,7 @@ pub(super) fn render_skills_installed( Constraint::Length(8), Constraint::Length(8), Constraint::Length(10), + Constraint::Length(8), ], ) .header(header) @@ -108,12 +111,19 @@ fn installed_summary(data: &UiData) -> String { .iter() .filter(|s| s.apps.opencode) .count(); + let enabled_hermes = data + .skills + .installed + .iter() + .filter(|s| s.apps.hermes) + .count(); texts::tui_skills_installed_counts( enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, + enabled_hermes, ) } diff --git a/src-tauri/src/cli/tui/ui/tests.rs b/src-tauri/src/cli/tui/ui/tests.rs index 63b3b618..ddaab11d 100644 --- a/src-tauri/src/cli/tui/ui/tests.rs +++ b/src-tauri/src/cli/tui/ui/tests.rs @@ -1022,6 +1022,7 @@ fn visible_apps_picker_uses_space_toggle_key() { codex: false, gemini: false, opencode: false, + hermes: false, openclaw: false, }, }; @@ -2368,7 +2369,7 @@ fn skills_page_renders_sync_method_and_installed_rows() { let buf = render(&app, &data); let all = all_text(&buf); - assert!(all.contains(&texts::tui_skills_installed_counts(1, 0, 0, 0))); + assert!(all.contains(&texts::tui_skills_installed_counts(1, 0, 0, 0, 0))); assert!(!all.contains(texts::tui_header_directory())); assert!(all.contains(AppType::Claude.as_str())); assert!(all.contains(AppType::Codex.as_str())); @@ -2462,6 +2463,33 @@ fn skills_page_shows_opencode_summary() { assert!(all.contains("OpenCode: 1")); } +#[test] +fn skills_page_renders_hermes_column_and_summary() { + let _lock = lock_env(); + let _no_color = EnvGuard::remove("NO_COLOR"); + + let mut app = App::new(Some(AppType::Hermes)); + app.route = Route::Skills; + app.focus = Focus::Content; + + let mut data = minimal_data(&app.app_type); + let mut skill = installed_skill("hello-skill", "Hello Skill"); + skill.apps = SkillApps { + claude: false, + codex: false, + gemini: false, + opencode: false, + hermes: true, + }; + data.skills.installed = vec![skill]; + + let buf = render(&app, &data); + let all = all_text(&buf); + + assert!(all.contains(AppType::Hermes.as_str()), "{all}"); + assert!(all.contains("Hermes: 1"), "{all}"); +} + #[test] fn skill_detail_page_shows_opencode_enabled_state() { let _lock = lock_env(); @@ -2492,6 +2520,36 @@ fn skill_detail_page_shows_opencode_enabled_state() { assert!(!all.contains("opencode=true")); } +#[test] +fn skill_detail_page_shows_hermes_enabled_state() { + let _lock = lock_env(); + let _no_color = EnvGuard::remove("NO_COLOR"); + + let mut app = App::new(Some(AppType::Hermes)); + app.route = Route::SkillDetail { + directory: "hello-skill".to_string(), + }; + app.focus = Focus::Content; + + let mut data = minimal_data(&app.app_type); + let mut skill = installed_skill("hello-skill", "Hello Skill"); + skill.apps = SkillApps { + claude: false, + codex: false, + gemini: false, + opencode: false, + hermes: true, + }; + data.skills.installed = vec![skill]; + + let buf = render(&app, &data); + let all = all_text(&buf); + + assert!(all.contains(texts::tui_label_enabled_for())); + assert!(all.contains("Hermes")); + assert!(!all.contains("hermes=true")); +} + #[test] fn skills_import_overlay_uses_friendly_copy() { let _lock = lock_env(); @@ -2557,6 +2615,43 @@ fn mcp_page_renders_opencode_column() { assert!(all.contains("opencode")); } +#[test] +fn mcp_page_renders_hermes_column_and_summary() { + let _lock = lock_env(); + let _no_color = EnvGuard::remove("NO_COLOR"); + + let mut app = App::new(Some(AppType::Hermes)); + app.route = Route::Mcp; + app.focus = Focus::Content; + + let mut data = minimal_data(&app.app_type); + data.mcp.rows = vec![super::super::data::McpRow { + id: "m1".to_string(), + server: crate::app_config::McpServer { + id: "m1".to_string(), + name: "Server".to_string(), + server: json!({}), + apps: crate::app_config::McpApps { + claude: false, + codex: false, + gemini: false, + opencode: false, + hermes: true, + }, + description: None, + homepage: None, + docs: None, + tags: vec![], + }, + }]; + + let buf = render(&app, &data); + let all = all_text(&buf); + + assert!(all.contains("hermes"), "{all}"); + assert!(all.contains("Hermes: 1"), "{all}"); +} + #[test] fn mcp_page_key_bar_hides_validate_action() { let _lock = lock_env(); @@ -5111,6 +5206,68 @@ fn workspace_openclaw_nav_uses_app_specific_labels_and_hides_generic_entries() { assert!(!all.contains(&nav_label_text(NavItem::Config)), "{all}"); } +#[test] +fn hermes_nav_uses_app_specific_labels_and_keeps_config_without_prompts() { + let _lock = lock_env(); + let _lang = use_test_language(Language::Chinese); + let _no_color = EnvGuard::remove("NO_COLOR"); + + let app = App::new(Some(AppType::Hermes)); + let buf = render(&app, &minimal_data(&app.app_type)); + let all = nav_text(&app, &buf); + let expected = [ + NavItem::Main, + NavItem::Providers, + NavItem::Mcp, + NavItem::Skills, + NavItem::HermesMemory, + NavItem::Config, + NavItem::Settings, + NavItem::Exit, + ] + .map(nav_label_text); + let positions = expected + .iter() + .map(|label| all.find(label).expect("Hermes nav label should render")) + .collect::>(); + + assert!(positions.windows(2).all(|pair| pair[0] < pair[1]), "{all}"); + assert!(!all.contains(&nav_label_text(NavItem::Prompts)), "{all}"); + assert!( + !all.contains(&nav_label_text(NavItem::OpenClawWorkspace)), + "{all}" + ); +} + +#[test] +fn hermes_memory_route_renders_memory_rows_and_limits() { + let _lock = lock_env(); + let _lang = use_test_language(Language::English); + let _no_color = EnvGuard::remove("NO_COLOR"); + + let mut app = App::new(Some(AppType::Hermes)); + app.route = Route::HermesMemory; + app.focus = Focus::Content; + let mut data = minimal_data(&app.app_type); + data.config.hermes_memory.memory_content = "Remember project decisions.".to_string(); + data.config.hermes_memory.user_content = "Alex prefers concise summaries.".to_string(); + data.config.hermes_memory.memory_limit = 2200; + data.config.hermes_memory.user_limit = 1375; + data.config.hermes_memory.memory_enabled = true; + data.config.hermes_memory.user_enabled = false; + + let buf = render(&app, &data); + let all = content_text(&app, &buf); + + assert!(all.contains("Memory"), "{all}"); + assert!(all.contains("Agent Memory (MEMORY.md)"), "{all}"); + assert!(all.contains("User Profile (USER.md)"), "{all}"); + assert!(all.contains("enabled"), "{all}"); + assert!(all.contains("disabled"), "{all}"); + assert!(all.contains("2200"), "{all}"); + assert!(all.contains("1375"), "{all}"); +} + #[test] fn workspace_non_openclaw_nav_keeps_generic_labels() { let _lock = lock_env(); diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs index 69a5e7ea..d38c5297 100644 --- a/src-tauri/src/database/dao/skills.rs +++ b/src-tauri/src/database/dao/skills.rs @@ -22,7 +22,7 @@ impl Database { let mut stmt = conn .prepare( "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, - readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at + readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes, installed_at FROM skills ORDER BY name ASC", ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -43,9 +43,9 @@ impl Database { codex: row.get(9)?, gemini: row.get(10)?, opencode: row.get(11)?, - hermes: false, + hermes: row.get(12)?, }, - installed_at: row.get(12)?, + installed_at: row.get(13)?, }) }) .map_err(|e| AppError::Database(e.to_string()))?; @@ -64,7 +64,7 @@ impl Database { let mut stmt = conn .prepare( "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, - readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at + readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes, installed_at FROM skills WHERE id = ?1", ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -84,9 +84,9 @@ impl Database { codex: row.get(9)?, gemini: row.get(10)?, opencode: row.get(11)?, - hermes: false, + hermes: row.get(12)?, }, - installed_at: row.get(12)?, + installed_at: row.get(13)?, }) }); @@ -103,8 +103,8 @@ impl Database { conn.execute( "INSERT OR REPLACE INTO skills (id, name, description, directory, repo_owner, repo_name, repo_branch, - readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes, installed_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", params![ skill.id, skill.name, @@ -118,6 +118,7 @@ impl Database { skill.apps.codex, skill.apps.gemini, skill.apps.opencode, + skill.apps.hermes, skill.installed_at, ], ) @@ -147,8 +148,8 @@ impl Database { let conn = lock_conn!(self.conn); let affected = conn .execute( - "UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4 WHERE id = ?5", - params![apps.claude, apps.codex, apps.gemini, apps.opencode, id], + "UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4, enabled_hermes = ?5 WHERE id = ?6", + params![apps.claude, apps.codex, apps.gemini, apps.opencode, apps.hermes, id], ) .map_err(|e| AppError::Database(e.to_string()))?; Ok(affected > 0) diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs index 554cd55b..217c118d 100644 --- a/src-tauri/src/hermes_config.rs +++ b/src-tauri/src/hermes_config.rs @@ -1,17 +1,55 @@ -use crate::config::{atomic_write, home_dir}; +//! Hermes Agent configuration read/write module (ported from upstream cc-switch). +//! +//! Handles read/write operations on `~/.hermes/config.yaml` (YAML format). +//! Hermes uses additive provider management: all provider configurations +//! coexist in the same config file. +//! +//! ## Example config layout +//! +//! ```yaml +//! model: +//! default: "anthropic/claude-opus-4-7" +//! provider: "openrouter" +//! base_url: "https://openrouter.ai/api/v1" +//! +//! agent: +//! max_turns: 50 +//! reasoning_effort: "high" +//! +//! custom_providers: +//! - name: openrouter +//! base_url: https://openrouter.ai/api/v1 +//! api_key: sk-or-... +//! model: anthropic/claude-opus-4-7 +//! models: +//! anthropic/claude-opus-4-7: +//! context_length: 200000 +//! +//! mcp_servers: +//! filesystem: +//! command: npx +//! args: ["-y", "@modelcontextprotocol/server-filesystem"] +//! ``` + +use crate::config::{atomic_write, get_app_config_dir, home_dir}; use crate::error::AppError; -use crate::settings::get_hermes_override_dir; +use crate::settings::{effective_backup_retain_count, get_hermes_override_dir}; +use chrono::Local; use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; +use std::collections::HashMap; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; -fn default_config() -> Value { - json!({ - "custom_providers": [] - }) -} +// ============================================================================ +// Path Functions +// ============================================================================ +/// Get the Hermes config directory. +/// +/// Default: `~/.hermes/`. Can be overridden via `settings.hermes_config_dir`. pub fn get_hermes_dir() -> PathBuf { if let Some(override_dir) = get_hermes_override_dir() { return override_dir; @@ -22,10 +60,52 @@ pub fn get_hermes_dir() -> PathBuf { .unwrap_or_else(|| PathBuf::from(".hermes")) } +/// Get the Hermes config file path (`/config.yaml`). pub fn get_hermes_config_path() -> PathBuf { get_hermes_dir().join("config.yaml") } +fn hermes_write_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +// ============================================================================ +// Type Definitions +// ============================================================================ + +/// Hermes write outcome (kept for upstream API compatibility; current CLI +/// callers do not consume this). +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct HermesWriteOutcome { + #[serde(skip_serializing_if = "Option::is_none")] + pub backup_path: Option, +} + +/// Hermes top-level `model:` section. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct HermesModelConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub base_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub context_length: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + /// Preserve unknown fields for forward compatibility. + #[serde(flatten)] + pub extra: HashMap, +} + +// ============================================================================ +// Core YAML / Text Read Functions +// ============================================================================ + +/// Read the raw Hermes config file (unparsed). Returns `None` if absent. pub fn read_hermes_config_source() -> Result, AppError> { let path = get_hermes_config_path(); if !path.exists() { @@ -37,59 +117,636 @@ pub fn read_hermes_config_source() -> Result, AppError> { .map_err(|e| AppError::io(&path, e)) } +/// Write raw Hermes config (no backup created; intended for snapshot/restore +/// callers). pub fn write_hermes_config_source(source: &str) -> Result<(), AppError> { let path = get_hermes_config_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; } - atomic_write(&path, source.as_bytes()) } -pub fn read_hermes_config() -> Result { +/// Read the Hermes config file as `serde_yaml::Value`. Returns an empty +/// `Mapping` if the file is missing or empty. +pub fn read_hermes_config_yaml() -> Result { let path = get_hermes_config_path(); if !path.exists() { - return Ok(default_config()); + return Ok(serde_yaml::Value::Mapping(serde_yaml::Mapping::new())); } - let source = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; - let yaml_value: serde_yaml::Value = serde_yaml::from_str(&source) - .map_err(|e| AppError::Config(format!("Failed to parse Hermes config as YAML: {e}")))?; - serde_json::to_value(yaml_value).map_err(|e| AppError::JsonSerialize { source: e }) + let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; + if content.trim().is_empty() { + return Ok(serde_yaml::Value::Mapping(serde_yaml::Mapping::new())); + } + + serde_yaml::from_str(&content) + .map_err(|e| AppError::Config(format!("Failed to parse Hermes config as YAML: {e}"))) +} + +/// Read the Hermes config file as `serde_json::Value` (legacy API used by +/// the service layer). +pub fn read_hermes_config() -> Result { + let yaml_value = read_hermes_config_yaml()?; + yaml_to_json(&yaml_value) +} + +// ============================================================================ +// YAML <-> JSON Conversion Helpers (public so e.g. `mcp::hermes_*` can reuse) +// ============================================================================ + +/// Convert `serde_yaml::Value` to `serde_json::Value`. +pub fn yaml_to_json(yaml: &serde_yaml::Value) -> Result { + let yaml_str = serde_yaml::to_string(yaml) + .map_err(|e| AppError::Config(format!("Failed to serialize YAML value: {e}")))?; + serde_yaml::from_str::(&yaml_str) + .map_err(|e| AppError::Config(format!("Failed to convert YAML to JSON: {e}"))) } -fn write_hermes_config(config: &Value) -> Result<(), AppError> { - let yaml_value = serde_yaml::to_value(config) - .map_err(|e| AppError::Config(format!("Failed to convert Hermes config to YAML: {e}")))?; - let yaml = serde_yaml::to_string(&yaml_value) - .map_err(|e| AppError::Config(format!("Failed to serialize Hermes config as YAML: {e}")))?; - write_hermes_config_source(&yaml) +/// Convert `serde_json::Value` to `serde_yaml::Value`. +pub fn json_to_yaml(json: &Value) -> Result { + let json_str = serde_json::to_string(json) + .map_err(|e| AppError::Config(format!("Failed to serialize JSON value: {e}")))?; + serde_yaml::from_str(&json_str) + .map_err(|e| AppError::Config(format!("Failed to convert JSON to YAML: {e}"))) } -fn ensure_object(value: &mut Value) -> &mut Map { - if !value.is_object() { - *value = Value::Object(Map::new()); +// ============================================================================ +// YAML Section-Level Replacement +// ============================================================================ + +/// Returns true if `line` is a YAML top-level key: column 0, not a comment, +/// not a sequence item, and contains a `:` followed by space/EOL. +fn is_top_level_key_line(line: &str) -> bool { + if line.is_empty() { + return false; + } + let first_char = line.as_bytes()[0]; + if first_char == b' ' || first_char == b'\t' || first_char == b'#' || first_char == b'-' { + return false; + } + if let Some(colon_pos) = line.find(':') { + let after_colon = &line[colon_pos + 1..]; + after_colon.is_empty() || after_colon.starts_with(' ') || after_colon.starts_with('\t') + } else { + false } - value - .as_object_mut() - .expect("value should be object after normalization") } -fn provider_id_from_value(value: &Value) -> Option { - let object = value.as_object()?; - for key in ["name", "id", "provider"] { - let candidate = object - .get(key) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()); - if let Some(id) = candidate { - return Some(id.to_string()); +/// Locate the byte range of a top-level YAML section. Returns +/// `(start_inclusive, end_exclusive)`, or `None` if the section is absent. +fn find_yaml_section_range(raw: &str, section_key: &str) -> Option<(usize, usize)> { + let target = format!("{}:", section_key); + let mut section_start = None; + let mut offset = 0; + + for line in raw.split('\n') { + if section_start.is_none() && is_top_level_key_line(line) && line.starts_with(&target) { + let after_target = &line[target.len()..]; + if after_target.is_empty() + || after_target.starts_with(' ') + || after_target.starts_with('\t') + || after_target.starts_with('\r') + { + section_start = Some(offset); + } + } else if section_start.is_some() && is_top_level_key_line(line) { + return Some((section_start.unwrap(), offset)); } + offset += line.len() + 1; // +1 for \n } - None + + section_start.map(|start| (start, raw.len())) +} + +/// Serialise `key: value` to a YAML fragment. +fn serialize_yaml_section(key: &str, value: &serde_yaml::Value) -> Result { + let mut section = serde_yaml::Mapping::new(); + section.insert(serde_yaml::Value::String(key.to_string()), value.clone()); + serde_yaml::to_string(&serde_yaml::Value::Mapping(section)) + .map_err(|e| AppError::Config(format!("Failed to serialize YAML section '{key}': {e}"))) } +/// Replace the named section in `raw`. If the section is absent, append it +/// to the end of the file. +fn replace_yaml_section( + raw: &str, + section_key: &str, + value: &serde_yaml::Value, +) -> Result { + let serialized = serialize_yaml_section(section_key, value)?; + + if let Some((start, end)) = find_yaml_section_range(raw, section_key) { + let mut result = String::with_capacity(raw.len()); + result.push_str(&raw[..start]); + result.push_str(&serialized); + let remainder = &raw[end..]; + if !serialized.ends_with('\n') && !remainder.is_empty() && !remainder.starts_with('\n') { + result.push('\n'); + } + result.push_str(remainder); + Ok(result) + } else { + let mut result = raw.to_string(); + if !result.is_empty() && !result.ends_with('\n') { + result.push('\n'); + } + result.push_str(&serialized); + if !result.ends_with('\n') { + result.push('\n'); + } + Ok(result) + } +} + +// ============================================================================ +// Backup & Cleanup +// ============================================================================ + +fn create_hermes_backup(source: &str) -> Result { + let backup_dir = get_app_config_dir().join("backups").join("hermes"); + fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; + + let base_id = format!("hermes_{}", Local::now().format("%Y%m%d_%H%M%S")); + let mut filename = format!("{base_id}.yaml"); + let mut backup_path = backup_dir.join(&filename); + let mut counter = 1; + + while backup_path.exists() { + filename = format!("{base_id}_{counter}.yaml"); + backup_path = backup_dir.join(&filename); + counter += 1; + } + + atomic_write(&backup_path, source.as_bytes())?; + cleanup_hermes_backups(&backup_dir)?; + Ok(backup_path) +} + +fn cleanup_hermes_backups(dir: &Path) -> Result<(), AppError> { + let retain = effective_backup_retain_count(); + let mut entries = fs::read_dir(dir) + .map_err(|e| AppError::io(dir, e))? + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry + .path() + .extension() + .map(|ext| ext == "yaml" || ext == "yml") + .unwrap_or(false) + }) + .collect::>(); + + if entries.len() <= retain { + return Ok(()); + } + + entries.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok()); + let remove_count = entries.len().saturating_sub(retain); + for entry in entries.into_iter().take(remove_count) { + if let Err(err) = fs::remove_file(entry.path()) { + log::warn!( + "Failed to remove old Hermes config backup {}: {err}", + entry.path().display() + ); + } + } + + Ok(()) +} + +// ============================================================================ +// High-level Section Write +// ============================================================================ + +fn write_yaml_section_to_config( + section_key: &str, + value: &serde_yaml::Value, +) -> Result { + let _guard = hermes_write_lock() + .lock() + .map_err(|e| AppError::Config(format!("Failed to acquire Hermes write lock: {e}")))?; + write_yaml_section_to_config_locked(section_key, value) +} + +fn write_yaml_section_to_config_locked( + section_key: &str, + value: &serde_yaml::Value, +) -> Result { + let config_path = get_hermes_config_path(); + let raw = if config_path.exists() { + fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))? + } else { + String::new() + }; + + let new_raw = replace_yaml_section(&raw, section_key, value)?; + + if new_raw == raw { + return Ok(HermesWriteOutcome::default()); + } + + let backup_path = if !raw.is_empty() { + Some(create_hermes_backup(&raw)?) + } else { + None + }; + + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + + atomic_write(&config_path, new_raw.as_bytes())?; + + log::debug!( + "Hermes config section '{}' written to {:?}", + section_key, + config_path + ); + Ok(HermesWriteOutcome { + backup_path: backup_path.map(|p| p.display().to_string()), + }) +} + +// ============================================================================ +// Provider Helpers: models array <-> dict, key normalization, source marker +// ============================================================================ + +/// Convert the UI-friendly array form of `models` to Hermes' YAML dict shape. +/// +/// Entries with a missing/empty `id` are dropped. The `id` field is hoisted +/// to be the map key. Insertion order is preserved (requires the +/// `preserve_order` feature on `serde_json`). +fn models_array_to_dict(array: Vec) -> Value { + let mut map = Map::new(); + for item in array { + let Value::Object(mut obj) = item else { + continue; + }; + let Some(id) = obj + .remove("id") + .and_then(|v| v.as_str().map(|s| s.trim().to_string())) + .filter(|s| !s.is_empty()) + else { + continue; + }; + map.insert(id, Value::Object(obj)); + } + Value::Object(map) +} + +/// Inverse of [`models_array_to_dict`]: YAML dict -> ordered array, with +/// `id` re-injected as an object field. +fn models_dict_to_array(dict: Map) -> Value { + let mut out = Vec::with_capacity(dict.len()); + for (id, value) in dict { + let mut obj = match value { + Value::Object(obj) => obj, + Value::Null => Map::new(), + other => { + log::warn!("Unexpected Hermes model entry for '{id}': {other:?}, skipping"); + continue; + } + }; + obj.insert("id".to_string(), Value::String(id)); + out.push(Value::Object(obj)); + } + Value::Array(out) +} + +/// Source marker: entries read from Hermes v12+ `providers:` dict carry +/// this field so the UI can render them read-only. +pub const PROVIDER_SOURCE_FIELD: &str = "_cc_source"; +pub const PROVIDER_SOURCE_CUSTOM_LIST: &str = "custom_providers"; +pub const PROVIDER_SOURCE_DICT: &str = "providers_dict"; + +/// Rewrite historical camelCase keys to Hermes' snake_case schema. +fn sanitize_hermes_provider_keys(config: &mut Value) { + const KEY_ALIASES: &[(&str, &str)] = &[ + ("baseUrl", "base_url"), + ("apiKey", "api_key"), + ("apiMode", "api_mode"), + ("maxTokens", "max_tokens"), + ("contextLength", "context_length"), + ]; + // Legacy fields that are neither valid Hermes keys nor mappable to + // `api_mode`; also strip UI-only source markers before writing YAML. + const LEGACY_FIELDS_TO_DROP: &[&str] = &["api", PROVIDER_SOURCE_FIELD, "provider_key"]; + + let Some(obj) = config.as_object_mut() else { + return; + }; + + for (from, to) in KEY_ALIASES { + if let Some(val) = obj.remove(*from) { + obj.entry((*to).to_string()).or_insert(val); + } + } + + for field in LEGACY_FIELDS_TO_DROP { + obj.remove(*field); + } +} + +/// Pre-write: if `models` is a JSON array, convert it in-place to a dict. +fn normalize_provider_models_for_write(config: &mut Value) { + let Some(obj) = config.as_object_mut() else { + return; + }; + let Some(models_val) = obj.get_mut("models") else { + return; + }; + if models_val.is_array() { + let taken = std::mem::take(models_val); + if let Value::Array(arr) = taken { + *models_val = models_array_to_dict(arr); + } + } +} + +/// Post-read: if `models` is a JSON dict, convert it in-place to an ordered +/// array. +fn denormalize_provider_models_for_read(config: &mut Value) { + let Some(obj) = config.as_object_mut() else { + return; + }; + let Some(models_val) = obj.get_mut("models") else { + return; + }; + if models_val.is_object() { + let taken = std::mem::take(models_val); + if let Value::Object(map) = taken { + *models_val = models_dict_to_array(map); + } + } +} + +/// Normalise a single `providers:` dict entry into the same shape as items +/// in the `custom_providers:` list. +fn normalize_providers_dict_entry( + key: &str, + entry: &serde_yaml::Value, +) -> Result, AppError> { + if !entry.is_mapping() { + return Ok(None); + } + let mut json_val = yaml_to_json(entry)?; + let Some(obj) = json_val.as_object_mut() else { + return Ok(None); + }; + let resolved_name = obj + .get("name") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| key.trim().to_string()); + if resolved_name.is_empty() { + return Ok(None); + } + obj.insert("name".to_string(), json!(resolved_name)); + obj.insert("provider_key".to_string(), json!(key)); + obj.insert( + PROVIDER_SOURCE_FIELD.to_string(), + json!(PROVIDER_SOURCE_DICT), + ); + Ok(Some(json_val)) +} + +/// Collect provider entries from the `providers:` dict. +fn read_providers_dict_entries(config: &serde_yaml::Value) -> Vec<(String, Value)> { + let Some(mapping) = config.get("providers").and_then(|v| v.as_mapping()) else { + return Vec::new(); + }; + let mut out = Vec::with_capacity(mapping.len()); + for (k, v) in mapping { + let Some(key_str) = k.as_str().map(str::trim).filter(|s| !s.is_empty()) else { + continue; + }; + match normalize_providers_dict_entry(key_str, v) { + Ok(Some(entry)) => { + let name = entry + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or(key_str) + .to_string(); + out.push((name, entry)); + } + Ok(None) => { + log::debug!("Skipping Hermes providers['{key_str}']: not a mapping"); + } + Err(e) => { + log::warn!("Failed to normalize Hermes providers['{key_str}']: {e}"); + } + } + } + out +} + +/// Returns true when `name` only appears in the `providers:` dict (i.e. it +/// is read-only from CC Switch's perspective). +fn is_dict_only_provider(config: &serde_yaml::Value, name: &str) -> bool { + let list_has = config + .get("custom_providers") + .and_then(|v| v.as_sequence()) + .map(|seq| { + seq.iter() + .any(|item| item.get("name").and_then(|n| n.as_str()) == Some(name)) + }) + .unwrap_or(false); + if list_has { + return false; + } + config + .get("providers") + .and_then(|v| v.as_mapping()) + .map(|m| { + m.iter().any(|(k, v)| { + let key_matches = k.as_str() == Some(name); + let name_matches = v + .get("name") + .and_then(|n| n.as_str()) + .map(|s| s == name) + .unwrap_or(false); + (key_matches || name_matches) && v.is_mapping() + }) + }) + .unwrap_or(false) +} + +/// Reject writes that target providers living in the `providers:` overlay +/// dict. +fn ensure_provider_writable( + config: &serde_yaml::Value, + name: &str, + verb: &str, +) -> Result<(), AppError> { + if is_dict_only_provider(config, name) { + return Err(AppError::Config(format!( + "Provider '{name}' is managed by the Hermes 'providers:' dict \ + — please {verb} it via the Hermes web UI" + ))); + } + Ok(()) +} + +// ============================================================================ +// Provider Public API +// ============================================================================ + +/// Get all providers indexed by name. +/// +/// Merges two sources: +/// - `custom_providers:` list (writable from CC Switch) +/// - `providers:` dict (v12+; read-only, tagged with +/// `_cc_source = "providers_dict"`) +/// +/// On name collision, the list wins. The `models` field is denormalised +/// from the YAML dict back to an ordered array. +pub fn get_providers() -> Result, AppError> { + let config = read_hermes_config_yaml()?; + let mut map = IndexMap::new(); + + if let Some(seq) = config.get("custom_providers").and_then(|v| v.as_sequence()) { + for item in seq { + if let Some(name) = item.get("name").and_then(|n| n.as_str()) { + match yaml_to_json(item) { + Ok(mut json_val) => { + sanitize_hermes_provider_keys(&mut json_val); + denormalize_provider_models_for_read(&mut json_val); + if let Some(obj) = json_val.as_object_mut() { + obj.insert( + PROVIDER_SOURCE_FIELD.to_string(), + json!(PROVIDER_SOURCE_CUSTOM_LIST), + ); + } + map.insert(name.to_string(), json_val); + } + Err(e) => { + log::warn!("Failed to convert Hermes provider '{name}' to JSON: {e}"); + } + } + } + } + } + + for (name, mut entry) in read_providers_dict_entries(&config) { + if map.contains_key(&name) { + continue; // List wins on name collision. + } + denormalize_provider_models_for_read(&mut entry); + map.insert(name, entry); + } + + Ok(map) +} + +/// Get a single provider by name. +pub fn get_provider(name: &str) -> Result, AppError> { + Ok(get_providers()?.get(name).cloned()) +} + +/// Insert or update a provider in the `custom_providers:` list. +/// +/// - Matches existing entries by `name`. +/// - Pre-write: camelCase keys are normalised to snake_case, and `models` +/// arrays are converted to dicts. +/// - Mirrors the first `models` key onto the top-level `model:` field +/// (this is what Hermes actually reads on activation). +/// - For existing entries, performs a forward-compat merge: fields present +/// on disk but not submitted by the UI are preserved. +/// - Holds the write lock end-to-end to avoid TOCTOU races. +pub fn set_provider(name: &str, provider_config: Value) -> Result<(), AppError> { + let _guard = hermes_write_lock() + .lock() + .map_err(|e| AppError::Config(format!("Failed to acquire Hermes write lock: {e}")))?; + + let config = read_hermes_config_yaml()?; + ensure_provider_writable(&config, name, "edit")?; + + let mut providers: Vec = config + .get("custom_providers") + .and_then(|v| v.as_sequence()) + .cloned() + .unwrap_or_default(); + + let mut normalized = provider_config; + sanitize_hermes_provider_keys(&mut normalized); + normalize_provider_models_for_write(&mut normalized); + + let first_model_id = normalized + .get("models") + .and_then(|v| v.as_object()) + .and_then(|obj| obj.keys().next()) + .cloned(); + + let mut yaml_val: serde_yaml::Value = json_to_yaml(&normalized)?; + if let serde_yaml::Value::Mapping(ref mut m) = yaml_val { + m.insert( + serde_yaml::Value::String("name".to_string()), + serde_yaml::Value::String(name.to_string()), + ); + if let Some(model_id) = first_model_id { + m.insert( + serde_yaml::Value::String("model".to_string()), + serde_yaml::Value::String(model_id), + ); + } else { + m.remove(serde_yaml::Value::String("model".to_string())); + } + } + + if let Some(existing) = providers + .iter_mut() + .find(|p| p.get("name").and_then(|n| n.as_str()) == Some(name)) + { + if let (Some(existing_map), serde_yaml::Value::Mapping(new_map)) = + (existing.as_mapping(), &mut yaml_val) + { + for (k, v) in existing_map { + new_map.entry(k.clone()).or_insert_with(|| v.clone()); + } + } + *existing = yaml_val; + } else { + providers.push(yaml_val); + } + + let providers_value = serde_yaml::Value::Sequence(providers); + write_yaml_section_to_config_locked("custom_providers", &providers_value)?; + Ok(()) +} + +/// Remove a provider from the `custom_providers:` list. +pub fn remove_provider(name: &str) -> Result<(), AppError> { + let _guard = hermes_write_lock() + .lock() + .map_err(|e| AppError::Config(format!("Failed to acquire Hermes write lock: {e}")))?; + let config = read_hermes_config_yaml()?; + + ensure_provider_writable(&config, name, "delete")?; + + let mut providers: Vec = config + .get("custom_providers") + .and_then(|v| v.as_sequence()) + .cloned() + .unwrap_or_default(); + + let original_len = providers.len(); + providers.retain(|p| p.get("name").and_then(|n| n.as_str()) != Some(name)); + if providers.len() == original_len { + return Ok(()); + } + + let providers_value = serde_yaml::Value::Sequence(providers); + write_yaml_section_to_config_locked("custom_providers", &providers_value)?; + Ok(()) +} + +// ============================================================================ +// Current Provider Helpers +// ============================================================================ + fn primary_model_id_from_value(value: &Value) -> Option { value .get("model") @@ -145,30 +802,8 @@ fn provider_matches_model(provider: &Value, model_id: &str) -> bool { }) } -fn normalize_provider_value(id: &str, provider: Value) -> Result { - let mut provider = provider; - let object = provider.as_object_mut().ok_or_else(|| { - AppError::localized( - "provider.hermes.settings.not_object", - "Hermes 配置必须是 JSON 对象", - "Hermes configuration must be a JSON object", - ) - })?; - - let has_identifier = ["name", "id", "provider"].iter().any(|key| { - object - .get(*key) - .and_then(Value::as_str) - .map(str::trim) - .is_some_and(|value| !value.is_empty()) - }); - if !has_identifier { - object.insert("name".to_string(), Value::String(id.to_string())); - } - - Ok(provider) -} - +/// Get the currently active provider id (driven by top-level +/// `model.provider`). pub fn get_current_provider_id() -> Result, AppError> { let config = read_hermes_config()?; let Some(model) = config.get("model").and_then(Value::as_object) else { @@ -188,6 +823,13 @@ pub fn get_current_provider_id() -> Result, AppError> { } } + // Direct name match (v12+ `providers:` dict). + if !provider_ref.is_empty() && provider_ref != "custom" { + if get_providers()?.contains_key(provider_ref) { + return Ok(Some(provider_ref.to_string())); + } + } + if provider_ref == "custom" { let default_model = model .get("default") @@ -206,102 +848,438 @@ pub fn get_current_provider_id() -> Result, AppError> { Ok(None) } -pub fn get_providers() -> Result, AppError> { - let config = read_hermes_config()?; - let custom_providers = config - .get("custom_providers") +/// Switch to a given provider by writing the top-level `model:` section. +/// +/// `model.provider` is always updated; `model.default` is only overwritten +/// when the new provider declares at least one model — otherwise the old +/// value is preserved to avoid leaving Hermes without an available model. +pub fn set_current_provider(id: &str, provider: &Value) -> Result<(), AppError> { + let current = get_model_config()?.unwrap_or_default(); + + let first_model_id = primary_model_id_from_value(provider); + let merged = HermesModelConfig { + default: first_model_id.or(current.default.clone()), + provider: Some(format!("custom:{id}")), + ..current + }; + set_model_config(&merged)?; + Ok(()) +} + +// ============================================================================ +// Model Section +// ============================================================================ + +/// Read the top-level `model:` section. +pub fn get_model_config() -> Result, AppError> { + let config = read_hermes_config_yaml()?; + let Some(model_value) = config.get("model") else { + return Ok(None); + }; + let json_val = yaml_to_json(model_value)?; + let model = serde_json::from_value(json_val) + .map_err(|e| AppError::Config(format!("Failed to parse Hermes model config: {e}")))?; + Ok(Some(model)) +} + +/// Write the top-level `model:` section. +pub fn set_model_config(model: &HermesModelConfig) -> Result<(), AppError> { + let json_val = + serde_json::to_value(model).map_err(|e| AppError::JsonSerialize { source: e })?; + let yaml_val = json_to_yaml(&json_val)?; + write_yaml_section_to_config("model", &yaml_val)?; + Ok(()) +} + +/// Refresh the top-level `model:` defaults when switching providers. +pub fn apply_switch_defaults(provider_id: &str, settings_config: &Value) -> Result<(), AppError> { + let first_model_id = primary_model_id_from_value(settings_config); + + let current = get_model_config()?.unwrap_or_default(); + let merged = HermesModelConfig { + default: first_model_id.or(current.default.clone()), + provider: Some(provider_id.to_string()), + ..current + }; + set_model_config(&merged)?; + Ok(()) +} + +// ============================================================================ +// MCP Section Access (consumed by `mcp::hermes_*` helpers) +// ============================================================================ + +/// Get the `mcp_servers:` section. +pub fn get_mcp_servers_yaml() -> Result { + let config = read_hermes_config_yaml()?; + Ok(config + .get("mcp_servers") + .and_then(|v| v.as_mapping()) .cloned() - .unwrap_or_else(|| Value::Array(Vec::new())); - - let mut providers = IndexMap::new(); - match custom_providers { - Value::Array(entries) => { - for entry in entries { - if let Some(id) = provider_id_from_value(&entry) { - providers.insert(id, entry); - } - } + .unwrap_or_default()) +} + +/// Read-modify-write the `mcp_servers:` section under the write lock. +pub fn update_mcp_servers_yaml(updater: F) -> Result<(), AppError> +where + F: FnOnce(&mut serde_yaml::Mapping) -> Result<(), AppError>, +{ + let _guard = hermes_write_lock() + .lock() + .map_err(|e| AppError::Config(format!("Failed to acquire Hermes write lock: {e}")))?; + let config = read_hermes_config_yaml()?; + let mut servers = config + .get("mcp_servers") + .and_then(|v| v.as_mapping()) + .cloned() + .unwrap_or_default(); + updater(&mut servers)?; + let value = serde_yaml::Value::Mapping(servers); + write_yaml_section_to_config_locked("mcp_servers", &value)?; + Ok(()) +} + +// ============================================================================ +// Memory Files (~/.hermes/memories/{MEMORY,USER}.md) +// ============================================================================ + +/// The two Hermes memory blob kinds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum MemoryKind { + Memory, + User, +} + +impl MemoryKind { + pub fn filename(self) -> &'static str { + match self { + Self::Memory => "MEMORY.md", + Self::User => "USER.md", } - Value::Object(entries) => { - for (id, entry) in entries { - providers.insert(id, entry); - } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Memory => "memory", + Self::User => "user", } - _ => {} } +} - Ok(providers) +fn memories_dir() -> PathBuf { + get_hermes_dir().join("memories") } -pub fn set_current_provider(id: &str, provider: &Value) -> Result<(), AppError> { - let mut config = read_hermes_config()?; - let root = ensure_object(&mut config); - let model = root - .entry("model".to_string()) - .or_insert_with(|| Value::Object(Map::new())); - let model = ensure_object(model); - - model.insert( - "provider".to_string(), - Value::String(format!("custom:{id}")), - ); - if let Some(model_id) = primary_model_id_from_value(provider) { - model.insert("default".to_string(), Value::String(model_id)); +/// Read a Hermes memory file. Returns an empty string when the file does +/// not exist. +pub fn read_memory(kind: MemoryKind) -> Result { + let path = memories_dir().join(kind.filename()); + match fs::read_to_string(&path) { + Ok(content) => Ok(content), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), + Err(e) => Err(AppError::io(&path, e)), } +} - write_hermes_config(&config) +/// Atomically write a Hermes memory file. +pub fn write_memory(kind: MemoryKind, content: &str) -> Result<(), AppError> { + let path = memories_dir().join(kind.filename()); + atomic_write(&path, content.as_bytes()) } -pub fn set_provider(id: &str, provider: Value) -> Result<(), AppError> { - let mut config = read_hermes_config()?; - let root = ensure_object(&mut config); - let normalized = normalize_provider_value(id, provider)?; +/// Per-blob character budget plus enable flags. Missing fields fall back +/// to defaults. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HermesMemoryLimits { + pub memory: usize, + pub user: usize, + pub memory_enabled: bool, + pub user_enabled: bool, +} - match root - .entry("custom_providers".to_string()) - .or_insert_with(|| Value::Array(Vec::new())) - { - Value::Array(entries) => { - let mut replaced = false; - for entry in entries.iter_mut() { - if provider_id_from_value(entry).as_deref() == Some(id) { - *entry = normalized.clone(); - replaced = true; - break; - } - } - if !replaced { - entries.push(normalized); - } - } - Value::Object(entries) => { - entries.insert(id.to_string(), normalized); - } - slot => { - *slot = Value::Array(vec![normalized]); +impl Default for HermesMemoryLimits { + fn default() -> Self { + Self { + memory: 2200, + user: 1375, + memory_enabled: true, + user_enabled: true, } } +} - write_hermes_config(&config) +/// Toggle a memory blob on/off while preserving the rest of the `memory:` +/// section. +pub fn set_memory_enabled(kind: MemoryKind, enabled: bool) -> Result<(), AppError> { + let _guard = hermes_write_lock() + .lock() + .map_err(|e| AppError::Config(format!("Failed to acquire Hermes write lock: {e}")))?; + let config = read_hermes_config_yaml()?; + + let mut memory = match config.get("memory") { + Some(serde_yaml::Value::Mapping(m)) => m.clone(), + _ => serde_yaml::Mapping::new(), + }; + + let key = match kind { + MemoryKind::Memory => "memory_enabled", + MemoryKind::User => "user_profile_enabled", + }; + memory.insert( + serde_yaml::Value::String(key.to_string()), + serde_yaml::Value::Bool(enabled), + ); + + write_yaml_section_to_config_locked("memory", &serde_yaml::Value::Mapping(memory))?; + Ok(()) } -pub fn remove_provider(id: &str) -> Result<(), AppError> { - let mut config = read_hermes_config()?; - let root = ensure_object(&mut config); +/// Read memory budgets + enable flags. Falls back to defaults for any +/// field that fails to parse. +pub fn read_memory_limits() -> Result { + let mut out = HermesMemoryLimits::default(); + let config = read_hermes_config_yaml()?; + let Some(memory) = config.get("memory") else { + return Ok(out); + }; - if let Some(custom_providers) = root.get_mut("custom_providers") { - match custom_providers { - Value::Array(entries) => { - entries.retain(|entry| provider_id_from_value(entry).as_deref() != Some(id)); - } - Value::Object(entries) => { - entries.remove(id); - } - _ => { - *custom_providers = Value::Array(Vec::new()); - } - } + if let Some(v) = memory.get("memory_char_limit").and_then(|v| v.as_u64()) { + out.memory = v as usize; + } + if let Some(v) = memory.get("user_char_limit").and_then(|v| v.as_u64()) { + out.user = v as usize; + } + if let Some(v) = memory.get("memory_enabled").and_then(|v| v.as_bool()) { + out.memory_enabled = v; + } + if let Some(v) = memory.get("user_profile_enabled").and_then(|v| v.as_bool()) { + out.user_enabled = v; + } + + Ok(out) +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use serial_test::serial; + use std::sync::{Mutex, OnceLock}; + + fn test_guard() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|err| err.into_inner()) + } + + fn with_test_home(test_fn: impl FnOnce() -> T) -> T { + let _guard = test_guard(); + let tmp = tempfile::tempdir().unwrap(); + crate::test_support::set_test_home_override(Some(tmp.path())); + let result = test_fn(); + crate::test_support::set_test_home_override(None); + result + } + + #[test] + fn sanitize_rewrites_camel_case_aliases() { + let mut v = json!({ + "baseUrl": "https://x", + "apiKey": "k", + "maxTokens": 4096, + "contextLength": 200000, + }); + sanitize_hermes_provider_keys(&mut v); + let obj = v.as_object().unwrap(); + assert!(obj.contains_key("base_url")); + assert!(obj.contains_key("api_key")); + assert!(obj.contains_key("max_tokens")); + assert!(obj.contains_key("context_length")); + assert!(!obj.contains_key("baseUrl")); } - write_hermes_config(&config) + #[test] + fn sanitize_drops_legacy_fields() { + let mut v = json!({ + "api": "openai-completions", + "_cc_source": "custom_providers", + "provider_key": "x", + "base_url": "https://x", + }); + sanitize_hermes_provider_keys(&mut v); + let obj = v.as_object().unwrap(); + assert!(!obj.contains_key("api")); + assert!(!obj.contains_key("_cc_source")); + assert!(!obj.contains_key("provider_key")); + assert!(obj.contains_key("base_url")); + } + + #[test] + fn models_array_to_dict_roundtrip() { + let arr = vec![ + json!({"id": "foo", "context_length": 200000}), + json!({"id": "bar"}), + json!({"id": " "}), + json!({"context_length": 1}), + ]; + let dict = models_array_to_dict(arr); + let map = dict.as_object().unwrap(); + assert_eq!(map.len(), 2); + assert!(map.contains_key("foo")); + assert!(map.contains_key("bar")); + + let back = models_dict_to_array(map.clone()); + let arr = back.as_array().unwrap(); + assert_eq!(arr.len(), 2); + let ids: Vec<&str> = arr.iter().filter_map(|v| v["id"].as_str()).collect(); + assert!(ids.contains(&"foo")); + assert!(ids.contains(&"bar")); + } + + #[test] + #[serial] + fn set_and_get_provider_roundtrip() { + with_test_home(|| { + let provider = json!({ + "base_url": "https://example.com/v1", + "api_key": "sk-test", + "models": [ + {"id": "gpt-4o", "context_length": 128000}, + {"id": "gpt-3.5"} + ] + }); + set_provider("acme", provider).unwrap(); + + let got = get_provider("acme").unwrap().expect("provider exists"); + assert_eq!(got["base_url"], "https://example.com/v1"); + // models should be in array form + let arr = got["models"].as_array().unwrap(); + assert_eq!(arr.len(), 2); + // first model id reflected to top-level + // (read goes through get_providers which strips dict form back to array) + let yaml = read_hermes_config_yaml().unwrap(); + let seq = yaml["custom_providers"].as_sequence().unwrap(); + let entry = seq + .iter() + .find(|p| p["name"].as_str() == Some("acme")) + .unwrap(); + assert_eq!(entry["model"].as_str(), Some("gpt-4o")); + // models in YAML must be a mapping + assert!(entry["models"].is_mapping()); + }); + } + + #[test] + #[serial] + fn remove_provider_works() { + with_test_home(|| { + set_provider("foo", json!({"base_url": "u1"})).unwrap(); + set_provider("bar", json!({"base_url": "u2"})).unwrap(); + remove_provider("foo").unwrap(); + let providers = get_providers().unwrap(); + assert!(!providers.contains_key("foo")); + assert!(providers.contains_key("bar")); + }); + } + + #[test] + #[serial] + fn dict_only_provider_is_read_only() { + with_test_home(|| { + let raw = "providers:\n remote:\n name: remote\n base_url: u\n"; + write_hermes_config_source(raw).unwrap(); + let providers = get_providers().unwrap(); + let entry = providers.get("remote").unwrap(); + assert_eq!(entry["_cc_source"], "providers_dict"); + // Edits / removes are rejected + let err = set_provider("remote", json!({"base_url": "x"})).unwrap_err(); + assert!(err.to_string().contains("providers")); + let err = remove_provider("remote").unwrap_err(); + assert!(err.to_string().contains("providers")); + }); + } + + #[test] + #[serial] + fn section_writes_preserve_other_sections() { + with_test_home(|| { + let raw = "# leading comment\n\ +agent:\n max_turns: 5\n\ +custom_providers: []\n"; + write_hermes_config_source(raw).unwrap(); + set_provider("foo", json!({"base_url": "u"})).unwrap(); + let text = read_hermes_config_source().unwrap().unwrap(); + assert!(text.contains("# leading comment")); + assert!(text.contains("agent:")); + assert!(text.contains("max_turns: 5")); + assert!(text.contains("foo")); + }); + } + + #[test] + #[serial] + fn memory_round_trip() { + with_test_home(|| { + write_memory(MemoryKind::Memory, "hello").unwrap(); + assert_eq!(read_memory(MemoryKind::Memory).unwrap(), "hello"); + assert_eq!(read_memory(MemoryKind::User).unwrap(), ""); + }); + } + + #[test] + #[serial] + fn memory_limits_defaults_when_absent() { + with_test_home(|| { + let limits = read_memory_limits().unwrap(); + assert_eq!(limits.memory, 2200); + assert_eq!(limits.user, 1375); + assert!(limits.memory_enabled); + assert!(limits.user_enabled); + }); + } + + #[test] + #[serial] + fn memory_set_enabled_preserves_other_fields() { + with_test_home(|| { + let raw = "memory:\n memory_char_limit: 4000\n user_char_limit: 2000\n"; + write_hermes_config_source(raw).unwrap(); + set_memory_enabled(MemoryKind::Memory, false).unwrap(); + let limits = read_memory_limits().unwrap(); + assert_eq!(limits.memory, 4000); + assert!(!limits.memory_enabled); + assert!(limits.user_enabled); + }); + } + + #[test] + #[serial] + fn set_current_provider_writes_model_section() { + with_test_home(|| { + set_provider( + "acme", + json!({ + "base_url": "https://x", + "models": [{"id": "model-a"}] + }), + ) + .unwrap(); + set_current_provider( + "acme", + &json!({ + "models": [{"id": "model-a"}] + }), + ) + .unwrap(); + let id = get_current_provider_id().unwrap().unwrap(); + assert_eq!(id, "acme"); + }); + } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 9bad2247..e8888a0c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -41,6 +41,7 @@ fn run(cli: Cli) -> Result<(), AppError> { Some(Commands::Failover(cmd)) => { cc_switch_lib::cli::commands::failover::execute(cmd, cli.app) } + Some(Commands::Hermes(cmd)) => cc_switch_lib::cli::commands::hermes::execute(cmd), #[cfg(unix)] Some(Commands::Start(cmd)) => cc_switch_lib::cli::commands::start::execute(cmd), Some(Commands::Env(cmd)) => cc_switch_lib::cli::commands::env::execute(cmd, cli.app), diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 856d72e3..331e1650 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -1388,3 +1388,430 @@ pub fn remove_server_from_opencode(id: &str) -> Result<(), AppError> { crate::opencode_config::remove_mcp_server(id) } + +// ============================================================================ +// Hermes MCP sync / remove / import +// ============================================================================ +// +// Behavioural notes (aligned with upstream `mcp/hermes.rs`): +// - Hermes has NO explicit `type` field; it infers `stdio` from `command` +// and `http` from `url`. +// - Hermes carries extra per-server fields: `enabled` / `timeout` / +// `connect_timeout` / `tools` / `sampling` / `roots` / `auth`. These are +// preserved on merge-on-write and stripped on import. + +/// Hermes-private fields preserved on write and stripped on import. +const HERMES_EXTRA_FIELDS: &[&str] = &[ + "enabled", + "timeout", + "connect_timeout", + "tools", + "sampling", + "roots", + "auth", +]; + +fn should_sync_hermes_mcp() -> bool { + crate::hermes_config::get_hermes_dir().exists() +} + +/// Convert CC Switch's unified MCP format to the Hermes YAML shape. +fn convert_to_hermes_mcp_spec(spec: &Value) -> Result { + let obj = spec + .as_object() + .ok_or_else(|| AppError::McpValidation("MCP spec must be a JSON object".into()))?; + + let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio"); + let mut result = serde_json::Map::new(); + + match typ { + "stdio" => { + if let Some(command) = obj.get("command") { + result.insert("command".into(), command.clone()); + } + if let Some(args) = obj.get("args") { + if args.is_array() && !args.as_array().map(|a| a.is_empty()).unwrap_or(true) { + result.insert("args".into(), args.clone()); + } + } + if let Some(env) = obj.get("env") { + if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) { + result.insert("env".into(), env.clone()); + } + } + } + "sse" | "http" => { + if let Some(url) = obj.get("url") { + result.insert("url".into(), url.clone()); + } + if let Some(headers) = obj.get("headers") { + if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true) + { + result.insert("headers".into(), headers.clone()); + } + } + } + other => { + return Err(AppError::McpValidation(format!( + "Unknown MCP type: {other}" + ))); + } + } + + // Hermes expects an explicit `enabled` flag; default to true on write. + result.insert("enabled".into(), json!(true)); + + Ok(Value::Object(result)) +} + +/// Convert Hermes YAML shape back to CC Switch's unified format, stripping +/// Hermes-private fields on the import path. +fn convert_from_hermes_mcp_spec(id: &str, spec: &Value) -> Result { + let obj = spec + .as_object() + .ok_or_else(|| AppError::McpValidation("Hermes MCP spec must be a JSON object".into()))?; + + let mut result = serde_json::Map::new(); + + if obj.contains_key("command") { + result.insert("type".into(), json!("stdio")); + + if let Some(command) = obj.get("command") { + result.insert("command".into(), command.clone()); + } + if let Some(args) = obj.get("args") { + if args.is_array() && !args.as_array().map(|a| a.is_empty()).unwrap_or(true) { + result.insert("args".into(), args.clone()); + } + } + if let Some(env) = obj.get("env") { + if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) { + result.insert("env".into(), env.clone()); + } + } + } else if obj.contains_key("url") { + result.insert("type".into(), json!("sse")); + + if let Some(url) = obj.get("url") { + result.insert("url".into(), url.clone()); + } + if let Some(headers) = obj.get("headers") { + if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true) { + result.insert("headers".into(), headers.clone()); + } + } + } else { + return Err(AppError::McpValidation(format!( + "Hermes MCP server '{id}' has neither a 'command' nor 'url' field" + ))); + } + + Ok(Value::Object(result)) +} + +/// Merge: core fields come from `new_spec`, Hermes-specific fields are +/// preserved from `existing`. +fn merge_hermes_spec(existing: &Value, new_spec: &Value) -> Value { + let mut result = serde_json::Map::new(); + + if let Some(existing_obj) = existing.as_object() { + for &field in HERMES_EXTRA_FIELDS { + if let Some(val) = existing_obj.get(field) { + result.insert(field.to_string(), val.clone()); + } + } + } + + if let Some(new_obj) = new_spec.as_object() { + for (key, val) in new_obj { + if HERMES_EXTRA_FIELDS.contains(&key.as_str()) && result.contains_key(key) { + continue; // Existing Hermes-private fields win. + } + result.insert(key.clone(), val.clone()); + } + } + + Value::Object(result) +} + +/// Sync a single MCP server to the Hermes live config using +/// merge-on-write semantics (preserves Hermes-private fields). +pub fn sync_single_server_to_hermes( + _config: &MultiAppConfig, + id: &str, + server_spec: &Value, +) -> Result<(), AppError> { + if !crate::sync_policy::should_sync_live(&AppType::Hermes) { + return Ok(()); + } + if !should_sync_hermes_mcp() { + return Ok(()); + } + + let hermes_spec = convert_to_hermes_mcp_spec(server_spec)?; + let id_owned = id.to_string(); + + crate::hermes_config::update_mcp_servers_yaml(|servers| { + let id_yaml = serde_yaml::Value::String(id_owned.clone()); + + let merged_json = if let Some(existing_yaml) = servers.get(&id_yaml) { + let existing_json = crate::hermes_config::yaml_to_json(existing_yaml)?; + merge_hermes_spec(&existing_json, &hermes_spec) + } else { + hermes_spec.clone() + }; + + let merged_yaml_value = crate::hermes_config::json_to_yaml(&merged_json)?; + servers.insert(id_yaml, merged_yaml_value); + Ok(()) + }) +} + +/// Remove a single MCP server from the Hermes live config. +pub fn remove_server_from_hermes(id: &str) -> Result<(), AppError> { + if !crate::sync_policy::should_sync_live(&AppType::Hermes) { + return Ok(()); + } + if !should_sync_hermes_mcp() { + return Ok(()); + } + + let id_owned = id.to_string(); + crate::hermes_config::update_mcp_servers_yaml(|servers| { + servers.remove(serde_yaml::Value::String(id_owned.clone())); + Ok(()) + }) +} + +/// Import MCP servers from the Hermes `mcp_servers:` section into the +/// unified store. +pub fn import_from_hermes(config: &mut MultiAppConfig) -> Result { + use crate::app_config::{McpApps, McpServer}; + + let yaml_map = crate::hermes_config::get_mcp_servers_yaml()?; + if yaml_map.is_empty() { + return Ok(0); + } + + if config.mcp.servers.is_none() { + config.mcp.servers = Some(HashMap::new()); + } + let servers = config.mcp.servers.as_mut().unwrap(); + + let mut changed = 0usize; + let mut errors = Vec::new(); + + for (key, spec_yaml) in &yaml_map { + let id = match key.as_str() { + Some(s) => s.to_string(), + None => { + log::warn!("Skipping Hermes MCP server with non-string key"); + continue; + } + }; + + let spec_json = match crate::hermes_config::yaml_to_json(spec_yaml) { + Ok(j) => j, + Err(e) => { + log::warn!("Skipping Hermes MCP '{id}': YAML->JSON conversion failed: {e}"); + errors.push(format!("{id}: {e}")); + continue; + } + }; + + let unified_spec = match convert_from_hermes_mcp_spec(&id, &spec_json) { + Ok(s) => s, + Err(e) => { + log::warn!("Skipping invalid Hermes MCP '{id}': {e}"); + errors.push(format!("{id}: {e}")); + continue; + } + }; + + if let Err(e) = validate_server_spec(&unified_spec) { + log::warn!("Skipping MCP '{id}' that remained invalid after conversion: {e}"); + errors.push(format!("{id}: {e}")); + continue; + } + + if let Some(existing) = servers.get_mut(&id) { + if !existing.apps.hermes { + existing.apps.hermes = true; + changed += 1; + log::info!("MCP server '{id}' enabled for Hermes"); + } + } else { + servers.insert( + id.clone(), + McpServer { + id: id.clone(), + name: id.clone(), + server: unified_spec, + apps: McpApps { + claude: false, + codex: false, + gemini: false, + opencode: false, + hermes: true, + }, + description: None, + homepage: None, + docs: None, + tags: Vec::new(), + }, + ); + changed += 1; + log::info!("Imported new MCP server '{id}' from Hermes"); + } + } + + if !errors.is_empty() { + log::warn!( + "Hermes MCP import finished with {} failure(s): {:?}", + errors.len(), + errors + ); + } + + Ok(changed) +} + +#[cfg(test)] +mod hermes_mcp_tests { + use super::*; + + #[test] + fn convert_stdio_to_hermes() { + let spec = json!({ + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem"], + "env": { "HOME": "/Users/test" } + }); + let result = convert_to_hermes_mcp_spec(&spec).unwrap(); + assert!(result.get("type").is_none()); + assert_eq!(result["command"], "npx"); + assert_eq!(result["args"][0], "-y"); + assert_eq!(result["env"]["HOME"], "/Users/test"); + assert_eq!(result["enabled"], true); + } + + #[test] + fn convert_sse_to_hermes() { + let spec = json!({ + "type": "sse", + "url": "https://example.com/mcp", + "headers": { "Authorization": "Bearer xxx" } + }); + let result = convert_to_hermes_mcp_spec(&spec).unwrap(); + assert!(result.get("type").is_none()); + assert_eq!(result["url"], "https://example.com/mcp"); + assert_eq!(result["headers"]["Authorization"], "Bearer xxx"); + assert_eq!(result["enabled"], true); + } + + #[test] + fn convert_stdio_empty_collections_are_omitted() { + let spec = json!({ + "type": "stdio", + "command": "node", + "args": [], + "env": {} + }); + let result = convert_to_hermes_mcp_spec(&spec).unwrap(); + assert_eq!(result["command"], "node"); + assert!(result.get("args").is_none()); + assert!(result.get("env").is_none()); + } + + #[test] + fn convert_from_hermes_stdio_strips_extras() { + let spec = json!({ + "command": "npx", + "args": ["-y", "x"], + "env": { "HOME": "/Users/test" }, + "enabled": true, + "timeout": 30, + "connect_timeout": 10, + "tools": { "include": ["read_file"] }, + "sampling": { "enabled": true } + }); + let result = convert_from_hermes_mcp_spec("fs", &spec).unwrap(); + assert_eq!(result["type"], "stdio"); + assert_eq!(result["command"], "npx"); + assert!(result.get("enabled").is_none()); + assert!(result.get("timeout").is_none()); + assert!(result.get("connect_timeout").is_none()); + assert!(result.get("tools").is_none()); + assert!(result.get("sampling").is_none()); + } + + #[test] + fn convert_from_hermes_http_strips_extras_and_auth() { + let spec = json!({ + "url": "https://mcp.example.com", + "auth": "oauth", + "enabled": true, + "timeout": 60 + }); + let result = convert_from_hermes_mcp_spec("remote", &spec).unwrap(); + assert_eq!(result["type"], "sse"); + assert_eq!(result["url"], "https://mcp.example.com"); + assert!( + result.get("auth").is_none(), + "auth must be stripped on import" + ); + assert!(result.get("enabled").is_none()); + } + + #[test] + fn convert_from_hermes_missing_endpoint_errors() { + let spec = json!({ "enabled": true, "timeout": 30 }); + assert!(convert_from_hermes_mcp_spec("bad", &spec).is_err()); + } + + #[test] + fn merge_preserves_hermes_extra_fields() { + let existing = json!({ + "command": "old-cmd", + "args": ["old-arg"], + "enabled": true, + "timeout": 30, + "connect_timeout": 10, + "tools": { "include": ["read_file"] }, + "sampling": { "enabled": true } + }); + let new_spec = json!({ + "command": "new-cmd", + "args": ["new-arg"], + "env": { "KEY": "value" }, + "enabled": true + }); + let merged = merge_hermes_spec(&existing, &new_spec); + assert_eq!(merged["command"], "new-cmd"); + assert_eq!(merged["args"][0], "new-arg"); + assert_eq!(merged["env"]["KEY"], "value"); + assert_eq!(merged["timeout"], 30); + assert_eq!(merged["connect_timeout"], 10); + assert_eq!(merged["tools"]["include"][0], "read_file"); + assert_eq!(merged["sampling"]["enabled"], true); + } + + #[test] + fn merge_preserves_auth_field_on_roundtrip() { + let existing = json!({ + "url": "https://mcp.example.com", + "auth": "oauth", + "enabled": true + }); + let new_spec = json!({ + "url": "https://mcp.example.com/updated", + "headers": { "X-Trace": "abc" }, + "enabled": true + }); + let merged = merge_hermes_spec(&existing, &new_spec); + assert_eq!(merged["url"], "https://mcp.example.com/updated"); + assert_eq!(merged["headers"]["X-Trace"], "abc"); + assert_eq!(merged["auth"], "oauth"); + } +} diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index 6af30cca..90505316 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -163,7 +163,9 @@ impl McpService { AppType::OpenCode => { mcp::sync_single_server_to_opencode(cfg, &server.id, &server.server)?; } - AppType::Hermes => {} + AppType::Hermes => { + mcp::sync_single_server_to_hermes(cfg, &server.id, &server.server)?; + } AppType::OpenClaw => {} } Ok(()) @@ -188,7 +190,7 @@ impl McpService { AppType::Codex => mcp::remove_server_from_codex(id)?, AppType::Gemini => mcp::remove_server_from_gemini(id)?, AppType::OpenCode => mcp::remove_server_from_opencode(id)?, - AppType::Hermes => {} + AppType::Hermes => mcp::remove_server_from_hermes(id)?, AppType::OpenClaw => {} } Ok(()) @@ -199,7 +201,7 @@ impl McpService { let servers = Self::get_all_servers(state)?; for app in AppType::all() { - if matches!(app, AppType::Hermes | AppType::OpenClaw) { + if matches!(app, AppType::OpenClaw) { continue; } @@ -298,4 +300,13 @@ impl McpService { state.save()?; Ok(count) } + + /// 从 Hermes 导入 MCP + pub fn import_from_hermes(state: &AppState) -> Result { + let mut cfg = state.config.write()?; + let count = mcp::import_from_hermes(&mut cfg)?; + drop(cfg); + state.save()?; + Ok(count) + } } diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index bf6ec8a2..d40c4f1e 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -921,7 +921,7 @@ impl ProviderService { } AppType::Gemini => live_settings.get("env") != provider_settings.get("env"), AppType::Claude => live_settings != provider_settings, - AppType::OpenCode | AppType::OpenClaw => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => false, } } @@ -1071,6 +1071,7 @@ impl ProviderService { AppType::Codex => Self::extract_codex_common_config(settings_config), AppType::Gemini => Self::extract_gemini_common_config(settings_config), 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), } } diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 07072b02..c09a7f81 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -382,7 +382,7 @@ pub struct SkillService { impl SkillService { fn app_supports_skills(app: &AppType) -> bool { - !matches!(app, AppType::Hermes | AppType::OpenClaw) + !matches!(app, AppType::OpenClaw) } fn supported_skill_apps() -> impl Iterator { @@ -391,6 +391,7 @@ impl SkillService { AppType::Codex, AppType::Gemini, AppType::OpenCode, + AppType::Hermes, ] .into_iter() } @@ -952,6 +953,7 @@ impl SkillService { AppType::Codex, AppType::Gemini, AppType::OpenCode, + AppType::Hermes, ] { if let Err(e) = Self::remove_from_app(&dir, &app) { log::warn!("从 {app:?} 删除 Skill {dir} 失败: {e}");