From 81af0a57f99af854c22094419412f05f8e2f212e Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 15 Apr 2026 16:22:35 +0800 Subject: [PATCH] feat: add Hermes Agent as 6th supported app type (Phase 1) Register AppType::Hermes across the entire Rust backend: - Add Hermes variant to AppType enum with additive mode and MCP support - Add hermes field to McpApps, SkillApps, CommonConfigSnippets, and all per-app structs (McpRoot, PromptRoot, VisibleApps, AppSettings) - Create minimal hermes_config.rs with get_hermes_dir() respecting settings override, matching the pattern of other app config modules - Update all match arms in commands, services, deeplink, proxy, mcp, session_manager, and test files - Extract shared build_additive_app_settings() to eliminate duplication between OpenClaw and Hermes deep link handling - Combine identical OpenClaw/Hermes proxy match arms into unified arms --- src-tauri/src/app_config.rs | 45 ++++++++++++-- src-tauri/src/commands/config.rs | 11 ++++ src-tauri/src/database/dao/mcp.rs | 1 + src-tauri/src/deeplink/mcp.rs | 5 ++ src-tauri/src/deeplink/parser.rs | 12 ++-- src-tauri/src/deeplink/provider.rs | 12 ++-- src-tauri/src/hermes_config.rs | 31 ++++++++++ src-tauri/src/lib.rs | 2 + src-tauri/src/mcp/claude.rs | 1 + src-tauri/src/mcp/codex.rs | 1 + src-tauri/src/mcp/gemini.rs | 1 + src-tauri/src/mcp/opencode.rs | 1 + src-tauri/src/prompt_files.rs | 4 +- src-tauri/src/proxy/providers/mod.rs | 18 ++---- src-tauri/src/services/config.rs | 3 + src-tauri/src/services/mcp.rs | 10 +++- src-tauri/src/services/provider/live.rs | 33 +++++++++-- src-tauri/src/services/provider/mod.rs | 30 +++++++++- src-tauri/src/services/proxy.rs | 79 +++++++------------------ src-tauri/src/services/skill.rs | 6 ++ src-tauri/src/services/stream_check.rs | 26 ++++++-- src-tauri/src/session_manager/mod.rs | 3 + src-tauri/src/settings.rs | 28 +++++++++ src-tauri/tests/import_export_sync.rs | 2 + src-tauri/tests/mcp_commands.rs | 8 +++ src-tauri/tests/provider_commands.rs | 1 + src-tauri/tests/provider_service.rs | 1 + 27 files changed, 272 insertions(+), 103 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 4a9fc7539..321d2c484 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -15,6 +15,8 @@ pub struct McpApps { pub gemini: bool, #[serde(default)] pub opencode: bool, + #[serde(default)] + pub hermes: bool, } impl McpApps { @@ -26,6 +28,7 @@ impl McpApps { AppType::Gemini => self.gemini, AppType::OpenCode => self.opencode, AppType::OpenClaw => false, // OpenClaw doesn't support MCP + AppType::Hermes => self.hermes, } } @@ -37,6 +40,7 @@ impl McpApps { AppType::Gemini => self.gemini = enabled, AppType::OpenCode => self.opencode = enabled, AppType::OpenClaw => {} // OpenClaw doesn't support MCP, ignore + AppType::Hermes => self.hermes = enabled, } } @@ -55,12 +59,15 @@ impl McpApps { if self.opencode { apps.push(AppType::OpenCode); } + if self.hermes { + apps.push(AppType::Hermes); + } apps } /// 检查是否所有应用都未启用 pub fn is_empty(&self) -> bool { - !self.claude && !self.codex && !self.gemini && !self.opencode + !self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes } } @@ -86,6 +93,7 @@ impl SkillApps { AppType::Gemini => self.gemini, AppType::OpenCode => self.opencode, AppType::OpenClaw => false, // OpenClaw doesn't support Skills + AppType::Hermes => false, // Hermes doesn't support Skills yet } } @@ -97,6 +105,7 @@ impl SkillApps { AppType::Gemini => self.gemini = enabled, AppType::OpenCode => self.opencode = enabled, AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore + AppType::Hermes => {} // Hermes doesn't support Skills yet, ignore } } @@ -251,6 +260,9 @@ pub struct McpRoot { /// OpenClaw MCP 配置(v4.1.0+,实际使用 openclaw.json) #[serde(default, skip_serializing_if = "McpConfig::is_empty")] pub openclaw: McpConfig, + /// Hermes MCP 配置(实际使用 config.yaml) + #[serde(default, skip_serializing_if = "McpConfig::is_empty")] + pub hermes: McpConfig, } impl Default for McpRoot { @@ -264,6 +276,7 @@ impl Default for McpRoot { gemini: McpConfig::default(), opencode: McpConfig::default(), openclaw: McpConfig::default(), + hermes: McpConfig::default(), } } } @@ -288,6 +301,8 @@ pub struct PromptRoot { pub opencode: PromptConfig, #[serde(default)] pub openclaw: PromptConfig, + #[serde(default)] + pub hermes: PromptConfig, } use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file}; @@ -304,6 +319,7 @@ pub enum AppType { Gemini, OpenCode, OpenClaw, + Hermes, } impl AppType { @@ -314,15 +330,19 @@ impl AppType { AppType::Gemini => "gemini", AppType::OpenCode => "opencode", AppType::OpenClaw => "openclaw", + AppType::Hermes => "hermes", } } /// Check if this app uses additive mode /// /// - Switch mode (false): Only the current provider is written to live config (Claude, Codex, Gemini) - /// - Additive mode (true): All providers are written to live config (OpenCode, OpenClaw) + /// - Additive mode (true): All providers are written to live config (OpenCode, OpenClaw, Hermes) pub fn is_additive_mode(&self) -> bool { - matches!(self, AppType::OpenCode | AppType::OpenClaw) + matches!( + self, + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes + ) } /// Return an iterator over all app types @@ -333,6 +353,7 @@ impl AppType { AppType::Gemini, AppType::OpenCode, AppType::OpenClaw, + AppType::Hermes, ] .into_iter() } @@ -349,10 +370,11 @@ impl FromStr for AppType { "gemini" => Ok(AppType::Gemini), "opencode" => Ok(AppType::OpenCode), "openclaw" => Ok(AppType::OpenClaw), + "hermes" => Ok(AppType::Hermes), other => Err(AppError::localized( "unsupported_app", - format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, openclaw。"), - format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, openclaw."), + format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, openclaw, hermes。"), + format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, openclaw, hermes."), )), } } @@ -375,6 +397,9 @@ pub struct CommonConfigSnippets { #[serde(default, skip_serializing_if = "Option::is_none")] pub openclaw: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hermes: Option, } impl CommonConfigSnippets { @@ -386,6 +411,7 @@ impl CommonConfigSnippets { AppType::Gemini => self.gemini.as_ref(), AppType::OpenCode => self.opencode.as_ref(), AppType::OpenClaw => self.openclaw.as_ref(), + AppType::Hermes => self.hermes.as_ref(), } } @@ -397,6 +423,7 @@ impl CommonConfigSnippets { AppType::Gemini => self.gemini = snippet, AppType::OpenCode => self.opencode = snippet, AppType::OpenClaw => self.openclaw = snippet, + AppType::Hermes => self.hermes = snippet, } } } @@ -438,6 +465,7 @@ impl Default for MultiAppConfig { apps.insert("gemini".to_string(), ProviderManager::default()); apps.insert("opencode".to_string(), ProviderManager::default()); apps.insert("openclaw".to_string(), ProviderManager::default()); + apps.insert("hermes".to_string(), ProviderManager::default()); Self { version: 2, @@ -598,6 +626,7 @@ impl MultiAppConfig { AppType::Gemini => &self.mcp.gemini, AppType::OpenCode => &self.mcp.opencode, AppType::OpenClaw => &self.mcp.openclaw, + AppType::Hermes => &self.mcp.hermes, } } @@ -609,6 +638,7 @@ impl MultiAppConfig { AppType::Gemini => &mut self.mcp.gemini, AppType::OpenCode => &mut self.mcp.opencode, AppType::OpenClaw => &mut self.mcp.openclaw, + AppType::Hermes => &mut self.mcp.hermes, } } @@ -624,6 +654,7 @@ impl MultiAppConfig { 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::OpenClaw)?; + Self::auto_import_prompt_if_exists(&mut config, AppType::Hermes)?; Ok(config) } @@ -645,6 +676,7 @@ impl MultiAppConfig { || !self.prompts.gemini.prompts.is_empty() || !self.prompts.opencode.prompts.is_empty() || !self.prompts.openclaw.prompts.is_empty() + || !self.prompts.hermes.prompts.is_empty() { return Ok(false); } @@ -658,6 +690,7 @@ impl MultiAppConfig { AppType::Gemini, AppType::OpenCode, AppType::OpenClaw, + AppType::Hermes, ] { // 复用已有的单应用导入逻辑 if Self::auto_import_prompt_if_exists(self, app)? { @@ -729,6 +762,7 @@ impl MultiAppConfig { AppType::Gemini => &mut config.prompts.gemini.prompts, AppType::OpenCode => &mut config.prompts.opencode.prompts, AppType::OpenClaw => &mut config.prompts.openclaw.prompts, + AppType::Hermes => &mut config.prompts.hermes.prompts, }; prompts.insert(id, prompt); @@ -769,6 +803,7 @@ impl MultiAppConfig { AppType::Gemini => &self.mcp.gemini.servers, AppType::OpenCode => &self.mcp.opencode.servers, AppType::OpenClaw => continue, // OpenClaw MCP is still in development, skip + AppType::Hermes => continue, // Hermes didn't exist in v3.6.x, skip }; for (id, entry) in old_servers { diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 60c0f1c79..48fd89e24 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -101,6 +101,15 @@ pub async fn get_config_status(app: String) -> Result { Ok(ConfigStatus { exists, path }) } + AppType::Hermes => { + let config_path = crate::hermes_config::get_hermes_config_path(); + let exists = config_path.exists(); + let path = crate::hermes_config::get_hermes_dir() + .to_string_lossy() + .to_string(); + + Ok(ConfigStatus { exists, path }) + } } } @@ -117,6 +126,7 @@ pub async fn get_config_dir(app: String) -> Result { AppType::Gemini => crate::gemini_config::get_gemini_dir(), AppType::OpenCode => crate::opencode_config::get_opencode_dir(), AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(), + AppType::Hermes => crate::hermes_config::get_hermes_dir(), }; Ok(dir.to_string_lossy().to_string()) @@ -130,6 +140,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result crate::gemini_config::get_gemini_dir(), AppType::OpenCode => crate::opencode_config::get_opencode_dir(), AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(), + AppType::Hermes => crate::hermes_config::get_hermes_dir(), }; if !config_dir.exists() { diff --git a/src-tauri/src/database/dao/mcp.rs b/src-tauri/src/database/dao/mcp.rs index d5c60163f..03188fb51 100644 --- a/src-tauri/src/database/dao/mcp.rs +++ b/src-tauri/src/database/dao/mcp.rs @@ -46,6 +46,7 @@ impl Database { codex: enabled_codex, gemini: enabled_gemini, opencode: enabled_opencode, + hermes: false, }, description, homepage, diff --git a/src-tauri/src/deeplink/mcp.rs b/src-tauri/src/deeplink/mcp.rs index 37df96579..94430c5e5 100644 --- a/src-tauri/src/deeplink/mcp.rs +++ b/src-tauri/src/deeplink/mcp.rs @@ -167,6 +167,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result { codex: false, gemini: false, opencode: false, + hermes: false, }; for app in apps_str.split(',') { @@ -179,6 +180,10 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result { // OpenClaw doesn't support MCP, ignore silently log::debug!("OpenClaw doesn't support MCP, ignoring in apps parameter"); } + "hermes" => { + // TODO: Hermes MCP sync not yet implemented, ignore silently for now + log::debug!("Hermes MCP sync not yet implemented, ignoring in apps parameter"); + } other => { return Err(AppError::InvalidInput(format!( "Invalid app in 'apps': {other}" diff --git a/src-tauri/src/deeplink/parser.rs b/src-tauri/src/deeplink/parser.rs index 290e30318..aca40fec6 100644 --- a/src-tauri/src/deeplink/parser.rs +++ b/src-tauri/src/deeplink/parser.rs @@ -81,10 +81,10 @@ fn parse_provider_deeplink( // Validate app type if !matches!( app.as_str(), - "claude" | "codex" | "gemini" | "opencode" | "openclaw" + "claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes" ) { return Err(AppError::InvalidInput(format!( - "Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', or 'openclaw', got '{app}'" + "Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'" ))); } @@ -190,10 +190,10 @@ fn parse_prompt_deeplink( // Validate app type if !matches!( app.as_str(), - "claude" | "codex" | "gemini" | "opencode" | "openclaw" + "claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes" ) { return Err(AppError::InvalidInput(format!( - "Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', or 'openclaw', got '{app}'" + "Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'" ))); } @@ -262,10 +262,10 @@ fn parse_mcp_deeplink( let trimmed = app.trim(); if !matches!( trimmed, - "claude" | "codex" | "gemini" | "opencode" | "openclaw" + "claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes" ) { return Err(AppError::InvalidInput(format!( - "Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'opencode', or 'openclaw', got '{trimmed}'" + "Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{trimmed}'" ))); } } diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 87e07282f..216e1ff04 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -146,7 +146,7 @@ pub(crate) fn build_provider_from_request( AppType::Codex => build_codex_settings(request), AppType::Gemini => build_gemini_settings(request), AppType::OpenCode => build_opencode_settings(request), - AppType::OpenClaw => build_openclaw_settings(request), + AppType::OpenClaw | AppType::Hermes => build_additive_app_settings(request), }; // Build usage script configuration if provided @@ -393,11 +393,11 @@ fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value }) } -fn build_openclaw_settings(request: &DeepLinkImportRequest) -> serde_json::Value { +/// Build settings for additive-mode apps (OpenClaw, Hermes) +/// Format: { baseUrl, apiKey, api, models } +fn build_additive_app_settings(request: &DeepLinkImportRequest) -> serde_json::Value { let endpoint = get_primary_endpoint(request); - // Build OpenClaw provider config - // Format: { baseUrl, apiKey, api, models } let mut config = serde_json::Map::new(); if !endpoint.is_empty() { @@ -408,10 +408,8 @@ fn build_openclaw_settings(request: &DeepLinkImportRequest) -> serde_json::Value config.insert("apiKey".to_string(), json!(api_key)); } - // Default to OpenAI-compatible API config.insert("api".to_string(), json!("openai-completions")); - // Build models array if let Some(model) = &request.model { config.insert( "models".to_string(), @@ -484,7 +482,7 @@ pub fn parse_and_merge_config( "codex" => merge_codex_config(&mut merged, &config_value)?, "gemini" => merge_gemini_config(&mut merged, &config_value)?, // Additive mode apps use JSON config directly; pass through as-is - "openclaw" | "opencode" => { + "openclaw" | "opencode" | "hermes" => { merge_additive_config(&mut merged, &config_value)?; } "" => { diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs new file mode 100644 index 000000000..74c926d24 --- /dev/null +++ b/src-tauri/src/hermes_config.rs @@ -0,0 +1,31 @@ +//! Hermes Agent 配置文件读写模块 +//! +//! 处理 `~/.hermes/config.yaml` 配置文件的读写操作(YAML 格式)。 +//! Hermes 使用累加式供应商管理,所有供应商配置共存于同一配置文件中。 + +use crate::settings::get_hermes_override_dir; +use std::path::PathBuf; + +// ============================================================================ +// Path Functions +// ============================================================================ + +/// 获取 Hermes 配置目录 +/// +/// 默认路径: `~/.hermes/` +/// 可通过 settings.hermes_config_dir 覆盖 +pub fn get_hermes_dir() -> PathBuf { + if let Some(override_dir) = get_hermes_override_dir() { + return override_dir; + } + + crate::config::get_home_dir().join(".hermes") +} + +/// 获取 Hermes 配置文件路径 +/// +/// 返回 `~/.hermes/config.yaml` +pub fn get_hermes_config_path() -> PathBuf { + get_hermes_dir().join("config.yaml") +} + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a1764143a..b6cc0b973 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,6 +11,7 @@ mod deeplink; mod error; mod gemini_config; mod gemini_mcp; +mod hermes_config; mod init_status; mod lightweight; #[cfg(target_os = "linux")] @@ -639,6 +640,7 @@ pub fn run() { crate::app_config::AppType::Gemini, crate::app_config::AppType::OpenCode, crate::app_config::AppType::OpenClaw, + crate::app_config::AppType::Hermes, ] { match crate::services::prompt::PromptService::import_from_file_on_first_launch( &app_state, diff --git a/src-tauri/src/mcp/claude.rs b/src-tauri/src/mcp/claude.rs index 25d2f426a..e1971fb73 100644 --- a/src-tauri/src/mcp/claude.rs +++ b/src-tauri/src/mcp/claude.rs @@ -92,6 +92,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result Result codex: true, gemini: false, opencode: false, + hermes: false, }, description: None, homepage: None, diff --git a/src-tauri/src/mcp/gemini.rs b/src-tauri/src/mcp/gemini.rs index c8a7809f3..73c2fc840 100644 --- a/src-tauri/src/mcp/gemini.rs +++ b/src-tauri/src/mcp/gemini.rs @@ -88,6 +88,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result Result Result { AppType::Gemini => get_gemini_dir(), AppType::OpenCode => get_opencode_dir(), AppType::OpenClaw => get_openclaw_dir(), + AppType::Hermes => crate::hermes_config::get_hermes_dir(), }; let filename = match app { AppType::Claude => "CLAUDE.md", AppType::Codex => "AGENTS.md", AppType::Gemini => "GEMINI.md", - AppType::OpenCode => "AGENTS.md", - AppType::OpenClaw => "AGENTS.md", // OpenClaw uses AGENTS.md for agent instructions + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md", }; Ok(base_dir.join(filename)) diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index 7e6238ec1..94475555b 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -174,13 +174,9 @@ impl ProviderType { } ProviderType::Gemini } - AppType::OpenCode => { - // OpenCode doesn't support proxy, but return a default type for completeness - ProviderType::Codex // Fallback to Codex-like type - } - AppType::OpenClaw => { - // OpenClaw doesn't support proxy, but return a default type for completeness - ProviderType::Codex // Fallback to Codex-like type + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + // These apps don't support proxy, fallback to Codex-like type + ProviderType::Codex } } } @@ -232,12 +228,8 @@ pub fn get_adapter(app_type: &AppType) -> Box { AppType::Claude => Box::new(ClaudeAdapter::new()), AppType::Codex => Box::new(CodexAdapter::new()), AppType::Gemini => Box::new(GeminiAdapter::new()), - AppType::OpenCode => { - // OpenCode doesn't support proxy, fallback to Codex adapter - Box::new(CodexAdapter::new()) - } - AppType::OpenClaw => { - // OpenClaw doesn't support proxy, fallback to Codex adapter + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + // These apps don't support proxy, fallback to Codex adapter Box::new(CodexAdapter::new()) } } diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index d354cccb1..7c116c31c 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -130,6 +130,9 @@ impl ConfigService { // OpenClaw uses additive mode, no live sync needed // OpenClaw providers are managed directly in the config file } + AppType::Hermes => { + // Hermes uses additive mode, no live sync needed + } } Ok(()) diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index 02b31aea9..5b8d24fe6 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -128,6 +128,10 @@ impl McpService { // Skip for now log::debug!("OpenClaw MCP support is still in development, skipping sync"); } + AppType::Hermes => { + // TODO: Hermes MCP sync not yet implemented + log::debug!("Hermes MCP sync not yet implemented, skipping sync"); + } } Ok(()) } @@ -157,6 +161,10 @@ impl McpService { // OpenClaw MCP support is still in development log::debug!("OpenClaw MCP support is still in development, skipping remove"); } + AppType::Hermes => { + // TODO: Hermes MCP sync not yet implemented + log::debug!("Hermes MCP sync not yet implemented, skipping remove"); + } } Ok(()) } @@ -166,7 +174,7 @@ impl McpService { let servers = Self::get_all_servers(state)?; for app in AppType::all() { - if matches!(app, AppType::OpenClaw) { + if matches!(app, AppType::OpenClaw | AppType::Hermes) { continue; } diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 21147da65..ac7492fe6 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -42,6 +42,10 @@ pub(crate) fn provider_exists_in_live_config( .map(|providers| providers.contains_key(provider_id)), AppType::OpenClaw => crate::openclaw_config::get_providers() .map(|providers| providers.contains_key(provider_id)), + AppType::Hermes => { + // TODO: hermes_config module not yet implemented + Ok(false) + } _ => Ok(false), } } @@ -345,7 +349,7 @@ fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet: } _ => false, }, - AppType::OpenCode | AppType::OpenClaw => false, + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => false, } } @@ -415,7 +419,7 @@ pub(crate) fn remove_common_config_from_settings( } Ok(result) } - AppType::OpenCode | AppType::OpenClaw => Ok(settings.clone()), + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Ok(settings.clone()), } } @@ -470,7 +474,7 @@ fn apply_common_config_to_settings( } Ok(result) } - AppType::OpenCode | AppType::OpenClaw => Ok(settings.clone()), + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Ok(settings.clone()), } } @@ -790,6 +794,13 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re } } } + AppType::Hermes => { + // TODO: hermes_config module not yet implemented + log::debug!( + "Hermes provider '{}' write to live config not yet implemented", + provider.id + ); + } } Ok(()) } @@ -985,6 +996,18 @@ pub fn read_live_settings(app_type: AppType) -> Result { let config = read_openclaw_config()?; Ok(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", + )); + } + // Return empty object until hermes_config is implemented + Ok(json!({})) + } } } @@ -1067,8 +1090,8 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result { + // OpenCode, OpenClaw and Hermes use additive mode and are handled by early return above + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { unreachable!("additive mode apps are handled by early return") } }; diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index a21ff1554..d8dcce3fe 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -1282,6 +1282,12 @@ impl ProviderService { match app_type { AppType::OpenCode => remove_opencode_provider_from_live(id)?, AppType::OpenClaw => remove_openclaw_provider_from_live(id)?, + AppType::Hermes => { + // TODO: hermes_config module not yet implemented + log::debug!( + "Hermes provider '{id}' removal from live config not yet implemented" + ); + } _ => {} } } @@ -1344,6 +1350,10 @@ impl ProviderService { AppType::OpenClaw => { remove_openclaw_provider_from_live(id)?; } + AppType::Hermes => { + // TODO: hermes_config module not yet implemented + log::debug!("Hermes provider '{id}' removal from live config not yet implemented"); + } _ => { return Err(AppError::Message(format!( "App {} does not support remove from live config", @@ -1532,6 +1542,10 @@ impl ProviderService { let rollback_result = match app_type { AppType::OpenCode => remove_opencode_provider_from_live(&provider.id), AppType::OpenClaw => remove_openclaw_provider_from_live(&provider.id), + AppType::Hermes => { + // TODO: hermes_config module not yet implemented + Ok(()) + } _ => Ok(()), }; @@ -1708,6 +1722,7 @@ impl ProviderService { AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config), AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config), AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config), + AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets } } @@ -1722,6 +1737,7 @@ impl ProviderService { AppType::Gemini => Self::extract_gemini_common_config(settings_config), AppType::OpenCode => Self::extract_opencode_common_config(settings_config), AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config), + AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets } } @@ -2087,6 +2103,16 @@ impl ProviderService { )); } } + AppType::Hermes => { + // Hermes: accept any JSON object for now + if !provider.settings_config.is_object() { + return Err(AppError::localized( + "provider.hermes.settings.not_object", + "Hermes 配置必须是 JSON 对象", + "Hermes configuration must be a JSON object", + )); + } + } } // Validate and clean UsageScript configuration (common for all app types) @@ -2258,8 +2284,8 @@ impl ProviderService { Ok((api_key, base_url)) } - AppType::OpenClaw => { - // OpenClaw uses apiKey and baseUrl directly on the object + AppType::OpenClaw | AppType::Hermes => { + // OpenClaw/Hermes use apiKey and baseUrl directly on the object let api_key = provider .settings_config .get("apiKey") diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 766d727fa..0683153bc 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -466,13 +466,9 @@ impl ProxyService { AppType::Claude => self.read_claude_live()?, AppType::Codex => self.read_codex_live()?, AppType::Gemini => self.read_gemini_live()?, - AppType::OpenCode => { - // OpenCode doesn't support proxy features - return Err("OpenCode 不支持代理功能".to_string()); - } - AppType::OpenClaw => { - // OpenClaw doesn't support proxy features - return Err("OpenClaw 不支持代理功能".to_string()); + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + // These apps don't support proxy features + return Err("该应用不支持代理功能".to_string()); } }; @@ -687,11 +683,8 @@ impl ProxyService { } } } - AppType::OpenCode => { - // OpenCode doesn't support proxy features, skip silently - } - AppType::OpenClaw => { - // OpenClaw doesn't support proxy features, skip silently + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + // These apps don't support proxy features, skip silently } } @@ -871,13 +864,9 @@ impl ProxyService { AppType::Claude => ("claude", self.read_claude_live()?), AppType::Codex => ("codex", self.read_codex_live()?), AppType::Gemini => ("gemini", self.read_gemini_live()?), - AppType::OpenCode => { - // OpenCode doesn't support proxy features - return Err("OpenCode 不支持代理功能".to_string()); - } - AppType::OpenClaw => { - // OpenClaw doesn't support proxy features - return Err("OpenClaw 不支持代理功能".to_string()); + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + // These apps don't support proxy features + return Err("该应用不支持代理功能".to_string()); } }; @@ -1019,13 +1008,9 @@ impl ProxyService { self.write_gemini_live(&live_config)?; log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}"); } - AppType::OpenCode => { - // OpenCode doesn't support proxy features - return Err("OpenCode 不支持代理功能".to_string()); - } - AppType::OpenClaw => { - // OpenClaw doesn't support proxy features - return Err("OpenClaw 不支持代理功能".to_string()); + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + // These apps don't support proxy features + return Err("该应用不支持代理功能".to_string()); } } @@ -1076,11 +1061,8 @@ impl ProxyService { let _ = self.write_gemini_live(&live_config); } } - AppType::OpenCode => { - // OpenCode doesn't support proxy features, skip silently - } - AppType::OpenClaw => { - // OpenClaw doesn't support proxy features, skip silently + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + // These apps don't support proxy features, skip silently } } @@ -1119,11 +1101,8 @@ impl ProxyService { log::info!("Gemini Live 配置已恢复"); } } - AppType::OpenCode => { - // OpenCode doesn't support proxy features, skip silently - } - AppType::OpenClaw => { - // OpenClaw doesn't support proxy features, skip silently + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + // These apps don't support proxy features, skip silently } } @@ -1213,13 +1192,9 @@ impl ProxyService { AppType::Claude => self.write_claude_live(config), AppType::Codex => self.write_codex_live(config), AppType::Gemini => self.write_gemini_live(config), - AppType::OpenCode => { - // OpenCode doesn't support proxy features - Err("OpenCode 不支持代理功能".to_string()) - } - AppType::OpenClaw => { - // OpenClaw doesn't support proxy features - Err("OpenClaw 不支持代理功能".to_string()) + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + // These apps don't support proxy features + Err("该应用不支持代理功能".to_string()) } } } @@ -1238,12 +1213,8 @@ impl ProxyService { Ok(config) => Self::is_gemini_live_taken_over(&config), Err(_) => false, }, - AppType::OpenCode => { - // OpenCode doesn't support proxy takeover - false - } - AppType::OpenClaw => { - // OpenClaw doesn't support proxy takeover + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + // These apps don't support proxy takeover false } } @@ -1285,12 +1256,8 @@ impl ProxyService { AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(), AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(), AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(), - AppType::OpenCode => { - // OpenCode doesn't support proxy features - Ok(()) - } - AppType::OpenClaw => { - // OpenClaw doesn't support proxy features + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + // These apps don't support proxy features Ok(()) } } @@ -1540,7 +1507,7 @@ impl ProxyService { serde_json::to_string(&env_backup) .map_err(|e| format!("序列化 Gemini 配置失败: {e}"))? } - AppType::OpenCode | AppType::OpenClaw => { + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { return Err(format!("未知的应用类型: {app_type}")); } }; diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 892c621b4..9de239d36 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -529,6 +529,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")); + } + } } // 默认路径:回退到用户主目录下的标准位置 @@ -544,6 +549,7 @@ impl SkillService { AppType::Gemini => home.join(".gemini").join("skills"), AppType::OpenCode => home.join(".config").join("opencode").join("skills"), AppType::OpenClaw => home.join(".openclaw").join("skills"), + AppType::Hermes => crate::hermes_config::get_hermes_dir().join("skills"), }) } diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 1bb630058..d85a79912 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -207,7 +207,10 @@ impl StreamCheckService { // OpenCode / OpenClaw 的 settings_config 结构与 Claude/Codex/Gemini 不同 // (baseUrl / apiKey 直接作为根字段而非嵌套在 env),并且协议由 `api` // 或 `npm` 字段显式指定。它们不走 get_adapter 路径,而是直接分发。 - if matches!(app_type, AppType::OpenCode | AppType::OpenClaw) { + if matches!( + app_type, + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes + ) { return Self::check_once_without_adapter(app_type, provider, config, start).await; } @@ -270,9 +273,9 @@ impl StreamCheckService { ) .await } - AppType::OpenCode | AppType::OpenClaw => { + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { // Already handled via early dispatch above - unreachable!("OpenCode/OpenClaw 已通过 check_once_without_adapter 处理") + unreachable!("OpenCode/OpenClaw/Hermes 已通过 check_once_without_adapter 处理") } }; @@ -700,7 +703,18 @@ impl StreamCheckService { ) .await } - _ => unreachable!("check_once_without_adapter 只处理 OpenCode/OpenClaw"), + AppType::Hermes => { + // Hermes uses the same check path as OpenClaw for now + Self::check_openclaw_stream( + &client, + provider, + &model_to_test, + test_prompt, + request_timeout, + ) + .await + } + _ => unreachable!("check_once_without_adapter 只处理 OpenCode/OpenClaw/Hermes"), }; let response_time = start.elapsed().as_millis() as u64; @@ -1241,8 +1255,8 @@ impl StreamCheckService { // Try to extract first model from the models object Self::extract_opencode_model(provider).unwrap_or_else(|| "gpt-4o".to_string()) } - AppType::OpenClaw => { - // OpenClaw uses models array in settings_config + AppType::OpenClaw | AppType::Hermes => { + // OpenClaw/Hermes use models array in settings_config // Try to extract first model from the models array Self::extract_openclaw_model(provider).unwrap_or_else(|| "gpt-4o".to_string()) } diff --git a/src-tauri/src/session_manager/mod.rs b/src-tauri/src/session_manager/mod.rs index 747f169e5..7facbc8f3 100644 --- a/src-tauri/src/session_manager/mod.rs +++ b/src-tauri/src/session_manager/mod.rs @@ -100,6 +100,7 @@ pub fn load_messages(provider_id: &str, source_path: &str) -> Result opencode::load_messages(path), "openclaw" => openclaw::load_messages(path), "gemini" => gemini::load_messages(path), + "hermes" => Err("Hermes session loading not yet implemented".to_string()), _ => Err(format!("Unsupported provider: {provider_id}")), } } @@ -150,6 +151,7 @@ fn delete_session_with_root( "opencode" => opencode::delete_session(&validated_root, &validated_source, session_id), "openclaw" => openclaw::delete_session(&validated_root, &validated_source, session_id), "gemini" => gemini::delete_session(&validated_root, &validated_source, session_id), + "hermes" => Err("Hermes session deletion not yet implemented".to_string()), _ => Err(format!("Unsupported provider: {provider_id}")), } } @@ -161,6 +163,7 @@ fn provider_root(provider_id: &str) -> Result { "opencode" => opencode::get_opencode_data_dir(), "openclaw" => crate::openclaw_config::get_openclaw_dir().join("agents"), "gemini" => crate::gemini_config::get_gemini_dir().join("tmp"), + "hermes" => crate::hermes_config::get_hermes_dir().join("sessions"), _ => return Err(format!("Unsupported provider: {provider_id}")), }; diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 8b41ba517..704ab71eb 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -36,6 +36,8 @@ pub struct VisibleApps { pub opencode: bool, #[serde(default = "default_true")] pub openclaw: bool, + #[serde(default)] + pub hermes: bool, } impl Default for VisibleApps { @@ -46,6 +48,7 @@ impl Default for VisibleApps { gemini: true, opencode: true, openclaw: true, + hermes: false, // 默认不显示,需用户手动启用 } } } @@ -59,6 +62,7 @@ impl VisibleApps { AppType::Gemini => self.gemini, AppType::OpenCode => self.opencode, AppType::OpenClaw => self.openclaw, + AppType::Hermes => self.hermes, } } } @@ -231,6 +235,8 @@ pub struct AppSettings { pub opencode_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 hermes_config_dir: Option, // ===== 当前供应商 ID(设备级)===== /// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current) @@ -248,6 +254,9 @@ pub struct AppSettings { /// 当前 OpenClaw 供应商 ID(本地存储,对 OpenClaw 可能无意义,但保持结构一致) #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_openclaw: Option, + /// 当前 Hermes 供应商 ID(本地存储,保持结构一致) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_provider_hermes: Option, // ===== Skill 同步设置 ===== /// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy @@ -315,11 +324,13 @@ impl Default for AppSettings { gemini_config_dir: None, opencode_config_dir: None, openclaw_config_dir: None, + hermes_config_dir: None, current_provider_claude: None, current_provider_codex: None, current_provider_gemini: None, current_provider_opencode: None, current_provider_openclaw: None, + current_provider_hermes: None, skill_sync_method: SyncMethod::default(), skill_storage_location: SkillStorageLocation::default(), webdav_sync: None, @@ -377,6 +388,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.language = self .language .as_ref() @@ -577,6 +595,14 @@ pub fn get_openclaw_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)) +} + // ===== 当前供应商管理函数 ===== /// 获取指定应用类型的当前供应商 ID(从本地 settings 读取) @@ -591,6 +617,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option { AppType::Gemini => settings.current_provider_gemini.clone(), AppType::OpenCode => settings.current_provider_opencode.clone(), AppType::OpenClaw => settings.current_provider_openclaw.clone(), + AppType::Hermes => settings.current_provider_hermes.clone(), } } @@ -606,6 +633,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppType::Gemini => settings.current_provider_gemini = id_owned.clone(), AppType::OpenCode => settings.current_provider_opencode = id_owned.clone(), AppType::OpenClaw => settings.current_provider_openclaw = id_owned.clone(), + AppType::Hermes => settings.current_provider_hermes = id_owned.clone(), }) } diff --git a/src-tauri/tests/import_export_sync.rs b/src-tauri/tests/import_export_sync.rs index 24a1667fb..2be08faff 100644 --- a/src-tauri/tests/import_export_sync.rs +++ b/src-tauri/tests/import_export_sync.rs @@ -554,6 +554,7 @@ command = "echo" codex: false, // 初始未启用 gemini: false, opencode: false, + hermes: false, }, description: None, homepage: None, @@ -682,6 +683,7 @@ fn import_from_claude_merges_into_config() { codex: false, gemini: false, opencode: false, + hermes: false, }, description: None, homepage: None, diff --git a/src-tauri/tests/mcp_commands.rs b/src-tauri/tests/mcp_commands.rs index 1ed24a99d..5cd31f615 100644 --- a/src-tauri/tests/mcp_commands.rs +++ b/src-tauri/tests/mcp_commands.rs @@ -217,6 +217,7 @@ fn set_mcp_enabled_for_codex_writes_live_config() { codex: false, // 初始未启用 gemini: false, opencode: false, + hermes: false, }, description: None, homepage: None, @@ -281,6 +282,7 @@ fn enabling_codex_mcp_skips_when_codex_dir_missing() { codex: false, gemini: false, opencode: false, + hermes: false, }, description: None, homepage: None, @@ -325,6 +327,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() { codex: false, gemini: false, opencode: false, + hermes: false, }, description: None, homepage: None, @@ -358,6 +361,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() { codex: false, gemini: false, opencode: false, + hermes: false, }, description: None, homepage: None, @@ -490,6 +494,7 @@ fn enabling_gemini_mcp_skips_when_gemini_dir_missing() { codex: false, gemini: false, opencode: false, + hermes: false, }, description: None, homepage: None, @@ -544,6 +549,7 @@ fn enabling_claude_mcp_skips_when_claude_config_absent() { codex: false, gemini: false, opencode: false, + hermes: false, }, description: None, homepage: None, @@ -604,6 +610,7 @@ fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries() codex: false, gemini: false, opencode: false, + hermes: false, }, description: None, homepage: None, @@ -625,6 +632,7 @@ fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries() codex: false, gemini: false, opencode: false, + hermes: false, }, description: None, homepage: None, diff --git a/src-tauri/tests/provider_commands.rs b/src-tauri/tests/provider_commands.rs index 328848926..71fcc48d9 100644 --- a/src-tauri/tests/provider_commands.rs +++ b/src-tauri/tests/provider_commands.rs @@ -75,6 +75,7 @@ command = "say" codex: true, // 启用 Codex gemini: false, opencode: false, + hermes: false, }, description: None, homepage: None, diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index 2b9eddaa9..d9679f921 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -163,6 +163,7 @@ command = "say" codex: true, gemini: false, opencode: false, + hermes: false, }, description: None, homepage: None,