diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 4a9fc75390..e63cf37d21 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 } } @@ -75,6 +82,8 @@ pub struct SkillApps { pub gemini: bool, #[serde(default)] pub opencode: bool, + #[serde(default)] + pub hermes: bool, } impl SkillApps { @@ -86,6 +95,7 @@ impl SkillApps { AppType::Gemini => self.gemini, AppType::OpenCode => self.opencode, AppType::OpenClaw => false, // OpenClaw doesn't support Skills + AppType::Hermes => self.hermes, } } @@ -97,6 +107,7 @@ impl SkillApps { AppType::Gemini => self.gemini = enabled, AppType::OpenCode => self.opencode = enabled, AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore + AppType::Hermes => self.hermes = enabled, } } @@ -115,12 +126,15 @@ impl SkillApps { 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 } /// 仅启用指定应用(其他应用设为禁用) @@ -251,6 +265,9 @@ pub struct McpRoot { /// OpenClaw MCP 配置(v4.1.0+,实际使用 openclaw.json) #[serde(default, skip_serializing_if = "McpConfig::is_empty")] pub openclaw: McpConfig, + /// Hermes MCP 配置(v4.2.0+,实际使用 hermes.json) + #[serde(default, skip_serializing_if = "McpConfig::is_empty")] + pub hermes: McpConfig, } impl Default for McpRoot { @@ -264,6 +281,7 @@ impl Default for McpRoot { gemini: McpConfig::default(), opencode: McpConfig::default(), openclaw: McpConfig::default(), + hermes: McpConfig::default(), } } } @@ -288,6 +306,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 +324,7 @@ pub enum AppType { Gemini, OpenCode, OpenClaw, + Hermes, } impl AppType { @@ -314,12 +335,13 @@ 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) + /// - Switch mode (false): Only the current provider is written to live config (Claude, Codex, Gemini, Hermes) /// - Additive mode (true): All providers are written to live config (OpenCode, OpenClaw) pub fn is_additive_mode(&self) -> bool { matches!(self, AppType::OpenCode | AppType::OpenClaw) @@ -333,6 +355,7 @@ impl AppType { AppType::Gemini, AppType::OpenCode, AppType::OpenClaw, + AppType::Hermes, ] .into_iter() } @@ -349,10 +372,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 +399,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 +413,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 +425,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 +467,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 +628,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 +640,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 +656,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 +678,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 +692,7 @@ impl MultiAppConfig { AppType::Gemini, AppType::OpenCode, AppType::OpenClaw, + AppType::Hermes, ] { // 复用已有的单应用导入逻辑 if Self::auto_import_prompt_if_exists(self, app)? { @@ -729,6 +764,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 +805,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 MCP is still in development, skip }; for (id, entry) in old_servers { diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 60c0f1c799..358aa288c4 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -101,6 +101,12 @@ pub async fn get_config_status(app: String) -> Result { Ok(ConfigStatus { exists, path }) } + AppType::Hermes => { + let hermes_dir = crate::config::get_home_dir().join(".hermes"); + let exists = hermes_dir.exists(); + let path = hermes_dir.to_string_lossy().to_string(); + Ok(ConfigStatus { exists, path }) + } } } @@ -117,6 +123,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::config::get_home_dir().join(".hermes"), }; Ok(dir.to_string_lossy().to_string()) @@ -130,6 +137,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::config::get_home_dir().join(".hermes"), }; if !config_dir.exists() { diff --git a/src-tauri/src/database/dao/mcp.rs b/src-tauri/src/database/dao/mcp.rs index d5c60163f2..07eb199a3a 100644 --- a/src-tauri/src/database/dao/mcp.rs +++ b/src-tauri/src/database/dao/mcp.rs @@ -13,7 +13,7 @@ impl Database { pub fn get_all_mcp_servers(&self) -> Result, AppError> { let conn = lock_conn!(self.conn); let mut stmt = conn.prepare( - "SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode + "SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes FROM mcp_servers ORDER BY name ASC, id ASC" ).map_err(|e| AppError::Database(e.to_string()))?; @@ -31,6 +31,7 @@ impl Database { let enabled_codex: bool = row.get(8)?; let enabled_gemini: bool = row.get(9)?; let enabled_opencode: bool = row.get(10)?; + let enabled_hermes: bool = row.get(11)?; let server = serde_json::from_str(&server_config_str).unwrap_or_default(); let tags = serde_json::from_str(&tags_str).unwrap_or_default(); @@ -46,6 +47,7 @@ impl Database { codex: enabled_codex, gemini: enabled_gemini, opencode: enabled_opencode, + hermes: enabled_hermes, }, description, homepage, @@ -70,8 +72,8 @@ impl Database { conn.execute( "INSERT OR REPLACE INTO mcp_servers ( id, name, server_config, description, homepage, docs, tags, - enabled_claude, enabled_codex, enabled_gemini, enabled_opencode - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", params![ server.id, server.name, @@ -87,6 +89,7 @@ impl Database { server.apps.codex, server.apps.gemini, server.apps.opencode, + server.apps.hermes, ], ) .map_err(|e| AppError::Database(e.to_string()))?; diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs index befedca128..1e047aad1b 100644 --- a/src-tauri/src/database/dao/skills.rs +++ b/src-tauri/src/database/dao/skills.rs @@ -3,7 +3,7 @@ //! 提供 Skills 和 Skill Repos 的 CRUD 操作。 //! //! v3.10.0+ 统一管理架构: -//! - Skills 使用统一的 id 主键,支持四应用启用标志 +//! - Skills 使用统一的 id 主键,支持五应用启用标志 //! - 实际文件存储在 ~/.cc-switch/skills/,同步到各应用目录 use crate::app_config::{InstalledSkill, SkillApps}; @@ -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, + readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at FROM skills ORDER BY name ASC", ) @@ -44,10 +44,11 @@ impl Database { codex: row.get(9)?, gemini: row.get(10)?, opencode: row.get(11)?, + hermes: row.get(12)?, }, - installed_at: row.get(12)?, - content_hash: row.get(13)?, - updated_at: row.get::<_, i64>(14).unwrap_or(0), + installed_at: row.get(13)?, + content_hash: row.get(14)?, + updated_at: row.get::<_, i64>(15).unwrap_or(0), }) }) .map_err(|e| AppError::Database(e.to_string()))?; @@ -66,7 +67,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, + readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at FROM skills WHERE id = ?1", ) @@ -87,10 +88,11 @@ impl Database { codex: row.get(9)?, gemini: row.get(10)?, opencode: row.get(11)?, + hermes: row.get(12)?, }, - installed_at: row.get(12)?, - content_hash: row.get(13)?, - updated_at: row.get::<_, i64>(14).unwrap_or(0), + installed_at: row.get(13)?, + content_hash: row.get(14)?, + updated_at: row.get::<_, i64>(15).unwrap_or(0), }) }); @@ -107,9 +109,9 @@ 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, + readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)", params![ skill.id, skill.name, @@ -123,6 +125,7 @@ impl Database { skill.apps.codex, skill.apps.gemini, skill.apps.opencode, + skill.apps.hermes, skill.installed_at, skill.content_hash, skill.updated_at, @@ -154,8 +157,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/database/mod.rs b/src-tauri/src/database/mod.rs index 1c7618072b..1c93ce9820 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -44,7 +44,7 @@ use std::sync::Mutex; /// 当前 Schema 版本号 /// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑 -pub(crate) const SCHEMA_VERSION: i32 = 9; +pub(crate) const SCHEMA_VERSION: i32 = 10; /// 安全地序列化 JSON,避免 unwrap panic pub(crate) fn to_json_string(value: &T) -> Result { diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 295641457a..70183bb5ae 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -65,7 +65,8 @@ impl Database { id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL, description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]', enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0, - enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0 + enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0, + enabled_hermes BOOLEAN NOT NULL DEFAULT 0 )", [], ) @@ -93,6 +94,7 @@ impl Database { enabled_codex BOOLEAN NOT NULL DEFAULT 0, enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0, + enabled_hermes BOOLEAN NOT NULL DEFAULT 0, installed_at INTEGER NOT NULL DEFAULT 0, content_hash TEXT, updated_at INTEGER NOT NULL DEFAULT 0 @@ -423,6 +425,11 @@ impl Database { Self::migrate_v8_to_v9(conn)?; Self::set_user_version(conn, 9)?; } + 9 => { + log::info!("迁移数据库从 v9 到 v10(Hermes 支持)"); + Self::migrate_v9_to_v10(conn)?; + Self::set_user_version(conn, 10)?; + } _ => { return Err(AppError::Database(format!( "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}" @@ -1168,6 +1175,34 @@ impl Database { Ok(()) } + /// v9 → v10: 添加 Hermes 支持 + /// + /// 为 mcp_servers 和 skills 表添加 enabled_hermes 列。 + fn migrate_v9_to_v10(conn: &Connection) -> Result<(), AppError> { + // 为 mcp_servers 表添加 enabled_hermes 列 + if Self::table_exists(conn, "mcp_servers")? { + Self::add_column_if_missing( + conn, + "mcp_servers", + "enabled_hermes", + "BOOLEAN NOT NULL DEFAULT 0", + )?; + } + + // 为 skills 表添加 enabled_hermes 列 + if Self::table_exists(conn, "skills")? { + Self::add_column_if_missing( + conn, + "skills", + "enabled_hermes", + "BOOLEAN NOT NULL DEFAULT 0", + )?; + } + + log::info!("v9 -> v10 迁移完成:已添加 Hermes 支持"); + Ok(()) + } + /// 插入默认模型定价数据 /// 格式: (model_id, display_name, input, output, cache_read, cache_creation) /// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致 diff --git a/src-tauri/src/deeplink/mcp.rs b/src-tauri/src/deeplink/mcp.rs index 37df965798..822cd839d1 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,7 @@ 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" => apps.hermes = true, other => { return Err(AppError::InvalidInput(format!( "Invalid app in 'apps': {other}" diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 87e07282f4..ed6639da57 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -147,6 +147,7 @@ pub(crate) fn build_provider_from_request( AppType::Gemini => build_gemini_settings(request), AppType::OpenCode => build_opencode_settings(request), AppType::OpenClaw => build_openclaw_settings(request), + AppType::Hermes => build_hermes_settings(request), }; // Build usage script configuration if provided @@ -422,6 +423,46 @@ fn build_openclaw_settings(request: &DeepLinkImportRequest) -> serde_json::Value json!(config) } +/// Build Hermes provider settings_config from deeplink request +/// +/// Hermes uses snake_case field names to match config.yaml format: +/// - name: Provider display name +/// - base_url: API endpoint +/// - api_key: API key +/// - model: Model name +/// - transport: Transport type (default: "openai_chat") +fn build_hermes_settings(request: &DeepLinkImportRequest) -> serde_json::Value { + let endpoint = get_primary_endpoint(request); + + // Build Hermes provider config using snake_case + let mut config = serde_json::Map::new(); + + // Provider name + if let Some(name) = &request.name { + config.insert("name".to_string(), json!(name)); + } + + // API endpoint (snake_case) + if !endpoint.is_empty() { + config.insert("base_url".to_string(), json!(endpoint)); + } + + // API key (snake_case) + if let Some(api_key) = &request.api_key { + config.insert("api_key".to_string(), json!(api_key)); + } + + // Model name (single string, not array) + if let Some(model) = &request.model { + config.insert("model".to_string(), json!(model)); + } + + // Transport type (default to openai_chat for compatibility) + config.insert("transport".to_string(), json!("openai_chat")); + + json!(config) +} + // ============================================================================= // Config Merge Logic // ============================================================================= diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs new file mode 100644 index 0000000000..e85012c08c --- /dev/null +++ b/src-tauri/src/hermes_config.rs @@ -0,0 +1,763 @@ +use crate::config::{get_home_dir, write_text_file}; +use crate::error::AppError; +use crate::gemini_config::{parse_env_file, serialize_env_file}; +use crate::provider::Provider; +use serde_json::Value as JsonValue; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +/// 获取 Hermes 配置目录路径(支持设置覆盖) +pub fn get_hermes_dir() -> PathBuf { + if let Some(custom) = crate::settings::get_hermes_override_dir() { + return custom; + } + + get_home_dir().join(".hermes") +} + +/// 获取 Hermes config.yaml 文件路径 +pub fn get_hermes_config_path() -> PathBuf { + get_hermes_dir().join("config.yaml") +} + +/// 获取 Hermes .env 文件路径 +pub fn get_hermes_env_path() -> PathBuf { + get_hermes_dir().join(".env") +} + +/// 读取 Hermes config.yaml 文件 +/// +/// 如果文件不存在,返回空的 YAML mapping。 +pub fn read_hermes_config() -> Result { + let path = get_hermes_config_path(); + + if !path.exists() { + return Ok(serde_yaml::Value::Mapping(serde_yaml::Mapping::new())); + } + + let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; + let yaml = serde_yaml::from_str(&content).map_err(|e| { + AppError::localized( + "hermes.config.parse_error", + format!("Hermes config.yaml 解析失败: {e}"), + format!("Failed to parse Hermes config.yaml: {e}"), + ) + })?; + + Ok(yaml) +} + +/// 原子写入 Hermes config.yaml 文件(temp + rename) +pub fn write_hermes_config_atomic(yaml: &serde_yaml::Value) -> 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))?; + } + + let content = serde_yaml::to_string(yaml).map_err(|e| { + AppError::localized( + "hermes.config.serialize_error", + format!("Hermes config.yaml 序列化失败: {e}"), + format!("Failed to serialize Hermes config.yaml: {e}"), + ) + })?; + + write_text_file(&path, &content)?; + + Ok(()) +} + +/// 读取 Hermes .env 文件 +#[allow(dead_code)] +pub fn read_hermes_env() -> Result, AppError> { + let path = get_hermes_env_path(); + + if !path.exists() { + return Ok(HashMap::new()); + } + + let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; + + Ok(parse_env_file(&content)) +} + +/// 原子写入 Hermes .env 文件(temp + rename) +pub fn write_hermes_env_atomic(env: &HashMap) -> Result<(), AppError> { + let path = get_hermes_env_path(); + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + + // 设置目录权限为 700(仅所有者可读写执行) + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(parent) + .map_err(|e| AppError::io(parent, e))? + .permissions(); + perms.set_mode(0o700); + fs::set_permissions(parent, perms).map_err(|e| AppError::io(parent, e))?; + } + } + + let content = serialize_env_file(env); + write_text_file(&path, &content)?; + + // 设置文件权限为 600(仅所有者可读写) + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&path) + .map_err(|e| AppError::io(&path, e))? + .permissions(); + perms.set_mode(0o600); + fs::set_permissions(&path, perms).map_err(|e| AppError::io(&path, e))?; + } + + Ok(()) +} + +/// 读取 Hermes 当前配置 +/// +/// 从 config.yaml 的 custom_providers 列表中获取当前 provider 的配置。 +/// 当前 provider 由 model.provider 字段指定。 +/// 返回 JSON `{model, base_url, api_key, provider_name}` +pub fn read_hermes_live_settings() -> Result { + let yaml = read_hermes_config()?; + + // 获取当前 model 设置 + let model_default = yaml + .get("model") + .and_then(|m| m.get("default")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let model_provider = yaml + .get("model") + .and_then(|m| m.get("provider")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + // 从 custom_providers 列表中查找当前 provider + let custom_providers = yaml.get("custom_providers").and_then(|p| p.as_sequence()); + + let mut base_url = String::new(); + let mut api_key = String::new(); + + if let Some(providers) = custom_providers { + for provider in providers { + if let Some(name) = provider.get("name").and_then(|n| n.as_str()) { + if name == model_provider { + base_url = provider + .get("base_url") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + api_key = provider + .get("api_key") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + break; + } + } + } + } + + Ok(serde_json::json!({ + "model": model_default, + "base_url": base_url, + "api_key": api_key, + "provider_name": model_provider, + })) +} + +/// 将 Provider 配置写入 Hermes 的 config.yaml +/// +/// Hermes 使用 custom_providers 列表存储 provider 配置。 +/// 更新 model.provider、model.default 指向当前 provider, +/// 并更新或添加 custom_providers 中的对应条目。 +pub fn write_hermes_live(provider: &Provider) -> Result<(), AppError> { + log::info!( + "[hermes_config] write_hermes_live: provider_id={}, provider_name={}", + provider.id, + provider.name + ); + + let mut yaml = read_hermes_config()?; + + // Ensure yaml is a mapping + if !yaml.is_mapping() { + yaml = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + } + + let settings = &provider.settings_config; + + let model_str = settings + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let base_url_str = settings + .get("base_url") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let api_key_str = settings + .get("api_key") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let provider_name = provider.name.clone(); + + // 1. 更新 model section + { + let mapping = yaml.as_mapping_mut().expect("yaml is a mapping"); + let model_key = serde_yaml::Value::String("model".to_string()); + + let model_section = mapping + .entry(model_key) + .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new())); + + if let Some(model_map) = model_section.as_mapping_mut() { + // 更新 default model + if !model_str.is_empty() { + model_map.insert( + serde_yaml::Value::String("default".to_string()), + serde_yaml::Value::String(model_str.clone()), + ); + } + // 更新 provider name + model_map.insert( + serde_yaml::Value::String("provider".to_string()), + serde_yaml::Value::String(provider_name.clone()), + ); + } + } + + // 2. 更新或添加 custom_providers + { + let mapping = yaml.as_mapping_mut().expect("yaml is a mapping"); + let providers_key = serde_yaml::Value::String("custom_providers".to_string()); + + // 确保 custom_providers 存在 + if !mapping.contains_key(&providers_key) { + mapping.insert(providers_key.clone(), serde_yaml::Value::Sequence(vec![])); + } + + let providers = mapping + .get_mut(&providers_key) + .expect("custom_providers exists"); + + if let Some(providers_seq) = providers.as_sequence_mut() { + // 查找是否已存在同名 provider + let mut found = false; + for existing_provider in providers_seq.iter_mut() { + if let Some(existing_name) = existing_provider.get("name").and_then(|n| n.as_str()) + { + if existing_name == provider_name { + // 更新现有 provider + if let Some(provider_map) = existing_provider.as_mapping_mut() { + if !base_url_str.is_empty() { + provider_map.insert( + serde_yaml::Value::String("base_url".to_string()), + serde_yaml::Value::String(base_url_str.clone()), + ); + } + if !api_key_str.is_empty() { + provider_map.insert( + serde_yaml::Value::String("api_key".to_string()), + serde_yaml::Value::String(api_key_str.clone()), + ); + } + if !model_str.is_empty() { + provider_map.insert( + serde_yaml::Value::String("model".to_string()), + serde_yaml::Value::String(model_str.clone()), + ); + } + // 确保 transport 字段存在 + if !provider_map + .contains_key(serde_yaml::Value::String("transport".to_string())) + { + provider_map.insert( + serde_yaml::Value::String("transport".to_string()), + serde_yaml::Value::String("openai_chat".to_string()), + ); + } + } + found = true; + break; + } + } + } + + // 如果不存在,添加新的 provider + if !found { + let mut new_provider = serde_yaml::Mapping::new(); + new_provider.insert( + serde_yaml::Value::String("name".to_string()), + serde_yaml::Value::String(provider_name.clone()), + ); + if !base_url_str.is_empty() { + new_provider.insert( + serde_yaml::Value::String("base_url".to_string()), + serde_yaml::Value::String(base_url_str), + ); + } + if !api_key_str.is_empty() { + new_provider.insert( + serde_yaml::Value::String("api_key".to_string()), + serde_yaml::Value::String(api_key_str), + ); + } + if !model_str.is_empty() { + new_provider.insert( + serde_yaml::Value::String("model".to_string()), + serde_yaml::Value::String(model_str), + ); + } + new_provider.insert( + serde_yaml::Value::String("transport".to_string()), + serde_yaml::Value::String("openai_chat".to_string()), + ); + providers_seq.push(serde_yaml::Value::Mapping(new_provider)); + } + } + } + + write_hermes_config_atomic(&yaml)?; + + log::info!("[hermes_config] write_hermes_live: done"); + Ok(()) +} + +/// 从 Hermes config.yaml 的 custom_providers 中移除指定 provider +/// +/// 仅删除 custom_providers 列表中的条目,不修改 model section。 +/// 如果 provider 不存在,静默返回成功。 +pub fn remove_hermes_provider_from_live(provider_name: &str) -> Result<(), AppError> { + log::info!( + "[hermes_config] remove_hermes_provider_from_live: provider_name={}", + provider_name + ); + + // 检查 Hermes 配置目录是否存在 + if !get_hermes_dir().exists() { + log::debug!("Hermes config directory doesn't exist, skipping removal"); + return Ok(()); + } + + let mut yaml = read_hermes_config()?; + + // 确保 yaml 是一个 mapping + if !yaml.is_mapping() { + return Ok(()); + } + + let mapping = yaml.as_mapping_mut().expect("yaml is a mapping"); + let providers_key = serde_yaml::Value::String("custom_providers".to_string()); + + // 检查 custom_providers 是否存在 + let Some(providers) = mapping.get_mut(&providers_key) else { + return Ok(()); + }; + + let Some(providers_seq) = providers.as_sequence_mut() else { + return Ok(()); + }; + + // 查找并移除指定 provider + let initial_len = providers_seq.len(); + providers_seq.retain(|p| { + p.get("name") + .and_then(|n| n.as_str()) + .is_none_or(|n| n != provider_name) + }); + + if providers_seq.len() < initial_len { + log::info!( + "[hermes_config] Removed provider '{}' from custom_providers", + provider_name + ); + write_hermes_config_atomic(&yaml)?; + } else { + log::debug!( + "[hermes_config] Provider '{}' not found in custom_providers", + provider_name + ); + } + + Ok(()) +} + +/// 仅更新 Hermes config.yaml 的 model section(切换当前 provider) +/// +/// 更新 model.provider 和 model.default,不修改 custom_providers 列表。 +/// 用于切换当前激活的 provider。 +#[allow(dead_code)] +pub fn set_current_hermes_provider( + provider_name: &str, + model: Option<&str>, +) -> Result<(), AppError> { + log::info!( + "[hermes_config] set_current_hermes_provider: provider_name={}, model={:?}", + provider_name, + model + ); + + // 检查 Hermes 配置目录是否存在 + if !get_hermes_dir().exists() { + log::debug!("Hermes config directory doesn't exist, skipping set current"); + return Ok(()); + } + + let mut yaml = read_hermes_config()?; + + // 确保 yaml 是一个 mapping + if !yaml.is_mapping() { + yaml = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + } + + let mapping = yaml.as_mapping_mut().expect("yaml is a mapping"); + let model_key = serde_yaml::Value::String("model".to_string()); + + let model_section = mapping + .entry(model_key) + .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new())); + + if let Some(model_map) = model_section.as_mapping_mut() { + // 更新 provider name + model_map.insert( + serde_yaml::Value::String("provider".to_string()), + serde_yaml::Value::String(provider_name.to_string()), + ); + + // 如果提供了 model,也更新 default + if let Some(model_str) = model { + if !model_str.is_empty() { + model_map.insert( + serde_yaml::Value::String("default".to_string()), + serde_yaml::Value::String(model_str.to_string()), + ); + } + } + } + + write_hermes_config_atomic(&yaml)?; + + log::info!("[hermes_config] set_current_hermes_provider: done"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::env; + + #[test] + #[serial] + fn test_read_write_hermes_config() { + let tmp = tempfile::tempdir().expect("create temp dir"); + let old_test_home = env::var_os("CC_SWITCH_TEST_HOME"); + env::set_var("CC_SWITCH_TEST_HOME", tmp.path()); + + let mut yaml = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + let mut model_map = serde_yaml::Mapping::new(); + model_map.insert( + serde_yaml::Value::String("default".to_string()), + serde_yaml::Value::String("claude-sonnet-4-6".to_string()), + ); + model_map.insert( + serde_yaml::Value::String("provider".to_string()), + serde_yaml::Value::String("api-proxy-claude".to_string()), + ); + yaml.as_mapping_mut().unwrap().insert( + serde_yaml::Value::String("model".to_string()), + serde_yaml::Value::Mapping(model_map), + ); + + let mut custom_providers = vec![]; + let mut provider_map = serde_yaml::Mapping::new(); + provider_map.insert( + serde_yaml::Value::String("name".to_string()), + serde_yaml::Value::String("api-proxy-claude".to_string()), + ); + provider_map.insert( + serde_yaml::Value::String("base_url".to_string()), + serde_yaml::Value::String("https://api.example.com/v1".to_string()), + ); + provider_map.insert( + serde_yaml::Value::String("api_key".to_string()), + serde_yaml::Value::String("sk-test-key".to_string()), + ); + provider_map.insert( + serde_yaml::Value::String("model".to_string()), + serde_yaml::Value::String("claude-sonnet-4-6".to_string()), + ); + provider_map.insert( + serde_yaml::Value::String("transport".to_string()), + serde_yaml::Value::String("openai_chat".to_string()), + ); + custom_providers.push(serde_yaml::Value::Mapping(provider_map)); + yaml.as_mapping_mut().unwrap().insert( + serde_yaml::Value::String("custom_providers".to_string()), + serde_yaml::Value::Sequence(custom_providers), + ); + + write_hermes_config_atomic(&yaml).unwrap(); + let read_yaml = read_hermes_config().unwrap(); + + assert_eq!( + read_yaml + .get("model") + .and_then(|m| m.get("default")) + .and_then(|v| v.as_str()), + Some("claude-sonnet-4-6") + ); + assert_eq!( + read_yaml + .get("model") + .and_then(|m| m.get("provider")) + .and_then(|v| v.as_str()), + Some("api-proxy-claude") + ); + + // Verify custom_providers + let providers = read_yaml + .get("custom_providers") + .and_then(|p| p.as_sequence()) + .unwrap(); + assert_eq!(providers.len(), 1); + let first_provider = &providers[0]; + assert_eq!( + first_provider.get("name").and_then(|n| n.as_str()), + Some("api-proxy-claude") + ); + assert_eq!( + first_provider.get("base_url").and_then(|v| v.as_str()), + Some("https://api.example.com/v1") + ); + + match old_test_home { + Some(value) => env::set_var("CC_SWITCH_TEST_HOME", value), + None => env::remove_var("CC_SWITCH_TEST_HOME"), + } + } + + #[test] + #[serial] + fn test_write_hermes_env_preserves_other_keys() { + let tmp = tempfile::tempdir().expect("create temp dir"); + let old_test_home = env::var_os("CC_SWITCH_TEST_HOME"); + env::set_var("CC_SWITCH_TEST_HOME", tmp.path()); + + let mut env: HashMap = HashMap::new(); + env.insert("TELEGRAM_BOT_TOKEN".to_string(), "test-token".to_string()); + env.insert("OTHER_KEY".to_string(), "other-value".to_string()); + + write_hermes_env_atomic(&env).unwrap(); + + let read_env = read_hermes_env().unwrap(); + assert_eq!( + read_env.get("TELEGRAM_BOT_TOKEN"), + Some(&"test-token".to_string()) + ); + assert_eq!(read_env.get("OTHER_KEY"), Some(&"other-value".to_string())); + + match old_test_home { + Some(value) => env::set_var("CC_SWITCH_TEST_HOME", value), + None => env::remove_var("CC_SWITCH_TEST_HOME"), + } + } + + #[test] + #[serial] + fn test_write_hermes_live() { + let tmp = tempfile::tempdir().expect("create temp dir"); + let old_test_home = env::var_os("CC_SWITCH_TEST_HOME"); + env::set_var("CC_SWITCH_TEST_HOME", tmp.path()); + + let provider = Provider::with_id( + "test-1".to_string(), + "My Custom Provider".to_string(), + serde_json::json!({ + "model": "claude-sonnet-4-6", + "base_url": "https://api.custom.com/v1", + "api_key": "sk-custom-key", + }), + None, + ); + + write_hermes_live(&provider).unwrap(); + + let yaml = read_hermes_config().unwrap(); + + // 验证 model section + assert_eq!( + yaml.get("model") + .and_then(|m| m.get("default")) + .and_then(|v| v.as_str()), + Some("claude-sonnet-4-6") + ); + assert_eq!( + yaml.get("model") + .and_then(|m| m.get("provider")) + .and_then(|v| v.as_str()), + Some("My Custom Provider") + ); + + // 验证 custom_providers + let providers = yaml + .get("custom_providers") + .and_then(|p| p.as_sequence()) + .unwrap(); + assert!(providers.len() >= 1); + let found = providers + .iter() + .any(|p| p.get("name").and_then(|n| n.as_str()) == Some("My Custom Provider")); + assert!( + found, + "custom_providers should contain 'My Custom Provider'" + ); + + match old_test_home { + Some(value) => env::set_var("CC_SWITCH_TEST_HOME", value), + None => env::remove_var("CC_SWITCH_TEST_HOME"), + } + } + + #[test] + #[serial] + fn test_remove_hermes_provider_from_live() { + let tmp = tempfile::tempdir().expect("create temp dir"); + let old_test_home = env::var_os("CC_SWITCH_TEST_HOME"); + env::set_var("CC_SWITCH_TEST_HOME", tmp.path()); + + // 创建两个 provider + let provider1 = Provider::with_id( + "test-1".to_string(), + "Provider One".to_string(), + serde_json::json!({ + "model": "claude-sonnet-4-6", + "base_url": "https://api.one.com/v1", + "api_key": "sk-key-one", + }), + None, + ); + + let provider2 = Provider::with_id( + "test-2".to_string(), + "Provider Two".to_string(), + serde_json::json!({ + "model": "claude-opus-4", + "base_url": "https://api.two.com/v1", + "api_key": "sk-key-two", + }), + None, + ); + + // 写入两个 provider + write_hermes_live(&provider1).unwrap(); + write_hermes_live(&provider2).unwrap(); + + // 验证两个 provider 都存在 + let yaml = read_hermes_config().unwrap(); + let providers = yaml + .get("custom_providers") + .and_then(|p| p.as_sequence()) + .unwrap(); + assert_eq!(providers.len(), 2); + + // 删除第一个 provider + remove_hermes_provider_from_live("Provider One").unwrap(); + + // 验证只剩一个 provider + let yaml = read_hermes_config().unwrap(); + let providers = yaml + .get("custom_providers") + .and_then(|p| p.as_sequence()) + .unwrap(); + assert_eq!(providers.len(), 1); + + // 验证剩余的是 Provider Two + let remaining_name = providers[0].get("name").and_then(|n| n.as_str()).unwrap(); + assert_eq!(remaining_name, "Provider Two"); + + // 验证删除不存在的 provider 不会出错 + remove_hermes_provider_from_live("Non-existent Provider").unwrap(); + + // 验证 provider 数量不变 + let yaml = read_hermes_config().unwrap(); + let providers = yaml + .get("custom_providers") + .and_then(|p| p.as_sequence()) + .unwrap(); + assert_eq!(providers.len(), 1); + + match old_test_home { + Some(value) => env::set_var("CC_SWITCH_TEST_HOME", value), + None => env::remove_var("CC_SWITCH_TEST_HOME"), + } + } + + #[test] + #[serial] + fn test_set_current_hermes_provider() { + let tmp = tempfile::tempdir().expect("create temp dir"); + let old_test_home = env::var_os("CC_SWITCH_TEST_HOME"); + env::set_var("CC_SWITCH_TEST_HOME", tmp.path()); + + // 创建一个 provider + let provider = Provider::with_id( + "test-1".to_string(), + "Test Provider".to_string(), + serde_json::json!({ + "model": "claude-sonnet-4-6", + "base_url": "https://api.test.com/v1", + "api_key": "sk-test-key", + }), + None, + ); + write_hermes_live(&provider).unwrap(); + + // 切换到不同的 provider(仅更新 model section) + set_current_hermes_provider("Another Provider", Some("claude-opus-4")).unwrap(); + + let yaml = read_hermes_config().unwrap(); + + // 验证 model section 已更新 + assert_eq!( + yaml.get("model") + .and_then(|m| m.get("provider")) + .and_then(|v| v.as_str()), + Some("Another Provider") + ); + assert_eq!( + yaml.get("model") + .and_then(|m| m.get("default")) + .and_then(|v| v.as_str()), + Some("claude-opus-4") + ); + + // 验证 custom_providers 仍然只有一个(没有被修改) + let providers = yaml + .get("custom_providers") + .and_then(|p| p.as_sequence()) + .unwrap(); + assert_eq!(providers.len(), 1); + + match old_test_home { + Some(value) => env::set_var("CC_SWITCH_TEST_HOME", value), + None => env::remove_var("CC_SWITCH_TEST_HOME"), + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dbfb63eae1..e6c02113bb 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")] @@ -40,11 +41,13 @@ pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file}; pub use database::Database; pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest}; pub use error::AppError; +pub use hermes_config::{get_hermes_config_path, get_hermes_env_path}; pub use mcp::{ - import_from_claude, import_from_codex, import_from_gemini, remove_server_from_claude, - remove_server_from_codex, remove_server_from_gemini, sync_enabled_to_claude, - sync_enabled_to_codex, sync_enabled_to_gemini, sync_single_server_to_claude, - sync_single_server_to_codex, sync_single_server_to_gemini, + import_from_claude, import_from_codex, import_from_gemini, import_from_hermes, + remove_server_from_claude, remove_server_from_codex, remove_server_from_gemini, + remove_server_from_hermes, sync_enabled_to_claude, sync_enabled_to_codex, + sync_enabled_to_gemini, sync_enabled_to_hermes, sync_single_server_to_claude, + sync_single_server_to_codex, sync_single_server_to_gemini, sync_single_server_to_hermes, }; pub use provider::{Provider, ProviderMeta}; pub use services::{ @@ -627,6 +630,14 @@ pub fn run() { Ok(_) => log::debug!("○ No OpenCode MCP servers found to import"), Err(e) => log::warn!("✗ Failed to import OpenCode MCP: {e}"), } + + match crate::services::mcp::McpService::import_from_hermes(&app_state) { + Ok(count) if count > 0 => { + log::info!("✓ Imported {count} MCP server(s) from Hermes"); + } + Ok(_) => log::debug!("○ No Hermes MCP servers found to import"), + Err(e) => log::warn!("✗ Failed to import Hermes MCP: {e}"), + } } // 4. 导入提示词文件(表空时触发) diff --git a/src-tauri/src/mcp/claude.rs b/src-tauri/src/mcp/claude.rs index 25d2f426a3..e1971fb73b 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 c8a7809f35..73c2fc8403 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 bool { + // Hermes 未安装/未初始化时:~/.hermes 目录不存在。 + // 按用户偏好:目录缺失时跳过写入/删除,不创建任何文件或目录。 + crate::hermes_config::get_hermes_dir().exists() +} + +/// 返回已启用的 MCP 服务器(过滤 enabled==true) +fn collect_enabled_servers(cfg: &McpConfig) -> HashMap { + let mut out = HashMap::new(); + for (id, entry) in cfg.servers.iter() { + let enabled = entry + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !enabled { + continue; + } + match extract_server_spec(entry) { + Ok(spec) => { + out.insert(id.clone(), spec); + } + Err(err) => { + log::warn!("跳过无效的 MCP 条目 '{id}': {err}"); + } + } + } + out +} + +/// 从 Hermes config.yaml 中读取 mcp_servers 映射 +/// +/// Hermes 使用 YAML 格式,mcp_servers 是一个 mapping。 +/// 返回的 Value 是 serde_json 格式,便于与其他模块统一处理。 +fn read_hermes_mcp_servers_map() -> Result, AppError> { + let yaml = read_hermes_config()?; + + let mcp_servers = yaml.get("mcp_servers").and_then(|v| v.as_mapping()); + + if let Some(servers_map) = mcp_servers { + let mut result = HashMap::new(); + for (key, val) in servers_map.iter() { + if let Some(key_str) = key.as_str() { + // 将 serde_yaml::Value 转换为 serde_json::Value + let json_val = serde_json::to_value(val).map_err(|e| { + AppError::localized( + "hermes.mcp.convert_error", + format!("Hermes MCP 配置转换失败: {e}"), + format!("Failed to convert Hermes MCP config: {e}"), + ) + })?; + result.insert(key_str.to_string(), json_val); + } + } + return Ok(result); + } + + Ok(HashMap::new()) +} + +/// 将 MCP 服务器映射写入 Hermes config.yaml 的 mcp_servers 字段 +fn set_hermes_mcp_servers_map(servers: &HashMap) -> Result<(), AppError> { + let mut yaml = read_hermes_config()?; + + // 确保 yaml 是一个 mapping + if !yaml.is_mapping() { + yaml = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + } + + let mapping = yaml.as_mapping_mut().expect("yaml is a mapping"); + let mcp_key = serde_yaml::Value::String("mcp_servers".to_string()); + + // 构建 mcp_servers mapping + let mut mcp_map = serde_yaml::Mapping::new(); + for (id, spec) in servers.iter() { + // 将 serde_json::Value 转换为 serde_yaml::Value + let yaml_val = serde_yaml::to_value(spec).map_err(|e| { + AppError::localized( + "hermes.mcp.convert_error", + format!("Hermes MCP 配置转换失败: {e}"), + format!("Failed to convert Hermes MCP config: {e}"), + ) + })?; + mcp_map.insert(serde_yaml::Value::String(id.clone()), yaml_val); + } + + mapping.insert(mcp_key, serde_yaml::Value::Mapping(mcp_map)); + + write_hermes_config_atomic(&yaml) +} + +/// 将 config.json 中 Hermes 的 enabled==true 项写入 Hermes MCP 配置 +pub fn sync_enabled_to_hermes(config: &MultiAppConfig) -> Result<(), AppError> { + if !should_sync_hermes_mcp() { + return Ok(()); + } + let enabled = collect_enabled_servers(&config.mcp.hermes); + set_hermes_mcp_servers_map(&enabled) +} + +/// 从 Hermes MCP 配置导入到统一结构 +/// 已存在的服务器将启用 Hermes 应用,不覆盖其他字段和应用状态 +pub fn import_from_hermes(config: &mut MultiAppConfig) -> Result { + let map = read_hermes_mcp_servers_map()?; + if map.is_empty() { + return Ok(0); + } + + // 确保新结构存在 + let servers = config.mcp.servers.get_or_insert_with(HashMap::new); + + let mut changed = 0; + let mut errors = Vec::new(); + + for (id, spec) in map.iter() { + // 校验:单项失败不中止,收集错误继续处理 + if let Err(e) = validate_server_spec(spec) { + log::warn!("跳过无效 MCP 服务器 '{id}': {e}"); + errors.push(format!("{id}: {e}")); + continue; + } + + if let Some(existing) = servers.get_mut(id) { + // 已存在:仅启用 Hermes 应用 + if !existing.apps.hermes { + existing.apps.hermes = true; + changed += 1; + log::info!("MCP 服务器 '{id}' 已启用 Hermes 应用"); + } + } else { + // 新建服务器:默认仅启用 Hermes + servers.insert( + id.clone(), + McpServer { + id: id.clone(), + name: id.clone(), + server: spec.clone(), + 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!("导入新 MCP 服务器 '{id}'"); + } + } + + if !errors.is_empty() { + log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors); + } + + Ok(changed) +} + +/// 将单个 MCP 服务器同步到 Hermes live 配置 +pub fn sync_single_server_to_hermes( + _config: &MultiAppConfig, + id: &str, + server_spec: &Value, +) -> Result<(), AppError> { + if !should_sync_hermes_mcp() { + return Ok(()); + } + // 读取现有的 MCP 配置 + let mut current = read_hermes_mcp_servers_map()?; + + // 添加/更新当前服务器 + current.insert(id.to_string(), server_spec.clone()); + + // 写回 + set_hermes_mcp_servers_map(¤t) +} + +/// 从 Hermes live 配置中移除单个 MCP 服务器 +pub fn remove_server_from_hermes(id: &str) -> Result<(), AppError> { + if !should_sync_hermes_mcp() { + return Ok(()); + } + // 读取现有的 MCP 配置 + let mut current = read_hermes_mcp_servers_map()?; + + // 移除指定服务器 + current.remove(id); + + // 写回 + set_hermes_mcp_servers_map(¤t) +} diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index 2f2b7f424e..b88efb2829 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -8,11 +8,13 @@ //! - `claude` - Claude MCP 同步和导入 //! - `codex` - Codex MCP 同步和导入(含 TOML 转换) //! - `gemini` - Gemini MCP 同步和导入 +//! - `hermes` - Hermes MCP 同步和导入 //! - `opencode` - OpenCode MCP 同步和导入(含 local/remote 格式转换) mod claude; mod codex; mod gemini; +mod hermes; mod opencode; mod validation; @@ -28,6 +30,10 @@ pub use gemini::{ import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini, sync_single_server_to_gemini, }; +pub use hermes::{ + import_from_hermes, remove_server_from_hermes, sync_enabled_to_hermes, + sync_single_server_to_hermes, +}; pub use opencode::{ import_from_opencode, remove_server_from_opencode, sync_single_server_to_opencode, }; diff --git a/src-tauri/src/mcp/opencode.rs b/src-tauri/src/mcp/opencode.rs index 687d5797c7..36dd25b57f 100644 --- a/src-tauri/src/mcp/opencode.rs +++ b/src-tauri/src/mcp/opencode.rs @@ -259,6 +259,7 @@ pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result Result { AppType::Gemini => get_gemini_dir(), AppType::OpenCode => get_opencode_dir(), AppType::OpenClaw => get_openclaw_dir(), + AppType::Hermes => crate::config::get_home_dir().join(".hermes"), }; let filename = match app { @@ -23,7 +24,8 @@ pub fn prompt_file_path(app: &AppType) -> Result { AppType::Codex => "AGENTS.md", AppType::Gemini => "GEMINI.md", AppType::OpenCode => "AGENTS.md", - AppType::OpenClaw => "AGENTS.md", // OpenClaw uses AGENTS.md for agent instructions + AppType::OpenClaw => "AGENTS.md", + 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 b6b8bb6433..c0b7054d67 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -170,6 +170,10 @@ impl ProviderType { // OpenClaw doesn't support proxy, but return a default type for completeness ProviderType::Codex // Fallback to Codex-like type } + AppType::Hermes => { + // Hermes doesn't support proxy yet, fallback to Codex-like type + ProviderType::Codex + } } } @@ -228,6 +232,10 @@ pub fn get_adapter(app_type: &AppType) -> Box { // OpenClaw doesn't support proxy, fallback to Codex adapter Box::new(CodexAdapter::new()) } + AppType::Hermes => { + // Hermes doesn't support proxy yet, 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 d354cccb13..650e9c0a82 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -130,6 +130,7 @@ impl ConfigService { // OpenClaw uses additive mode, no live sync needed // OpenClaw providers are managed directly in the config file } + AppType::Hermes => Self::sync_hermes_live(config, ¤t_id, &provider)?, } Ok(()) @@ -228,4 +229,22 @@ impl ConfigService { Ok(()) } + + fn sync_hermes_live( + config: &mut MultiAppConfig, + provider_id: &str, + provider: &Provider, + ) -> Result<(), AppError> { + crate::hermes_config::write_hermes_live(provider)?; + + // 读回实际写入的内容并更新到配置中 + let live_after = crate::hermes_config::read_hermes_live_settings()?; + if let Some(manager) = config.get_manager_mut(&AppType::Hermes) { + if let Some(target) = manager.providers.get_mut(provider_id) { + target.settings_config = live_after; + } + } + + Ok(()) + } } diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index 02b31aea93..d8aaa9320d 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -40,6 +40,9 @@ impl McpService { if prev_apps.opencode && !server.apps.opencode { Self::remove_server_from_app(state, &server.id, &AppType::OpenCode)?; } + if prev_apps.hermes && !server.apps.hermes { + Self::remove_server_from_app(state, &server.id, &AppType::Hermes)?; + } // 同步到各个启用的应用 Self::sync_server_to_apps(state, &server)?; @@ -128,6 +131,9 @@ impl McpService { // Skip for now log::debug!("OpenClaw MCP support is still in development, skipping sync"); } + AppType::Hermes => { + mcp::sync_single_server_to_hermes(&Default::default(), &server.id, &server.server)?; + } } Ok(()) } @@ -157,6 +163,9 @@ impl McpService { // OpenClaw MCP support is still in development log::debug!("OpenClaw MCP support is still in development, skipping remove"); } + AppType::Hermes => { + mcp::remove_server_from_hermes(id)?; + } } Ok(()) } @@ -381,4 +390,42 @@ impl McpService { Ok(new_count) } + + /// 从 Hermes 导入 MCP(v3.13.0+ 新增) + pub fn import_from_hermes(state: &AppState) -> Result { + // 创建临时 MultiAppConfig 用于导入 + let mut temp_config = crate::app_config::MultiAppConfig::default(); + + // 调用原有的导入逻辑(从 mcp/hermes.rs) + let count = crate::mcp::import_from_hermes(&mut temp_config)?; + + let mut new_count = 0; + + // 如果有导入的服务器,保存到数据库 + if count > 0 { + if let Some(servers) = &temp_config.mcp.servers { + let mut existing = state.db.get_all_mcp_servers()?; + for server in servers.values() { + // 已存在:仅启用 Hermes,不覆盖其他字段(与导入模块语义保持一致) + let to_save = if let Some(existing_server) = existing.get(&server.id) { + let mut merged = existing_server.clone(); + merged.apps.hermes = true; + merged + } else { + // 真正的新服务器 + new_count += 1; + server.clone() + }; + + state.db.save_mcp_server(&to_save)?; + existing.insert(to_save.id.clone(), to_save.clone()); + + // 同步到对应应用 live 配置 + Self::sync_server_to_apps(state, &to_save)?; + } + } + } + + Ok(new_count) + } } diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 21147da650..be81b174a2 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -332,7 +332,7 @@ fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet: toml_item_is_subset(target_doc.as_item(), source_doc.as_item()) } - AppType::Gemini => match serde_json::from_str::(trimmed) { + AppType::Gemini | AppType::Hermes => match serde_json::from_str::(trimmed) { Ok(Value::Object(source_map)) => { let Some(target_map) = settings.get("env").and_then(Value::as_object) else { return false; @@ -406,9 +406,17 @@ pub(crate) fn remove_common_config_from_settings( } Ok(result) } - AppType::Gemini => { - let source = serde_json::from_str::(trimmed) - .map_err(|e| AppError::Message(format!("Invalid Gemini common config: {e}")))?; + AppType::Gemini | AppType::Hermes => { + let source = serde_json::from_str::(trimmed).map_err(|e| { + AppError::Message(format!( + "Invalid {} common config: {e}", + if matches!(app_type, AppType::Gemini) { + "Gemini" + } else { + "Hermes" + } + )) + })?; let mut result = settings.clone(); if let Some(env) = result.get_mut("env") { json_deep_remove(env, &source); @@ -459,9 +467,17 @@ fn apply_common_config_to_settings( } Ok(result) } - AppType::Gemini => { - let source = serde_json::from_str::(trimmed) - .map_err(|e| AppError::Message(format!("Invalid Gemini common config: {e}")))?; + AppType::Gemini | AppType::Hermes => { + let source = serde_json::from_str::(trimmed).map_err(|e| { + AppError::Message(format!( + "Invalid {} common config: {e}", + if matches!(app_type, AppType::Gemini) { + "Gemini" + } else { + "Hermes" + } + )) + })?; let mut result = settings.clone(); if let Some(env) = result.get_mut("env") { json_deep_merge(env, &source); @@ -603,6 +619,10 @@ pub(crate) enum LiveSnapshot { env: Option>, config: Option, }, + Hermes { + config: Option, + env: Option>, + }, } impl LiveSnapshot { @@ -656,6 +676,25 @@ impl LiveSnapshot { _ => {} } } + LiveSnapshot::Hermes { config, env } => { + use crate::hermes_config::{ + get_hermes_config_path, get_hermes_env_path, write_hermes_config_atomic, + write_hermes_env_atomic, + }; + let config_path = get_hermes_config_path(); + if let Some(yaml) = config { + write_hermes_config_atomic(yaml)?; + } else if config_path.exists() { + delete_file(&config_path)?; + } + + let env_path = get_hermes_env_path(); + if let Some(env_map) = env { + write_hermes_env_atomic(env_map)?; + } else if env_path.exists() { + delete_file(&env_path)?; + } + } } Ok(()) } @@ -790,6 +829,13 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re } } } + AppType::Hermes => { + log::info!( + "write_live_snapshot: writing Hermes provider '{}'", + provider.id + ); + crate::hermes_config::write_hermes_live(provider)?; + } } Ok(()) } @@ -985,6 +1031,7 @@ pub fn read_live_settings(app_type: AppType) -> Result { let config = read_openclaw_config()?; Ok(config) } + AppType::Hermes => crate::hermes_config::read_hermes_live_settings(), } } @@ -1071,14 +1118,99 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result { unreachable!("additive mode apps are handled by early return") } + AppType::Hermes => { + use crate::hermes_config::{get_hermes_config_path, read_hermes_config}; + + // Check if config.yaml exists + let config_path = get_hermes_config_path(); + if !config_path.exists() { + return Err(AppError::localized( + "hermes.live.missing", + "Hermes 配置文件不存在", + "Hermes configuration file is missing", + )); + } + + // Read config.yaml + let yaml_config = read_hermes_config()?; + + // Get current provider name from model.provider + let current_provider_name = yaml_config + .get("model") + .and_then(|m| m.get("provider")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + // Get current model from model.default + let model_default = yaml_config + .get("model") + .and_then(|m| m.get("default")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + // Find the current provider in custom_providers list + let custom_providers = yaml_config + .get("custom_providers") + .and_then(|p| p.as_sequence()); + + let (base_url, api_key, provider_model) = if let Some(providers) = custom_providers { + let mut found_base_url = String::new(); + let mut found_api_key = String::new(); + let mut found_model = String::new(); + + for provider in providers { + if let Some(name) = provider.get("name").and_then(|n| n.as_str()) { + if name == current_provider_name { + found_base_url = provider + .get("base_url") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + found_api_key = provider + .get("api_key") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + found_model = provider + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + break; + } + } + } + + (found_base_url, found_api_key, found_model) + } else { + (String::new(), String::new(), String::new()) + }; + + // Build settings_config using snake_case to match write_hermes_live format + json!({ + "name": current_provider_name, + "model": if !provider_model.is_empty() { provider_model } else { model_default.to_string() }, + "base_url": base_url, + "api_key": api_key, + "transport": "openai_chat" + }) + } }; - let mut provider = Provider::with_id( - "default".to_string(), - "default".to_string(), - settings_config, - None, - ); + // For Hermes, extract provider name from settings_config + let provider_name = if matches!(app_type, AppType::Hermes) { + settings_config + .get("name") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .unwrap_or("default") + .to_string() + } else { + "default".to_string() + }; + + let mut provider = + Provider::with_id("default".to_string(), provider_name, settings_config, None); provider.category = Some("custom".to_string()); state.db.save_provider(app_type.as_str(), &provider)?; @@ -1190,6 +1322,16 @@ pub(crate) fn remove_opencode_provider_from_live(provider_id: &str) -> Result<() Ok(()) } +/// Remove a Hermes provider from the live configuration +/// +/// Removes the provider from custom_providers list in ~/.hermes/config.yaml. +/// Note: provider_name (display name) is used as the key, not provider_id. +pub(crate) fn remove_hermes_provider_from_live(provider_name: &str) -> Result<(), AppError> { + crate::hermes_config::remove_hermes_provider_from_live(provider_name)?; + log::info!("Hermes provider '{provider_name}' removed from live config"); + Ok(()) +} + /// Import all providers from OpenCode live config to database /// /// This imports existing providers from ~/.config/opencode/opencode.json diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index d16f180893..02bcdbd5e9 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -35,7 +35,8 @@ pub(crate) use live::{ // Internal re-exports use live::{ - remove_openclaw_provider_from_live, remove_opencode_provider_from_live, write_gemini_live, + remove_hermes_provider_from_live, remove_openclaw_provider_from_live, + remove_opencode_provider_from_live, write_gemini_live, }; use usage::validate_usage_script; @@ -1299,6 +1300,13 @@ impl ProviderService { )); } + // For Hermes: remove from custom_providers in live config + if matches!(app_type, AppType::Hermes) { + if let Some(provider) = state.db.get_provider_by_id(id, app_type.as_str())? { + remove_hermes_provider_from_live(&provider.name)?; + } + } + state.db.delete_provider(app_type.as_str(), id) } @@ -1708,6 +1716,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 => Self::extract_hermes_common_config(&provider.settings_config), } } @@ -1722,6 +1731,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 => Self::extract_hermes_common_config(settings_config), } } @@ -1899,6 +1909,41 @@ impl ProviderService { .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) } + /// Extract common config for Hermes (env-style, same pattern as Gemini) + fn extract_hermes_common_config(settings: &Value) -> Result { + let env = settings.get("env").and_then(|v| v.as_object()); + + let mut snippet = serde_json::Map::new(); + if let Some(env) = env { + // Exclude provider-specific API key env vars + const HERMES_API_KEY_VARS: &[&str] = &[ + "ANTHROPIC_API_KEY", + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + "HERMES_API_KEY", + ]; + for (key, value) in env { + if HERMES_API_KEY_VARS.contains(&key.as_str()) { + continue; + } + let Value::String(v) = value else { + continue; + }; + let trimmed = v.trim(); + if !trimmed.is_empty() { + snippet.insert(key.to_string(), Value::String(trimmed.to_string())); + } + } + } + + if snippet.is_empty() { + return Ok("{}".to_string()); + } + + serde_json::to_string_pretty(&Value::Object(snippet)) + .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) + } + /// Import default configuration from live files (re-export) /// /// Returns `Ok(true)` if imported, `Ok(false)` if skipped. @@ -2087,6 +2132,16 @@ impl ProviderService { )); } } + AppType::Hermes => { + // Hermes validation not yet implemented + 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) @@ -2280,6 +2335,42 @@ impl ProviderService { .unwrap_or("") .to_string(); + Ok((api_key, base_url)) + } + AppType::Hermes => { + // Hermes uses env-style config (like Gemini) + // Extract env map from settings_config + let env_map = if let Some(env_obj) = provider + .settings_config + .get("env") + .and_then(|v| v.as_object()) + { + env_obj + .iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect::>() + } else { + std::collections::HashMap::new() + }; + + // Try multiple possible API key env vars (in priority order) + let api_key = env_map + .get("ANTHROPIC_API_KEY") + .or_else(|| env_map.get("OPENROUTER_API_KEY")) + .or_else(|| env_map.get("HERMES_API_KEY")) + .or_else(|| env_map.get("OPENAI_API_KEY")) + .cloned() + .ok_or_else(|| { + AppError::localized( + "hermes.missing_api_key", + "缺少 API Key(需要 ANTHROPIC_API_KEY、OPENROUTER_API_KEY 或 HERMES_API_KEY)", + "Missing API Key (need ANTHROPIC_API_KEY, OPENROUTER_API_KEY, or HERMES_API_KEY)", + ) + })?; + + // Base URL is optional for Hermes + let base_url = env_map.get("HERMES_BASE_URL").cloned().unwrap_or_default(); + Ok((api_key, base_url)) } } diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 267d71ca79..d2e96f0647 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -474,6 +474,10 @@ impl ProxyService { // OpenClaw doesn't support proxy features return Err("OpenClaw 不支持代理功能".to_string()); } + AppType::Hermes => { + // Hermes doesn't support proxy features + return Err("Hermes 不支持代理功能".to_string()); + } }; self.sync_live_config_to_provider(app_type, &live_config) @@ -693,6 +697,9 @@ impl ProxyService { AppType::OpenClaw => { // OpenClaw doesn't support proxy features, skip silently } + AppType::Hermes => { + // Hermes doesn't support proxy features, skip silently + } } Ok(()) @@ -879,6 +886,10 @@ impl ProxyService { // OpenClaw doesn't support proxy features return Err("OpenClaw 不支持代理功能".to_string()); } + AppType::Hermes => { + // Hermes doesn't support proxy features + return Err("Hermes 不支持代理功能".to_string()); + } }; let json_str = serde_json::to_string(&config) @@ -1027,6 +1038,10 @@ impl ProxyService { // OpenClaw doesn't support proxy features return Err("OpenClaw 不支持代理功能".to_string()); } + AppType::Hermes => { + // Hermes doesn't support proxy features + return Err("Hermes 不支持代理功能".to_string()); + } } Ok(()) @@ -1082,6 +1097,9 @@ impl ProxyService { AppType::OpenClaw => { // OpenClaw doesn't support proxy features, skip silently } + AppType::Hermes => { + // Hermes doesn't support proxy features, skip silently + } } Ok(()) @@ -1125,6 +1143,9 @@ impl ProxyService { AppType::OpenClaw => { // OpenClaw doesn't support proxy features, skip silently } + AppType::Hermes => { + // Hermes doesn't support proxy features, skip silently + } } Ok(()) @@ -1221,6 +1242,10 @@ impl ProxyService { // OpenClaw doesn't support proxy features Err("OpenClaw 不支持代理功能".to_string()) } + AppType::Hermes => { + // Hermes doesn't support proxy features + Err("Hermes 不支持代理功能".to_string()) + } } } @@ -1246,6 +1271,10 @@ impl ProxyService { // OpenClaw doesn't support proxy takeover false } + AppType::Hermes => { + // Hermes doesn't support proxy takeover + false + } } } @@ -1293,6 +1322,10 @@ impl ProxyService { // OpenClaw doesn't support proxy features Ok(()) } + AppType::Hermes => { + // Hermes doesn't support proxy features + Ok(()) + } } } @@ -1540,7 +1573,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 2acbdfae9e..8fef7b5f43 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 => home.join(".hermes").join("skills"), }) } diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index de1e4cc236..3c27ec990e 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -268,9 +268,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 处理") } }; @@ -667,7 +667,17 @@ impl StreamCheckService { ) .await } - _ => unreachable!("check_once_without_adapter 只处理 OpenCode/OpenClaw"), + AppType::Hermes => { + Self::check_hermes_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; @@ -1040,6 +1050,88 @@ impl StreamCheckService { } } + /// Hermes 流式检查 + /// + /// Hermes 使用 snake_case 配置格式: + /// - `base_url`: API 端点 + /// - `api_key`: API 密钥 + /// - `transport`: 传输类型(默认 "openai_chat") + /// + /// 目前仅支持 `openai_chat` 传输类型,使用 OpenAI Chat Completions API 格式。 + async fn check_hermes_stream( + client: &Client, + provider: &Provider, + model: &str, + test_prompt: &str, + timeout: std::time::Duration, + ) -> Result<(u16, String), AppError> { + let base_url = Self::extract_hermes_base_url(provider)?; + let api_key = Self::extract_hermes_api_key(provider)?; + let transport = Self::extract_hermes_transport(provider); + + match transport.as_deref() { + Some("openai_chat") | None => { + // 默认使用 OpenAI Chat Completions API 格式 + let auth = AuthInfo::new(api_key, AuthStrategy::Bearer); + Self::check_claude_stream( + client, + &base_url, + &auth, + model, + test_prompt, + timeout, + provider, + Some("openai_chat"), + None, + ) + .await + } + Some(other) => Err(AppError::localized( + "hermes_transport_not_supported", + format!("Hermes 暂不支持传输类型: {other}"), + format!("Hermes transport type not supported: {other}"), + )), + } + } + + fn extract_hermes_base_url(provider: &Provider) -> Result { + provider + .settings_config + .get("base_url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| { + AppError::localized( + "hermes_base_url_missing", + "Hermes 供应商缺少 base_url 字段", + "Hermes provider is missing the `base_url` field", + ) + }) + } + + fn extract_hermes_api_key(provider: &Provider) -> Result { + provider + .settings_config + .get("api_key") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| { + AppError::localized( + "hermes_api_key_missing", + "Hermes 供应商缺少 api_key 字段", + "Hermes provider is missing the `api_key` field", + ) + }) + } + + fn extract_hermes_transport(provider: &Provider) -> Option { + provider + .settings_config + .get("transport") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + } + /// 按 OpenCode 的实际 SDK 包特性确定 baseURL: /// - 用户显式填写的 `options.baseURL` 总是优先 /// - 否则根据 `npm` 返回 AI SDK 包自带的默认端点 @@ -1213,9 +1305,23 @@ impl StreamCheckService { // Try to extract first model from the models array Self::extract_openclaw_model(provider).unwrap_or_else(|| "gpt-4o".to_string()) } + AppType::Hermes => { + // Hermes uses model field directly in settings_config (snake_case) + Self::extract_hermes_model(provider) + .unwrap_or_else(|| "claude-sonnet-4-6".to_string()) + } } } + fn extract_hermes_model(provider: &Provider) -> Option { + // Hermes settings_config: { name, base_url, api_key, model, transport } + provider + .settings_config + .get("model") + .and_then(|m| m.as_str()) + .map(|s| s.to_string()) + } + fn extract_opencode_model(provider: &Provider) -> Option { let models = provider .settings_config diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 8b41ba5179..aa87c9d0cc 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 = "default_true")] + pub hermes: bool, } impl Default for VisibleApps { @@ -46,6 +48,7 @@ impl Default for VisibleApps { gemini: true, opencode: true, openclaw: true, + hermes: true, } } } @@ -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(本地存储,优先于数据库 is_current) + #[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 24a1667fbb..2be08faff0 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 1ed24a99d1..006b036748 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, @@ -655,3 +663,92 @@ fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries() "live entries unknown to DB should be preserved" ); } + +#[test] +fn import_default_config_hermes_reads_from_custom_providers() { + use cc_switch_lib::get_hermes_config_path; + + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + // 创建 Hermes 配置目录和文件 + let hermes_dir = home.join(".hermes"); + fs::create_dir_all(&hermes_dir).expect("create hermes dir"); + + let config_path = get_hermes_config_path(); + let config_yaml = r#" +model: + default: claude-sonnet-4-6 + provider: "My Custom Provider" + +custom_providers: + - name: "My Custom Provider" + base_url: "https://api.custom.com/v1" + api_key: "sk-custom-key" + model: "claude-sonnet-4-6" + transport: "openai_chat" + + - name: "Another Provider" + base_url: "https://api.another.com/v1" + api_key: "sk-another-key" + model: "claude-opus-4" + transport: "openai_chat" +"#; + fs::write(&config_path, config_yaml).expect("write hermes config.yaml"); + + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::Hermes); + let state = create_test_state_with_config(&config).expect("create test state"); + + import_default_config_test_hook(&state, AppType::Hermes) + .expect("import default config succeeds"); + + // 验证导入的 provider + let providers = state + .db + .get_all_providers(AppType::Hermes.as_str()) + .expect("get all providers"); + + // 只应该导入当前激活的 provider(由 model.provider 指定) + let current_id = state + .db + .get_current_provider(AppType::Hermes.as_str()) + .expect("get current provider"); + assert_eq!(current_id.as_deref(), Some("default")); + + let default_provider = providers.get("default").expect("default provider"); + + // 验证 provider name 是从 custom_providers 中读取的 + assert_eq!(default_provider.name, "My Custom Provider"); + + // 验证 settings_config 包含正确的字段 + assert_eq!( + default_provider + .settings_config + .get("name") + .and_then(|v| v.as_str()), + Some("My Custom Provider") + ); + assert_eq!( + default_provider + .settings_config + .get("base_url") + .and_then(|v| v.as_str()), + Some("https://api.custom.com/v1") + ); + assert_eq!( + default_provider + .settings_config + .get("api_key") + .and_then(|v| v.as_str()), + Some("sk-custom-key") + ); + assert_eq!( + default_provider + .settings_config + .get("model") + .and_then(|v| v.as_str()), + Some("claude-sonnet-4-6") + ); +} diff --git a/src-tauri/tests/provider_commands.rs b/src-tauri/tests/provider_commands.rs index 3288489266..71fcc48d91 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 2b9eddaa96..d9679f921f 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, diff --git a/src-tauri/tests/skill_sync.rs b/src-tauri/tests/skill_sync.rs index b1e0c3a61a..9334bb0aa7 100644 --- a/src-tauri/tests/skill_sync.rs +++ b/src-tauri/tests/skill_sync.rs @@ -57,6 +57,7 @@ fn import_from_apps_respects_explicit_app_selection() { codex: false, gemini: false, opencode: true, + hermes: false, }, }], ) @@ -108,6 +109,7 @@ fn sync_to_app_removes_disabled_and_orphaned_ssot_symlinks() { codex: false, gemini: false, opencode: false, + hermes: false, }, installed_at: 0, content_hash: None, @@ -154,6 +156,7 @@ fn uninstall_skill_creates_backup_before_removing_ssot() { codex: false, gemini: false, opencode: false, + hermes: false, }, installed_at: 123, content_hash: None, @@ -224,6 +227,7 @@ fn restore_skill_backup_restores_files_to_ssot_and_current_app() { codex: false, gemini: false, opencode: false, + hermes: false, }, installed_at: 456, content_hash: None, @@ -307,6 +311,7 @@ fn delete_skill_backup_removes_backup_directory() { codex: false, gemini: false, opencode: false, + hermes: false, }, installed_at: 789, content_hash: None, diff --git a/src/App.tsx b/src/App.tsx index 4406ac7538..e22b419cbf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -172,6 +172,7 @@ function App() { claude: true, codex: true, gemini: true, + hermes: true, opencode: true, openclaw: true, }; @@ -182,6 +183,7 @@ function App() { if (visibleApps.gemini) return "gemini"; if (visibleApps.opencode) return "opencode"; if (visibleApps.openclaw) return "openclaw"; + if (visibleApps.hermes) return "hermes"; return "claude"; // fallback }; diff --git a/src/components/AppSwitcher.tsx b/src/components/AppSwitcher.tsx index 61d780e90a..4fe2004d95 100644 --- a/src/components/AppSwitcher.tsx +++ b/src/components/AppSwitcher.tsx @@ -10,7 +10,15 @@ interface AppSwitcherProps { compact?: boolean; } -const ALL_APPS: AppId[] = ["claude", "codex", "gemini", "opencode", "openclaw"]; +const ALL_APPS: AppId[] = [ + "claude", + "codex", + "gemini", + "opencode", + "openclaw", + "hermes", +]; + const STORAGE_KEY = "cc-switch-last-app"; export function AppSwitcher({ @@ -29,6 +37,7 @@ export function AppSwitcher({ claude: "claude", codex: "openai", gemini: "gemini", + hermes: "terminal", opencode: "opencode", openclaw: "openclaw", }; @@ -36,6 +45,7 @@ export function AppSwitcher({ claude: "Claude", codex: "Codex", gemini: "Gemini", + hermes: "Hermes", opencode: "OpenCode", openclaw: "OpenClaw", }; diff --git a/src/components/mcp/McpFormModal.tsx b/src/components/mcp/McpFormModal.tsx index 70ab3b03a0..8e4ddb2897 100644 --- a/src/components/mcp/McpFormModal.tsx +++ b/src/components/mcp/McpFormModal.tsx @@ -7,7 +7,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import JsonEditor from "@/components/JsonEditor"; import type { AppId } from "@/lib/api/types"; -import { McpServer, McpServerSpec } from "@/types"; +import { McpApps, McpServer, McpServerSpec } from "@/types"; import { mcpPresets, getMcpPresetWithDescription } from "@/config/mcpPresets"; import McpWizardModal from "./McpWizardModal"; import { @@ -61,13 +61,7 @@ const McpFormModal: React.FC = ({ const [formDocs, setFormDocs] = useState(initialData?.docs || ""); const [formTags, setFormTags] = useState(initialData?.tags?.join(", ") || ""); - const [enabledApps, setEnabledApps] = useState<{ - claude: boolean; - codex: boolean; - gemini: boolean; - opencode: boolean; - openclaw: boolean; - }>(() => { + const [enabledApps, setEnabledApps] = useState(() => { if (initialData?.apps) { return { ...initialData.apps }; } @@ -77,6 +71,7 @@ const McpFormModal: React.FC = ({ gemini: defaultEnabledApps.includes("gemini"), opencode: defaultEnabledApps.includes("opencode"), openclaw: defaultEnabledApps.includes("openclaw"), + hermes: defaultEnabledApps.includes("hermes"), }; }); diff --git a/src/components/mcp/UnifiedMcpPanel.tsx b/src/components/mcp/UnifiedMcpPanel.tsx index 761ddcd651..daed5698c4 100644 --- a/src/components/mcp/UnifiedMcpPanel.tsx +++ b/src/components/mcp/UnifiedMcpPanel.tsx @@ -56,7 +56,14 @@ const UnifiedMcpPanel = React.forwardRef< }, [serversMap]); const enabledCounts = useMemo(() => { - const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 }; + const counts = { + claude: 0, + codex: 0, + gemini: 0, + opencode: 0, + openclaw: 0, + hermes: 0, + }; serverEntries.forEach(([_, server]) => { for (const app of MCP_SKILLS_APP_IDS) { if (server.apps[app]) counts[app]++; diff --git a/src/components/prompts/PromptFormModal.tsx b/src/components/prompts/PromptFormModal.tsx index 84e18d2551..0a9c86f942 100644 --- a/src/components/prompts/PromptFormModal.tsx +++ b/src/components/prompts/PromptFormModal.tsx @@ -35,6 +35,7 @@ const PromptFormModal: React.FC = ({ codex: "AGENTS.md", gemini: "GEMINI.md", opencode: "AGENTS.md", + hermes: "AGENTS.md", }; const filename = filenameMap[appId as Exclude]; const [name, setName] = useState(""); diff --git a/src/components/prompts/PromptFormPanel.tsx b/src/components/prompts/PromptFormPanel.tsx index c4481fa4cf..d946cd3093 100644 --- a/src/components/prompts/PromptFormPanel.tsx +++ b/src/components/prompts/PromptFormPanel.tsx @@ -28,6 +28,7 @@ const PromptFormPanel: React.FC = ({ claude: "CLAUDE.md", codex: "AGENTS.md", gemini: "GEMINI.md", + hermes: "AGENTS.md", opencode: "AGENTS.md", openclaw: "AGENTS.md", }; diff --git a/src/components/providers/forms/EndpointSpeedTest.tsx b/src/components/providers/forms/EndpointSpeedTest.tsx index 2143d21dbb..27913254b9 100644 --- a/src/components/providers/forms/EndpointSpeedTest.tsx +++ b/src/components/providers/forms/EndpointSpeedTest.tsx @@ -15,6 +15,7 @@ const ENDPOINT_TIMEOUT_SECS: Record = { gemini: 8, opencode: 8, openclaw: 8, + hermes: 8, }; interface TestResult { diff --git a/src/components/providers/forms/HermesFormFields.tsx b/src/components/providers/forms/HermesFormFields.tsx new file mode 100644 index 0000000000..16ff99cd0c --- /dev/null +++ b/src/components/providers/forms/HermesFormFields.tsx @@ -0,0 +1,211 @@ +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { FormLabel } from "@/components/ui/form"; +import { Download, Loader2, Info } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import EndpointSpeedTest from "./EndpointSpeedTest"; +import { ApiKeySection, EndpointField, ModelInputWithFetch } from "./shared"; +import { + fetchModelsForConfig, + showFetchModelsError, + type FetchedModel, +} from "@/lib/api/model-fetch"; +import type { ProviderCategory } from "@/types"; + +interface EndpointCandidate { + url: string; +} + +interface HermesFormFieldsProps { + providerId?: string; + // API Key + apiKey: string; + onApiKeyChange: (key: string) => void; + category?: ProviderCategory; + shouldShowApiKeyLink: boolean; + websiteUrl: string; + isPartner?: boolean; + partnerPromotionKey?: string; + + // Base URL + shouldShowSpeedTest: boolean; + baseUrl: string; + onBaseUrlChange: (url: string) => void; + isEndpointModalOpen: boolean; + onEndpointModalToggle: (open: boolean) => void; + onCustomEndpointsChange: (endpoints: string[]) => void; + autoSelect: boolean; + onAutoSelectChange: (checked: boolean) => void; + + // Model + shouldShowModelField: boolean; + model: string; + onModelChange: (value: string) => void; + + // Speed Test Endpoints + speedTestEndpoints: EndpointCandidate[]; +} + +export function HermesFormFields({ + providerId, + apiKey, + onApiKeyChange, + category, + shouldShowApiKeyLink, + websiteUrl, + isPartner, + partnerPromotionKey, + shouldShowSpeedTest, + baseUrl, + onBaseUrlChange, + isEndpointModalOpen, + onEndpointModalToggle, + onCustomEndpointsChange, + autoSelect, + onAutoSelectChange, + shouldShowModelField, + model, + onModelChange, + speedTestEndpoints, +}: HermesFormFieldsProps) { + const { t } = useTranslation(); + + const [fetchedModels, setFetchedModels] = useState([]); + const [isFetchingModels, setIsFetchingModels] = useState(false); + + const handleFetchModels = useCallback(() => { + if (!baseUrl || !apiKey) { + showFetchModelsError(null, t, { + hasApiKey: !!apiKey, + hasBaseUrl: !!baseUrl, + }); + return; + } + setIsFetchingModels(true); + fetchModelsForConfig(baseUrl, apiKey) + .then((models) => { + setFetchedModels(models); + if (models.length === 0) { + toast.info(t("providerForm.fetchModelsEmpty")); + } else { + toast.success( + t("providerForm.fetchModelsSuccess", { count: models.length }), + ); + } + }) + .catch((err) => { + console.warn("[ModelFetch] Failed:", err); + showFetchModelsError(err, t); + }) + .finally(() => setIsFetchingModels(false)); + }, [baseUrl, apiKey, t]); + + // Check if this is official provider (no API key needed) + const isOfficial = category === "official"; + + return ( + <> + {/* Official Provider hint */} + {isOfficial && ( +
+
+ +
+

+ {t("provider.form.hermes.officialTitle", { + defaultValue: "Official Provider", + })} +

+

+ {t("provider.form.hermes.officialHint", { + defaultValue: + "Official provider uses default settings. No API key required.", + })} +

+
+
+
+ )} + + {/* API Key input - always show for non-official providers */} + {!isOfficial && ( + + )} + + {/* Base URL input */} + {shouldShowSpeedTest && ( + onEndpointModalToggle(true)} + /> + )} + + {/* Model input */} + {shouldShowModelField && ( +
+
+ + {t("provider.form.hermes.model", { defaultValue: "Model" })} + + +
+ +
+ )} + + {/* Endpoint speed test modal */} + {shouldShowSpeedTest && isEndpointModalOpen && ( + onEndpointModalToggle(false)} + autoSelect={autoSelect} + onAutoSelectChange={onAutoSelectChange} + onCustomEndpointsChange={onCustomEndpointsChange} + /> + )} + + ); +} diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 7f8cf560e7..b11374cd3e 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -37,6 +37,10 @@ import { type OpenClawProviderPreset, type OpenClawSuggestedDefaults, } from "@/config/openclawProviderPresets"; +import { + hermesProviderPresets, + type HermesProviderPreset, +} from "@/config/hermesProviderPresets"; import { OpenCodeFormFields } from "./OpenCodeFormFields"; import { OpenClawFormFields } from "./OpenClawFormFields"; import type { UniversalProviderPreset } from "@/config/universalProviderPresets"; @@ -56,6 +60,7 @@ import { BasicFormFields } from "./BasicFormFields"; import { ClaudeFormFields } from "./ClaudeFormFields"; import { CodexFormFields } from "./CodexFormFields"; import { GeminiFormFields } from "./GeminiFormFields"; +import { HermesFormFields } from "./HermesFormFields"; import { OmoFormFields } from "./OmoFormFields"; import { parseOmoOtherFieldsObject } from "@/types/omo"; import { @@ -75,6 +80,7 @@ import { useSpeedTestEndpoints, useCodexTomlValidation, useGeminiConfigState, + useHermesConfigState, useGeminiCommonConfig, useOmoModelSource, useOpencodeFormState, @@ -91,6 +97,7 @@ import { GEMINI_DEFAULT_CONFIG, OPENCODE_DEFAULT_CONFIG, OPENCLAW_DEFAULT_CONFIG, + HERMES_DEFAULT_CONFIG, normalizePricingSource, } from "./helpers/opencodeFormUtils"; import { resolveManagedAccountId } from "@/lib/authBinding"; @@ -103,7 +110,8 @@ type PresetEntry = { | CodexProviderPreset | GeminiProviderPreset | OpenCodeProviderPreset - | OpenClawProviderPreset; + | OpenClawProviderPreset + | HermesProviderPreset; }; interface ProviderFormProps { @@ -253,7 +261,9 @@ export function ProviderForm({ ? OPENCODE_DEFAULT_CONFIG : appId === "openclaw" ? OPENCLAW_DEFAULT_CONFIG - : CLAUDE_DEFAULT_CONFIG, + : appId === "hermes" + ? HERMES_DEFAULT_CONFIG + : CLAUDE_DEFAULT_CONFIG, icon: initialData?.icon ?? "", iconColor: initialData?.iconColor ?? "", }), @@ -450,6 +460,11 @@ export function ProviderForm({ id: `openclaw-${index}`, preset, })); + } else if (appId === "hermes") { + return hermesProviderPresets.map((preset, index) => ({ + id: `hermes-${index}`, + preset, + })); } return providerPresets .filter((p) => !p.hidden) @@ -594,6 +609,23 @@ export function ProviderForm({ selectedPresetId: selectedPresetId ?? undefined, }); + // ── Extracted hooks: Hermes ───────────────────── + + const { + hermesApiKey, + hermesBaseUrl, + hermesModel, + handleHermesApiKeyChange, + handleHermesBaseUrlChange, + handleHermesModelChange, + resetHermesConfig, + buildHermesSettingsConfig, + } = useHermesConfigState({ + initialData: appId === "hermes" ? initialData : undefined, + onSettingsConfigChange: (config) => form.setValue("settingsConfig", config), + getSettingsConfig: () => form.getValues("settingsConfig"), + }); + // ── Extracted hooks: OpenCode / OMO / OpenClaw ───────────────────── const { @@ -906,6 +938,9 @@ export function ProviderForm({ } catch (err) { settingsConfig = values.settingsConfig.trim(); } + } else if (appId === "hermes") { + const hermesConfig = buildHermesSettingsConfig(); + settingsConfig = JSON.stringify(hermesConfig); } else if ( appId === "opencode" && (category === "omo" || category === "omo-slim") @@ -1182,6 +1217,20 @@ export function ProviderForm({ formWebsiteUrl: form.watch("websiteUrl") || "", }); + // 使用 API Key 链接 hook (Hermes) + const { + shouldShowApiKeyLink: shouldShowHermesApiKeyLink, + websiteUrl: hermesWebsiteUrl, + isPartner: isHermesPartner, + partnerPromotionKey: hermesPartnerPromotionKey, + } = useApiKeyLink({ + appId: "hermes", + category, + selectedPresetId, + presetEntries, + formWebsiteUrl: form.watch("websiteUrl") || "", + }); + // 使用端点测速候选 hook const speedTestEndpoints = useSpeedTestEndpoints({ appId, @@ -1213,6 +1262,10 @@ export function ProviderForm({ if (appId === "openclaw") { openclawForm.resetOpenclawState(); } + // Hermes 自定义模式:重置为空配置 + if (appId === "hermes") { + resetHermesConfig({}); + } return; } @@ -1262,6 +1315,22 @@ export function ProviderForm({ return; } + if (appId === "hermes") { + const preset = entry.preset as ProviderPreset; + const config = preset.settingsConfig as Record; + + resetHermesConfig(config); + + form.reset({ + name: preset.nameKey ? t(preset.nameKey) : preset.name, + websiteUrl: preset.websiteUrl ?? "", + settingsConfig: JSON.stringify(config, null, 2), + icon: preset.icon ?? "", + iconColor: preset.iconColor ?? "", + }); + return; + } + if (appId === "opencode") { const preset = entry.preset as OpenCodeProviderPreset; const config = preset.settingsConfig; @@ -1640,6 +1709,31 @@ export function ProviderForm({ /> )} + {appId === "hermes" && ( + + )} + {appId === "opencode" && !isAnyOmoCategory && ( ; + }; + onSettingsConfigChange?: (config: string) => void; + getSettingsConfig?: () => string; +} + +/** + * 管理 Hermes 配置状态 + * Hermes 配置结构: { name, base_url, api_key, model, transport } + * 使用蛇形命名以匹配后端 Hermes config.yaml 格式 + */ +export function useHermesConfigState({ + initialData, + onSettingsConfigChange, + getSettingsConfig, +}: UseHermesConfigStateProps) { + const [hermesApiKey, setHermesApiKey] = useState(""); + const [hermesBaseUrl, setHermesBaseUrl] = useState(""); + const [hermesModel, setHermesModel] = useState(""); + const [hermesProviderName, setHermesProviderName] = useState(""); + + // 构建 settingsConfig JSON(使用蛇形命名) + const buildSettingsConfig = useCallback(() => { + const config: Record = { + transport: "openai_chat", + }; + + if (hermesProviderName) { + config.name = hermesProviderName; + } + if (hermesBaseUrl) { + config.base_url = hermesBaseUrl; + } + if (hermesApiKey) { + config.api_key = hermesApiKey; + } + if (hermesModel) { + config.model = hermesModel; + } + + return config; + }, [hermesProviderName, hermesBaseUrl, hermesApiKey, hermesModel]); + + // 更新 settingsConfig(用于实时同步) + const updateHermesConfig = useCallback( + (updater: (config: Record) => void) => { + if (!onSettingsConfigChange || !getSettingsConfig) return; + + try { + const configStr = getSettingsConfig(); + const config = configStr ? JSON.parse(configStr) : {}; + updater(config); + onSettingsConfigChange(JSON.stringify(config, null, 2)); + } catch { + // 如果解析失败,从当前状态重新构建 + const config = buildSettingsConfig(); + updater(config); + onSettingsConfigChange(JSON.stringify(config, null, 2)); + } + }, + [onSettingsConfigChange, getSettingsConfig, buildSettingsConfig], + ); + + // 初始化 Hermes 配置(编辑模式) + useEffect(() => { + if (!initialData) return; + + const config = initialData.settingsConfig; + if (typeof config === "object" && config !== null) { + const cfg = config as Record; + + // 使用蛇形命名读取 + if (typeof cfg.api_key === "string") { + setHermesApiKey(cfg.api_key); + } + if (typeof cfg.base_url === "string") { + setHermesBaseUrl(cfg.base_url); + } + if (typeof cfg.model === "string") { + setHermesModel(cfg.model); + } + if (typeof cfg.name === "string") { + setHermesProviderName(cfg.name); + } + // 兼容驼峰命名(如果之前使用了驼峰) + if (typeof cfg.apiKey === "string" && !cfg.api_key) { + setHermesApiKey(cfg.apiKey as string); + } + if (typeof cfg.baseUrl === "string" && !cfg.base_url) { + setHermesBaseUrl(cfg.baseUrl as string); + } + if (typeof cfg.provider_name === "string" && !cfg.name) { + setHermesProviderName(cfg.provider_name as string); + } + } + }, [initialData]); + + // 处理 Hermes API Key 输入 + const handleHermesApiKeyChange = useCallback( + (key: string) => { + const trimmed = key.trim(); + setHermesApiKey(trimmed); + updateHermesConfig((config) => { + config.api_key = trimmed; + }); + }, + [updateHermesConfig], + ); + + // 处理 Hermes Base URL 变化 + const handleHermesBaseUrlChange = useCallback( + (url: string) => { + const sanitized = url.trim().replace(/\/+$/, ""); + setHermesBaseUrl(sanitized); + updateHermesConfig((config) => { + config.base_url = sanitized; + }); + }, + [updateHermesConfig], + ); + + // 处理 Hermes Model 变化 + const handleHermesModelChange = useCallback( + (model: string) => { + const trimmed = model.trim(); + setHermesModel(trimmed); + updateHermesConfig((config) => { + config.model = trimmed; + }); + }, + [updateHermesConfig], + ); + + // 处理 Hermes Provider Name 变化 + const handleHermesProviderNameChange = useCallback( + (name: string) => { + const trimmed = name.trim(); + setHermesProviderName(trimmed); + updateHermesConfig((config) => { + config.name = trimmed; + }); + }, + [updateHermesConfig], + ); + + // 重置配置(用于预设切换) + const resetHermesConfig = useCallback( + (settingsConfig: Record) => { + const cfg = settingsConfig as Record; + + // 使用蛇形命名 + if (typeof cfg.api_key === "string") { + setHermesApiKey(cfg.api_key); + } else if (typeof cfg.apiKey === "string") { + setHermesApiKey(cfg.apiKey as string); + } else { + setHermesApiKey(""); + } + + if (typeof cfg.base_url === "string") { + setHermesBaseUrl(cfg.base_url); + } else if (typeof cfg.baseUrl === "string") { + setHermesBaseUrl(cfg.baseUrl as string); + } else { + setHermesBaseUrl(""); + } + + if (typeof cfg.model === "string") { + setHermesModel(cfg.model); + } else { + setHermesModel(""); + } + + if (typeof cfg.name === "string") { + setHermesProviderName(cfg.name); + } else if (typeof cfg.provider_name === "string") { + setHermesProviderName(cfg.provider_name as string); + } else { + setHermesProviderName(""); + } + }, + [], + ); + + return { + hermesApiKey, + hermesBaseUrl, + hermesModel, + hermesProviderName, + setHermesApiKey, + setHermesBaseUrl, + setHermesModel, + setHermesProviderName, + handleHermesApiKeyChange, + handleHermesBaseUrlChange, + handleHermesModelChange, + handleHermesProviderNameChange, + resetHermesConfig, + buildHermesSettingsConfig: buildSettingsConfig, + }; +} diff --git a/src/components/proxy/ProxyPanel.tsx b/src/components/proxy/ProxyPanel.tsx index 1052f2ece1..2fc8e3bd75 100644 --- a/src/components/proxy/ProxyPanel.tsx +++ b/src/components/proxy/ProxyPanel.tsx @@ -252,29 +252,31 @@ export function ProxyPanel({ })}

- {(["claude", "codex", "gemini"] as const).map((appType) => { - const isEnabled = - takeoverStatus?.[ - appType as keyof typeof takeoverStatus - ] ?? false; - return ( -
- - {appType} - - - handleTakeoverChange(appType, checked) - } - disabled={setTakeoverForApp.isPending} - /> -
- ); - })} + {(["claude", "codex", "gemini", "hermes"] as const).map( + (appType) => { + const isEnabled = + takeoverStatus?.[ + appType as keyof typeof takeoverStatus + ] ?? false; + return ( +
+ + {appType} + + + handleTakeoverChange(appType, checked) + } + disabled={setTakeoverForApp.isPending} + /> +
+ ); + }, + )}

{t("proxy.takeover.hint", { diff --git a/src/components/settings/AppVisibilitySettings.tsx b/src/components/settings/AppVisibilitySettings.tsx index 0c30472ce1..a6e4e63cc8 100644 --- a/src/components/settings/AppVisibilitySettings.tsx +++ b/src/components/settings/AppVisibilitySettings.tsx @@ -21,6 +21,7 @@ const APP_CONFIG: Array<{ { id: "gemini", icon: "gemini", nameKey: "apps.gemini" }, { id: "opencode", icon: "opencode", nameKey: "apps.opencode" }, { id: "openclaw", icon: "openclaw", nameKey: "apps.openclaw" }, + { id: "hermes", icon: "terminal", nameKey: "apps.hermes" }, ]; export function AppVisibilitySettings({ @@ -33,6 +34,7 @@ export function AppVisibilitySettings({ claude: true, codex: true, gemini: true, + hermes: true, opencode: true, openclaw: true, }; diff --git a/src/components/settings/DirectorySettings.tsx b/src/components/settings/DirectorySettings.tsx index c633782cbb..3e3b310d5e 100644 --- a/src/components/settings/DirectorySettings.tsx +++ b/src/components/settings/DirectorySettings.tsx @@ -16,6 +16,7 @@ interface DirectorySettingsProps { codexDir?: string; geminiDir?: string; opencodeDir?: string; + hermesDir?: string; onDirectoryChange: (app: AppId, value?: string) => void; onBrowseDirectory: (app: AppId) => Promise; onResetDirectory: (app: AppId) => Promise; @@ -31,6 +32,7 @@ export function DirectorySettings({ codexDir, geminiDir, opencodeDir, + hermesDir: _hermesDir, onDirectoryChange, onBrowseDirectory, onResetDirectory, diff --git a/src/components/skills/UnifiedSkillsPanel.tsx b/src/components/skills/UnifiedSkillsPanel.tsx index 701af49292..e2962ab048 100644 --- a/src/components/skills/UnifiedSkillsPanel.tsx +++ b/src/components/skills/UnifiedSkillsPanel.tsx @@ -113,7 +113,14 @@ const UnifiedSkillsPanel = React.forwardRef< }, [skillUpdates]); const enabledCounts = useMemo(() => { - const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 }; + const counts = { + claude: 0, + codex: 0, + gemini: 0, + opencode: 0, + openclaw: 0, + hermes: 0, + }; if (!skills) return counts; skills.forEach((skill) => { for (const app of MCP_SKILLS_APP_IDS) { @@ -734,6 +741,7 @@ const ImportSkillsDialog: React.FC = ({ claude: skill.foundIn.includes("claude"), codex: skill.foundIn.includes("codex"), gemini: skill.foundIn.includes("gemini"), + hermes: skill.foundIn.includes("hermes"), opencode: skill.foundIn.includes("opencode"), openclaw: false, }, @@ -761,6 +769,7 @@ const ImportSkillsDialog: React.FC = ({ gemini: false, opencode: false, openclaw: false, + hermes: false, }, })), ); @@ -815,6 +824,7 @@ const ImportSkillsDialog: React.FC = ({ gemini: false, opencode: false, openclaw: false, + hermes: false, }), [app]: enabled, }, diff --git a/src/components/universal/UniversalProviderFormModal.tsx b/src/components/universal/UniversalProviderFormModal.tsx index e085797ba5..bc482a7d08 100644 --- a/src/components/universal/UniversalProviderFormModal.tsx +++ b/src/components/universal/UniversalProviderFormModal.tsx @@ -50,6 +50,7 @@ export function UniversalProviderFormModal({ const [claudeEnabled, setClaudeEnabled] = useState(true); const [codexEnabled, setCodexEnabled] = useState(true); const [geminiEnabled, setGeminiEnabled] = useState(true); + const [hermesEnabled, setHermesEnabled] = useState(true); // 模型配置 const [models, setModels] = useState({}); @@ -71,6 +72,7 @@ export function UniversalProviderFormModal({ setClaudeEnabled(editingProvider.apps.claude); setCodexEnabled(editingProvider.apps.codex); setGeminiEnabled(editingProvider.apps.gemini); + setHermesEnabled(editingProvider.apps.hermes); setModels(editingProvider.models || {}); // 尝试匹配预设 @@ -90,6 +92,7 @@ export function UniversalProviderFormModal({ setClaudeEnabled(defaultPreset.defaultApps.claude); setCodexEnabled(defaultPreset.defaultApps.codex); setGeminiEnabled(defaultPreset.defaultApps.gemini); + setHermesEnabled(defaultPreset.defaultApps.hermes); setModels(JSON.parse(JSON.stringify(defaultPreset.defaultModels))); } }, [editingProvider, initialPreset, isOpen]); @@ -103,6 +106,7 @@ export function UniversalProviderFormModal({ setClaudeEnabled(preset.defaultApps.claude); setCodexEnabled(preset.defaultApps.codex); setGeminiEnabled(preset.defaultApps.gemini); + setHermesEnabled(preset.defaultApps.hermes); setModels(JSON.parse(JSON.stringify(preset.defaultModels))); } }, @@ -200,6 +204,7 @@ requires_openai_auth = true`; claude: claudeEnabled, codex: codexEnabled, gemini: geminiEnabled, + hermes: hermesEnabled, }, models, } @@ -217,6 +222,7 @@ requires_openai_auth = true`; claude: claudeEnabled, codex: codexEnabled, gemini: geminiEnabled, + hermes: hermesEnabled, }; provider.models = models; provider.websiteUrl = websiteUrl.trim() || undefined; @@ -259,6 +265,7 @@ requires_openai_auth = true`; claude: claudeEnabled, codex: codexEnabled, gemini: geminiEnabled, + hermes: hermesEnabled, }, models, } @@ -276,6 +283,7 @@ requires_openai_auth = true`; claude: claudeEnabled, codex: codexEnabled, gemini: geminiEnabled, + hermes: hermesEnabled, }; provider.models = models; provider.websiteUrl = websiteUrl.trim() || undefined; @@ -511,6 +519,16 @@ requires_openai_auth = true`; onCheckedChange={setGeminiEnabled} /> +

+
+ + Hermes +
+ +
diff --git a/src/components/usage/PricingConfigPanel.tsx b/src/components/usage/PricingConfigPanel.tsx index 29169a38fb..935f748b37 100644 --- a/src/components/usage/PricingConfigPanel.tsx +++ b/src/components/usage/PricingConfigPanel.tsx @@ -33,7 +33,7 @@ import { Plus, Pencil, Trash2, Loader2 } from "lucide-react"; import { toast } from "sonner"; import { proxyApi } from "@/lib/api/proxy"; -const PRICING_APPS = ["claude", "codex", "gemini"] as const; +const PRICING_APPS = ["claude", "codex", "gemini", "hermes"] as const; type PricingApp = (typeof PRICING_APPS)[number]; type PricingModelSource = "request" | "response"; @@ -52,11 +52,12 @@ export function PricingConfigPanel() { const [isAddingNew, setIsAddingNew] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(null); - // 三个应用的配置状态 + // 应用的配置状态 const [appConfigs, setAppConfigs] = useState({ claude: { multiplier: "1", source: "response" }, codex: { multiplier: "1", source: "response" }, gemini: { multiplier: "1", source: "response" }, + hermes: { multiplier: "1", source: "response" }, }); const [originalConfigs, setOriginalConfigs] = useState( null, @@ -102,6 +103,7 @@ export function PricingConfigPanel() { claude: { multiplier: "1", source: "response" }, codex: { multiplier: "1", source: "response" }, gemini: { multiplier: "1", source: "response" }, + hermes: { multiplier: "1", source: "response" }, }; for (const result of results) { newState[result.app] = { diff --git a/src/config/appConfig.tsx b/src/config/appConfig.tsx index db87b75a84..f3fc031f57 100644 --- a/src/config/appConfig.tsx +++ b/src/config/appConfig.tsx @@ -21,6 +21,7 @@ export const APP_IDS: AppId[] = [ "gemini", "opencode", "openclaw", + "hermes", ]; /** App IDs shown in MCP & Skills panels (excludes OpenClaw) */ @@ -29,6 +30,7 @@ export const MCP_SKILLS_APP_IDS: AppId[] = [ "codex", "gemini", "opencode", + "hermes", ]; export const APP_ICON_MAP: Record = { @@ -56,6 +58,21 @@ export const APP_ICON_MAP: Record = { badgeClass: "bg-blue-500/10 text-blue-700 dark:text-blue-300 hover:bg-blue-500/20 border-0 gap-1.5", }, + hermes: { + label: "Hermes", + icon: ( + + ), + activeClass: + "bg-emerald-500/10 ring-1 ring-emerald-500/20 hover:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400", + badgeClass: + "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-500/20 border-0 gap-1.5", + }, opencode: { label: "OpenCode", icon: ( diff --git a/src/config/hermesProviderPresets.ts b/src/config/hermesProviderPresets.ts new file mode 100644 index 0000000000..71168e61f0 --- /dev/null +++ b/src/config/hermesProviderPresets.ts @@ -0,0 +1,47 @@ +/** + * Hermes provider presets + */ +import type { ProviderPreset } from "./claudeProviderPresets"; + +export type HermesProviderPreset = ProviderPreset; + +export const hermesProviderPresets: HermesProviderPreset[] = [ + { + name: "Anthropic Official", + websiteUrl: "https://www.anthropic.com", + settingsConfig: { + provider: "anthropic", + apiKey: "", + model: "claude-sonnet-4.6", + }, + isOfficial: true, + category: "official", + icon: "anthropic", + iconColor: "#D4915D", + }, + { + name: "OpenRouter", + websiteUrl: "https://openrouter.ai", + apiKeyUrl: "https://openrouter.ai/keys", + settingsConfig: { + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "", + model: "anthropic/claude-sonnet-4.6", + }, + category: "aggregator", + icon: "openrouter", + iconColor: "#6566F1", + }, + { + name: "Custom", + websiteUrl: "", + settingsConfig: { + provider: "", + baseUrl: "", + apiKey: "", + model: "", + }, + category: "custom", + }, +]; diff --git a/src/config/universalProviderPresets.ts b/src/config/universalProviderPresets.ts index 1f829308d8..b1eecb9a97 100644 --- a/src/config/universalProviderPresets.ts +++ b/src/config/universalProviderPresets.ts @@ -65,6 +65,7 @@ export const universalProviderPresets: UniversalProviderPreset[] = [ claude: true, codex: true, gemini: true, + hermes: true, }, defaultModels: NEWAPI_DEFAULT_MODELS, websiteUrl: "https://www.newapi.pro", @@ -80,6 +81,7 @@ export const universalProviderPresets: UniversalProviderPreset[] = [ claude: true, codex: true, gemini: true, + hermes: true, }, defaultModels: NEWAPI_DEFAULT_MODELS, icon: "openai", diff --git a/src/hooks/useDirectorySettings.ts b/src/hooks/useDirectorySettings.ts index 42e63ef9de..19b59c1fbf 100644 --- a/src/hooks/useDirectorySettings.ts +++ b/src/hooks/useDirectorySettings.ts @@ -5,7 +5,13 @@ import { homeDir, join } from "@tauri-apps/api/path"; import { settingsApi, type AppId } from "@/lib/api"; import type { SettingsFormState } from "./useSettingsForm"; -type DirectoryKey = "appConfig" | "claude" | "codex" | "gemini" | "opencode"; +type DirectoryKey = + | "appConfig" + | "claude" + | "codex" + | "gemini" + | "opencode" + | "hermes"; export interface ResolvedDirectories { appConfig: string; @@ -13,6 +19,7 @@ export interface ResolvedDirectories { codex: string; gemini: string; opencode: string; + hermes: string; } const sanitizeDir = (value?: string | null): string | undefined => { @@ -46,7 +53,9 @@ const computeDefaultConfigDir = async ( ? ".codex" : app === "gemini" ? ".gemini" - : ".config/opencode"; + : app === "opencode" + ? ".config/opencode" + : ".hermes"; return await join(home, folder); } catch (error) { console.error( @@ -78,6 +87,7 @@ export interface UseDirectorySettingsResult { codexDir?: string, geminiDir?: string, opencodeDir?: string, + hermesDir?: string, ) => void; } @@ -105,6 +115,7 @@ export function useDirectorySettings({ codex: "", gemini: "", opencode: "", + hermes: "", }); const [isLoading, setIsLoading] = useState(true); @@ -114,6 +125,7 @@ export function useDirectorySettings({ codex: "", gemini: "", opencode: "", + hermes: "", }); const initialAppConfigDirRef = useRef(undefined); @@ -130,22 +142,26 @@ export function useDirectorySettings({ codexDir, geminiDir, opencodeDir, + hermesDir, defaultAppConfig, defaultClaudeDir, defaultCodexDir, defaultGeminiDir, defaultOpencodeDir, + defaultHermesDir, ] = await Promise.all([ settingsApi.getAppConfigDirOverride(), settingsApi.getConfigDir("claude"), settingsApi.getConfigDir("codex"), settingsApi.getConfigDir("gemini"), settingsApi.getConfigDir("opencode"), + settingsApi.getConfigDir("hermes"), computeDefaultAppConfigDir(), computeDefaultConfigDir("claude"), computeDefaultConfigDir("codex"), computeDefaultConfigDir("gemini"), computeDefaultConfigDir("opencode"), + computeDefaultConfigDir("hermes"), ]); if (!active) return; @@ -158,6 +174,7 @@ export function useDirectorySettings({ codex: defaultCodexDir ?? "", gemini: defaultGeminiDir ?? "", opencode: defaultOpencodeDir ?? "", + hermes: defaultHermesDir ?? "", }; setAppConfigDir(normalizedOverride); @@ -169,6 +186,7 @@ export function useDirectorySettings({ codex: codexDir || defaultsRef.current.codex, gemini: geminiDir || defaultsRef.current.gemini, opencode: opencodeDir || defaultsRef.current.opencode, + hermes: hermesDir || defaultsRef.current.hermes, }); } catch (error) { console.error( @@ -201,7 +219,9 @@ export function useDirectorySettings({ ? { codexConfigDir: sanitized } : key === "gemini" ? { geminiConfigDir: sanitized } - : { opencodeConfigDir: sanitized }, + : key === "opencode" + ? { opencodeConfigDir: sanitized } + : { hermesConfigDir: sanitized }, ); } @@ -229,7 +249,9 @@ export function useDirectorySettings({ ? "codex" : app === "gemini" ? "gemini" - : "opencode", + : app === "opencode" + ? "opencode" + : "hermes", value, ); }, @@ -245,7 +267,9 @@ export function useDirectorySettings({ ? "codex" : app === "gemini" ? "gemini" - : "opencode"; + : app === "opencode" + ? "opencode" + : "hermes"; const currentValue = key === "claude" ? (settings?.claudeConfigDir ?? resolvedDirs.claude) @@ -253,7 +277,9 @@ export function useDirectorySettings({ ? (settings?.codexConfigDir ?? resolvedDirs.codex) : key === "gemini" ? (settings?.geminiConfigDir ?? resolvedDirs.gemini) - : (settings?.opencodeConfigDir ?? resolvedDirs.opencode); + : key === "opencode" + ? (settings?.opencodeConfigDir ?? resolvedDirs.opencode) + : (settings?.hermesConfigDir ?? resolvedDirs.hermes); try { const picked = await settingsApi.selectConfigDirectory(currentValue); @@ -301,7 +327,9 @@ export function useDirectorySettings({ ? "codex" : app === "gemini" ? "gemini" - : "opencode"; + : app === "opencode" + ? "opencode" + : "hermes"; if (!defaultsRef.current[key]) { const fallback = await computeDefaultConfigDir(app); if (fallback) { @@ -335,6 +363,7 @@ export function useDirectorySettings({ codexDir?: string, geminiDir?: string, opencodeDir?: string, + hermesDir?: string, ) => { setAppConfigDir(initialAppConfigDirRef.current); setResolvedDirs({ @@ -344,6 +373,7 @@ export function useDirectorySettings({ codex: codexDir ?? defaultsRef.current.codex, gemini: geminiDir ?? defaultsRef.current.gemini, opencode: opencodeDir ?? defaultsRef.current.opencode, + hermes: hermesDir ?? defaultsRef.current.hermes, }); }, [], diff --git a/src/hooks/useSettingsForm.ts b/src/hooks/useSettingsForm.ts index 0944b91d10..24d3b8946b 100644 --- a/src/hooks/useSettingsForm.ts +++ b/src/hooks/useSettingsForm.ts @@ -90,6 +90,7 @@ export function useSettingsForm(): UseSettingsFormResult { codexConfigDir: sanitizeDir(data.codexConfigDir), geminiConfigDir: sanitizeDir(data.geminiConfigDir), opencodeConfigDir: sanitizeDir(data.opencodeConfigDir), + hermesConfigDir: sanitizeDir(data.hermesConfigDir), language: normalizedLanguage, }; @@ -150,6 +151,7 @@ export function useSettingsForm(): UseSettingsFormResult { codexConfigDir: sanitizeDir(serverData.codexConfigDir), geminiConfigDir: sanitizeDir(serverData.geminiConfigDir), opencodeConfigDir: sanitizeDir(serverData.opencodeConfigDir), + hermesConfigDir: sanitizeDir(serverData.hermesConfigDir), language: normalizedLanguage, }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7cfc3f5a6c..51c7fa0e4f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -167,6 +167,15 @@ "oauthTitle": "OAuth Authentication Mode", "oauthHint": "Google official uses OAuth personal authentication, no need to fill in API Key. The browser will automatically open for login on first use.", "apiKeyPlaceholder": "Enter Gemini API Key" + }, + "hermes": { + "model": "Model", + "authStatusTitle": "Hermes OAuth Status", + "authLoggedIn": "Logged in as: {{account}}", + "authNotLoggedIn": "Not logged in. Run 'hermes auth login' in terminal to authenticate.", + "officialTitle": "Official Provider", + "officialHint": "Anthropic official provider uses default settings. No API key required.", + "apiKeyPlaceholder": "Enter API Key" } } }, @@ -655,7 +664,8 @@ "codex": "Codex", "gemini": "Gemini", "opencode": "OpenCode", - "openclaw": "OpenClaw" + "openclaw": "OpenClaw", + "hermes": "Hermes" }, "sessionManager": { "title": "Session Manager", @@ -1288,7 +1298,8 @@ "codex": "Codex", "gemini": "Gemini", "opencode": "OpenCode", - "openclaw": "OpenClaw" + "openclaw": "OpenClaw", + "hermes": "Hermes" } }, "userLevelPath": "User-level MCP path", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 2d867c3948..594aa79134 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -167,6 +167,15 @@ "oauthTitle": "OAuth 認証モード", "oauthHint": "Google 公式は OAuth 個人認証を使用するため API Key は不要です。初回利用時にブラウザが開きます。", "apiKeyPlaceholder": "Gemini API Key を入力" + }, + "hermes": { + "model": "モデル", + "authStatusTitle": "Hermes OAuth ステータス", + "authLoggedIn": "ログイン中:{{account}}", + "authNotLoggedIn": "未ログインです。ターミナルで 'hermes auth login' を実行して認証してください。", + "officialTitle": "公式プロバイダー", + "officialHint": "Anthropic 公式プロバイダーはデフォルト設定を使用します。API Key は不要です。", + "apiKeyPlaceholder": "API Key を入力" } } }, @@ -655,7 +664,8 @@ "codex": "Codex", "gemini": "Gemini", "opencode": "OpenCode", - "openclaw": "OpenClaw" + "openclaw": "OpenClaw", + "hermes": "Hermes" }, "sessionManager": { "title": "セッション管理", @@ -1288,7 +1298,8 @@ "codex": "Codex", "gemini": "Gemini", "opencode": "OpenCode", - "openclaw": "OpenClaw" + "openclaw": "OpenClaw", + "hermes": "Hermes" } }, "userLevelPath": "ユーザーレベルの MCP パス", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 3a5069b958..00e75d098a 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -167,6 +167,15 @@ "oauthTitle": "OAuth 认证模式", "oauthHint": "Google 官方使用 OAuth 个人认证,无需填写 API Key。首次使用时会自动打开浏览器进行登录。", "apiKeyPlaceholder": "请输入 Gemini API Key" + }, + "hermes": { + "model": "模型", + "authStatusTitle": "Hermes OAuth 状态", + "authLoggedIn": "已登录:{{account}}", + "authNotLoggedIn": "未登录。请在终端运行 'hermes auth login' 进行认证。", + "officialTitle": "官方供应商", + "officialHint": "Anthropic 官方供应商使用默认设置,无需 API Key。", + "apiKeyPlaceholder": "请输入 API Key" } } }, @@ -655,7 +664,8 @@ "codex": "Codex", "gemini": "Gemini", "opencode": "OpenCode", - "openclaw": "OpenClaw" + "openclaw": "OpenClaw", + "hermes": "Hermes" }, "sessionManager": { "title": "会话管理", @@ -1289,7 +1299,8 @@ "codex": "Codex", "gemini": "Gemini", "opencode": "OpenCode", - "openclaw": "OpenClaw" + "openclaw": "OpenClaw", + "hermes": "Hermes" } }, "userLevelPath": "用户级 MCP 配置路径", diff --git a/src/lib/api/config.ts b/src/lib/api/config.ts index 1fbbb30cf8..72209bd425 100644 --- a/src/lib/api/config.ts +++ b/src/lib/api/config.ts @@ -1,7 +1,13 @@ // 配置相关 API import { invoke } from "@tauri-apps/api/core"; -export type AppType = "claude" | "codex" | "gemini" | "omo" | "omo_slim"; +export type AppType = + | "claude" + | "codex" + | "gemini" + | "hermes" + | "omo" + | "omo_slim"; /** * 获取 Claude 通用配置片段(已废弃,使用 getCommonConfigSnippet) diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts index 456e57b670..3c52630730 100644 --- a/src/lib/api/skills.ts +++ b/src/lib/api/skills.ts @@ -2,7 +2,13 @@ import { invoke } from "@tauri-apps/api/core"; import type { AppId } from "@/lib/api/types"; -export type AppType = "claude" | "codex" | "gemini" | "opencode" | "openclaw"; +export type AppType = + | "claude" + | "codex" + | "gemini" + | "opencode" + | "openclaw" + | "hermes"; /** Skill 应用启用状态 */ export interface SkillApps { @@ -11,6 +17,7 @@ export interface SkillApps { gemini: boolean; opencode: boolean; openclaw: boolean; + hermes: boolean; } /** 已安装的 Skill(v3.10.0+ 统一结构) */ diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 1fff721fc5..4ec68d6b23 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -1,2 +1,8 @@ // 前端统一使用 AppId 作为应用标识(与后端命令参数 `app` 一致) -export type AppId = "claude" | "codex" | "gemini" | "opencode" | "openclaw"; +export type AppId = + | "claude" + | "codex" + | "gemini" + | "opencode" + | "openclaw" + | "hermes"; diff --git a/src/types.ts b/src/types.ts index e5fbb7def1..b2fdbc8033 100644 --- a/src/types.ts +++ b/src/types.ts @@ -178,6 +178,7 @@ export interface VisibleApps { gemini: boolean; opencode: boolean; openclaw: boolean; + hermes: boolean; } // WebDAV 同步状态 @@ -271,6 +272,8 @@ export interface Settings { opencodeConfigDir?: string; // 覆盖 OpenClaw 配置目录(可选) openclawConfigDir?: string; + // 覆盖 Hermes 配置目录(可选) + hermesConfigDir?: string; // ===== 当前供应商 ID(设备级)===== // 当前 Claude 供应商 ID(优先于数据库 is_current) @@ -344,6 +347,7 @@ export interface McpApps { gemini: boolean; opencode: boolean; openclaw: boolean; + hermes: boolean; } // MCP 服务器条目(v3.7.0 统一结构) @@ -387,6 +391,7 @@ export interface UniversalProviderApps { claude: boolean; codex: boolean; gemini: boolean; + hermes: boolean; } // Claude 模型配置 diff --git a/src/types/proxy.ts b/src/types/proxy.ts index 7aa6f42de3..3243090be3 100644 --- a/src/types/proxy.ts +++ b/src/types/proxy.ts @@ -47,6 +47,7 @@ export interface ProxyTakeoverStatus { gemini: boolean; opencode: boolean; openclaw: boolean; + hermes: boolean; } export interface ProviderHealth { diff --git a/tests/msw/state.ts b/tests/msw/state.ts index 9768914d92..7e0afb29ab 100644 --- a/tests/msw/state.ts +++ b/tests/msw/state.ts @@ -66,6 +66,7 @@ const createDefaultProviders = (): ProvidersByApp => ({ }, opencode: {}, openclaw: {}, + hermes: {}, }); const createDefaultCurrent = (): CurrentProviderState => ({ @@ -74,6 +75,7 @@ const createDefaultCurrent = (): CurrentProviderState => ({ gemini: "gemini-1", opencode: "", openclaw: "", + hermes: "", }); let providers = createDefaultProviders(); @@ -153,6 +155,7 @@ let mcpConfigs: McpConfigState = { gemini: false, opencode: false, openclaw: false, + hermes: false, }, server: { type: "stdio", @@ -171,6 +174,7 @@ let mcpConfigs: McpConfigState = { gemini: false, opencode: false, openclaw: false, + hermes: false, }, server: { type: "http", @@ -181,6 +185,7 @@ let mcpConfigs: McpConfigState = { gemini: {}, opencode: {}, openclaw: {}, + hermes: {}, }; const cloneProviders = (value: ProvidersByApp) => @@ -216,6 +221,7 @@ export const resetProviderState = () => { gemini: false, opencode: false, openclaw: false, + hermes: false, }, server: { type: "stdio", @@ -234,6 +240,7 @@ export const resetProviderState = () => { gemini: false, opencode: false, openclaw: false, + hermes: false, }, server: { type: "http", @@ -244,6 +251,7 @@ export const resetProviderState = () => { gemini: {}, opencode: {}, openclaw: {}, + hermes: {}, }; };