From 2c9252dec58cc16de5cb63113ca592961b35b13e Mon Sep 17 00:00:00 2001 From: Suda202 <154338956+Suda202@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:42:00 +0800 Subject: [PATCH 001/156] Fix Ghostty session restore launch path (#1976) * fix: launch Ghostty via shell command Use Ghostty's shell execution path instead of injecting raw terminal input so Claude resume commands run reliably when opening a session terminal. Co-Authored-By: Claude Opus 4.6 * fix(ghostty): pass cwd via --working-directory instead of shell string Use Ghostty's native --working-directory flag to set the working directory, matching the pattern used by Alacritty. This avoids shell expansion of special characters (e.g. $VAR, spaces) in project paths. The command is now passed directly to -c without a cd prefix. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Opus 4.6 --- src-tauri/src/session_manager/terminal/mod.rs | 102 +++++++----------- 1 file changed, 36 insertions(+), 66 deletions(-) diff --git a/src-tauri/src/session_manager/terminal/mod.rs b/src-tauri/src/session_manager/terminal/mod.rs index 3e35df30e..085a86f0d 100644 --- a/src-tauri/src/session_manager/terminal/mod.rs +++ b/src-tauri/src/session_manager/terminal/mod.rs @@ -78,22 +78,7 @@ end tell"# } fn launch_ghostty(command: &str, cwd: Option<&str>) -> Result<(), String> { - let args = build_ghostty_args(command, cwd); - - let status = Command::new("open") - .args(args.iter().map(String::as_str)) - .status() - .map_err(|e| format!("Failed to launch Ghostty: {e}"))?; - - if status.success() { - Ok(()) - } else { - Err("Failed to launch Ghostty. Make sure it is installed.".to_string()) - } -} - -fn build_ghostty_args(command: &str, cwd: Option<&str>) -> Vec { - let input = ghostty_raw_input(command); + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); let mut args = vec![ "-na".to_string(), @@ -108,22 +93,22 @@ fn build_ghostty_args(command: &str, cwd: Option<&str>) -> Vec { } } - args.push(format!("--input={input}")); - args -} + args.push("-e".to_string()); + args.push(shell); + args.push("-l".to_string()); + args.push("-c".to_string()); + args.push(command.to_string()); -fn ghostty_raw_input(command: &str) -> String { - let mut escaped = String::from("raw:"); - for ch in command.chars() { - match ch { - '\\' => escaped.push_str("\\\\"), - '\n' => escaped.push_str("\\n"), - '\r' => escaped.push_str("\\r"), - _ => escaped.push(ch), - } + let status = Command::new("open") + .args(&args) + .status() + .map_err(|e| format!("Failed to launch Ghostty: {e}"))?; + + if status.success() { + Ok(()) + } else { + Err("Failed to launch Ghostty. Make sure it is installed.".to_string()) } - escaped.push_str("\\n"); - escaped } fn launch_kitty(command: &str, cwd: Option<&str>) -> Result<(), String> { @@ -300,43 +285,10 @@ mod tests { use super::*; #[test] - fn ghostty_uses_shell_mode_for_resume_commands() { - let args = build_ghostty_args("claude --resume abc-123", Some("/tmp/project dir")); - + fn build_shell_command_keeps_command_without_cwd_prefix_when_not_provided() { assert_eq!( - args, - vec![ - "-na", - "Ghostty", - "--args", - "--quit-after-last-window-closed=true", - "--working-directory=/tmp/project dir", - "--input=raw:claude --resume abc-123\\n", - ] - ); - } - - #[test] - fn ghostty_keeps_command_without_cwd_prefix_when_not_provided() { - let args = build_ghostty_args("claude --resume abc-123", None); - - assert_eq!( - args, - vec![ - "-na", - "Ghostty", - "--args", - "--quit-after-last-window-closed=true", - "--input=raw:claude --resume abc-123\\n", - ] - ); - } - - #[test] - fn ghostty_escapes_newlines_and_backslashes_in_input() { - assert_eq!( - ghostty_raw_input("echo foo\\\\bar\npwd"), - "raw:echo foo\\\\\\\\bar\\npwd\\n" + build_shell_command("claude --resume abc-123", None), + "claude --resume abc-123" ); } @@ -365,4 +317,22 @@ mod tests { ] ); } + + #[test] + fn ghostty_uses_working_directory_arg_for_cwd() { + // cwd should be passed as --working-directory, not embedded in the shell command string + // This avoids shell expansion of special characters in directory paths + let cwd = "/tmp/project dir"; + let command = "claude --resume abc-123"; + + // Verify build_shell_command does NOT include cwd when used in ghostty context + // (ghostty passes cwd via --working-directory flag instead) + assert_eq!( + build_shell_command(command, None), + "claude --resume abc-123" + ); + + // Verify shell_escape works correctly for paths with spaces + assert_eq!(shell_escape(cwd), "\"/tmp/project dir\""); + } } From 61bfc29d8239a1f69dabdf0555301f9247f434fe Mon Sep 17 00:00:00 2001 From: Li Yang Date: Tue, 21 Apr 2026 10:42:59 +0800 Subject: [PATCH 002/156] fix(tray): use an app-specific tray id (#1978) Co-authored-by: liyang --- src-tauri/src/commands/failover.rs | 2 +- src-tauri/src/lib.rs | 4 ++-- src-tauri/src/proxy/failover_switch.rs | 2 +- src-tauri/src/tray.rs | 18 +++++++++++++++--- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/commands/failover.rs b/src-tauri/src/commands/failover.rs index d0aad3900..e4e48ddc1 100644 --- a/src-tauri/src/commands/failover.rs +++ b/src-tauri/src/commands/failover.rs @@ -162,7 +162,7 @@ pub async fn set_auto_failover_enabled( // 刷新托盘菜单,确保状态同步 if let Ok(new_menu) = crate::tray::create_tray_menu(&app, &state) { - if let Some(tray) = app.tray_by_id("main") { + if let Some(tray) = app.tray_by_id(crate::tray::TRAY_ID) { let _ = tray.set_menu(Some(new_menu)); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dbfb63eae..a1764143a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -168,7 +168,7 @@ async fn update_tray_menu( ) -> Result { match tray::create_tray_menu(&app, state.inner()) { Ok(new_menu) => { - if let Some(tray) = app.tray_by_id("main") { + if let Some(tray) = app.tray_by_id(tray::TRAY_ID) { tray.set_menu(Some(new_menu)) .map_err(|e| format!("更新托盘菜单失败: {e}"))?; return Ok(true); @@ -728,7 +728,7 @@ pub fn run() { let menu = tray::create_tray_menu(app.handle(), &app_state)?; // 构建托盘 - let mut tray_builder = TrayIconBuilder::with_id("main") + let mut tray_builder = TrayIconBuilder::with_id(tray::TRAY_ID) .on_tray_icon_event(|_tray, event| match event { // 左键点击已通过 show_menu_on_left_click(true) 打开菜单,这里不再额外处理 TrayIconEvent::Click { .. } => {} diff --git a/src-tauri/src/proxy/failover_switch.rs b/src-tauri/src/proxy/failover_switch.rs index 1aa1d1b0e..135115ea3 100644 --- a/src-tauri/src/proxy/failover_switch.rs +++ b/src-tauri/src/proxy/failover_switch.rs @@ -111,7 +111,7 @@ impl FailoverSwitchManager { } if let Ok(new_menu) = crate::tray::create_tray_menu(app, app_state.inner()) { - if let Some(tray) = app.tray_by_id("main") { + if let Some(tray) = app.tray_by_id(crate::tray::TRAY_ID) { if let Err(e) = tray.set_menu(Some(new_menu)) { log::error!("[Failover] 更新托盘菜单失败: {e}"); } diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 4bd63f1f6..a1d852bc9 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -58,6 +58,7 @@ pub struct TrayAppSection { /// Auto 菜单项后缀 pub const AUTO_SUFFIX: &str = "auto"; +pub const TRAY_ID: &str = "cc-switch"; pub const TRAY_SECTIONS: [TrayAppSection; 3] = [ TrayAppSection { @@ -207,7 +208,7 @@ fn handle_auto_click(app: &tauri::AppHandle, app_type: &AppType) -> Result<(), A // 4) 更新托盘菜单 if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) { - if let Some(tray) = app.tray_by_id("main") { + if let Some(tray) = app.tray_by_id(TRAY_ID) { let _ = tray.set_menu(Some(new_menu)); } } @@ -255,7 +256,7 @@ fn handle_provider_click( // 更新托盘菜单 if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) { - if let Some(tray) = app.tray_by_id("main") { + if let Some(tray) = app.tray_by_id(TRAY_ID) { let _ = tray.set_menu(Some(new_menu)); } } @@ -402,7 +403,7 @@ pub fn refresh_tray_menu(app: &tauri::AppHandle) { if let Some(state) = app.try_state::() { if let Ok(new_menu) = create_tray_menu(app, state.inner()) { - if let Some(tray) = app.tray_by_id("main") { + if let Some(tray) = app.tray_by_id(TRAY_ID) { if let Err(e) = tray.set_menu(Some(new_menu)) { log::error!("刷新托盘菜单失败: {e}"); } @@ -411,6 +412,17 @@ pub fn refresh_tray_menu(app: &tauri::AppHandle) { } } +#[cfg(test)] +mod tests { + use super::TRAY_ID; + + #[test] + fn tray_id_is_unique_to_app() { + assert_eq!(TRAY_ID, "cc-switch"); + assert_ne!(TRAY_ID, "main"); + } +} + #[cfg(target_os = "macos")] pub fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) { use tauri::ActivationPolicy; From 1b345fbefb195a6d3b36d29d3073da32def43ccf Mon Sep 17 00:00:00 2001 From: hengm3467 <100685635+hengm3467@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:46:26 +0800 Subject: [PATCH 003/156] Add StepFun and StepFun en Step Plan presets (#2155) * Add StepFun CN and EN presets * Add StepFun 2603 model presets * Make StepFun 2603 the default model * Revert StepFun branding assets --- src/config/claudeProviderPresets.ts | 38 +++++++++++---- src/config/openclawProviderPresets.ts | 69 +++++++++++++++++++++++---- src/config/opencodeProviderPresets.ts | 51 +++++++++++++++++--- 3 files changed, 134 insertions(+), 24 deletions(-) diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index 26c19a00e..4b7feddce 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -234,23 +234,41 @@ export const providerPresets: ProviderPreset[] = [ }, { name: "StepFun", - websiteUrl: "https://platform.stepfun.ai", - apiKeyUrl: "https://platform.stepfun.ai/interface-key", + websiteUrl: "https://platform.stepfun.com/step-plan", + apiKeyUrl: "https://platform.stepfun.com/interface-key", settingsConfig: { env: { - ANTHROPIC_BASE_URL: "https://api.stepfun.ai/v1", + ANTHROPIC_BASE_URL: "https://api.stepfun.com/step_plan", ANTHROPIC_AUTH_TOKEN: "", - ANTHROPIC_MODEL: "step-3.5-flash", - ANTHROPIC_DEFAULT_HAIKU_MODEL: "step-3.5-flash", - ANTHROPIC_DEFAULT_SONNET_MODEL: "step-3.5-flash", - ANTHROPIC_DEFAULT_OPUS_MODEL: "step-3.5-flash", + ANTHROPIC_MODEL: "step-3.5-flash-2603", + ANTHROPIC_DEFAULT_HAIKU_MODEL: "step-3.5-flash-2603", + ANTHROPIC_DEFAULT_SONNET_MODEL: "step-3.5-flash-2603", + ANTHROPIC_DEFAULT_OPUS_MODEL: "step-3.5-flash-2603", }, }, category: "cn_official", - endpointCandidates: ["https://api.stepfun.ai/v1"], - apiFormat: "openai_chat", + endpointCandidates: ["https://api.stepfun.com/step_plan"], icon: "stepfun", - iconColor: "#005AFF", + iconColor: "#16D6D2", + }, + { + name: "StepFun en", + websiteUrl: "https://platform.stepfun.ai/step-plan", + apiKeyUrl: "https://platform.stepfun.ai/interface-key", + settingsConfig: { + env: { + ANTHROPIC_BASE_URL: "https://api.stepfun.ai/step_plan", + ANTHROPIC_AUTH_TOKEN: "", + ANTHROPIC_MODEL: "step-3.5-flash-2603", + ANTHROPIC_DEFAULT_HAIKU_MODEL: "step-3.5-flash-2603", + ANTHROPIC_DEFAULT_SONNET_MODEL: "step-3.5-flash-2603", + ANTHROPIC_DEFAULT_OPUS_MODEL: "step-3.5-flash-2603", + }, + }, + category: "cn_official", + endpointCandidates: ["https://api.stepfun.ai/step_plan"], + icon: "stepfun", + iconColor: "#16D6D2", }, { name: "ModelScope", diff --git a/src/config/openclawProviderPresets.ts b/src/config/openclawProviderPresets.ts index d75822197..53f8e5c33 100644 --- a/src/config/openclawProviderPresets.ts +++ b/src/config/openclawProviderPresets.ts @@ -341,13 +341,18 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, { name: "StepFun", - websiteUrl: "https://platform.stepfun.ai", - apiKeyUrl: "https://platform.stepfun.ai/interface-key", + websiteUrl: "https://platform.stepfun.com/step-plan", + apiKeyUrl: "https://platform.stepfun.com/interface-key", settingsConfig: { - baseUrl: "https://api.stepfun.ai/v1", + baseUrl: "https://api.stepfun.com/step_plan/v1", apiKey: "", api: "openai-completions", models: [ + { + id: "step-3.5-flash-2603", + name: "Step 3.5 Flash 2603", + contextWindow: 262144, + }, { id: "step-3.5-flash", name: "Step 3.5 Flash", @@ -357,12 +362,12 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, category: "cn_official", icon: "stepfun", - iconColor: "#005AFF", + iconColor: "#16D6D2", templateValues: { baseUrl: { label: "Base URL", - placeholder: "https://api.stepfun.ai/v1", - defaultValue: "https://api.stepfun.ai/v1", + placeholder: "https://api.stepfun.com/step_plan/v1", + defaultValue: "https://api.stepfun.com/step_plan/v1", editorValue: "", }, apiKey: { @@ -372,8 +377,56 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, }, suggestedDefaults: { - model: { primary: "stepfun/step-3.5-flash" }, - modelCatalog: { "stepfun/step-3.5-flash": { alias: "StepFun" } }, + model: { primary: "stepfun/step-3.5-flash-2603" }, + modelCatalog: { + "stepfun/step-3.5-flash-2603": { alias: "StepFun" }, + "stepfun/step-3.5-flash": { alias: "StepFun Flash" }, + }, + }, + }, + { + name: "StepFun en", + websiteUrl: "https://platform.stepfun.ai/step-plan", + apiKeyUrl: "https://platform.stepfun.ai/interface-key", + settingsConfig: { + baseUrl: "https://api.stepfun.ai/step_plan/v1", + apiKey: "", + api: "openai-completions", + models: [ + { + id: "step-3.5-flash-2603", + name: "Step 3.5 Flash 2603", + contextWindow: 262144, + }, + { + id: "step-3.5-flash", + name: "Step 3.5 Flash", + contextWindow: 262144, + }, + ], + }, + category: "cn_official", + icon: "stepfun", + iconColor: "#16D6D2", + templateValues: { + baseUrl: { + label: "Base URL", + placeholder: "https://api.stepfun.ai/step_plan/v1", + defaultValue: "https://api.stepfun.ai/step_plan/v1", + editorValue: "", + }, + apiKey: { + label: "API Key", + placeholder: "step-...", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { primary: "stepfun-en/step-3.5-flash-2603" }, + modelCatalog: { + "stepfun-en/step-3.5-flash-2603": { alias: "StepFun" }, + "stepfun-en/step-3.5-flash": { alias: "StepFun Flash" }, + }, }, }, { diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts index f52080f6c..8ec12b99c 100644 --- a/src/config/opencodeProviderPresets.ts +++ b/src/config/opencodeProviderPresets.ts @@ -62,6 +62,11 @@ export const OPENCODE_PRESET_MODEL_VARIANTS: Record< outputLimit: 262144, modalities: { input: ["text", "image", "video"], output: ["text"] }, }, + { + id: "step-3.5-flash-2603", + name: "Step 3.5 Flash 2603", + contextLimit: 262144, + }, { id: "step-3.5-flash", name: "Step 3.5 Flash", @@ -513,28 +518,62 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, { name: "StepFun", - websiteUrl: "https://platform.stepfun.ai", - apiKeyUrl: "https://platform.stepfun.ai/interface-key", + websiteUrl: "https://platform.stepfun.com/step-plan", + apiKeyUrl: "https://platform.stepfun.com/interface-key", settingsConfig: { npm: "@ai-sdk/openai-compatible", name: "StepFun", options: { - baseURL: "https://api.stepfun.ai/v1", + baseURL: "https://api.stepfun.com/step_plan/v1", apiKey: "", setCacheKey: true, }, models: { + "step-3.5-flash-2603": { name: "Step 3.5 Flash 2603" }, "step-3.5-flash": { name: "Step 3.5 Flash" }, }, }, category: "cn_official", icon: "stepfun", - iconColor: "#005AFF", + iconColor: "#16D6D2", templateValues: { baseURL: { label: "Base URL", - placeholder: "https://api.stepfun.ai/v1", - defaultValue: "https://api.stepfun.ai/v1", + placeholder: "https://api.stepfun.com/step_plan/v1", + defaultValue: "https://api.stepfun.com/step_plan/v1", + editorValue: "", + }, + apiKey: { + label: "API Key", + placeholder: "step-...", + editorValue: "", + }, + }, + }, + { + name: "StepFun en", + websiteUrl: "https://platform.stepfun.ai/step-plan", + apiKeyUrl: "https://platform.stepfun.ai/interface-key", + settingsConfig: { + npm: "@ai-sdk/openai-compatible", + name: "StepFun en", + options: { + baseURL: "https://api.stepfun.ai/step_plan/v1", + apiKey: "", + }, + models: { + "step-3.5-flash-2603": { name: "Step 3.5 Flash 2603" }, + "step-3.5-flash": { name: "Step 3.5 Flash" }, + }, + }, + category: "cn_official", + icon: "stepfun", + iconColor: "#16D6D2", + templateValues: { + baseURL: { + label: "Base URL", + placeholder: "https://api.stepfun.ai/step_plan/v1", + defaultValue: "https://api.stepfun.ai/step_plan/v1", editorValue: "", }, apiKey: { From cc77a040e2f3ac2c1b0262f4e543c09482337c1e Mon Sep 17 00:00:00 2001 From: zxZeng <33950891+zxZeng@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:04:31 +0800 Subject: [PATCH 004/156] fix: common config checkbox state not persisting for Codex/Claude/Gemini (#2191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复:Codex/Claude/Gemini 通用配置勾选状态无法正确保存的问题 Co-authored-by: 曾兴 --- .../providers/forms/hooks/useCodexCommonConfig.ts | 8 ++++++-- .../forms/hooks/useCommonConfigSnippet.ts | 15 +++++++++------ .../forms/hooks/useGeminiCommonConfig.ts | 8 ++++++-- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/components/providers/forms/hooks/useCodexCommonConfig.ts b/src/components/providers/forms/hooks/useCodexCommonConfig.ts index 228ab419f..27b2963b7 100644 --- a/src/components/providers/forms/hooks/useCodexCommonConfig.ts +++ b/src/components/providers/forms/hooks/useCodexCommonConfig.ts @@ -160,9 +160,13 @@ export function useCodexCommonConfig({ config, commonConfigSnippet, ); - const hasCommon = initialEnabled ?? inferredHasCommon; - if (hasCommon && !inferredHasCommon) { + // 优先级:显式设置的 initialEnabled > 从配置推断的值 + // 如果 initialEnabled 为 undefined,使用推断值 + const hasCommon = initialEnabled !== undefined ? initialEnabled : inferredHasCommon; + + // 如果应该启用通用配置但配置中还没有,则自动添加 + if (hasCommon && !inferredHasCommon && parsedSnippet.hasContent) { const { updatedConfig, error } = updateTomlCommonConfigSnippet( codexConfig, commonConfigSnippet, diff --git a/src/components/providers/forms/hooks/useCommonConfigSnippet.ts b/src/components/providers/forms/hooks/useCommonConfigSnippet.ts index 4b8fbed7b..bbc4c5669 100644 --- a/src/components/providers/forms/hooks/useCommonConfigSnippet.ts +++ b/src/components/providers/forms/hooks/useCommonConfigSnippet.ts @@ -118,17 +118,22 @@ export function useCommonConfigSnippet({ // 初始化时检查通用配置片段(编辑模式) useEffect(() => { if (!enabled) return; - if (initialData && !isLoading) { + if (initialData && !isLoading && !hasInitializedEditMode.current) { + hasInitializedEditMode.current = true; + const configString = JSON.stringify(initialData.settingsConfig, null, 2); const inferredHasCommon = hasCommonConfigSnippet( configString, commonConfigSnippet, ); - const hasCommon = initialEnabled ?? inferredHasCommon; + + // 优先级:显式设置的 initialEnabled > 从配置推断的值 + // 如果 initialEnabled 为 undefined,使用推断值 + const hasCommon = initialEnabled !== undefined ? initialEnabled : inferredHasCommon; setUseCommonConfig(hasCommon); - if (hasCommon && !inferredHasCommon && !hasInitializedEditMode.current) { - hasInitializedEditMode.current = true; + // 如果应该启用通用配置但配置中还没有,则自动添加 + if (hasCommon && !inferredHasCommon) { const { updatedConfig, error } = updateCommonConfigSnippet( settingsConfig, commonConfigSnippet, @@ -141,8 +146,6 @@ export function useCommonConfigSnippet({ isUpdatingFromCommonConfig.current = false; }, 0); } - } else { - hasInitializedEditMode.current = true; } } }, [ diff --git a/src/components/providers/forms/hooks/useGeminiCommonConfig.ts b/src/components/providers/forms/hooks/useGeminiCommonConfig.ts index 9a02a2cee..0c0d7aeb3 100644 --- a/src/components/providers/forms/hooks/useGeminiCommonConfig.ts +++ b/src/components/providers/forms/hooks/useGeminiCommonConfig.ts @@ -244,9 +244,13 @@ export function useGeminiCommonConfig({ env, parsed.env as Record, ); - const hasCommon = initialEnabled ?? inferredHasCommon; - if (hasCommon && !inferredHasCommon) { + // 优先级:显式设置的 initialEnabled > 从配置推断的值 + // 如果 initialEnabled 为 undefined,使用推断值 + const hasCommon = initialEnabled !== undefined ? initialEnabled : inferredHasCommon; + + // 如果应该启用通用配置但配置中还没有,则自动添加 + if (hasCommon && !inferredHasCommon && Object.keys(parsed.env).length > 0) { const currentEnv = envStringToObj(envValue); const merged = applySnippetToEnv(currentEnv, parsed.env); const nextEnvString = envObjToString(merged); From c5b15dd25e30587a412e2bd1d03d6c264a69971f Mon Sep 17 00:00:00 2001 From: chengww Date: Tue, 21 Apr 2026 11:56:13 +0800 Subject: [PATCH 005/156] fix(claude-plugin): sync current provider config to settings.json (#1905) * fix(claude-plugin): sync current provider config to settings.json on toggle enable - Extract syncClaudePluginIfChanged to share logic between autoSaveSettings and saveSettings - Fix P1: enableClaudePluginIntegration toggle in General tab now actually syncs ~/.claude/settings.json - Fix P2: check syncCurrentProvidersLiveSafe() return value and show toast on failure - Fix P3: sync providers on both enable and disable, not just enable - Fix P4: avoid double syncCurrentProvidersLiveSafe when plugin toggle + dir change happen together - Remove duplicate comment - Add missing providersApi.getCurrent/getAll mocks in tests * style: reformat after rebase onto main Prettier flagged a line-break introduced by the openclaw directory change (from main) after rebase. * fix(claude-plugin): read prev enabled state from live cache to avoid stale closure syncClaudePluginIfChanged compared enabled against data?.enableClaudePluginIntegration captured in a useCallback closure. After invalidateQueries + refetch, the React Query cache is up to date, but the consuming hook's closure does not see the new value until React re-renders. Quick on->off toggles could therefore skip applyClaudePluginConfig, leaving ~/.claude/config.json in the previously enabled state even though settings.json was persisted as disabled. Read the previous value synchronously from queryClient.getQueryData(["settings"]) before saveMutation.mutateAsync(), then pass it to the helper as prevEnabled. getQueryData bypasses the closure and reflects the live cache at call time. Test covers the race: closure data stays at false while the cache reports true; the helper must still call applyClaudePluginConfig({ official: true }). --------- Co-authored-by: Jason --- src/hooks/useSettings.ts | 118 ++++++++++++++++++++++--------- tests/hooks/useSettings.test.tsx | 66 ++++++++++++++++- 2 files changed, 151 insertions(+), 33 deletions(-) diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 121f25c72..c7c77af96 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -1,6 +1,7 @@ import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; +import { useQueryClient } from "@tanstack/react-query"; import { providersApi, settingsApi, type AppId } from "@/lib/api"; import { syncCurrentProvidersLiveSafe } from "@/utils/postChangeSync"; import { useSettingsQuery, useSaveSettingsMutation } from "@/lib/query"; @@ -63,6 +64,7 @@ export function useSettings(): UseSettingsResult { const { t } = useTranslation(); const { data } = useSettingsQuery(); const saveMutation = useSaveSettingsMutation(); + const queryClient = useQueryClient(); // 1️⃣ 表单状态管理 const { @@ -122,6 +124,58 @@ export function useSettings(): UseSettingsResult { setRequiresRestart, ]); + // 同步 Claude 插件集成配置到 ~/.claude/settings.json + // 返回 true 表示已执行过 syncCurrentProvidersLiveSafe,调用方可跳过重复同步 + // prevEnabled 必须由调用方在 saveMutation 之前从实时缓存(queryClient.getQueryData)捕获, + // 避免 useCallback closure 中 data 因未 re-render 而滞后导致的快速连切 race。 + const syncClaudePluginIfChanged = useCallback( + async ( + enabled: boolean | undefined, + prevEnabled: boolean | undefined, + ): Promise => { + if (enabled === undefined || enabled === prevEnabled) return false; + try { + if (enabled) { + const currentId = await providersApi.getCurrent("claude"); + let isOfficial = false; + if (currentId) { + const allProviders = await providersApi.getAll("claude"); + isOfficial = allProviders[currentId]?.category === "official"; + } + await settingsApi.applyClaudePluginConfig({ official: isOfficial }); + } else { + await settingsApi.applyClaudePluginConfig({ official: true }); + } + + const syncResult = await syncCurrentProvidersLiveSafe(); + if (!syncResult.ok) { + console.warn( + "[useSettings] Failed to sync providers after toggling Claude plugin", + syncResult.error, + ); + toast.error( + t("notifications.syncClaudePluginFailed", { + defaultValue: "同步 Claude 插件失败", + }), + ); + } + return true; + } catch (error) { + console.warn( + "[useSettings] Failed to sync Claude plugin config", + error, + ); + toast.error( + t("notifications.syncClaudePluginFailed", { + defaultValue: "同步 Claude 插件失败", + }), + ); + return false; + } + }, + [t], + ); + // 即时保存设置(用于 General 标签页的实时更新) // 保存基础配置 + 独立的系统 API 调用(开机自启) const autoSaveSettings = useCallback( @@ -152,6 +206,12 @@ export function useSettings(): UseSettingsResult { language: mergedSettings.language, }; + // 在 mutate 之前从实时缓存捕获上一次持久化的插件集成状态, + // 避免 closure 里的 data 因 React 尚未 re-render 而滞后 + const prevPluginEnabled = queryClient.getQueryData([ + "settings", + ])?.enableClaudePluginIntegration; + // 保存到配置文件 await saveMutation.mutateAsync(payload); @@ -202,6 +262,11 @@ export function useSettings(): UseSettingsResult { } } + await syncClaudePluginIfChanged( + payload.enableClaudePluginIntegration, + prevPluginEnabled, + ); + // 持久化语言偏好 try { if (typeof window !== "undefined" && updates.language) { @@ -233,7 +298,7 @@ export function useSettings(): UseSettingsResult { throw error; } }, - [data, saveMutation, settings, t], + [data, queryClient, saveMutation, settings, syncClaudePluginIfChanged, t], ); // 完整保存设置(用于 Advanced 标签页的手动保存) @@ -275,6 +340,12 @@ export function useSettings(): UseSettingsResult { language: mergedSettings.language, }; + // 在 mutate 之前从实时缓存捕获上一次持久化的插件集成状态, + // 避免 closure 里的 data 因 React 尚未 re-render 而滞后 + const prevPluginEnabled = queryClient.getQueryData([ + "settings", + ])?.enableClaudePluginIntegration; + await saveMutation.mutateAsync(payload); await settingsApi.setAppConfigDirOverride(sanitizedAppDir ?? null); @@ -323,30 +394,10 @@ export function useSettings(): UseSettingsResult { } } - // 只在 Claude 插件集成状态真正改变时调用系统 API - if ( - payload.enableClaudePluginIntegration !== undefined && - payload.enableClaudePluginIntegration !== - data?.enableClaudePluginIntegration - ) { - try { - if (payload.enableClaudePluginIntegration) { - await settingsApi.applyClaudePluginConfig({ official: false }); - } else { - await settingsApi.applyClaudePluginConfig({ official: true }); - } - } catch (error) { - console.warn( - "[useSettings] Failed to sync Claude plugin config", - error, - ); - toast.error( - t("notifications.syncClaudePluginFailed", { - defaultValue: "同步 Claude 插件失败", - }), - ); - } - } + const pluginSynced = await syncClaudePluginIfChanged( + payload.enableClaudePluginIntegration, + prevPluginEnabled, + ); try { if (typeof window !== "undefined") { @@ -369,18 +420,19 @@ export function useSettings(): UseSettingsResult { } // 如果 Claude/Codex/Gemini/OpenCode/OpenClaw 的目录覆盖发生变化,则立即将"当前使用的供应商"写回对应应用的 live 配置 + // 如果插件同步已经执行过 syncCurrentProvidersLiveSafe,则跳过避免重复 const claudeDirChanged = sanitizedClaudeDir !== previousClaudeDir; const codexDirChanged = sanitizedCodexDir !== previousCodexDir; const geminiDirChanged = sanitizedGeminiDir !== previousGeminiDir; const opencodeDirChanged = sanitizedOpencodeDir !== previousOpencodeDir; - const openclawDirChanged = - sanitizedOpenclawDir !== previousOpenclawDir; + const openclawDirChanged = sanitizedOpenclawDir !== previousOpenclawDir; if ( - claudeDirChanged || - codexDirChanged || - geminiDirChanged || - opencodeDirChanged || - openclawDirChanged + !pluginSynced && + (claudeDirChanged || + codexDirChanged || + geminiDirChanged || + opencodeDirChanged || + openclawDirChanged) ) { const syncResult = await syncCurrentProvidersLiveSafe(); if (!syncResult.ok) { @@ -419,9 +471,11 @@ export function useSettings(): UseSettingsResult { appConfigDir, data, initialAppConfigDir, + queryClient, saveMutation, settings, setRequiresRestart, + syncClaudePluginIfChanged, t, ], ); diff --git a/tests/hooks/useSettings.test.tsx b/tests/hooks/useSettings.test.tsx index 98f11176a..a4c011e9d 100644 --- a/tests/hooks/useSettings.test.tsx +++ b/tests/hooks/useSettings.test.tsx @@ -11,6 +11,9 @@ const applyClaudeOnboardingSkipMock = vi.fn(); const clearClaudeOnboardingSkipMock = vi.fn(); const syncCurrentProvidersLiveMock = vi.fn(); const updateTrayMenuMock = vi.fn(); +const getCurrentMock = vi.fn(); +const getAllMock = vi.fn(); +const getQueryDataMock = vi.fn(); const toastErrorMock = vi.fn(); const toastSuccessMock = vi.fn(); @@ -46,6 +49,18 @@ vi.mock("@/lib/query", () => ({ }), })); +vi.mock("@tanstack/react-query", async () => { + const actual = await vi.importActual( + "@tanstack/react-query", + ); + return { + ...actual, + useQueryClient: () => ({ + getQueryData: (...args: unknown[]) => getQueryDataMock(...args), + }), + }; +}); + vi.mock("@/lib/api", () => ({ settingsApi: { setAppConfigDirOverride: (...args: unknown[]) => @@ -61,6 +76,8 @@ vi.mock("@/lib/api", () => ({ }, providersApi: { updateTrayMenu: (...args: unknown[]) => updateTrayMenuMock(...args), + getCurrent: (...args: unknown[]) => getCurrentMock(...args), + getAll: (...args: unknown[]) => getAllMock(...args), }, })); @@ -127,6 +144,9 @@ describe("useSettings hook", () => { applyClaudeOnboardingSkipMock.mockReset(); clearClaudeOnboardingSkipMock.mockReset(); syncCurrentProvidersLiveMock.mockReset(); + getCurrentMock.mockReset(); + getAllMock.mockReset(); + getQueryDataMock.mockReset(); toastErrorMock.mockReset(); toastSuccessMock.mockReset(); window.localStorage.clear(); @@ -163,6 +183,11 @@ describe("useSettings hook", () => { applyClaudePluginConfigMock.mockResolvedValue(true); applyClaudeOnboardingSkipMock.mockResolvedValue(true); clearClaudeOnboardingSkipMock.mockResolvedValue(true); + syncCurrentProvidersLiveMock.mockResolvedValue({ ok: true }); + getCurrentMock.mockResolvedValue(null); + getAllMock.mockResolvedValue({}); + // 默认将 queryClient 缓存对齐到 serverSettings,既有断言的 "prev === data" 语义保持不变 + getQueryDataMock.mockImplementation(() => serverSettings); }); it("auto-saves and applies Claude onboarding skip when toggled on", async () => { @@ -276,7 +301,7 @@ describe("useSettings hook", () => { expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(true); expect(window.localStorage.getItem("language")).toBe("en"); expect(toastErrorMock).not.toHaveBeenCalled(); - // 目录有变化,应触发一次同步当前供应商到 live + // 插件同步已包含 syncCurrentProvidersLiveSafe,目录变更不再重复调用 expect(syncCurrentProvidersLiveMock).toHaveBeenCalledTimes(1); }); @@ -360,6 +385,45 @@ describe("useSettings hook", () => { expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(true); }); + it("detects plugin toggle via live cache even when closure data is stale", async () => { + // 模拟快速连切后的 race:useSettingsQueryMock 的 data 滞后停留在 false(closure 未更新), + // 但 queryClient 缓存(getQueryData)实时值已为 true(上次持久化到 enabled), + // form 里用户想切回 false。旧实现会因 data === form 而跳过副作用;新实现应读 prev=true 并执行。 + serverSettings = { + ...serverSettings, + enableClaudePluginIntegration: false, + }; + useSettingsQueryMock.mockReturnValue({ + data: serverSettings, + isLoading: false, + }); + + settingsFormMock = createSettingsFormMock({ + settings: { + ...serverSettings, + enableClaudePluginIntegration: false, + language: "zh", + }, + }); + directorySettingsMock = createDirectorySettingsMock(); + + // 缓存里的"真实上次值"是 true(enabled),与 closure data(false) 有时序差 + getQueryDataMock.mockImplementation(() => ({ + ...serverSettings, + enableClaudePluginIntegration: true, + })); + + const { result } = renderHook(() => useSettings()); + + await act(async () => { + await result.current.saveSettings(undefined, { silent: true }); + }); + + // 修复生效:读的是缓存实时值 true,payload=false,差异触发 clear_claude_config + expect(applyClaudePluginConfigMock).toHaveBeenCalledWith({ official: true }); + expect(syncCurrentProvidersLiveMock).toHaveBeenCalled(); + }); + it("resets form, language and directories using server data", () => { serverSettings = { ...serverSettings, From 4e790ac0593ce54303bd00f84364e3917413b281 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 15 Apr 2026 09:33:55 +0800 Subject: [PATCH 006/156] fix: hide unknown subscription quota tiers from provider card UI --- src/components/SubscriptionQuotaFooter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/SubscriptionQuotaFooter.tsx b/src/components/SubscriptionQuotaFooter.tsx index fce6ea09e..fd1ebca11 100644 --- a/src/components/SubscriptionQuotaFooter.tsx +++ b/src/components/SubscriptionQuotaFooter.tsx @@ -205,7 +205,7 @@ export const SubscriptionQuotaView: React.FC = ({ } // 成功获取数据 - const tiers = quota.tiers || []; + const tiers = (quota.tiers || []).filter((tier) => tier.name in TIER_I18N_KEYS); if (tiers.length === 0) return null; // ── inline 模式:紧凑两行显示 ── From eab0a69d2c215ad52c85d19f583c6a0c049c7dcc Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 15 Apr 2026 09:35:20 +0800 Subject: [PATCH 007/156] fix: unify weekly_limit tier label to match official 7-day naming --- src/components/SubscriptionQuotaFooter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/SubscriptionQuotaFooter.tsx b/src/components/SubscriptionQuotaFooter.tsx index fd1ebca11..b1bf73151 100644 --- a/src/components/SubscriptionQuotaFooter.tsx +++ b/src/components/SubscriptionQuotaFooter.tsx @@ -31,7 +31,7 @@ export const TIER_I18N_KEYS: Record = { gemini_flash: "subscription.geminiFlash", gemini_flash_lite: "subscription.geminiFlashLite", // Token Plan(five_hour 已在上方官方映射中) - weekly_limit: "subscription.weeklyLimit", + weekly_limit: "subscription.sevenDay", // GitHub Copilot premium: "subscription.copilotPremium", }; From e4c34b34e75f727278a24c87f650b670f1fdb9e2 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 15 Apr 2026 10:09:26 +0800 Subject: [PATCH 008/156] fix: remove ANTHROPIC_REASONING_MODEL to decouple thinking from model selection (#2081) ANTHROPIC_REASONING_MODEL was a non-official env var that forced all requests with thinking params to use a single "reasoning model", overriding the user's /model selection. Since new Claude Code versions send adaptive thinking by default, this caused /model to silently fail. - Remove reasoning_model field and has_thinking_enabled() from model_mapper - Simplify map_model() to pure type-based matching (haiku/sonnet/opus) - Remove reasoning model UI field from provider form - Retain ANTHROPIC_REASONING_MODEL in ENV_EXCLUDES and override-key cleanup lists so legacy configs don't leak into common config --- src-tauri/src/proxy/model_mapper.rs | 123 +++--------------- src-tauri/src/services/provider/mod.rs | 4 +- src-tauri/src/services/proxy.rs | 2 +- .../providers/forms/ClaudeFormFields.tsx | 16 --- .../providers/forms/ProviderForm.tsx | 2 - .../providers/forms/hooks/useModelState.ts | 24 +--- src/i18n/locales/en.json | 1 - src/i18n/locales/ja.json | 1 - src/i18n/locales/zh.json | 1 - 9 files changed, 22 insertions(+), 152 deletions(-) diff --git a/src-tauri/src/proxy/model_mapper.rs b/src-tauri/src/proxy/model_mapper.rs index 4f0f94d81..c7f543e1e 100644 --- a/src-tauri/src/proxy/model_mapper.rs +++ b/src-tauri/src/proxy/model_mapper.rs @@ -11,7 +11,6 @@ pub struct ModelMapping { pub sonnet_model: Option, pub opus_model: Option, pub default_model: Option, - pub reasoning_model: Option, } impl ModelMapping { @@ -40,11 +39,6 @@ impl ModelMapping { .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) .map(String::from), - reasoning_model: env - .and_then(|e| e.get("ANTHROPIC_REASONING_MODEL")) - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(String::from), } } @@ -54,21 +48,13 @@ impl ModelMapping { || self.sonnet_model.is_some() || self.opus_model.is_some() || self.default_model.is_some() - || self.reasoning_model.is_some() } /// 根据原始模型名称获取映射后的模型 - pub fn map_model(&self, original_model: &str, has_thinking: bool) -> String { + pub fn map_model(&self, original_model: &str) -> String { let model_lower = original_model.to_lowercase(); - // 1. thinking 模式优先使用推理模型 - if has_thinking { - if let Some(ref m) = self.reasoning_model { - return m.clone(); - } - } - - // 2. 按模型类型匹配 + // 1. 按模型类型匹配 if model_lower.contains("haiku") { if let Some(ref m) = self.haiku_model { return m.clone(); @@ -85,35 +71,16 @@ impl ModelMapping { } } - // 3. 默认模型 + // 2. 默认模型 if let Some(ref m) = self.default_model { return m.clone(); } - // 4. 无映射,保持原样 + // 3. 无映射,保持原样 original_model.to_string() } } -/// 检测请求是否启用了 thinking 模式 -pub fn has_thinking_enabled(body: &Value) -> bool { - match body - .get("thinking") - .and_then(|v| v.as_object()) - .and_then(|o| o.get("type")) - .and_then(|t| t.as_str()) - { - Some("enabled") | Some("adaptive") => true, - Some("disabled") | None => false, - Some(other) => { - log::warn!( - "[ModelMapper] 未知 thinking.type='{other}',按 disabled 处理以避免误路由 reasoning 模型" - ); - false - } - } -} - /// 对请求体应用模型映射 /// /// 返回 (映射后的请求体, 原始模型名, 映射后模型名) @@ -133,8 +100,7 @@ pub fn apply_model_mapping( let original_model = body.get("model").and_then(|m| m.as_str()).map(String::from); if let Some(ref original) = original_model { - let has_thinking = has_thinking_enabled(&body); - let mapped = mapping.map_model(original, has_thinking); + let mapped = mapping.map_model(original); if mapped != *original { log::debug!("[ModelMapper] 模型映射: {original} → {mapped}"); @@ -160,8 +126,7 @@ mod tests { "ANTHROPIC_MODEL": "default-model", "ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-mapped", "ANTHROPIC_DEFAULT_SONNET_MODEL": "sonnet-mapped", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped", - "ANTHROPIC_REASONING_MODEL": "reasoning-model" + "ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped" } }), website_url: None, @@ -193,27 +158,6 @@ mod tests { } } - fn create_provider_with_reasoning_only() -> Provider { - Provider { - id: "test".to_string(), - name: "Test".to_string(), - settings_config: json!({ - "env": { - "ANTHROPIC_REASONING_MODEL": "reasoning-only-model" - } - }), - website_url: None, - category: None, - created_at: None, - sort_index: None, - notes: None, - meta: None, - icon: None, - icon_color: None, - in_failover_queue: false, - } - } - #[test] fn test_sonnet_mapping() { let provider = create_provider_with_mapping(); @@ -243,40 +187,29 @@ mod tests { } #[test] - fn test_thinking_mode() { + fn test_thinking_does_not_affect_model_mapping() { + // Issue #2081: thinking 参数不应影响模型映射 let provider = create_provider_with_mapping(); let body = json!({ "model": "claude-sonnet-4-5", "thinking": {"type": "enabled"} }); let (result, _, mapped) = apply_model_mapping(body, &provider); - assert_eq!(result["model"], "reasoning-model"); - assert_eq!(mapped, Some("reasoning-model".to_string())); + assert_eq!(result["model"], "sonnet-mapped"); + assert_eq!(mapped, Some("sonnet-mapped".to_string())); } #[test] - fn test_reasoning_only_mapping_in_thinking_mode() { - let provider = create_provider_with_reasoning_only(); + fn test_thinking_adaptive_does_not_affect_model_mapping() { + // Issue #2081: adaptive thinking 也不应影响模型映射 + let provider = create_provider_with_mapping(); let body = json!({ "model": "claude-sonnet-4-5", - "thinking": {"type": "enabled"} + "thinking": {"type": "adaptive"} }); let (result, _, mapped) = apply_model_mapping(body, &provider); - assert_eq!(result["model"], "reasoning-only-model"); - assert_eq!(mapped, Some("reasoning-only-model".to_string())); - } - - #[test] - fn test_reasoning_only_mapping_does_not_affect_non_thinking() { - let provider = create_provider_with_reasoning_only(); - let body = json!({ - "model": "claude-sonnet-4-5", - "thinking": {"type": "disabled"} - }); - let (result, original, mapped) = apply_model_mapping(body, &provider); - assert_eq!(result["model"], "claude-sonnet-4-5"); - assert_eq!(original, Some("claude-sonnet-4-5".to_string())); - assert!(mapped.is_none()); + assert_eq!(result["model"], "sonnet-mapped"); + assert_eq!(mapped, Some("sonnet-mapped".to_string())); } #[test] @@ -310,30 +243,6 @@ mod tests { assert!(mapped.is_none()); } - #[test] - fn test_thinking_adaptive() { - let provider = create_provider_with_mapping(); - let body = json!({ - "model": "claude-sonnet-4-5", - "thinking": {"type": "adaptive"} - }); - let (result, _, mapped) = apply_model_mapping(body, &provider); - assert_eq!(result["model"], "reasoning-model"); - assert_eq!(mapped, Some("reasoning-model".to_string())); - } - - #[test] - fn test_thinking_unknown_type() { - let provider = create_provider_with_mapping(); - let body = json!({ - "model": "claude-sonnet-4-5", - "thinking": {"type": "some_future_type"} - }); - let (result, _, mapped) = apply_model_mapping(body, &provider); - assert_eq!(result["model"], "sonnet-mapped"); - assert_eq!(mapped, Some("sonnet-mapped".to_string())); - } - #[test] fn test_case_insensitive() { let provider = create_provider_with_mapping(); diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index d16f18089..a21ff1554 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -1734,9 +1734,9 @@ impl ProviderService { // Auth "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", - // Models (5 fields) + // Models (4 fields + 1 legacy) "ANTHROPIC_MODEL", - "ANTHROPIC_REASONING_MODEL", + "ANTHROPIC_REASONING_MODEL", // legacy: 已废弃,但旧配置可能残留 "ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 267d71ca7..766d727fa 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -27,7 +27,7 @@ const PROXY_TOKEN_PLACEHOLDER: &str = "PROXY_MANAGED"; /// Claude Code 会继续以旧模型名发起请求,导致新供应商不支持时失败。 const CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 6] = [ "ANTHROPIC_MODEL", - "ANTHROPIC_REASONING_MODEL", + "ANTHROPIC_REASONING_MODEL", // legacy: 已废弃,但旧配置可能残留 "ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index 8362015f4..3df63410a 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -102,14 +102,12 @@ interface ClaudeFormFieldsProps { // Model Selector shouldShowModelSelector: boolean; claudeModel: string; - reasoningModel: string; defaultHaikuModel: string; defaultSonnetModel: string; defaultOpusModel: string; onModelChange: ( field: | "ANTHROPIC_MODEL" - | "ANTHROPIC_REASONING_MODEL" | "ANTHROPIC_DEFAULT_HAIKU_MODEL" | "ANTHROPIC_DEFAULT_SONNET_MODEL" | "ANTHROPIC_DEFAULT_OPUS_MODEL", @@ -164,7 +162,6 @@ export function ClaudeFormFields({ onAutoSelectChange, shouldShowModelSelector, claudeModel, - reasoningModel, defaultHaikuModel, defaultSonnetModel, defaultOpusModel, @@ -180,7 +177,6 @@ export function ClaudeFormFields({ const { t } = useTranslation(); const hasAnyAdvancedValue = !!( claudeModel || - reasoningModel || defaultHaikuModel || defaultSonnetModel || defaultOpusModel || @@ -656,18 +652,6 @@ export function ClaudeFormFields({ )} - {/* 推理模型 */} -
- - {t("providerForm.anthropicReasoningModel")} - - {renderModelInput( - "reasoningModel", - reasoningModel, - "ANTHROPIC_REASONING_MODEL", - )} -
- {/* 默认 Haiku */}
diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 7f8cf560e..6d414c769 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -314,7 +314,6 @@ export function ProviderForm({ const { claudeModel, - reasoningModel, defaultHaikuModel, defaultSonnetModel, defaultOpusModel, @@ -1567,7 +1566,6 @@ export function ProviderForm({ onAutoSelectChange={setEndpointAutoSelect} shouldShowModelSelector={category !== "official"} claudeModel={claudeModel} - reasoningModel={reasoningModel} defaultHaikuModel={defaultHaikuModel} defaultSonnetModel={defaultSonnetModel} defaultOpusModel={defaultOpusModel} diff --git a/src/components/providers/forms/hooks/useModelState.ts b/src/components/providers/forms/hooks/useModelState.ts index 01278c4b5..d7c333e9d 100644 --- a/src/components/providers/forms/hooks/useModelState.ts +++ b/src/components/providers/forms/hooks/useModelState.ts @@ -14,11 +14,6 @@ function parseModelsFromConfig(settingsConfig: string) { const env = cfg?.env || {}; const model = typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL : ""; - const explicitReasoning = - typeof env.ANTHROPIC_REASONING_MODEL === "string" - ? env.ANTHROPIC_REASONING_MODEL - : ""; - const reasoning = explicitReasoning || model; const small = typeof env.ANTHROPIC_SMALL_FAST_MODEL === "string" ? env.ANTHROPIC_SMALL_FAST_MODEL @@ -36,15 +31,15 @@ function parseModelsFromConfig(settingsConfig: string) { ? env.ANTHROPIC_DEFAULT_OPUS_MODEL : model || small; - return { model, reasoning, haiku, sonnet, opus }; + return { model, haiku, sonnet, opus }; } catch { - return { model: "", reasoning: "", haiku: "", sonnet: "", opus: "" }; + return { model: "", haiku: "", sonnet: "", opus: "" }; } } /** * 管理模型选择状态 - * 支持 ANTHROPIC_MODEL, ANTHROPIC_REASONING_MODEL 和各类型默认模型 + * 支持 ANTHROPIC_MODEL 和各类型默认模型 */ export function useModelState({ settingsConfig, @@ -54,9 +49,6 @@ export function useModelState({ const [claudeModel, setClaudeModel] = useState( () => parseModelsFromConfig(settingsConfig).model, ); - const [reasoningModel, setReasoningModel] = useState( - () => parseModelsFromConfig(settingsConfig).reasoning, - ); const [defaultHaikuModel, setDefaultHaikuModel] = useState( () => parseModelsFromConfig(settingsConfig).haiku, ); @@ -93,11 +85,6 @@ export function useModelState({ const env = cfg?.env || {}; const model = typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL : ""; - const explicitReasoning = - typeof env.ANTHROPIC_REASONING_MODEL === "string" - ? env.ANTHROPIC_REASONING_MODEL - : ""; - const reasoning = explicitReasoning || model; const small = typeof env.ANTHROPIC_SMALL_FAST_MODEL === "string" ? env.ANTHROPIC_SMALL_FAST_MODEL @@ -116,7 +103,6 @@ export function useModelState({ : model || small; setClaudeModel(model || ""); - setReasoningModel(reasoning || ""); setDefaultHaikuModel(haiku || ""); setDefaultSonnetModel(sonnet || ""); setDefaultOpusModel(opus || ""); @@ -129,7 +115,6 @@ export function useModelState({ ( field: | "ANTHROPIC_MODEL" - | "ANTHROPIC_REASONING_MODEL" | "ANTHROPIC_DEFAULT_HAIKU_MODEL" | "ANTHROPIC_DEFAULT_SONNET_MODEL" | "ANTHROPIC_DEFAULT_OPUS_MODEL", @@ -138,7 +123,6 @@ export function useModelState({ isUserEditingRef.current = true; if (field === "ANTHROPIC_MODEL") setClaudeModel(value); - if (field === "ANTHROPIC_REASONING_MODEL") setReasoningModel(value); if (field === "ANTHROPIC_DEFAULT_HAIKU_MODEL") setDefaultHaikuModel(value); if (field === "ANTHROPIC_DEFAULT_SONNET_MODEL") @@ -173,8 +157,6 @@ export function useModelState({ return { claudeModel, setClaudeModel, - reasoningModel, - setReasoningModel, defaultHaikuModel, setDefaultHaikuModel, defaultSonnetModel, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index cefc88203..117dd0b31 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -820,7 +820,6 @@ "visitWebsite": "Visit {{url}}", "anthropicModel": "Main Model", "anthropicSmallFastModel": "Fast Model", - "anthropicReasoningModel": "Reasoning Model (Thinking)", "apiFormat": "API Format", "apiFormatHint": "Select the input format for the provider's API", "fullUrlLabel": "Full URL", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 56c3968ae..b7b238884 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -820,7 +820,6 @@ "visitWebsite": "{{url}} を開く", "anthropicModel": "メインモデル", "anthropicSmallFastModel": "高速モデル", - "anthropicReasoningModel": "推論モデル(Thinking)", "apiFormat": "API フォーマット", "apiFormatHint": "プロバイダー API の入力フォーマットを選択", "fullUrlLabel": "フル URL", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 9059ef323..2ce2aeb4a 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -821,7 +821,6 @@ "visitWebsite": "访问 {{url}}", "anthropicModel": "主模型", "anthropicSmallFastModel": "快速模型", - "anthropicReasoningModel": "推理模型 (Thinking)", "apiFormat": "API 格式", "apiFormatHint": "选择供应商 API 的输入格式", "fullUrlLabel": "完整 URL", From 701e7d9581d92d113d9a4c12fdc9fe6adaf67963 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 15 Apr 2026 11:48:24 +0800 Subject: [PATCH 009/156] fix: surface backend error details in proxy toast messages The takeover.failed i18n template lacked the {{detail}} placeholder and three useProxyStatus onError callbacks omitted the detail variable, so proxy start/stop/takeover failures all displayed a generic message regardless of the underlying cause. --- src/components/SubscriptionQuotaFooter.tsx | 4 +++- src/hooks/useProxyStatus.ts | 3 +++ src/i18n/locales/en.json | 2 +- src/i18n/locales/ja.json | 2 +- src/i18n/locales/zh.json | 2 +- 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/components/SubscriptionQuotaFooter.tsx b/src/components/SubscriptionQuotaFooter.tsx index b1bf73151..a07914080 100644 --- a/src/components/SubscriptionQuotaFooter.tsx +++ b/src/components/SubscriptionQuotaFooter.tsx @@ -205,7 +205,9 @@ export const SubscriptionQuotaView: React.FC = ({ } // 成功获取数据 - const tiers = (quota.tiers || []).filter((tier) => tier.name in TIER_I18N_KEYS); + const tiers = (quota.tiers || []).filter( + (tier) => tier.name in TIER_I18N_KEYS, + ); if (tiers.length === 0) return null; // ── inline 模式:紧凑两行显示 ── diff --git a/src/hooks/useProxyStatus.ts b/src/hooks/useProxyStatus.ts index aa6d4a08e..d193b5972 100644 --- a/src/hooks/useProxyStatus.ts +++ b/src/hooks/useProxyStatus.ts @@ -57,6 +57,7 @@ export function useProxyStatus() { t("common.unknown", { defaultValue: "未知错误" }); toast.error( t("proxy.server.startFailed", { + detail, defaultValue: `启动代理服务失败: ${detail}`, }), ); @@ -87,6 +88,7 @@ export function useProxyStatus() { t("common.unknown", { defaultValue: "未知错误" }); toast.error( t("proxy.stopWithRestoreFailed", { + detail, defaultValue: `停止失败: ${detail}`, }), ); @@ -129,6 +131,7 @@ export function useProxyStatus() { t("common.unknown", { defaultValue: "未知错误" }); toast.error( t("proxy.takeover.failed", { + detail, defaultValue: `操作失败: ${detail}`, }), ); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 117dd0b31..c7f089cdd 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2056,7 +2056,7 @@ "hint": "Select apps to route — once enabled, requests from that app will go through local routing", "enabled": "{{app}} routing enabled", "disabled": "{{app}} routing disabled", - "failed": "Failed to toggle routing", + "failed": "Failed to toggle routing: {{detail}}", "tooltip": { "active": "{{appLabel}} is routing - {{address}}:{{port}}\nSwitch provider for hot switching", "broken": "{{appLabel}} is routing, but routing service is not running", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index b7b238884..156653fde 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2056,7 +2056,7 @@ "hint": "ルーティングするアプリを選択します。有効にすると、そのアプリのリクエストはローカルルーティング経由で転送されます", "enabled": "{{app}} ルーティング有効", "disabled": "{{app}} ルーティング無効", - "failed": "ルーティングの切り替えに失敗しました", + "failed": "ルーティングの切り替えに失敗しました: {{detail}}", "tooltip": { "active": "{{appLabel}} がルーティング中 - {{address}}:{{port}}\nホットスイッチングのためプロバイダを切り替え", "broken": "{{appLabel}} がルーティング中ですが、ルーティングサービスが実行されていません", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 2ce2aeb4a..f8a59d5da 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -2057,7 +2057,7 @@ "hint": "选择要路由的应用,启用后该应用的请求将通过本地路由转发", "enabled": "{{app}} 路由已启用", "disabled": "{{app}} 路由已关闭", - "failed": "切换路由状态失败", + "failed": "切换路由状态失败: {{detail}}", "tooltip": { "active": "{{appLabel}} 路由中 - {{address}}:{{port}}\n切换该应用供应商为热切换", "broken": "{{appLabel}} 路由中,但路由服务未运行", From 81af0a57f99af854c22094419412f05f8e2f212e Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 15 Apr 2026 16:22:35 +0800 Subject: [PATCH 010/156] feat: add Hermes Agent as 6th supported app type (Phase 1) Register AppType::Hermes across the entire Rust backend: - Add Hermes variant to AppType enum with additive mode and MCP support - Add hermes field to McpApps, SkillApps, CommonConfigSnippets, and all per-app structs (McpRoot, PromptRoot, VisibleApps, AppSettings) - Create minimal hermes_config.rs with get_hermes_dir() respecting settings override, matching the pattern of other app config modules - Update all match arms in commands, services, deeplink, proxy, mcp, session_manager, and test files - Extract shared build_additive_app_settings() to eliminate duplication between OpenClaw and Hermes deep link handling - Combine identical OpenClaw/Hermes proxy match arms into unified arms --- src-tauri/src/app_config.rs | 45 ++++++++++++-- src-tauri/src/commands/config.rs | 11 ++++ src-tauri/src/database/dao/mcp.rs | 1 + src-tauri/src/deeplink/mcp.rs | 5 ++ src-tauri/src/deeplink/parser.rs | 12 ++-- src-tauri/src/deeplink/provider.rs | 12 ++-- src-tauri/src/hermes_config.rs | 31 ++++++++++ src-tauri/src/lib.rs | 2 + src-tauri/src/mcp/claude.rs | 1 + src-tauri/src/mcp/codex.rs | 1 + src-tauri/src/mcp/gemini.rs | 1 + src-tauri/src/mcp/opencode.rs | 1 + src-tauri/src/prompt_files.rs | 4 +- src-tauri/src/proxy/providers/mod.rs | 18 ++---- src-tauri/src/services/config.rs | 3 + src-tauri/src/services/mcp.rs | 10 +++- src-tauri/src/services/provider/live.rs | 33 +++++++++-- src-tauri/src/services/provider/mod.rs | 30 +++++++++- src-tauri/src/services/proxy.rs | 79 +++++++------------------ src-tauri/src/services/skill.rs | 6 ++ src-tauri/src/services/stream_check.rs | 26 ++++++-- src-tauri/src/session_manager/mod.rs | 3 + src-tauri/src/settings.rs | 28 +++++++++ src-tauri/tests/import_export_sync.rs | 2 + src-tauri/tests/mcp_commands.rs | 8 +++ src-tauri/tests/provider_commands.rs | 1 + src-tauri/tests/provider_service.rs | 1 + 27 files changed, 272 insertions(+), 103 deletions(-) create mode 100644 src-tauri/src/hermes_config.rs diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 4a9fc7539..321d2c484 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -15,6 +15,8 @@ pub struct McpApps { pub gemini: bool, #[serde(default)] pub opencode: bool, + #[serde(default)] + pub hermes: bool, } impl McpApps { @@ -26,6 +28,7 @@ impl McpApps { AppType::Gemini => self.gemini, AppType::OpenCode => self.opencode, AppType::OpenClaw => false, // OpenClaw doesn't support MCP + AppType::Hermes => self.hermes, } } @@ -37,6 +40,7 @@ impl McpApps { AppType::Gemini => self.gemini = enabled, AppType::OpenCode => self.opencode = enabled, AppType::OpenClaw => {} // OpenClaw doesn't support MCP, ignore + AppType::Hermes => self.hermes = enabled, } } @@ -55,12 +59,15 @@ impl McpApps { if self.opencode { apps.push(AppType::OpenCode); } + if self.hermes { + apps.push(AppType::Hermes); + } apps } /// 检查是否所有应用都未启用 pub fn is_empty(&self) -> bool { - !self.claude && !self.codex && !self.gemini && !self.opencode + !self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes } } @@ -86,6 +93,7 @@ impl SkillApps { AppType::Gemini => self.gemini, AppType::OpenCode => self.opencode, AppType::OpenClaw => false, // OpenClaw doesn't support Skills + AppType::Hermes => false, // Hermes doesn't support Skills yet } } @@ -97,6 +105,7 @@ impl SkillApps { AppType::Gemini => self.gemini = enabled, AppType::OpenCode => self.opencode = enabled, AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore + AppType::Hermes => {} // Hermes doesn't support Skills yet, ignore } } @@ -251,6 +260,9 @@ pub struct McpRoot { /// OpenClaw MCP 配置(v4.1.0+,实际使用 openclaw.json) #[serde(default, skip_serializing_if = "McpConfig::is_empty")] pub openclaw: McpConfig, + /// Hermes MCP 配置(实际使用 config.yaml) + #[serde(default, skip_serializing_if = "McpConfig::is_empty")] + pub hermes: McpConfig, } impl Default for McpRoot { @@ -264,6 +276,7 @@ impl Default for McpRoot { gemini: McpConfig::default(), opencode: McpConfig::default(), openclaw: McpConfig::default(), + hermes: McpConfig::default(), } } } @@ -288,6 +301,8 @@ pub struct PromptRoot { pub opencode: PromptConfig, #[serde(default)] pub openclaw: PromptConfig, + #[serde(default)] + pub hermes: PromptConfig, } use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file}; @@ -304,6 +319,7 @@ pub enum AppType { Gemini, OpenCode, OpenClaw, + Hermes, } impl AppType { @@ -314,15 +330,19 @@ impl AppType { AppType::Gemini => "gemini", AppType::OpenCode => "opencode", AppType::OpenClaw => "openclaw", + AppType::Hermes => "hermes", } } /// Check if this app uses additive mode /// /// - Switch mode (false): Only the current provider is written to live config (Claude, Codex, Gemini) - /// - Additive mode (true): All providers are written to live config (OpenCode, OpenClaw) + /// - Additive mode (true): All providers are written to live config (OpenCode, OpenClaw, Hermes) pub fn is_additive_mode(&self) -> bool { - matches!(self, AppType::OpenCode | AppType::OpenClaw) + matches!( + self, + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes + ) } /// Return an iterator over all app types @@ -333,6 +353,7 @@ impl AppType { AppType::Gemini, AppType::OpenCode, AppType::OpenClaw, + AppType::Hermes, ] .into_iter() } @@ -349,10 +370,11 @@ impl FromStr for AppType { "gemini" => Ok(AppType::Gemini), "opencode" => Ok(AppType::OpenCode), "openclaw" => Ok(AppType::OpenClaw), + "hermes" => Ok(AppType::Hermes), other => Err(AppError::localized( "unsupported_app", - format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, openclaw。"), - format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, openclaw."), + format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, openclaw, hermes。"), + format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, openclaw, hermes."), )), } } @@ -375,6 +397,9 @@ pub struct CommonConfigSnippets { #[serde(default, skip_serializing_if = "Option::is_none")] pub openclaw: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hermes: Option, } impl CommonConfigSnippets { @@ -386,6 +411,7 @@ impl CommonConfigSnippets { AppType::Gemini => self.gemini.as_ref(), AppType::OpenCode => self.opencode.as_ref(), AppType::OpenClaw => self.openclaw.as_ref(), + AppType::Hermes => self.hermes.as_ref(), } } @@ -397,6 +423,7 @@ impl CommonConfigSnippets { AppType::Gemini => self.gemini = snippet, AppType::OpenCode => self.opencode = snippet, AppType::OpenClaw => self.openclaw = snippet, + AppType::Hermes => self.hermes = snippet, } } } @@ -438,6 +465,7 @@ impl Default for MultiAppConfig { apps.insert("gemini".to_string(), ProviderManager::default()); apps.insert("opencode".to_string(), ProviderManager::default()); apps.insert("openclaw".to_string(), ProviderManager::default()); + apps.insert("hermes".to_string(), ProviderManager::default()); Self { version: 2, @@ -598,6 +626,7 @@ impl MultiAppConfig { AppType::Gemini => &self.mcp.gemini, AppType::OpenCode => &self.mcp.opencode, AppType::OpenClaw => &self.mcp.openclaw, + AppType::Hermes => &self.mcp.hermes, } } @@ -609,6 +638,7 @@ impl MultiAppConfig { AppType::Gemini => &mut self.mcp.gemini, AppType::OpenCode => &mut self.mcp.opencode, AppType::OpenClaw => &mut self.mcp.openclaw, + AppType::Hermes => &mut self.mcp.hermes, } } @@ -624,6 +654,7 @@ impl MultiAppConfig { Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?; Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?; Self::auto_import_prompt_if_exists(&mut config, AppType::OpenClaw)?; + Self::auto_import_prompt_if_exists(&mut config, AppType::Hermes)?; Ok(config) } @@ -645,6 +676,7 @@ impl MultiAppConfig { || !self.prompts.gemini.prompts.is_empty() || !self.prompts.opencode.prompts.is_empty() || !self.prompts.openclaw.prompts.is_empty() + || !self.prompts.hermes.prompts.is_empty() { return Ok(false); } @@ -658,6 +690,7 @@ impl MultiAppConfig { AppType::Gemini, AppType::OpenCode, AppType::OpenClaw, + AppType::Hermes, ] { // 复用已有的单应用导入逻辑 if Self::auto_import_prompt_if_exists(self, app)? { @@ -729,6 +762,7 @@ impl MultiAppConfig { AppType::Gemini => &mut config.prompts.gemini.prompts, AppType::OpenCode => &mut config.prompts.opencode.prompts, AppType::OpenClaw => &mut config.prompts.openclaw.prompts, + AppType::Hermes => &mut config.prompts.hermes.prompts, }; prompts.insert(id, prompt); @@ -769,6 +803,7 @@ impl MultiAppConfig { AppType::Gemini => &self.mcp.gemini.servers, AppType::OpenCode => &self.mcp.opencode.servers, AppType::OpenClaw => continue, // OpenClaw MCP is still in development, skip + AppType::Hermes => continue, // Hermes didn't exist in v3.6.x, skip }; for (id, entry) in old_servers { diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 60c0f1c79..48fd89e24 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -101,6 +101,15 @@ pub async fn get_config_status(app: String) -> Result { Ok(ConfigStatus { exists, path }) } + AppType::Hermes => { + let config_path = crate::hermes_config::get_hermes_config_path(); + let exists = config_path.exists(); + let path = crate::hermes_config::get_hermes_dir() + .to_string_lossy() + .to_string(); + + Ok(ConfigStatus { exists, path }) + } } } @@ -117,6 +126,7 @@ pub async fn get_config_dir(app: String) -> Result { AppType::Gemini => crate::gemini_config::get_gemini_dir(), AppType::OpenCode => crate::opencode_config::get_opencode_dir(), AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(), + AppType::Hermes => crate::hermes_config::get_hermes_dir(), }; Ok(dir.to_string_lossy().to_string()) @@ -130,6 +140,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result crate::gemini_config::get_gemini_dir(), AppType::OpenCode => crate::opencode_config::get_opencode_dir(), AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(), + AppType::Hermes => crate::hermes_config::get_hermes_dir(), }; if !config_dir.exists() { diff --git a/src-tauri/src/database/dao/mcp.rs b/src-tauri/src/database/dao/mcp.rs index d5c60163f..03188fb51 100644 --- a/src-tauri/src/database/dao/mcp.rs +++ b/src-tauri/src/database/dao/mcp.rs @@ -46,6 +46,7 @@ impl Database { codex: enabled_codex, gemini: enabled_gemini, opencode: enabled_opencode, + hermes: false, }, description, homepage, diff --git a/src-tauri/src/deeplink/mcp.rs b/src-tauri/src/deeplink/mcp.rs index 37df96579..94430c5e5 100644 --- a/src-tauri/src/deeplink/mcp.rs +++ b/src-tauri/src/deeplink/mcp.rs @@ -167,6 +167,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result { codex: false, gemini: false, opencode: false, + hermes: false, }; for app in apps_str.split(',') { @@ -179,6 +180,10 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result { // OpenClaw doesn't support MCP, ignore silently log::debug!("OpenClaw doesn't support MCP, ignoring in apps parameter"); } + "hermes" => { + // TODO: Hermes MCP sync not yet implemented, ignore silently for now + log::debug!("Hermes MCP sync not yet implemented, ignoring in apps parameter"); + } other => { return Err(AppError::InvalidInput(format!( "Invalid app in 'apps': {other}" diff --git a/src-tauri/src/deeplink/parser.rs b/src-tauri/src/deeplink/parser.rs index 290e30318..aca40fec6 100644 --- a/src-tauri/src/deeplink/parser.rs +++ b/src-tauri/src/deeplink/parser.rs @@ -81,10 +81,10 @@ fn parse_provider_deeplink( // Validate app type if !matches!( app.as_str(), - "claude" | "codex" | "gemini" | "opencode" | "openclaw" + "claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes" ) { return Err(AppError::InvalidInput(format!( - "Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', or 'openclaw', got '{app}'" + "Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'" ))); } @@ -190,10 +190,10 @@ fn parse_prompt_deeplink( // Validate app type if !matches!( app.as_str(), - "claude" | "codex" | "gemini" | "opencode" | "openclaw" + "claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes" ) { return Err(AppError::InvalidInput(format!( - "Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', or 'openclaw', got '{app}'" + "Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'" ))); } @@ -262,10 +262,10 @@ fn parse_mcp_deeplink( let trimmed = app.trim(); if !matches!( trimmed, - "claude" | "codex" | "gemini" | "opencode" | "openclaw" + "claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes" ) { return Err(AppError::InvalidInput(format!( - "Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'opencode', or 'openclaw', got '{trimmed}'" + "Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{trimmed}'" ))); } } diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 87e07282f..216e1ff04 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -146,7 +146,7 @@ pub(crate) fn build_provider_from_request( AppType::Codex => build_codex_settings(request), AppType::Gemini => build_gemini_settings(request), AppType::OpenCode => build_opencode_settings(request), - AppType::OpenClaw => build_openclaw_settings(request), + AppType::OpenClaw | AppType::Hermes => build_additive_app_settings(request), }; // Build usage script configuration if provided @@ -393,11 +393,11 @@ fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value }) } -fn build_openclaw_settings(request: &DeepLinkImportRequest) -> serde_json::Value { +/// Build settings for additive-mode apps (OpenClaw, Hermes) +/// Format: { baseUrl, apiKey, api, models } +fn build_additive_app_settings(request: &DeepLinkImportRequest) -> serde_json::Value { let endpoint = get_primary_endpoint(request); - // Build OpenClaw provider config - // Format: { baseUrl, apiKey, api, models } let mut config = serde_json::Map::new(); if !endpoint.is_empty() { @@ -408,10 +408,8 @@ fn build_openclaw_settings(request: &DeepLinkImportRequest) -> serde_json::Value config.insert("apiKey".to_string(), json!(api_key)); } - // Default to OpenAI-compatible API config.insert("api".to_string(), json!("openai-completions")); - // Build models array if let Some(model) = &request.model { config.insert( "models".to_string(), @@ -484,7 +482,7 @@ pub fn parse_and_merge_config( "codex" => merge_codex_config(&mut merged, &config_value)?, "gemini" => merge_gemini_config(&mut merged, &config_value)?, // Additive mode apps use JSON config directly; pass through as-is - "openclaw" | "opencode" => { + "openclaw" | "opencode" | "hermes" => { merge_additive_config(&mut merged, &config_value)?; } "" => { diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs new file mode 100644 index 000000000..74c926d24 --- /dev/null +++ b/src-tauri/src/hermes_config.rs @@ -0,0 +1,31 @@ +//! Hermes Agent 配置文件读写模块 +//! +//! 处理 `~/.hermes/config.yaml` 配置文件的读写操作(YAML 格式)。 +//! Hermes 使用累加式供应商管理,所有供应商配置共存于同一配置文件中。 + +use crate::settings::get_hermes_override_dir; +use std::path::PathBuf; + +// ============================================================================ +// Path Functions +// ============================================================================ + +/// 获取 Hermes 配置目录 +/// +/// 默认路径: `~/.hermes/` +/// 可通过 settings.hermes_config_dir 覆盖 +pub fn get_hermes_dir() -> PathBuf { + if let Some(override_dir) = get_hermes_override_dir() { + return override_dir; + } + + crate::config::get_home_dir().join(".hermes") +} + +/// 获取 Hermes 配置文件路径 +/// +/// 返回 `~/.hermes/config.yaml` +pub fn get_hermes_config_path() -> PathBuf { + get_hermes_dir().join("config.yaml") +} + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a1764143a..b6cc0b973 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,6 +11,7 @@ mod deeplink; mod error; mod gemini_config; mod gemini_mcp; +mod hermes_config; mod init_status; mod lightweight; #[cfg(target_os = "linux")] @@ -639,6 +640,7 @@ pub fn run() { crate::app_config::AppType::Gemini, crate::app_config::AppType::OpenCode, crate::app_config::AppType::OpenClaw, + crate::app_config::AppType::Hermes, ] { match crate::services::prompt::PromptService::import_from_file_on_first_launch( &app_state, diff --git a/src-tauri/src/mcp/claude.rs b/src-tauri/src/mcp/claude.rs index 25d2f426a..e1971fb73 100644 --- a/src-tauri/src/mcp/claude.rs +++ b/src-tauri/src/mcp/claude.rs @@ -92,6 +92,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result Result codex: true, gemini: false, opencode: false, + hermes: false, }, description: None, homepage: None, diff --git a/src-tauri/src/mcp/gemini.rs b/src-tauri/src/mcp/gemini.rs index c8a7809f3..73c2fc840 100644 --- a/src-tauri/src/mcp/gemini.rs +++ b/src-tauri/src/mcp/gemini.rs @@ -88,6 +88,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result Result Result { AppType::Gemini => get_gemini_dir(), AppType::OpenCode => get_opencode_dir(), AppType::OpenClaw => get_openclaw_dir(), + AppType::Hermes => crate::hermes_config::get_hermes_dir(), }; let filename = match app { AppType::Claude => "CLAUDE.md", AppType::Codex => "AGENTS.md", AppType::Gemini => "GEMINI.md", - AppType::OpenCode => "AGENTS.md", - AppType::OpenClaw => "AGENTS.md", // OpenClaw uses AGENTS.md for agent instructions + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md", }; Ok(base_dir.join(filename)) diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index 7e6238ec1..94475555b 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -174,13 +174,9 @@ impl ProviderType { } ProviderType::Gemini } - AppType::OpenCode => { - // OpenCode doesn't support proxy, but return a default type for completeness - ProviderType::Codex // Fallback to Codex-like type - } - AppType::OpenClaw => { - // OpenClaw doesn't support proxy, but return a default type for completeness - ProviderType::Codex // Fallback to Codex-like type + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + // These apps don't support proxy, fallback to Codex-like type + ProviderType::Codex } } } @@ -232,12 +228,8 @@ pub fn get_adapter(app_type: &AppType) -> Box { AppType::Claude => Box::new(ClaudeAdapter::new()), AppType::Codex => Box::new(CodexAdapter::new()), AppType::Gemini => Box::new(GeminiAdapter::new()), - AppType::OpenCode => { - // OpenCode doesn't support proxy, fallback to Codex adapter - Box::new(CodexAdapter::new()) - } - AppType::OpenClaw => { - // OpenClaw doesn't support proxy, fallback to Codex adapter + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + // These apps don't support proxy, fallback to Codex adapter Box::new(CodexAdapter::new()) } } diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index d354cccb1..7c116c31c 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -130,6 +130,9 @@ impl ConfigService { // OpenClaw uses additive mode, no live sync needed // OpenClaw providers are managed directly in the config file } + AppType::Hermes => { + // Hermes uses additive mode, no live sync needed + } } Ok(()) diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index 02b31aea9..5b8d24fe6 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -128,6 +128,10 @@ impl McpService { // Skip for now log::debug!("OpenClaw MCP support is still in development, skipping sync"); } + AppType::Hermes => { + // TODO: Hermes MCP sync not yet implemented + log::debug!("Hermes MCP sync not yet implemented, skipping sync"); + } } Ok(()) } @@ -157,6 +161,10 @@ impl McpService { // OpenClaw MCP support is still in development log::debug!("OpenClaw MCP support is still in development, skipping remove"); } + AppType::Hermes => { + // TODO: Hermes MCP sync not yet implemented + log::debug!("Hermes MCP sync not yet implemented, skipping remove"); + } } Ok(()) } @@ -166,7 +174,7 @@ impl McpService { let servers = Self::get_all_servers(state)?; for app in AppType::all() { - if matches!(app, AppType::OpenClaw) { + if matches!(app, AppType::OpenClaw | AppType::Hermes) { continue; } diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 21147da65..ac7492fe6 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -42,6 +42,10 @@ pub(crate) fn provider_exists_in_live_config( .map(|providers| providers.contains_key(provider_id)), AppType::OpenClaw => crate::openclaw_config::get_providers() .map(|providers| providers.contains_key(provider_id)), + AppType::Hermes => { + // TODO: hermes_config module not yet implemented + Ok(false) + } _ => Ok(false), } } @@ -345,7 +349,7 @@ fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet: } _ => false, }, - AppType::OpenCode | AppType::OpenClaw => false, + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => false, } } @@ -415,7 +419,7 @@ pub(crate) fn remove_common_config_from_settings( } Ok(result) } - AppType::OpenCode | AppType::OpenClaw => Ok(settings.clone()), + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Ok(settings.clone()), } } @@ -470,7 +474,7 @@ fn apply_common_config_to_settings( } Ok(result) } - AppType::OpenCode | AppType::OpenClaw => Ok(settings.clone()), + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Ok(settings.clone()), } } @@ -790,6 +794,13 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re } } } + AppType::Hermes => { + // TODO: hermes_config module not yet implemented + log::debug!( + "Hermes provider '{}' write to live config not yet implemented", + provider.id + ); + } } Ok(()) } @@ -985,6 +996,18 @@ pub fn read_live_settings(app_type: AppType) -> Result { let config = read_openclaw_config()?; Ok(config) } + AppType::Hermes => { + let config_path = crate::hermes_config::get_hermes_config_path(); + if !config_path.exists() { + return Err(AppError::localized( + "hermes.config.missing", + "Hermes 配置文件不存在", + "Hermes configuration file not found", + )); + } + // Return empty object until hermes_config is implemented + Ok(json!({})) + } } } @@ -1067,8 +1090,8 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result { + // OpenCode, OpenClaw and Hermes use additive mode and are handled by early return above + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { unreachable!("additive mode apps are handled by early return") } }; diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index a21ff1554..d8dcce3fe 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -1282,6 +1282,12 @@ impl ProviderService { match app_type { AppType::OpenCode => remove_opencode_provider_from_live(id)?, AppType::OpenClaw => remove_openclaw_provider_from_live(id)?, + AppType::Hermes => { + // TODO: hermes_config module not yet implemented + log::debug!( + "Hermes provider '{id}' removal from live config not yet implemented" + ); + } _ => {} } } @@ -1344,6 +1350,10 @@ impl ProviderService { AppType::OpenClaw => { remove_openclaw_provider_from_live(id)?; } + AppType::Hermes => { + // TODO: hermes_config module not yet implemented + log::debug!("Hermes provider '{id}' removal from live config not yet implemented"); + } _ => { return Err(AppError::Message(format!( "App {} does not support remove from live config", @@ -1532,6 +1542,10 @@ impl ProviderService { let rollback_result = match app_type { AppType::OpenCode => remove_opencode_provider_from_live(&provider.id), AppType::OpenClaw => remove_openclaw_provider_from_live(&provider.id), + AppType::Hermes => { + // TODO: hermes_config module not yet implemented + Ok(()) + } _ => Ok(()), }; @@ -1708,6 +1722,7 @@ impl ProviderService { AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config), AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config), AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config), + AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets } } @@ -1722,6 +1737,7 @@ impl ProviderService { AppType::Gemini => Self::extract_gemini_common_config(settings_config), AppType::OpenCode => Self::extract_opencode_common_config(settings_config), AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config), + AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets } } @@ -2087,6 +2103,16 @@ impl ProviderService { )); } } + AppType::Hermes => { + // Hermes: accept any JSON object for now + if !provider.settings_config.is_object() { + return Err(AppError::localized( + "provider.hermes.settings.not_object", + "Hermes 配置必须是 JSON 对象", + "Hermes configuration must be a JSON object", + )); + } + } } // Validate and clean UsageScript configuration (common for all app types) @@ -2258,8 +2284,8 @@ impl ProviderService { Ok((api_key, base_url)) } - AppType::OpenClaw => { - // OpenClaw uses apiKey and baseUrl directly on the object + AppType::OpenClaw | AppType::Hermes => { + // OpenClaw/Hermes use apiKey and baseUrl directly on the object let api_key = provider .settings_config .get("apiKey") diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 766d727fa..0683153bc 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -466,13 +466,9 @@ impl ProxyService { AppType::Claude => self.read_claude_live()?, AppType::Codex => self.read_codex_live()?, AppType::Gemini => self.read_gemini_live()?, - AppType::OpenCode => { - // OpenCode doesn't support proxy features - return Err("OpenCode 不支持代理功能".to_string()); - } - AppType::OpenClaw => { - // OpenClaw doesn't support proxy features - return Err("OpenClaw 不支持代理功能".to_string()); + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + // These apps don't support proxy features + return Err("该应用不支持代理功能".to_string()); } }; @@ -687,11 +683,8 @@ impl ProxyService { } } } - AppType::OpenCode => { - // OpenCode doesn't support proxy features, skip silently - } - AppType::OpenClaw => { - // OpenClaw doesn't support proxy features, skip silently + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + // These apps don't support proxy features, skip silently } } @@ -871,13 +864,9 @@ impl ProxyService { AppType::Claude => ("claude", self.read_claude_live()?), AppType::Codex => ("codex", self.read_codex_live()?), AppType::Gemini => ("gemini", self.read_gemini_live()?), - AppType::OpenCode => { - // OpenCode doesn't support proxy features - return Err("OpenCode 不支持代理功能".to_string()); - } - AppType::OpenClaw => { - // OpenClaw doesn't support proxy features - return Err("OpenClaw 不支持代理功能".to_string()); + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + // These apps don't support proxy features + return Err("该应用不支持代理功能".to_string()); } }; @@ -1019,13 +1008,9 @@ impl ProxyService { self.write_gemini_live(&live_config)?; log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}"); } - AppType::OpenCode => { - // OpenCode doesn't support proxy features - return Err("OpenCode 不支持代理功能".to_string()); - } - AppType::OpenClaw => { - // OpenClaw doesn't support proxy features - return Err("OpenClaw 不支持代理功能".to_string()); + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + // These apps don't support proxy features + return Err("该应用不支持代理功能".to_string()); } } @@ -1076,11 +1061,8 @@ impl ProxyService { let _ = self.write_gemini_live(&live_config); } } - AppType::OpenCode => { - // OpenCode doesn't support proxy features, skip silently - } - AppType::OpenClaw => { - // OpenClaw doesn't support proxy features, skip silently + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + // These apps don't support proxy features, skip silently } } @@ -1119,11 +1101,8 @@ impl ProxyService { log::info!("Gemini Live 配置已恢复"); } } - AppType::OpenCode => { - // OpenCode doesn't support proxy features, skip silently - } - AppType::OpenClaw => { - // OpenClaw doesn't support proxy features, skip silently + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + // These apps don't support proxy features, skip silently } } @@ -1213,13 +1192,9 @@ impl ProxyService { AppType::Claude => self.write_claude_live(config), AppType::Codex => self.write_codex_live(config), AppType::Gemini => self.write_gemini_live(config), - AppType::OpenCode => { - // OpenCode doesn't support proxy features - Err("OpenCode 不支持代理功能".to_string()) - } - AppType::OpenClaw => { - // OpenClaw doesn't support proxy features - Err("OpenClaw 不支持代理功能".to_string()) + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + // These apps don't support proxy features + Err("该应用不支持代理功能".to_string()) } } } @@ -1238,12 +1213,8 @@ impl ProxyService { Ok(config) => Self::is_gemini_live_taken_over(&config), Err(_) => false, }, - AppType::OpenCode => { - // OpenCode doesn't support proxy takeover - false - } - AppType::OpenClaw => { - // OpenClaw doesn't support proxy takeover + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + // These apps don't support proxy takeover false } } @@ -1285,12 +1256,8 @@ impl ProxyService { AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(), AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(), AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(), - AppType::OpenCode => { - // OpenCode doesn't support proxy features - Ok(()) - } - AppType::OpenClaw => { - // OpenClaw doesn't support proxy features + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { + // These apps don't support proxy features Ok(()) } } @@ -1540,7 +1507,7 @@ impl ProxyService { serde_json::to_string(&env_backup) .map_err(|e| format!("序列化 Gemini 配置失败: {e}"))? } - AppType::OpenCode | AppType::OpenClaw => { + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { return Err(format!("未知的应用类型: {app_type}")); } }; diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 892c621b4..9de239d36 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -529,6 +529,11 @@ impl SkillService { return Ok(custom.join("skills")); } } + AppType::Hermes => { + if let Some(custom) = crate::settings::get_hermes_override_dir() { + return Ok(custom.join("skills")); + } + } } // 默认路径:回退到用户主目录下的标准位置 @@ -544,6 +549,7 @@ impl SkillService { AppType::Gemini => home.join(".gemini").join("skills"), AppType::OpenCode => home.join(".config").join("opencode").join("skills"), AppType::OpenClaw => home.join(".openclaw").join("skills"), + AppType::Hermes => crate::hermes_config::get_hermes_dir().join("skills"), }) } diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 1bb630058..d85a79912 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -207,7 +207,10 @@ impl StreamCheckService { // OpenCode / OpenClaw 的 settings_config 结构与 Claude/Codex/Gemini 不同 // (baseUrl / apiKey 直接作为根字段而非嵌套在 env),并且协议由 `api` // 或 `npm` 字段显式指定。它们不走 get_adapter 路径,而是直接分发。 - if matches!(app_type, AppType::OpenCode | AppType::OpenClaw) { + if matches!( + app_type, + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes + ) { return Self::check_once_without_adapter(app_type, provider, config, start).await; } @@ -270,9 +273,9 @@ impl StreamCheckService { ) .await } - AppType::OpenCode | AppType::OpenClaw => { + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { // Already handled via early dispatch above - unreachable!("OpenCode/OpenClaw 已通过 check_once_without_adapter 处理") + unreachable!("OpenCode/OpenClaw/Hermes 已通过 check_once_without_adapter 处理") } }; @@ -700,7 +703,18 @@ impl StreamCheckService { ) .await } - _ => unreachable!("check_once_without_adapter 只处理 OpenCode/OpenClaw"), + AppType::Hermes => { + // Hermes uses the same check path as OpenClaw for now + Self::check_openclaw_stream( + &client, + provider, + &model_to_test, + test_prompt, + request_timeout, + ) + .await + } + _ => unreachable!("check_once_without_adapter 只处理 OpenCode/OpenClaw/Hermes"), }; let response_time = start.elapsed().as_millis() as u64; @@ -1241,8 +1255,8 @@ impl StreamCheckService { // Try to extract first model from the models object Self::extract_opencode_model(provider).unwrap_or_else(|| "gpt-4o".to_string()) } - AppType::OpenClaw => { - // OpenClaw uses models array in settings_config + AppType::OpenClaw | AppType::Hermes => { + // OpenClaw/Hermes use models array in settings_config // Try to extract first model from the models array Self::extract_openclaw_model(provider).unwrap_or_else(|| "gpt-4o".to_string()) } diff --git a/src-tauri/src/session_manager/mod.rs b/src-tauri/src/session_manager/mod.rs index 747f169e5..7facbc8f3 100644 --- a/src-tauri/src/session_manager/mod.rs +++ b/src-tauri/src/session_manager/mod.rs @@ -100,6 +100,7 @@ pub fn load_messages(provider_id: &str, source_path: &str) -> Result opencode::load_messages(path), "openclaw" => openclaw::load_messages(path), "gemini" => gemini::load_messages(path), + "hermes" => Err("Hermes session loading not yet implemented".to_string()), _ => Err(format!("Unsupported provider: {provider_id}")), } } @@ -150,6 +151,7 @@ fn delete_session_with_root( "opencode" => opencode::delete_session(&validated_root, &validated_source, session_id), "openclaw" => openclaw::delete_session(&validated_root, &validated_source, session_id), "gemini" => gemini::delete_session(&validated_root, &validated_source, session_id), + "hermes" => Err("Hermes session deletion not yet implemented".to_string()), _ => Err(format!("Unsupported provider: {provider_id}")), } } @@ -161,6 +163,7 @@ fn provider_root(provider_id: &str) -> Result { "opencode" => opencode::get_opencode_data_dir(), "openclaw" => crate::openclaw_config::get_openclaw_dir().join("agents"), "gemini" => crate::gemini_config::get_gemini_dir().join("tmp"), + "hermes" => crate::hermes_config::get_hermes_dir().join("sessions"), _ => return Err(format!("Unsupported provider: {provider_id}")), }; diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 8b41ba517..704ab71eb 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -36,6 +36,8 @@ pub struct VisibleApps { pub opencode: bool, #[serde(default = "default_true")] pub openclaw: bool, + #[serde(default)] + pub hermes: bool, } impl Default for VisibleApps { @@ -46,6 +48,7 @@ impl Default for VisibleApps { gemini: true, opencode: true, openclaw: true, + hermes: false, // 默认不显示,需用户手动启用 } } } @@ -59,6 +62,7 @@ impl VisibleApps { AppType::Gemini => self.gemini, AppType::OpenCode => self.opencode, AppType::OpenClaw => self.openclaw, + AppType::Hermes => self.hermes, } } } @@ -231,6 +235,8 @@ pub struct AppSettings { pub opencode_config_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub openclaw_config_dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hermes_config_dir: Option, // ===== 当前供应商 ID(设备级)===== /// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current) @@ -248,6 +254,9 @@ pub struct AppSettings { /// 当前 OpenClaw 供应商 ID(本地存储,对 OpenClaw 可能无意义,但保持结构一致) #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_openclaw: Option, + /// 当前 Hermes 供应商 ID(本地存储,保持结构一致) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_provider_hermes: Option, // ===== Skill 同步设置 ===== /// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy @@ -315,11 +324,13 @@ impl Default for AppSettings { gemini_config_dir: None, opencode_config_dir: None, openclaw_config_dir: None, + hermes_config_dir: None, current_provider_claude: None, current_provider_codex: None, current_provider_gemini: None, current_provider_opencode: None, current_provider_openclaw: None, + current_provider_hermes: None, skill_sync_method: SyncMethod::default(), skill_storage_location: SkillStorageLocation::default(), webdav_sync: None, @@ -377,6 +388,13 @@ impl AppSettings { .filter(|s| !s.is_empty()) .map(|s| s.to_string()); + self.hermes_config_dir = self + .hermes_config_dir + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + self.language = self .language .as_ref() @@ -577,6 +595,14 @@ pub fn get_openclaw_override_dir() -> Option { .map(|p| resolve_override_path(p)) } +pub fn get_hermes_override_dir() -> Option { + let settings = settings_store().read().ok()?; + settings + .hermes_config_dir + .as_ref() + .map(|p| resolve_override_path(p)) +} + // ===== 当前供应商管理函数 ===== /// 获取指定应用类型的当前供应商 ID(从本地 settings 读取) @@ -591,6 +617,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option { AppType::Gemini => settings.current_provider_gemini.clone(), AppType::OpenCode => settings.current_provider_opencode.clone(), AppType::OpenClaw => settings.current_provider_openclaw.clone(), + AppType::Hermes => settings.current_provider_hermes.clone(), } } @@ -606,6 +633,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppType::Gemini => settings.current_provider_gemini = id_owned.clone(), AppType::OpenCode => settings.current_provider_opencode = id_owned.clone(), AppType::OpenClaw => settings.current_provider_openclaw = id_owned.clone(), + AppType::Hermes => settings.current_provider_hermes = id_owned.clone(), }) } diff --git a/src-tauri/tests/import_export_sync.rs b/src-tauri/tests/import_export_sync.rs index 24a1667fb..2be08faff 100644 --- a/src-tauri/tests/import_export_sync.rs +++ b/src-tauri/tests/import_export_sync.rs @@ -554,6 +554,7 @@ command = "echo" codex: false, // 初始未启用 gemini: false, opencode: false, + hermes: false, }, description: None, homepage: None, @@ -682,6 +683,7 @@ fn import_from_claude_merges_into_config() { codex: false, gemini: false, opencode: false, + hermes: false, }, description: None, homepage: None, diff --git a/src-tauri/tests/mcp_commands.rs b/src-tauri/tests/mcp_commands.rs index 1ed24a99d..5cd31f615 100644 --- a/src-tauri/tests/mcp_commands.rs +++ b/src-tauri/tests/mcp_commands.rs @@ -217,6 +217,7 @@ fn set_mcp_enabled_for_codex_writes_live_config() { codex: false, // 初始未启用 gemini: false, opencode: false, + hermes: false, }, description: None, homepage: None, @@ -281,6 +282,7 @@ fn enabling_codex_mcp_skips_when_codex_dir_missing() { codex: false, gemini: false, opencode: false, + hermes: false, }, description: None, homepage: None, @@ -325,6 +327,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() { codex: false, gemini: false, opencode: false, + hermes: false, }, description: None, homepage: None, @@ -358,6 +361,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() { codex: false, gemini: false, opencode: false, + hermes: false, }, description: None, homepage: None, @@ -490,6 +494,7 @@ fn enabling_gemini_mcp_skips_when_gemini_dir_missing() { codex: false, gemini: false, opencode: false, + hermes: false, }, description: None, homepage: None, @@ -544,6 +549,7 @@ fn enabling_claude_mcp_skips_when_claude_config_absent() { codex: false, gemini: false, opencode: false, + hermes: false, }, description: None, homepage: None, @@ -604,6 +610,7 @@ fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries() codex: false, gemini: false, opencode: false, + hermes: false, }, description: None, homepage: None, @@ -625,6 +632,7 @@ fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries() codex: false, gemini: false, opencode: false, + hermes: false, }, description: None, homepage: None, diff --git a/src-tauri/tests/provider_commands.rs b/src-tauri/tests/provider_commands.rs index 328848926..71fcc48d9 100644 --- a/src-tauri/tests/provider_commands.rs +++ b/src-tauri/tests/provider_commands.rs @@ -75,6 +75,7 @@ command = "say" codex: true, // 启用 Codex gemini: false, opencode: false, + hermes: false, }, description: None, homepage: None, diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index 2b9eddaa9..d9679f921 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -163,6 +163,7 @@ command = "say" codex: true, gemini: false, opencode: false, + hermes: false, }, description: None, homepage: None, From a2e9e1938b8c647bcf77ad28ed37c3d46b634cd5 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 15 Apr 2026 16:44:28 +0800 Subject: [PATCH 011/156] =?UTF-8?q?feat:=20add=20database=20migration=20v9?= =?UTF-8?q?=E2=86=92v10=20for=20Hermes=20support=20(Phase=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump SCHEMA_VERSION from 9 to 10 - Add enabled_hermes column to mcp_servers and skills tables - Add migrate_v9_to_v10 with table_exists guard for skills (may not exist in databases migrated from very old versions) - Update dao/mcp.rs to fully read/write enabled_hermes in all queries - Update dao/skills.rs: don't SELECT enabled_hermes (Hermes doesn't support Skills yet), keep column indices clean --- src-tauri/src/database/dao/mcp.rs | 10 +++++---- src-tauri/src/database/dao/skills.rs | 4 ++-- src-tauri/src/database/mod.rs | 2 +- src-tauri/src/database/schema.rs | 32 +++++++++++++++++++++++++++- 4 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/database/dao/mcp.rs b/src-tauri/src/database/dao/mcp.rs index 03188fb51..07eb199a3 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,7 +47,7 @@ impl Database { codex: enabled_codex, gemini: enabled_gemini, opencode: enabled_opencode, - hermes: false, + hermes: enabled_hermes, }, description, homepage, @@ -71,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, @@ -88,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 befedca12..530541a1d 100644 --- a/src-tauri/src/database/dao/skills.rs +++ b/src-tauri/src/database/dao/skills.rs @@ -107,9 +107,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, 0, ?13, ?14, ?15)", params![ skill.id, skill.name, diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 1c7618072..1c93ce982 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 295641457..900b86c05 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 Agent 支持)"); + Self::migrate_v9_to_v10(conn)?; + Self::set_user_version(conn, 10)?; + } _ => { return Err(AppError::Database(format!( "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}" @@ -1168,6 +1175,29 @@ impl Database { Ok(()) } + /// v9 -> v10 迁移:添加 Hermes Agent 支持 + fn migrate_v9_to_v10(conn: &Connection) -> Result<(), AppError> { + Self::add_column_if_missing( + conn, + "mcp_servers", + "enabled_hermes", + "BOOLEAN NOT NULL DEFAULT 0", + )?; + + // skills table may not exist in databases migrated from very old versions + 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 Agent 支持"); + Ok(()) + } + /// 插入默认模型定价数据 /// 格式: (model_id, display_name, input, output, cache_read, cache_creation) /// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致 From 6d0e9f4c74078506ebe9b5ca3b22e4040d01f301 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 15 Apr 2026 17:13:03 +0800 Subject: [PATCH 012/156] feat: implement Hermes config module and commands (Phase 3) Add hermes_config.rs (~1190 lines) with YAML section-level replacement that preserves comments and formatting in unmanaged sections: - Type definitions: HermesModelConfig, HermesAgentConfig, HermesEnvConfig - YAML section finder (find_yaml_section_range) with column-0 key detection - Provider CRUD on custom_providers array (indexed by name field) - Model/Agent config get/set via yaml<->json conversion - .env dotenv read/write preserving comments and line ordering - Health check, backup with rotation, write lock (OnceLock) - MCP section access stubs for Phase 4 - 19 unit tests Add commands/hermes.rs with 10 Tauri commands registered in lib.rs. Replace all Hermes TODO stubs in services/provider/live.rs with real implementations (import, remove, write-to-live, read-live-settings). --- src-tauri/src/commands/hermes.rs | 94 ++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/hermes_config.rs | 1181 ++++++++++++++++++++++- src-tauri/src/lib.rs | 18 + src-tauri/src/services/provider/live.rs | 88 +- src-tauri/src/services/provider/mod.rs | 23 +- 6 files changed, 1378 insertions(+), 28 deletions(-) create mode 100644 src-tauri/src/commands/hermes.rs diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs new file mode 100644 index 000000000..d6a9286d1 --- /dev/null +++ b/src-tauri/src/commands/hermes.rs @@ -0,0 +1,94 @@ +use tauri::State; + +use crate::hermes_config; +use crate::store::AppState; + +// ============================================================================ +// Hermes Provider Commands +// ============================================================================ + +/// Import providers from Hermes live config to database. +/// +/// Hermes uses additive mode — users may already have providers +/// configured in config.yaml. +#[tauri::command] +pub fn import_hermes_providers_from_live(state: State<'_, AppState>) -> Result { + crate::services::provider::import_hermes_providers_from_live(state.inner()) + .map_err(|e| e.to_string()) +} + +/// Get provider names in the Hermes live config. +#[tauri::command] +pub fn get_hermes_live_provider_ids() -> Result, String> { + hermes_config::get_providers() + .map(|providers| providers.keys().cloned().collect()) + .map_err(|e| e.to_string()) +} + +/// Get a single Hermes provider fragment from live config. +#[tauri::command] +pub fn get_hermes_live_provider( + #[allow(non_snake_case)] providerId: String, +) -> Result, String> { + hermes_config::get_provider(&providerId).map_err(|e| e.to_string()) +} + +/// Scan config.yaml for known configuration hazards. +#[tauri::command] +pub fn scan_hermes_config_health() -> Result, String> { + hermes_config::scan_hermes_config_health().map_err(|e| e.to_string()) +} + +// ============================================================================ +// Model Configuration Commands +// ============================================================================ + +/// Get Hermes model config (model section of config.yaml) +#[tauri::command] +pub fn get_hermes_model_config() -> Result, String> { + hermes_config::get_model_config().map_err(|e| e.to_string()) +} + +/// Set Hermes model config (model section of config.yaml) +#[tauri::command] +pub fn set_hermes_model_config( + model: hermes_config::HermesModelConfig, +) -> Result { + hermes_config::set_model_config(&model).map_err(|e| e.to_string()) +} + +// ============================================================================ +// Agent Configuration Commands +// ============================================================================ + +/// Get Hermes agent config (agent section of config.yaml) +#[tauri::command] +pub fn get_hermes_agent_config() -> Result, String> { + hermes_config::get_agent_config().map_err(|e| e.to_string()) +} + +/// Set Hermes agent config (agent section of config.yaml) +#[tauri::command] +pub fn set_hermes_agent_config( + agent: hermes_config::HermesAgentConfig, +) -> Result { + hermes_config::set_agent_config(&agent).map_err(|e| e.to_string()) +} + +// ============================================================================ +// Env Configuration Commands +// ============================================================================ + +/// Get Hermes env config (.env file) +#[tauri::command] +pub fn get_hermes_env() -> Result { + hermes_config::read_env().map_err(|e| e.to_string()) +} + +/// Set Hermes env config (.env file) +#[tauri::command] +pub fn set_hermes_env( + env: hermes_config::HermesEnvConfig, +) -> Result { + hermes_config::write_env(&env).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 6bf8a8c73..6b8aff027 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -10,6 +10,7 @@ mod deeplink; mod env; mod failover; mod global_proxy; +mod hermes; mod import_export; mod mcp; mod misc; @@ -42,6 +43,7 @@ pub use deeplink::*; pub use env::*; pub use failover::*; pub use global_proxy::*; +pub use hermes::*; pub use import_export::*; pub use mcp::*; pub use misc::*; diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs index 74c926d24..4957ddf8d 100644 --- a/src-tauri/src/hermes_config.rs +++ b/src-tauri/src/hermes_config.rs @@ -2,9 +2,39 @@ //! //! 处理 `~/.hermes/config.yaml` 配置文件的读写操作(YAML 格式)。 //! Hermes 使用累加式供应商管理,所有供应商配置共存于同一配置文件中。 +//! +//! ## 配置结构示例 +//! +//! ```yaml +//! model: +//! default: "anthropic/claude-opus-4-6" +//! provider: "openrouter" +//! base_url: "https://openrouter.ai/api/v1" +//! +//! agent: +//! max_turns: 50 +//! reasoning_effort: "high" +//! +//! custom_providers: +//! - name: openrouter +//! base_url: https://openrouter.ai/api/v1 +//! api_key: sk-or-... +//! +//! mcp_servers: +//! filesystem: +//! command: npx +//! args: ["-y", "@modelcontextprotocol/server-filesystem"] +//! ``` -use crate::settings::get_hermes_override_dir; -use std::path::PathBuf; +use crate::config::{atomic_write, get_app_config_dir}; +use crate::error::AppError; +use crate::settings::{effective_backup_retain_count, get_hermes_override_dir}; +use chrono::Local; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; // ============================================================================ // Path Functions @@ -29,3 +59,1150 @@ pub fn get_hermes_config_path() -> PathBuf { get_hermes_dir().join("config.yaml") } +fn hermes_write_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +// ============================================================================ +// Type Definitions +// ============================================================================ + +/// Hermes 健康检查警告 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct HermesHealthWarning { + pub code: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +/// Hermes 写入结果 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct HermesWriteOutcome { + #[serde(skip_serializing_if = "Option::is_none")] + pub backup_path: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, +} + +/// Hermes model section config +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HermesModelConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub base_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub context_length: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + /// Preserve unknown fields for forward compatibility + #[serde(flatten)] + pub extra: HashMap, +} + +/// Hermes agent section config (agent + approvals) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HermesAgentConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub max_turns: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_use_enforcement: Option, + /// Preserve unknown fields for forward compatibility + #[serde(flatten)] + pub extra: HashMap, +} + +/// Hermes env config (from .env file, not config.yaml) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HermesEnvConfig { + #[serde(flatten)] + pub vars: HashMap, +} + +// ============================================================================ +// Core YAML Read Functions +// ============================================================================ + +/// 读取 Hermes 配置文件为 serde_yaml::Value +/// +/// 如果文件不存在,返回空 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))?; + if content.trim().is_empty() { + return Ok(serde_yaml::Value::Mapping(serde_yaml::Mapping::new())); + } + + serde_yaml::from_str(&content) + .map_err(|e| AppError::Config(format!("Failed to parse Hermes config as YAML: {e}"))) +} + +// ============================================================================ +// YAML Section-Level Replacement +// ============================================================================ + +/// Check if a line is a YAML top-level key (mapping key at column 0). +/// +/// A top-level key line must: +/// - Start at column 0 (no leading whitespace) +/// - Not be empty or whitespace-only +/// - Not be a comment (starting with `#`) +/// - Not be a sequence item (starting with `-`) +/// - Contain `:` followed by space, tab, newline, or end-of-line +fn is_top_level_key_line(line: &str) -> bool { + if line.is_empty() { + return false; + } + let first_char = line.as_bytes()[0]; + if first_char == b' ' || first_char == b'\t' || first_char == b'#' || first_char == b'-' { + return false; + } + if let Some(colon_pos) = line.find(':') { + let after_colon = &line[colon_pos + 1..]; + after_colon.is_empty() + || after_colon.starts_with(' ') + || after_colon.starts_with('\t') + } else { + false + } +} + +/// Find the byte range of a top-level YAML section. +/// +/// A YAML top-level key is a line that starts at column 0 (no leading +/// whitespace), is not a comment, and contains `:` after the key name. +/// +/// Returns `(start_byte_inclusive, end_byte_exclusive)` or `None` if not found. +fn find_yaml_section_range(raw: &str, section_key: &str) -> Option<(usize, usize)> { + let target = format!("{}:", section_key); + let mut section_start = None; + let mut offset = 0; + + for line in raw.split('\n') { + if section_start.is_none() && is_top_level_key_line(line) && line.starts_with(&target) { + // Verify exact match: after "key:" must be whitespace or EOL + let after_target = &line[target.len()..]; + if after_target.is_empty() + || after_target.starts_with(' ') + || after_target.starts_with('\t') + || after_target.starts_with('\r') + { + section_start = Some(offset); + } + } else if section_start.is_some() && is_top_level_key_line(line) { + // Found the next top-level key — this is the end of our section + return Some((section_start.unwrap(), offset)); + } + offset += line.len() + 1; // +1 for the \n + } + + // Section extends to end of file + section_start.map(|start| (start, raw.len())) +} + +/// Serialize a section key + value into a YAML fragment like: +/// +/// ```yaml +/// model: +/// default: "anthropic/claude-opus-4-6" +/// provider: "openrouter" +/// ``` +fn serialize_yaml_section(key: &str, value: &serde_yaml::Value) -> Result { + let mut section = serde_yaml::Mapping::new(); + section.insert( + serde_yaml::Value::String(key.to_string()), + value.clone(), + ); + let yaml_str = serde_yaml::to_string(&serde_yaml::Value::Mapping(section)) + .map_err(|e| AppError::Config(format!("Failed to serialize YAML section '{key}': {e}")))?; + Ok(yaml_str) +} + +/// Replace a YAML section in raw text, or append it if not found. +fn replace_yaml_section( + raw: &str, + section_key: &str, + value: &serde_yaml::Value, +) -> Result { + let serialized = serialize_yaml_section(section_key, value)?; + + if let Some((start, end)) = find_yaml_section_range(raw, section_key) { + let mut result = String::with_capacity(raw.len()); + result.push_str(&raw[..start]); + result.push_str(&serialized); + // Ensure proper separation between sections + let remainder = &raw[end..]; + if !serialized.ends_with('\n') && !remainder.is_empty() && !remainder.starts_with('\n') { + result.push('\n'); + } + result.push_str(remainder); + Ok(result) + } else { + // Section not found — append at end + let mut result = raw.to_string(); + if !result.is_empty() && !result.ends_with('\n') { + result.push('\n'); + } + result.push_str(&serialized); + if !result.ends_with('\n') { + result.push('\n'); + } + Ok(result) + } +} + +// ============================================================================ +// Backup & Cleanup +// ============================================================================ + +fn create_hermes_backup(source: &str) -> Result { + let backup_dir = get_app_config_dir().join("backups").join("hermes"); + fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; + + let base_id = format!("hermes_{}", Local::now().format("%Y%m%d_%H%M%S")); + let mut filename = format!("{base_id}.yaml"); + let mut backup_path = backup_dir.join(&filename); + let mut counter = 1; + + while backup_path.exists() { + filename = format!("{base_id}_{counter}.yaml"); + backup_path = backup_dir.join(&filename); + counter += 1; + } + + atomic_write(&backup_path, source.as_bytes())?; + cleanup_hermes_backups(&backup_dir)?; + Ok(backup_path) +} + +fn cleanup_hermes_backups(dir: &Path) -> Result<(), AppError> { + let retain = effective_backup_retain_count(); + let mut entries = fs::read_dir(dir) + .map_err(|e| AppError::io(dir, e))? + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry + .path() + .extension() + .map(|ext| ext == "yaml" || ext == "yml") + .unwrap_or(false) + }) + .collect::>(); + + if entries.len() <= retain { + return Ok(()); + } + + entries.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok()); + let remove_count = entries.len().saturating_sub(retain); + for entry in entries.into_iter().take(remove_count) { + if let Err(err) = fs::remove_file(entry.path()) { + log::warn!( + "Failed to remove old Hermes config backup {}: {err}", + entry.path().display() + ); + } + } + + Ok(()) +} + +// ============================================================================ +// High-level Write Helper +// ============================================================================ + +/// Write a single top-level YAML section to config.yaml using section-level replacement. +/// +/// This preserves comments and unrelated sections while only modifying the +/// target section. +fn write_yaml_section_to_config( + section_key: &str, + value: &serde_yaml::Value, +) -> Result { + let _guard = hermes_write_lock().lock()?; + write_yaml_section_to_config_locked(section_key, value) +} + +/// Inner write helper — caller must already hold the write lock. +fn write_yaml_section_to_config_locked( + section_key: &str, + value: &serde_yaml::Value, +) -> Result { + let config_path = get_hermes_config_path(); + let raw = if config_path.exists() { + fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))? + } else { + String::new() + }; + + let new_raw = replace_yaml_section(&raw, section_key, value)?; + + if new_raw == raw { + return Ok(HermesWriteOutcome::default()); + } + + let backup_path = if !raw.is_empty() { + Some(create_hermes_backup(&raw)?) + } else { + None + }; + + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + + atomic_write(&config_path, new_raw.as_bytes())?; + + let warnings = scan_hermes_health_internal(&new_raw); + + log::debug!("Hermes config section '{section_key}' written to {:?}", config_path); + Ok(HermesWriteOutcome { + backup_path: backup_path.map(|p| p.display().to_string()), + warnings, + }) +} + +// ============================================================================ +// Provider Functions +// ============================================================================ + +/// Get all custom providers as a JSON map keyed by provider name. +/// +/// The `custom_providers` section is a YAML sequence where each item has a +/// `name` field. This function converts it to a map for CC Switch consumption. +pub fn get_providers() -> Result, AppError> { + let config = read_hermes_config()?; + let providers = config + .get("custom_providers") + .and_then(|v| v.as_sequence()) + .map(|seq| { + let mut map = serde_json::Map::new(); + for item in seq { + if let Some(name) = item.get("name").and_then(|n| n.as_str()) { + match yaml_to_json(item) { + Ok(json_val) => { + map.insert(name.to_string(), json_val); + } + Err(e) => { + log::warn!("Failed to convert Hermes provider '{name}' to JSON: {e}"); + } + } + } + } + map + }) + .unwrap_or_default(); + Ok(providers) +} + +/// Get a single custom provider by name. +pub fn get_provider(name: &str) -> Result, AppError> { + Ok(get_providers()?.get(name).cloned()) +} + +/// Set (upsert) a custom provider by name. +/// +/// If a provider with the same name exists, it is replaced; otherwise a new +/// entry is appended. The entire read-modify-write is done under the write +/// lock to prevent TOCTOU races. +pub fn set_provider( + name: &str, + provider_config: serde_json::Value, +) -> Result { + let _guard = hermes_write_lock().lock()?; + + let config = read_hermes_config()?; + let mut providers: Vec = config + .get("custom_providers") + .and_then(|v| v.as_sequence()) + .cloned() + .unwrap_or_default(); + + let mut yaml_val: serde_yaml::Value = json_to_yaml(&provider_config)?; + if let serde_yaml::Value::Mapping(ref mut m) = yaml_val { + m.insert( + serde_yaml::Value::String("name".to_string()), + serde_yaml::Value::String(name.to_string()), + ); + } + + if let Some(existing) = providers.iter_mut().find(|p| { + p.get("name") + .and_then(|n| n.as_str()) + == Some(name) + }) { + *existing = yaml_val; + } else { + providers.push(yaml_val); + } + + let providers_value = serde_yaml::Value::Sequence(providers); + write_yaml_section_to_config_locked("custom_providers", &providers_value) +} + +/// Remove a custom provider by name. +/// +/// The entire read-modify-write is done under the write lock to prevent +/// TOCTOU races. +pub fn remove_provider(name: &str) -> Result { + let _guard = hermes_write_lock().lock()?; + + let config = read_hermes_config()?; + let mut providers: Vec = config + .get("custom_providers") + .and_then(|v| v.as_sequence()) + .cloned() + .unwrap_or_default(); + + let original_len = providers.len(); + providers.retain(|p| { + p.get("name") + .and_then(|n| n.as_str()) + != Some(name) + }); + + if providers.len() == original_len { + return Ok(HermesWriteOutcome::default()); + } + + let providers_value = serde_yaml::Value::Sequence(providers); + write_yaml_section_to_config_locked("custom_providers", &providers_value) +} + +// ============================================================================ +// Model Config Functions +// ============================================================================ + +/// Get the `model` section as a typed config. +pub fn get_model_config() -> Result, AppError> { + let config = read_hermes_config()?; + let Some(model_value) = config.get("model") else { + return Ok(None); + }; + let json_val = yaml_to_json(model_value)?; + let model = serde_json::from_value(json_val) + .map_err(|e| AppError::Config(format!("Failed to parse Hermes model config: {e}")))?; + Ok(Some(model)) +} + +/// Set the `model` section. +pub fn set_model_config(model: &HermesModelConfig) -> Result { + let json_val = + serde_json::to_value(model).map_err(|e| AppError::JsonSerialize { source: e })?; + let yaml_val = json_to_yaml(&json_val)?; + write_yaml_section_to_config("model", &yaml_val) +} + +// ============================================================================ +// Agent Config Functions +// ============================================================================ + +/// Get the `agent` section as a typed config. +pub fn get_agent_config() -> Result, AppError> { + let config = read_hermes_config()?; + let Some(agent_value) = config.get("agent") else { + return Ok(None); + }; + let json_val = yaml_to_json(agent_value)?; + let agent = serde_json::from_value(json_val) + .map_err(|e| AppError::Config(format!("Failed to parse Hermes agent config: {e}")))?; + Ok(Some(agent)) +} + +/// Set the `agent` section. +pub fn set_agent_config(agent: &HermesAgentConfig) -> Result { + let json_val = + serde_json::to_value(agent).map_err(|e| AppError::JsonSerialize { source: e })?; + let yaml_val = json_to_yaml(&json_val)?; + write_yaml_section_to_config("agent", &yaml_val) +} + +// ============================================================================ +// .env Functions +// ============================================================================ + +/// Read the Hermes `.env` file (`~/.hermes/.env`). +/// +/// Parses dotenv format (KEY=VALUE, `#` comments, blank lines). +pub fn read_env() -> Result { + let path = get_hermes_dir().join(".env"); + if !path.exists() { + return Ok(HermesEnvConfig { + vars: HashMap::new(), + }); + } + let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; + let mut vars = HashMap::new(); + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + if let Some((key, value)) = trimmed.split_once('=') { + let key = key.trim().to_string(); + let value = value + .trim() + .trim_matches('"') + .trim_matches('\'') + .to_string(); + vars.insert(key, serde_json::Value::String(value)); + } + } + Ok(HermesEnvConfig { vars }) +} + +/// Write the Hermes `.env` file (`~/.hermes/.env`). +/// +/// Preserves comment lines and ordering. Keys not present in the new env are +/// removed; new keys are appended. +pub fn write_env(env: &HermesEnvConfig) -> Result { + let path = get_hermes_dir().join(".env"); + let _guard = hermes_write_lock().lock()?; + + // Read existing file to preserve comments and ordering + let existing_content = if path.exists() { + fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))? + } else { + String::new() + }; + + // Build new content: preserve comment lines, update/add key-value pairs + let mut remaining_keys: HashSet = env.vars.keys().cloned().collect(); + let mut lines: Vec = Vec::new(); + + for line in existing_content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + lines.push(line.to_string()); + continue; + } + if let Some((key, _)) = trimmed.split_once('=') { + let key = key.trim(); + if let Some(new_value) = env.vars.get(key) { + // Update existing key + let value_str = json_value_as_env_str(new_value); + lines.push(format!("{key}={value_str}")); + remaining_keys.remove(key); + } + // If key is not in new env, it's deleted (don't add it) + } else { + lines.push(line.to_string()); + } + } + + // Add new keys that weren't in the original file (sorted for determinism) + let mut new_keys: Vec = remaining_keys.into_iter().collect(); + new_keys.sort(); + for key in new_keys { + if let Some(value) = env.vars.get(&key) { + let value_str = json_value_as_env_str(value); + lines.push(format!("{key}={value_str}")); + } + } + + let mut new_content = lines.join("\n"); + if !new_content.is_empty() && !new_content.ends_with('\n') { + new_content.push('\n'); + } + + if new_content == existing_content { + return Ok(HermesWriteOutcome::default()); + } + + // Backup if file existed with content + let backup_path = if !existing_content.is_empty() { + Some(create_hermes_backup(&existing_content)?) + } else { + None + }; + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + atomic_write(&path, new_content.as_bytes())?; + + Ok(HermesWriteOutcome { + backup_path: backup_path.map(|p| p.display().to_string()), + warnings: Vec::new(), + }) +} + +/// Convert a serde_json::Value to a string suitable for .env files. +fn json_value_as_env_str(value: &serde_json::Value) -> String { + match value { + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +// ============================================================================ +// Health Check +// ============================================================================ + +/// Scan Hermes config for known configuration hazards. +/// +/// Parse failures are reported as warnings (not errors) so the UI can +/// display them without blocking. +pub fn scan_hermes_config_health() -> Result, AppError> { + let path = get_hermes_config_path(); + if !path.exists() { + return Ok(Vec::new()); + } + let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; + Ok(scan_hermes_health_internal(&content)) +} + +fn scan_hermes_health_internal(content: &str) -> Vec { + let mut warnings = Vec::new(); + + if content.trim().is_empty() { + return warnings; + } + + match serde_yaml::from_str::(content) { + Ok(config) => { + // Check for model section issues + if let Some(model) = config.get("model") { + if model.get("default").is_none() && model.get("provider").is_none() { + warnings.push(HermesHealthWarning { + code: "model_no_default".to_string(), + message: "No default model or provider configured in 'model' section" + .to_string(), + path: Some("model".to_string()), + }); + } + } + + // Check custom_providers is a sequence + if let Some(providers) = config.get("custom_providers") { + if !providers.is_sequence() { + warnings.push(HermesHealthWarning { + code: "custom_providers_not_list".to_string(), + message: + "custom_providers should be a YAML list (sequence), not a mapping" + .to_string(), + path: Some("custom_providers".to_string()), + }); + } + } + } + Err(err) => { + warnings.push(HermesHealthWarning { + code: "config_parse_failed".to_string(), + message: format!("Hermes config could not be parsed as YAML: {err}"), + path: Some(get_hermes_config_path().display().to_string()), + }); + } + } + + warnings +} + +// ============================================================================ +// MCP Section Access (for mcp/hermes.rs to use in Phase 4) +// ============================================================================ + +/// Get the `mcp_servers` section as a YAML Mapping. +#[allow(dead_code)] +pub fn get_mcp_servers_yaml() -> Result { + let config = read_hermes_config()?; + Ok(config + .get("mcp_servers") + .and_then(|v| v.as_mapping()) + .cloned() + .unwrap_or_default()) +} + +/// Set the `mcp_servers` section. +#[allow(dead_code)] +pub fn set_mcp_servers_yaml(servers: &serde_yaml::Mapping) -> Result<(), AppError> { + let value = serde_yaml::Value::Mapping(servers.clone()); + write_yaml_section_to_config("mcp_servers", &value)?; + Ok(()) +} + +// ============================================================================ +// YAML ↔ JSON Conversion Helpers +// ============================================================================ + +/// Convert a `serde_yaml::Value` to a `serde_json::Value`. +pub(crate) fn yaml_to_json(yaml: &serde_yaml::Value) -> Result { + // Serialize YAML value to string, then parse as JSON value. + // This handles all type mappings correctly. + let yaml_str = serde_yaml::to_string(yaml) + .map_err(|e| AppError::Config(format!("Failed to serialize YAML value: {e}")))?; + serde_yaml::from_str::(&yaml_str) + .map_err(|e| AppError::Config(format!("Failed to convert YAML to JSON: {e}"))) +} + +/// Convert a `serde_json::Value` to a `serde_yaml::Value`. +fn json_to_yaml(json: &serde_json::Value) -> Result { + let json_str = serde_json::to_string(json) + .map_err(|e| AppError::Config(format!("Failed to serialize JSON value: {e}")))?; + serde_yaml::from_str(&json_str) + .map_err(|e| AppError::Config(format!("Failed to convert JSON to YAML: {e}"))) +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::sync::{Mutex, OnceLock}; + + fn test_guard() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|err| err.into_inner()) + } + + /// Run a test with an isolated temp home directory. + /// + /// Saves and restores `CC_SWITCH_TEST_HOME` to avoid interfering with + /// parallel tests in other modules. + fn with_test_home(test_fn: impl FnOnce() -> T) -> T { + let _guard = test_guard(); + let tmp = tempfile::tempdir().unwrap(); + let old_test_home = std::env::var_os("CC_SWITCH_TEST_HOME"); + std::env::set_var("CC_SWITCH_TEST_HOME", tmp.path()); + let result = test_fn(); + match old_test_home { + Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value), + None => std::env::remove_var("CC_SWITCH_TEST_HOME"), + } + result + } + + // ---- find_yaml_section_range tests ---- + + #[test] + fn find_section_in_multi_section_yaml() { + let yaml = "\ +model: + default: gpt-4 + provider: openai +agent: + max_turns: 10 +custom_providers: + - name: foo +"; + let (start, end) = find_yaml_section_range(yaml, "agent").unwrap(); + let section = &yaml[start..end]; + assert!(section.starts_with("agent:")); + assert!(section.contains("max_turns")); + assert!(!section.contains("custom_providers")); + } + + #[test] + fn find_section_at_end_of_file() { + let yaml = "\ +model: + default: gpt-4 +agent: + max_turns: 10 +"; + let (start, end) = find_yaml_section_range(yaml, "agent").unwrap(); + let section = &yaml[start..end]; + assert!(section.starts_with("agent:")); + assert!(section.contains("max_turns")); + assert_eq!(end, yaml.len()); + } + + #[test] + fn find_section_not_found() { + let yaml = "\ +model: + default: gpt-4 +"; + assert!(find_yaml_section_range(yaml, "agent").is_none()); + } + + #[test] + fn find_section_with_comments_between() { + let yaml = "\ +model: + default: gpt-4 + +# This is a comment + # indented comment + +agent: + max_turns: 10 +"; + // model section should span from start to "agent:" + let (start, end) = find_yaml_section_range(yaml, "model").unwrap(); + let section = &yaml[start..end]; + assert!(section.starts_with("model:")); + // Comments and blank lines between sections are included in the prior section + assert!(section.contains("# This is a comment")); + } + + #[test] + fn find_section_with_empty_lines() { + let yaml = "\ +model: + default: gpt-4 + +agent: + max_turns: 10 +"; + let (start, end) = find_yaml_section_range(yaml, "model").unwrap(); + let section = &yaml[start..end]; + assert!(section.starts_with("model:")); + // Empty lines don't terminate a section + assert!(section.contains('\n')); + } + + #[test] + fn find_section_does_not_match_substring_key() { + let yaml = "\ +model_extra: + foo: bar +model: + default: gpt-4 +"; + let (start, _end) = find_yaml_section_range(yaml, "model").unwrap(); + let section = &yaml[start..]; + // Should match "model:", not "model_extra:" + assert!(section.starts_with("model:")); + assert!(!section.starts_with("model_extra:")); + } + + // ---- replace_yaml_section tests ---- + + #[test] + fn replace_existing_section() { + let yaml = "\ +model: + default: gpt-4 + provider: openai +agent: + max_turns: 10 +"; + let new_model = serde_yaml::Value::Mapping({ + let mut m = serde_yaml::Mapping::new(); + m.insert( + serde_yaml::Value::String("default".to_string()), + serde_yaml::Value::String("claude-opus-4-6".to_string()), + ); + m.insert( + serde_yaml::Value::String("provider".to_string()), + serde_yaml::Value::String("anthropic".to_string()), + ); + m + }); + + let result = replace_yaml_section(yaml, "model", &new_model).unwrap(); + // The result should still contain the agent section + assert!(result.contains("agent:")); + assert!(result.contains("max_turns")); + // And the model section should be updated + assert!(result.contains("claude-opus-4-6")); + assert!(result.contains("anthropic")); + assert!(!result.contains("gpt-4")); + assert!(!result.contains("openai")); + } + + #[test] + fn append_new_section() { + let yaml = "\ +model: + default: gpt-4 +"; + let new_agent = serde_yaml::Value::Mapping({ + let mut m = serde_yaml::Mapping::new(); + m.insert( + serde_yaml::Value::String("max_turns".to_string()), + serde_yaml::Value::Number(serde_yaml::Number::from(50)), + ); + m + }); + + let result = replace_yaml_section(yaml, "agent", &new_agent).unwrap(); + assert!(result.contains("model:")); + assert!(result.contains("gpt-4")); + assert!(result.contains("agent:")); + assert!(result.contains("max_turns: 50")); + } + + #[test] + fn replace_section_in_empty_file() { + let yaml = ""; + let new_model = serde_yaml::Value::Mapping({ + let mut m = serde_yaml::Mapping::new(); + m.insert( + serde_yaml::Value::String("default".to_string()), + serde_yaml::Value::String("gpt-4".to_string()), + ); + m + }); + + let result = replace_yaml_section(yaml, "model", &new_model).unwrap(); + assert!(result.contains("model:")); + assert!(result.contains("gpt-4")); + assert!(result.ends_with('\n')); + } + + // ---- Provider CRUD via mock config ---- + + #[test] + #[serial] + fn provider_crud_roundtrip() { + with_test_home(|| { + // Initially no providers + let providers = get_providers().unwrap(); + assert!(providers.is_empty()); + + // Add a provider + let config = serde_json::json!({ + "base_url": "https://openrouter.ai/api/v1", + "api_key": "sk-or-test" + }); + set_provider("openrouter", config).unwrap(); + + let providers = get_providers().unwrap(); + assert_eq!(providers.len(), 1); + assert!(providers.contains_key("openrouter")); + + let provider = get_provider("openrouter").unwrap().unwrap(); + assert_eq!(provider["base_url"], "https://openrouter.ai/api/v1"); + assert_eq!(provider["name"], "openrouter"); + + // Update the provider + let config2 = serde_json::json!({ + "base_url": "https://openrouter.ai/api/v2", + "api_key": "sk-or-updated" + }); + set_provider("openrouter", config2).unwrap(); + + let provider = get_provider("openrouter").unwrap().unwrap(); + assert_eq!(provider["base_url"], "https://openrouter.ai/api/v2"); + + // Remove the provider + remove_provider("openrouter").unwrap(); + let providers = get_providers().unwrap(); + assert!(providers.is_empty()); + }); + } + + // ---- .env read/write tests ---- + + #[test] + #[serial] + fn env_read_write_roundtrip() { + with_test_home(|| { + // Write initial env + let env = HermesEnvConfig { + vars: { + let mut m = HashMap::new(); + m.insert( + "API_KEY".to_string(), + serde_json::Value::String("sk-test-123".to_string()), + ); + m.insert( + "DEBUG".to_string(), + serde_json::Value::String("true".to_string()), + ); + m + }, + }; + write_env(&env).unwrap(); + + // Read back + let env_read = read_env().unwrap(); + assert_eq!( + env_read.vars.get("API_KEY").unwrap().as_str().unwrap(), + "sk-test-123" + ); + assert_eq!( + env_read.vars.get("DEBUG").unwrap().as_str().unwrap(), + "true" + ); + + // Update: remove DEBUG, add NEW_VAR + let env2 = HermesEnvConfig { + vars: { + let mut m = HashMap::new(); + m.insert( + "API_KEY".to_string(), + serde_json::Value::String("sk-test-456".to_string()), + ); + m.insert( + "NEW_VAR".to_string(), + serde_json::Value::String("hello".to_string()), + ); + m + }, + }; + write_env(&env2).unwrap(); + + let env_read2 = read_env().unwrap(); + assert_eq!( + env_read2.vars.get("API_KEY").unwrap().as_str().unwrap(), + "sk-test-456" + ); + assert!(env_read2.vars.get("DEBUG").is_none()); + assert_eq!( + env_read2.vars.get("NEW_VAR").unwrap().as_str().unwrap(), + "hello" + ); + }); + } + + #[test] + #[serial] + fn env_preserves_comments() { + with_test_home(|| { + let hermes_dir = get_hermes_dir(); + fs::create_dir_all(&hermes_dir).unwrap(); + let env_path = hermes_dir.join(".env"); + fs::write( + &env_path, + "# Hermes environment config\nAPI_KEY=old-key\n# Keep this comment\nDEBUG=true\n", + ) + .unwrap(); + + let env = HermesEnvConfig { + vars: { + let mut m = HashMap::new(); + m.insert( + "API_KEY".to_string(), + serde_json::Value::String("new-key".to_string()), + ); + m.insert( + "DEBUG".to_string(), + serde_json::Value::String("false".to_string()), + ); + m + }, + }; + write_env(&env).unwrap(); + + let content = fs::read_to_string(&env_path).unwrap(); + assert!(content.contains("# Hermes environment config")); + assert!(content.contains("# Keep this comment")); + assert!(content.contains("API_KEY=new-key")); + assert!(content.contains("DEBUG=false")); + }); + } + + // ---- Model/Agent config tests ---- + + #[test] + #[serial] + fn model_config_roundtrip() { + with_test_home(|| { + // Initially none + assert!(get_model_config().unwrap().is_none()); + + let model = HermesModelConfig { + default: Some("anthropic/claude-opus-4-6".to_string()), + provider: Some("openrouter".to_string()), + base_url: Some("https://openrouter.ai/api/v1".to_string()), + context_length: Some(200000), + max_tokens: None, + extra: HashMap::new(), + }; + set_model_config(&model).unwrap(); + + let read_model = get_model_config().unwrap().unwrap(); + assert_eq!( + read_model.default.as_deref(), + Some("anthropic/claude-opus-4-6") + ); + assert_eq!(read_model.provider.as_deref(), Some("openrouter")); + assert_eq!(read_model.context_length, Some(200000)); + }); + } + + #[test] + #[serial] + fn agent_config_roundtrip() { + with_test_home(|| { + assert!(get_agent_config().unwrap().is_none()); + + let agent = HermesAgentConfig { + max_turns: Some(50), + reasoning_effort: Some("high".to_string()), + tool_use_enforcement: None, + extra: HashMap::new(), + }; + set_agent_config(&agent).unwrap(); + + let read_agent = get_agent_config().unwrap().unwrap(); + assert_eq!(read_agent.max_turns, Some(50)); + assert_eq!(read_agent.reasoning_effort.as_deref(), Some("high")); + }); + } + + // ---- Health check tests ---- + + #[test] + fn health_check_on_invalid_yaml() { + let warnings = scan_hermes_health_internal("not: valid: yaml: ["); + assert!(!warnings.is_empty()); + assert_eq!(warnings[0].code, "config_parse_failed"); + } + + #[test] + fn health_check_model_no_default() { + let yaml = "model:\n context_length: 200000\n"; + let warnings = scan_hermes_health_internal(yaml); + assert!(warnings.iter().any(|w| w.code == "model_no_default")); + } + + #[test] + fn health_check_custom_providers_not_list() { + let yaml = "custom_providers:\n foo:\n base_url: http://localhost\n"; + let warnings = scan_hermes_health_internal(yaml); + assert!(warnings + .iter() + .any(|w| w.code == "custom_providers_not_list")); + } + + #[test] + fn health_check_valid_config() { + let yaml = "\ +model: + default: gpt-4 + provider: openai +custom_providers: + - name: openrouter + base_url: https://openrouter.ai/api/v1 +"; + let warnings = scan_hermes_health_internal(yaml); + assert!(warnings.is_empty()); + } + + // ---- yaml_to_json / json_to_yaml ---- + + #[test] + fn yaml_json_conversion_roundtrip() { + let json = serde_json::json!({ + "name": "test", + "count": 42, + "nested": { + "flag": true + } + }); + let yaml = json_to_yaml(&json).unwrap(); + let back = yaml_to_json(&yaml).unwrap(); + assert_eq!(json, back); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b6cc0b973..5788a2f0a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -541,6 +541,13 @@ pub fn run() { Ok(_) => log::debug!("○ No new OpenClaw providers to import"), Err(e) => log::warn!("✗ Failed to import OpenClaw providers: {e}"), } + match crate::services::provider::import_hermes_providers_from_live(&app_state) { + Ok(count) if count > 0 => { + log::info!("✓ Imported {count} Hermes provider(s) from live config"); + } + Ok(_) => log::debug!("○ No new Hermes providers to import"), + Err(e) => log::warn!("✗ Failed to import Hermes providers: {e}"), + } // 2. OMO 配置导入(当数据库中无 OMO provider 时,从本地文件导入) { @@ -1236,6 +1243,17 @@ pub fn run() { commands::set_openclaw_env, commands::get_openclaw_tools, commands::set_openclaw_tools, + // Hermes specific + commands::import_hermes_providers_from_live, + commands::get_hermes_live_provider_ids, + commands::get_hermes_live_provider, + commands::scan_hermes_config_health, + commands::get_hermes_model_config, + commands::set_hermes_model_config, + commands::get_hermes_agent_config, + commands::set_hermes_agent_config, + commands::get_hermes_env, + commands::set_hermes_env, // Global upstream proxy commands::get_global_proxy_url, commands::set_global_proxy_url, diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index ac7492fe6..e50fd1023 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -42,10 +42,8 @@ pub(crate) fn provider_exists_in_live_config( .map(|providers| providers.contains_key(provider_id)), AppType::OpenClaw => crate::openclaw_config::get_providers() .map(|providers| providers.contains_key(provider_id)), - AppType::Hermes => { - // TODO: hermes_config module not yet implemented - Ok(false) - } + AppType::Hermes => crate::hermes_config::get_providers() + .map(|providers| providers.contains_key(provider_id)), _ => Ok(false), } } @@ -795,11 +793,8 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re } } AppType::Hermes => { - // TODO: hermes_config module not yet implemented - log::debug!( - "Hermes provider '{}' write to live config not yet implemented", - provider.id - ); + crate::hermes_config::set_provider(&provider.id, provider.settings_config.clone())?; + log::debug!("Hermes provider '{}' written to live config", provider.id); } } Ok(()) @@ -1005,8 +1000,9 @@ pub fn read_live_settings(app_type: AppType) -> Result { "Hermes configuration file not found", )); } - // Return empty object until hermes_config is implemented - Ok(json!({})) + let yaml_config = crate::hermes_config::read_hermes_config()?; + let config = crate::hermes_config::yaml_to_json(&yaml_config)?; + Ok(config) } } } @@ -1339,6 +1335,76 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result Result { + use crate::hermes_config; + + let providers = hermes_config::get_providers()?; + if providers.is_empty() { + return Ok(0); + } + + let mut imported = 0; + let existing_ids = state.db.get_provider_ids("hermes")?; + + for (name, config) in providers { + // Validate: skip entries with empty name + if name.trim().is_empty() { + log::warn!("Skipping Hermes provider with empty name"); + continue; + } + + // Skip if already exists in database + if existing_ids.contains(&name) { + log::debug!("Hermes provider '{name}' already exists in database, skipping"); + continue; + } + + // Create provider + let mut provider = Provider::with_id(name.clone(), name.clone(), config, None); + provider.meta = Some(crate::provider::ProviderMeta { + live_config_managed: Some(true), + ..Default::default() + }); + + // Save to database + if let Err(e) = state.db.save_provider("hermes", &provider) { + log::warn!("Failed to import Hermes provider '{name}': {e}"); + continue; + } + + imported += 1; + log::info!("Imported Hermes provider '{name}' from live config"); + } + + Ok(imported) +} + +/// Remove a Hermes provider from live config +/// +/// This removes a specific provider from ~/.hermes/config.yaml +/// without affecting other providers in the file. +pub fn remove_hermes_provider_from_live(provider_id: &str) -> Result<(), AppError> { + use crate::hermes_config; + + // Check if Hermes config directory exists + if !hermes_config::get_hermes_dir().exists() { + log::debug!( + "Hermes config directory doesn't exist, skipping removal of '{provider_id}'" + ); + return Ok(()); + } + + hermes_config::remove_provider(provider_id)?; + log::info!("Hermes provider '{provider_id}' removed from live config"); + + Ok(()) +} + /// Remove an OpenClaw provider from live config /// /// This removes a specific provider from ~/.openclaw/openclaw.json diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index d8dcce3fe..cae1cd5b9 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -21,8 +21,9 @@ use crate::store::AppState; // Re-export sub-module functions for external access pub use live::{ - import_default_config, import_openclaw_providers_from_live, - import_opencode_providers_from_live, read_live_settings, sync_current_to_live, + import_default_config, import_hermes_providers_from_live, + import_openclaw_providers_from_live, import_opencode_providers_from_live, read_live_settings, + sync_current_to_live, }; // Internal re-exports (pub(crate)) @@ -35,7 +36,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; @@ -1282,12 +1284,7 @@ impl ProviderService { match app_type { AppType::OpenCode => remove_opencode_provider_from_live(id)?, AppType::OpenClaw => remove_openclaw_provider_from_live(id)?, - AppType::Hermes => { - // TODO: hermes_config module not yet implemented - log::debug!( - "Hermes provider '{id}' removal from live config not yet implemented" - ); - } + AppType::Hermes => remove_hermes_provider_from_live(id)?, _ => {} } } @@ -1351,8 +1348,7 @@ impl ProviderService { remove_openclaw_provider_from_live(id)?; } AppType::Hermes => { - // TODO: hermes_config module not yet implemented - log::debug!("Hermes provider '{id}' removal from live config not yet implemented"); + remove_hermes_provider_from_live(id)?; } _ => { return Err(AppError::Message(format!( @@ -1542,10 +1538,7 @@ impl ProviderService { let rollback_result = match app_type { AppType::OpenCode => remove_opencode_provider_from_live(&provider.id), AppType::OpenClaw => remove_openclaw_provider_from_live(&provider.id), - AppType::Hermes => { - // TODO: hermes_config module not yet implemented - Ok(()) - } + AppType::Hermes => remove_hermes_provider_from_live(&provider.id), _ => Ok(()), }; From 576ff53a7554bf6df2c626c1a05d6b7e66653bf8 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 15 Apr 2026 17:33:13 +0800 Subject: [PATCH 013/156] feat: implement Hermes MCP sync module (Phase 4) Add mcp/hermes.rs with bidirectional MCP format conversion: - convert_to_hermes_format: strip type field, infer from command/url - convert_from_hermes_format: infer type, strip Hermes-specific fields - Merge-on-write: existing Hermes fields (tools, sampling, timeout, roots, enabled) preserved when user has customized them - update_mcp_servers_yaml: closure-based read-modify-write under write lock to prevent TOCTOU races in concurrent sync operations - 9 unit tests for format conversion and merge logic Wire up all MCP service dispatch: - Replace Hermes TODO stubs with real sync/remove calls - Remove Hermes from sync_all_enabled skip list - Enable deep link hermes MCP flag (apps.hermes = true) - Add Hermes import to import_mcp_from_apps command --- src-tauri/src/commands/mcp.rs | 1 + src-tauri/src/deeplink/mcp.rs | 5 +- src-tauri/src/hermes_config.rs | 25 +- src-tauri/src/mcp/hermes.rs | 531 +++++++++++++++++++++++++++++++++ src-tauri/src/mcp/mod.rs | 3 + src-tauri/src/services/mcp.rs | 53 +++- 6 files changed, 602 insertions(+), 16 deletions(-) create mode 100644 src-tauri/src/mcp/hermes.rs diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs index 8963584c8..5d2558e91 100644 --- a/src-tauri/src/commands/mcp.rs +++ b/src-tauri/src/commands/mcp.rs @@ -202,5 +202,6 @@ pub async fn import_mcp_from_apps(state: State<'_, AppState>) -> Result Result { // OpenClaw doesn't support MCP, ignore silently log::debug!("OpenClaw doesn't support MCP, ignoring in apps parameter"); } - "hermes" => { - // TODO: Hermes MCP sync not yet implemented, ignore silently for now - log::debug!("Hermes MCP sync not yet implemented, ignoring in apps parameter"); - } + "hermes" => apps.hermes = true, other => { return Err(AppError::InvalidInput(format!( "Invalid app in 'apps': {other}" diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs index 4957ddf8d..49a74812a 100644 --- a/src-tauri/src/hermes_config.rs +++ b/src-tauri/src/hermes_config.rs @@ -715,7 +715,6 @@ fn scan_hermes_health_internal(content: &str) -> Vec { // ============================================================================ /// Get the `mcp_servers` section as a YAML Mapping. -#[allow(dead_code)] pub fn get_mcp_servers_yaml() -> Result { let config = read_hermes_config()?; Ok(config @@ -725,11 +724,23 @@ pub fn get_mcp_servers_yaml() -> Result { .unwrap_or_default()) } -/// Set the `mcp_servers` section. -#[allow(dead_code)] -pub fn set_mcp_servers_yaml(servers: &serde_yaml::Mapping) -> Result<(), AppError> { - let value = serde_yaml::Value::Mapping(servers.clone()); - write_yaml_section_to_config("mcp_servers", &value)?; +/// Atomically read-modify-write the `mcp_servers` section under the write lock. +/// +/// Prevents TOCTOU races when multiple sync operations run concurrently. +pub fn update_mcp_servers_yaml(updater: F) -> Result<(), AppError> +where + F: FnOnce(&mut serde_yaml::Mapping) -> Result<(), AppError>, +{ + let _guard = hermes_write_lock().lock()?; + let config = read_hermes_config()?; + let mut servers = config + .get("mcp_servers") + .and_then(|v| v.as_mapping()) + .cloned() + .unwrap_or_default(); + updater(&mut servers)?; + let value = serde_yaml::Value::Mapping(servers); + write_yaml_section_to_config_locked("mcp_servers", &value)?; Ok(()) } @@ -748,7 +759,7 @@ pub(crate) fn yaml_to_json(yaml: &serde_yaml::Value) -> Result Result { +pub(crate) fn json_to_yaml(json: &serde_json::Value) -> Result { let json_str = serde_json::to_string(json) .map_err(|e| AppError::Config(format!("Failed to serialize JSON value: {e}")))?; serde_yaml::from_str(&json_str) diff --git a/src-tauri/src/mcp/hermes.rs b/src-tauri/src/mcp/hermes.rs new file mode 100644 index 000000000..69279b765 --- /dev/null +++ b/src-tauri/src/mcp/hermes.rs @@ -0,0 +1,531 @@ +//! Hermes MCP sync and import module +//! +//! Handles conversion between CC Switch unified MCP format and Hermes config.yaml format. +//! +//! ## Format mapping +//! +//! | CC Switch unified (JSON) | Hermes config.yaml (YAML) | +//! |-------------------------------------------------|---------------------------------| +//! | `{"type":"stdio","command":"npx","args":[...],"env":{}}` | `command: npx`, `args: [...]`, `env: {}` | +//! | `{"type":"sse"/"http","url":"...","headers":{}}` | `url: "..."`, `headers: {}` | +//! +//! Key differences from Claude format: +//! - Hermes has NO explicit `type` field -- it infers stdio (has `command`) vs HTTP (has `url`) +//! - Hermes has extra fields: `enabled`, `timeout`, `connect_timeout`, `tools`, `sampling` +//! - These Hermes-specific fields are preserved on merge-on-write and stripped on import + +use serde_json::{json, Value}; +use std::collections::HashMap; + +use crate::app_config::{McpApps, McpServer, MultiAppConfig}; +use crate::error::AppError; +use crate::hermes_config; + +use super::validation::validate_server_spec; + +/// Hermes-specific fields preserved on merge-on-write, stripped on import. +/// Update this list when Hermes adds new per-server config fields. +const HERMES_EXTRA_FIELDS: &[&str] = &[ + "enabled", + "timeout", + "connect_timeout", + "tools", + "sampling", + "roots", +]; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/// Check if Hermes MCP sync should proceed +fn should_sync_hermes_mcp() -> bool { + hermes_config::get_hermes_dir().exists() +} + +// ============================================================================ +// Format Conversion: CC Switch -> Hermes +// ============================================================================ + +/// Convert CC Switch unified format to Hermes format +/// +/// Conversion rules: +/// - `stdio`: output `command`, `args`, `env` (strip `type` field) +/// - `sse`/`http`: output `url`, `headers` (strip `type` field) +/// - Always add `enabled: true` +fn convert_to_hermes_format(spec: &Value) -> Result { + let obj = spec + .as_object() + .ok_or_else(|| AppError::McpValidation("MCP spec must be a JSON object".into()))?; + + let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio"); + + let mut result = serde_json::Map::new(); + + match typ { + "stdio" => { + if let Some(command) = obj.get("command") { + result.insert("command".into(), command.clone()); + } + if let Some(args) = obj.get("args") { + if args.is_array() && !args.as_array().map(|a| a.is_empty()).unwrap_or(true) { + result.insert("args".into(), args.clone()); + } + } + if let Some(env) = obj.get("env") { + if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) { + result.insert("env".into(), env.clone()); + } + } + } + "sse" | "http" => { + if let Some(url) = obj.get("url") { + result.insert("url".into(), url.clone()); + } + if let Some(headers) = obj.get("headers") { + if headers.is_object() + && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true) + { + result.insert("headers".into(), headers.clone()); + } + } + } + _ => { + return Err(AppError::McpValidation(format!("Unknown MCP type: {typ}"))); + } + } + + result.insert("enabled".into(), json!(true)); + + Ok(Value::Object(result)) +} + +// ============================================================================ +// Format Conversion: Hermes -> CC Switch +// ============================================================================ + +/// Convert Hermes format to CC Switch unified format +/// +/// Conversion rules: +/// - If `command` exists: set `type: "stdio"`, extract `command`, `args`, `env` +/// - If `url` exists: set `type: "sse"`, extract `url`, `headers` +/// - Strip Hermes-specific fields: `enabled`, `timeout`, `connect_timeout`, `tools`, `sampling` +fn convert_from_hermes_format(id: &str, spec: &Value) -> Result { + let obj = spec + .as_object() + .ok_or_else(|| AppError::McpValidation("Hermes MCP spec must be a JSON object".into()))?; + + let mut result = serde_json::Map::new(); + + if obj.contains_key("command") { + // stdio type + result.insert("type".into(), json!("stdio")); + + if let Some(command) = obj.get("command") { + result.insert("command".into(), command.clone()); + } + if let Some(args) = obj.get("args") { + if args.is_array() && !args.as_array().map(|a| a.is_empty()).unwrap_or(true) { + result.insert("args".into(), args.clone()); + } + } + if let Some(env) = obj.get("env") { + if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) { + result.insert("env".into(), env.clone()); + } + } + } else if obj.contains_key("url") { + // HTTP/SSE type + result.insert("type".into(), json!("sse")); + + if let Some(url) = obj.get("url") { + result.insert("url".into(), url.clone()); + } + if let Some(headers) = obj.get("headers") { + if headers.is_object() + && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true) + { + result.insert("headers".into(), headers.clone()); + } + } + } else { + return Err(AppError::McpValidation(format!( + "Hermes MCP server '{id}' has neither 'command' nor 'url' field" + ))); + } + + // Note: Hermes-specific fields (enabled, timeout, connect_timeout, tools, sampling) + // are intentionally NOT copied -- they are stripped on import. + + Ok(Value::Object(result)) +} + +// ============================================================================ +// Public API: Sync Functions +// ============================================================================ + +/// Sync a single MCP server to Hermes live config (merge-on-write) +/// +/// Strategy: +/// 1. Read existing mcp_servers from config.yaml +/// 2. If server already exists, merge: keep Hermes-specific fields, overwrite core fields +/// 3. Set `enabled: true` +/// 4. Write back +pub fn sync_single_server_to_hermes( + _config: &MultiAppConfig, + id: &str, + server_spec: &Value, +) -> Result<(), AppError> { + if !should_sync_hermes_mcp() { + return Ok(()); + } + + let hermes_spec = convert_to_hermes_format(server_spec)?; + let id_owned = id.to_string(); + + hermes_config::update_mcp_servers_yaml(|servers| { + let id_yaml = serde_yaml::Value::String(id_owned.clone()); + + let merged_json = if let Some(existing_yaml) = servers.get(&id_yaml) { + let existing_json = hermes_config::yaml_to_json(existing_yaml)?; + merge_hermes_spec(&existing_json, &hermes_spec) + } else { + hermes_spec.clone() + }; + + let merged_yaml_value = hermes_config::json_to_yaml(&merged_json)?; + servers.insert(id_yaml, merged_yaml_value); + Ok(()) + }) +} + +/// Merge new spec into existing Hermes spec, preserving Hermes-specific fields. +/// +/// Core fields (command, args, env, url, headers) come from `new_spec`. +/// Hermes-specific fields (enabled, tools, sampling, etc.) are kept from +/// `existing` — this prevents CC Switch from overwriting user customizations. +fn merge_hermes_spec(existing: &Value, new_spec: &Value) -> Value { + let mut result = serde_json::Map::new(); + + // Copy Hermes-specific fields from existing config + if let Some(existing_obj) = existing.as_object() { + for &field in HERMES_EXTRA_FIELDS { + if let Some(val) = existing_obj.get(field) { + result.insert(field.to_string(), val.clone()); + } + } + } + + // Overwrite with core fields from new spec; for Hermes-specific fields, + // only apply from new_spec if existing didn't already have them + if let Some(new_obj) = new_spec.as_object() { + for (key, val) in new_obj { + if HERMES_EXTRA_FIELDS.contains(&key.as_str()) && result.contains_key(key) { + continue; // Existing Hermes-specific field takes precedence + } + result.insert(key.clone(), val.clone()); + } + } + + Value::Object(result) +} + +/// Remove a single MCP server from Hermes live config +pub fn remove_server_from_hermes(id: &str) -> Result<(), AppError> { + if !should_sync_hermes_mcp() { + return Ok(()); + } + + let id_owned = id.to_string(); + hermes_config::update_mcp_servers_yaml(|servers| { + servers.remove(serde_yaml::Value::String(id_owned.clone())); + Ok(()) + }) +} + +/// Import MCP servers from Hermes config to unified structure +/// +/// Existing servers will have Hermes app enabled without overwriting other fields. +pub fn import_from_hermes(config: &mut MultiAppConfig) -> Result { + let yaml_map = hermes_config::get_mcp_servers_yaml()?; + if yaml_map.is_empty() { + return Ok(0); + } + + // Ensure servers map exists + let servers = config.mcp.servers.get_or_insert_with(HashMap::new); + + let mut changed = 0; + let mut errors = Vec::new(); + + for (key, spec_yaml) in &yaml_map { + let id = match key.as_str() { + Some(s) => s.to_string(), + None => { + log::warn!("Skip Hermes MCP server with non-string key"); + continue; + } + }; + + // Convert YAML value to JSON + let spec_json = match hermes_config::yaml_to_json(spec_yaml) { + Ok(j) => j, + Err(e) => { + log::warn!("Skip Hermes MCP server '{id}': failed to convert YAML to JSON: {e}"); + errors.push(format!("{id}: {e}")); + continue; + } + }; + + // Convert from Hermes format to unified format + let unified_spec = match convert_from_hermes_format(&id, &spec_json) { + Ok(s) => s, + Err(e) => { + log::warn!("Skip invalid Hermes MCP server '{id}': {e}"); + errors.push(format!("{id}: {e}")); + continue; + } + }; + + // Validate the converted spec + if let Err(e) = validate_server_spec(&unified_spec) { + log::warn!("Skip invalid MCP server '{id}' after conversion: {e}"); + errors.push(format!("{id}: {e}")); + continue; + } + + if let Some(existing) = servers.get_mut(&id) { + // Existing server: just enable Hermes app + if !existing.apps.hermes { + existing.apps.hermes = true; + changed += 1; + log::info!("MCP server '{id}' enabled for Hermes"); + } + } else { + // New server: default to only Hermes enabled + servers.insert( + id.clone(), + McpServer { + id: id.clone(), + name: id.clone(), + server: unified_spec, + apps: McpApps { + claude: false, + codex: false, + gemini: false, + opencode: false, + hermes: true, + }, + description: None, + homepage: None, + docs: None, + tags: Vec::new(), + }, + ); + changed += 1; + log::info!("Imported new MCP server '{id}' from Hermes"); + } + } + + if !errors.is_empty() { + log::warn!( + "Import completed with {} failures: {:?}", + errors.len(), + errors + ); + } + + Ok(changed) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ======================================================================== + // convert_to_hermes_format tests + // ======================================================================== + + #[test] + fn test_convert_stdio_to_hermes() { + let spec = json!({ + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem"], + "env": { "HOME": "/Users/test" } + }); + + let result = convert_to_hermes_format(&spec).unwrap(); + // No type field in Hermes format + assert!(result.get("type").is_none()); + assert_eq!(result["command"], "npx"); + assert_eq!(result["args"][0], "-y"); + assert_eq!(result["args"][1], "@modelcontextprotocol/server-filesystem"); + assert_eq!(result["env"]["HOME"], "/Users/test"); + assert_eq!(result["enabled"], true); + } + + #[test] + fn test_convert_http_to_hermes() { + let spec = json!({ + "type": "sse", + "url": "https://example.com/mcp", + "headers": { "Authorization": "Bearer xxx" } + }); + + let result = convert_to_hermes_format(&spec).unwrap(); + assert!(result.get("type").is_none()); + assert_eq!(result["url"], "https://example.com/mcp"); + assert_eq!(result["headers"]["Authorization"], "Bearer xxx"); + assert_eq!(result["enabled"], true); + } + + #[test] + fn test_convert_http_type_to_hermes() { + let spec = json!({ + "type": "http", + "url": "https://example.com/mcp" + }); + + let result = convert_to_hermes_format(&spec).unwrap(); + assert!(result.get("type").is_none()); + assert_eq!(result["url"], "https://example.com/mcp"); + assert_eq!(result["enabled"], true); + } + + #[test] + fn test_convert_stdio_empty_env_to_hermes() { + let spec = json!({ + "type": "stdio", + "command": "node", + "args": [], + "env": {} + }); + + let result = convert_to_hermes_format(&spec).unwrap(); + assert_eq!(result["command"], "node"); + // Empty args and env should be omitted + assert!(result.get("args").is_none()); + assert!(result.get("env").is_none()); + assert_eq!(result["enabled"], true); + } + + #[test] + fn test_convert_unknown_type_to_hermes_fails() { + let spec = json!({ "type": "grpc", "command": "foo" }); + assert!(convert_to_hermes_format(&spec).is_err()); + } + + // ======================================================================== + // convert_from_hermes_format tests + // ======================================================================== + + #[test] + fn test_convert_hermes_stdio_to_unified() { + let spec = json!({ + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem"], + "env": { "HOME": "/Users/test" }, + "enabled": true, + "timeout": 30, + "connect_timeout": 10, + "tools": { "include": ["read_file"] }, + "sampling": { "enabled": true } + }); + + let result = convert_from_hermes_format("filesystem", &spec).unwrap(); + assert_eq!(result["type"], "stdio"); + assert_eq!(result["command"], "npx"); + assert_eq!(result["args"][0], "-y"); + assert_eq!(result["args"][1], "@modelcontextprotocol/server-filesystem"); + assert_eq!(result["env"]["HOME"], "/Users/test"); + // Hermes-specific fields should be stripped + assert!(result.get("enabled").is_none()); + assert!(result.get("timeout").is_none()); + assert!(result.get("connect_timeout").is_none()); + assert!(result.get("tools").is_none()); + assert!(result.get("sampling").is_none()); + } + + #[test] + fn test_convert_hermes_http_to_unified() { + let spec = json!({ + "url": "https://example.com/mcp", + "headers": { "Authorization": "Bearer xxx" }, + "enabled": true, + "timeout": 60 + }); + + let result = convert_from_hermes_format("remote-server", &spec).unwrap(); + assert_eq!(result["type"], "sse"); + assert_eq!(result["url"], "https://example.com/mcp"); + assert_eq!(result["headers"]["Authorization"], "Bearer xxx"); + // Hermes-specific fields should be stripped + assert!(result.get("enabled").is_none()); + assert!(result.get("timeout").is_none()); + } + + #[test] + fn test_convert_hermes_no_command_no_url_fails() { + let spec = json!({ "enabled": true, "timeout": 30 }); + assert!(convert_from_hermes_format("bad-server", &spec).is_err()); + } + + // ======================================================================== + // Merge-on-write tests + // ======================================================================== + + #[test] + fn test_merge_preserves_hermes_specific_fields() { + let existing = json!({ + "command": "old-cmd", + "args": ["old-arg"], + "enabled": true, + "timeout": 30, + "connect_timeout": 10, + "tools": { "include": ["read_file"] }, + "sampling": { "enabled": true } + }); + + let new_spec = json!({ + "command": "new-cmd", + "args": ["new-arg"], + "env": { "KEY": "value" }, + "enabled": true + }); + + let merged = merge_hermes_spec(&existing, &new_spec); + + // Core fields should be overwritten + assert_eq!(merged["command"], "new-cmd"); + assert_eq!(merged["args"][0], "new-arg"); + assert_eq!(merged["env"]["KEY"], "value"); + + // Hermes-specific fields should be preserved from existing + assert_eq!(merged["timeout"], 30); + assert_eq!(merged["connect_timeout"], 10); + assert_eq!(merged["tools"]["include"][0], "read_file"); + assert_eq!(merged["sampling"]["enabled"], true); + assert_eq!(merged["enabled"], true); + } + + #[test] + fn test_merge_new_server_no_existing_extra_fields() { + let existing = json!({ + "command": "old-cmd" + }); + + let new_spec = json!({ + "command": "new-cmd", + "args": ["arg1"], + "enabled": true + }); + + let merged = merge_hermes_spec(&existing, &new_spec); + assert_eq!(merged["command"], "new-cmd"); + assert_eq!(merged["args"][0], "arg1"); + assert_eq!(merged["enabled"], true); + // No extra fields to preserve + assert!(merged.get("timeout").is_none()); + } +} diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index 2f2b7f424..c411ac467 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -9,10 +9,12 @@ //! - `codex` - Codex MCP 同步和导入(含 TOML 转换) //! - `gemini` - Gemini MCP 同步和导入 //! - `opencode` - OpenCode MCP 同步和导入(含 local/remote 格式转换) +//! - `hermes` - Hermes MCP 同步和导入 mod claude; mod codex; mod gemini; +mod hermes; mod opencode; mod validation; @@ -28,6 +30,7 @@ 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_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/services/mcp.rs b/src-tauri/src/services/mcp.rs index 5b8d24fe6..37e04c1c6 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)?; @@ -129,8 +132,11 @@ impl McpService { log::debug!("OpenClaw MCP support is still in development, skipping sync"); } AppType::Hermes => { - // TODO: Hermes MCP sync not yet implemented - log::debug!("Hermes MCP sync not yet implemented, skipping sync"); + mcp::sync_single_server_to_hermes( + &Default::default(), + &server.id, + &server.server, + )?; } } Ok(()) @@ -162,8 +168,7 @@ impl McpService { log::debug!("OpenClaw MCP support is still in development, skipping remove"); } AppType::Hermes => { - // TODO: Hermes MCP sync not yet implemented - log::debug!("Hermes MCP sync not yet implemented, skipping remove"); + mcp::remove_server_from_hermes(id)?; } } Ok(()) @@ -174,7 +179,7 @@ impl McpService { let servers = Self::get_all_servers(state)?; for app in AppType::all() { - if matches!(app, AppType::OpenClaw | AppType::Hermes) { + if matches!(app, AppType::OpenClaw) { continue; } @@ -389,4 +394,42 @@ impl McpService { Ok(new_count) } + + /// 从 Hermes 导入 MCP + 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) + } } From a0b585992ab760b9eb2bdedd27b94a0ed8de6ba8 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 15 Apr 2026 17:41:33 +0800 Subject: [PATCH 014/156] feat: add Hermes frontend types, API layer, and hooks (Phase 7) - Add "hermes" to AppId union type and all exhaustive Record - Add HermesModelConfig, HermesAgentConfig, HermesEnvConfig types - Add hermes field to VisibleApps, McpApps, ProxyTakeoverStatus - Create src/lib/api/hermes.ts with Tauri invoke wrappers - Create src/hooks/useHermes.ts with 5 query + 3 mutation hooks - Register hermes in APP_IDS, APP_ICON_MAP (violet color scheme) - Split MCP_SKILLS_APP_IDS into MCP_APP_IDS (includes hermes) and SKILLS_APP_IDS (excludes hermes, since Hermes has no Skills support) - Wire hermes additive-mode into App.tsx (remove/duplicate handlers), ProviderList.tsx (live provider ID query + In Config badge), mutations.ts (cache invalidation on switch/add/delete) - Add Hermes checkbox to McpFormModal - Add basic hermes i18n keys (en/zh/ja) --- src/App.tsx | 30 +++- src/components/AppSwitcher.tsx | 11 +- src/components/mcp/McpFormModal.tsx | 18 +++ src/components/mcp/UnifiedMcpPanel.tsx | 17 ++- src/components/prompts/PromptFormModal.tsx | 1 + src/components/prompts/PromptFormPanel.tsx | 1 + src/components/providers/ProviderList.tsx | 15 +- .../providers/forms/EndpointSpeedTest.tsx | 1 + .../settings/AppVisibilitySettings.tsx | 2 + src/components/skills/UnifiedSkillsPanel.tsx | 23 ++- src/config/appConfig.tsx | 23 ++- src/hooks/useHermes.ts | 131 ++++++++++++++++++ src/i18n/locales/en.json | 6 +- src/i18n/locales/ja.json | 6 +- src/i18n/locales/zh.json | 6 +- src/lib/api/hermes.ts | 92 ++++++++++++ src/lib/api/providers.ts | 16 +++ src/lib/api/skills.ts | 9 +- src/lib/api/types.ts | 8 +- src/lib/query/mutations.ts | 28 +++- src/types.ts | 39 ++++++ src/types/proxy.ts | 1 + tests/msw/state.ts | 16 ++- 23 files changed, 467 insertions(+), 33 deletions(-) create mode 100644 src/hooks/useHermes.ts create mode 100644 src/lib/api/hermes.ts diff --git a/src/App.tsx b/src/App.tsx index 4406ac753..8d2f04f71 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -39,6 +39,7 @@ import { import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env"; import { useProviderActions } from "@/hooks/useProviderActions"; import { openclawKeys, useOpenClawHealth } from "@/hooks/useOpenClaw"; +import { hermesKeys } from "@/hooks/useHermes"; import { useProxyStatus } from "@/hooks/useProxyStatus"; import { useAutoCompact } from "@/hooks/useAutoCompact"; import { useLastValidValue } from "@/hooks/useLastValidValue"; @@ -114,6 +115,7 @@ const VALID_APPS: AppId[] = [ "gemini", "opencode", "openclaw", + "hermes", ]; const getInitialApp = (): AppId => { @@ -174,6 +176,7 @@ function App() { gemini: true, opencode: true, openclaw: true, + hermes: true, }; const getFirstVisibleApp = (): AppId => { @@ -182,6 +185,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 }; @@ -654,6 +658,13 @@ function App() { await queryClient.invalidateQueries({ queryKey: openclawKeys.health, }); + } else if (activeApp === "hermes") { + await queryClient.invalidateQueries({ + queryKey: hermesKeys.liveProviderIds, + }); + await queryClient.invalidateQueries({ + queryKey: hermesKeys.health, + }); } toast.success( t("notifications.removeFromConfigSuccess", { @@ -704,7 +715,11 @@ function App() { iconColor: provider.iconColor, }; - if (activeApp === "opencode" || activeApp === "openclaw") { + if ( + activeApp === "opencode" || + activeApp === "openclaw" || + activeApp === "hermes" + ) { let liveProviderIds: string[] = []; try { liveProviderIds = @@ -713,10 +728,15 @@ function App() { queryKey: ["opencodeLiveProviderIds"], queryFn: () => providersApi.getOpenCodeLiveProviderIds(), }) - : await queryClient.ensureQueryData({ - queryKey: openclawKeys.liveProviderIds, - queryFn: () => providersApi.getOpenClawLiveProviderIds(), - }); + : activeApp === "openclaw" + ? await queryClient.ensureQueryData({ + queryKey: openclawKeys.liveProviderIds, + queryFn: () => providersApi.getOpenClawLiveProviderIds(), + }) + : await queryClient.ensureQueryData({ + queryKey: hermesKeys.liveProviderIds, + queryFn: () => providersApi.getHermesLiveProviderIds(), + }); } catch (error) { console.error( "[App] Failed to load live provider IDs for duplication", diff --git a/src/components/AppSwitcher.tsx b/src/components/AppSwitcher.tsx index 61d780e90..a10900248 100644 --- a/src/components/AppSwitcher.tsx +++ b/src/components/AppSwitcher.tsx @@ -10,7 +10,14 @@ 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({ @@ -31,6 +38,7 @@ export function AppSwitcher({ gemini: "gemini", opencode: "opencode", openclaw: "openclaw", + hermes: "hermes", }; const appDisplayName: Record = { claude: "Claude", @@ -38,6 +46,7 @@ export function AppSwitcher({ gemini: "Gemini", opencode: "OpenCode", openclaw: "OpenClaw", + hermes: "Hermes", }; // Filter apps based on visibility settings (default all visible) diff --git a/src/components/mcp/McpFormModal.tsx b/src/components/mcp/McpFormModal.tsx index 70ab3b03a..0fd784b05 100644 --- a/src/components/mcp/McpFormModal.tsx +++ b/src/components/mcp/McpFormModal.tsx @@ -67,6 +67,7 @@ const McpFormModal: React.FC = ({ gemini: boolean; opencode: boolean; openclaw: boolean; + hermes: boolean; }>(() => { if (initialData?.apps) { return { ...initialData.apps }; @@ -77,6 +78,7 @@ const McpFormModal: React.FC = ({ gemini: defaultEnabledApps.includes("gemini"), opencode: defaultEnabledApps.includes("opencode"), openclaw: defaultEnabledApps.includes("openclaw"), + hermes: defaultEnabledApps.includes("hermes"), }; }); @@ -579,6 +581,22 @@ const McpFormModal: React.FC = ({ {t("mcp.unifiedPanel.apps.opencode")}
+ +
+ + setEnabledApps({ ...enabledApps, hermes: checked }) + } + /> + +
diff --git a/src/components/mcp/UnifiedMcpPanel.tsx b/src/components/mcp/UnifiedMcpPanel.tsx index 761ddcd65..d83355a3e 100644 --- a/src/components/mcp/UnifiedMcpPanel.tsx +++ b/src/components/mcp/UnifiedMcpPanel.tsx @@ -17,7 +17,7 @@ import { Edit3, Trash2, ExternalLink } from "lucide-react"; import { settingsApi } from "@/lib/api"; import { mcpPresets } from "@/config/mcpPresets"; import { toast } from "sonner"; -import { MCP_SKILLS_APP_IDS } from "@/config/appConfig"; +import { MCP_APP_IDS } from "@/config/appConfig"; import { AppCountBar } from "@/components/common/AppCountBar"; import { AppToggleGroup } from "@/components/common/AppToggleGroup"; import { ListItemRow } from "@/components/common/ListItemRow"; @@ -56,9 +56,16 @@ 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) { + for (const app of MCP_APP_IDS) { if (server.apps[app]) counts[app]++; } }); @@ -136,7 +143,7 @@ const UnifiedMcpPanel = React.forwardRef<
@@ -278,7 +285,7 @@ const UnifiedMcpListItem: React.FC = ({ onToggleApp(id, app, enabled)} - appIds={MCP_SKILLS_APP_IDS} + appIds={MCP_APP_IDS} />
diff --git a/src/components/prompts/PromptFormModal.tsx b/src/components/prompts/PromptFormModal.tsx index 84e18d255..0a9c86f94 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 c4481fa4c..cdd80d0d8 100644 --- a/src/components/prompts/PromptFormPanel.tsx +++ b/src/components/prompts/PromptFormPanel.tsx @@ -30,6 +30,7 @@ const PromptFormPanel: React.FC = ({ gemini: "GEMINI.md", opencode: "AGENTS.md", openclaw: "AGENTS.md", + hermes: "AGENTS.md", }; const filename = filenameMap[appId]; const [name, setName] = useState(""); diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx index c0485b159..bdaaeb37d 100644 --- a/src/components/providers/ProviderList.tsx +++ b/src/components/providers/ProviderList.tsx @@ -25,6 +25,7 @@ import { useOpenClawLiveProviderIds, useOpenClawDefaultModel, } from "@/hooks/useOpenClaw"; +import { useHermesLiveProviderIds } from "@/hooks/useHermes"; import { useStreamCheck } from "@/hooks/useStreamCheck"; import { ProviderCard } from "@/components/providers/ProviderCard"; import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState"; @@ -105,7 +106,10 @@ export function ProviderList({ appId === "openclaw", ); - // 判断供应商是否已添加到配置(累加模式应用:OpenCode/OpenClaw) + // Hermes: 查询 live 配置中的供应商 ID 列表,用于判断 isInConfig + const { data: hermesLiveIds } = useHermesLiveProviderIds(appId === "hermes"); + + // 判断供应商是否已添加到配置(累加模式应用:OpenCode/OpenClaw/Hermes) const isProviderInConfig = useCallback( (providerId: string): boolean => { if (appId === "opencode") { @@ -114,9 +118,12 @@ export function ProviderList({ if (appId === "openclaw") { return openclawLiveIds?.includes(providerId) ?? false; } + if (appId === "hermes") { + return hermesLiveIds?.includes(providerId) ?? false; + } return true; // 其他应用始终返回 true }, - [appId, opencodeLiveIds, openclawLiveIds], + [appId, opencodeLiveIds, openclawLiveIds, hermesLiveIds], ); // OpenClaw: query default model to determine which provider is default @@ -229,6 +236,10 @@ export function ProviderList({ const count = await providersApi.importOpenClawFromLive(); return count > 0; } + if (appId === "hermes") { + const count = await providersApi.importHermesFromLive(); + return count > 0; + } return providersApi.importDefault(appId); }, onSuccess: (imported) => { diff --git a/src/components/providers/forms/EndpointSpeedTest.tsx b/src/components/providers/forms/EndpointSpeedTest.tsx index 2143d21db..27913254b 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/settings/AppVisibilitySettings.tsx b/src/components/settings/AppVisibilitySettings.tsx index 0c30472ce..98f1bdd47 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: "hermes", nameKey: "apps.hermes" }, ]; export function AppVisibilitySettings({ @@ -35,6 +36,7 @@ export function AppVisibilitySettings({ gemini: true, opencode: true, openclaw: true, + hermes: true, }; // Count how many apps are currently visible diff --git a/src/components/skills/UnifiedSkillsPanel.tsx b/src/components/skills/UnifiedSkillsPanel.tsx index 701af4929..77199ef17 100644 --- a/src/components/skills/UnifiedSkillsPanel.tsx +++ b/src/components/skills/UnifiedSkillsPanel.tsx @@ -31,7 +31,7 @@ import type { AppId } from "@/lib/api/types"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { settingsApi, skillsApi } from "@/lib/api"; import { toast } from "sonner"; -import { MCP_SKILLS_APP_IDS } from "@/config/appConfig"; +import { SKILLS_APP_IDS } from "@/config/appConfig"; import { AppCountBar } from "@/components/common/AppCountBar"; import { AppToggleGroup } from "@/components/common/AppToggleGroup"; import { ListItemRow } from "@/components/common/ListItemRow"; @@ -113,10 +113,17 @@ 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) { + for (const app of SKILLS_APP_IDS) { if (skill.apps[app]) counts[app]++; } }); @@ -342,7 +349,7 @@ const UnifiedSkillsPanel = React.forwardRef<
= ({ onToggleApp(skill.id, app, enabled)} - appIds={MCP_SKILLS_APP_IDS} + appIds={SKILLS_APP_IDS} />
= ({ gemini: skill.foundIn.includes("gemini"), opencode: skill.foundIn.includes("opencode"), openclaw: false, + hermes: skill.foundIn.includes("hermes"), }, ]), ), @@ -761,6 +769,7 @@ const ImportSkillsDialog: React.FC = ({ gemini: false, opencode: false, openclaw: false, + hermes: false, }, })), ); @@ -803,6 +812,7 @@ const ImportSkillsDialog: React.FC = ({ gemini: false, opencode: false, openclaw: false, + hermes: false, } } onToggle={(app, enabled) => { @@ -815,12 +825,13 @@ const ImportSkillsDialog: React.FC = ({ gemini: false, opencode: false, openclaw: false, + hermes: false, }), [app]: enabled, }, })); }} - appIds={MCP_SKILLS_APP_IDS} + appIds={SKILLS_APP_IDS} />
= { claude: { label: "Claude", @@ -79,4 +83,19 @@ export const APP_ICON_MAP: Record = { badgeClass: "bg-rose-500/10 text-rose-700 dark:text-rose-300 hover:bg-rose-500/20 border-0 gap-1.5", }, + hermes: { + label: "Hermes", + icon: ( + + ), + activeClass: + "bg-violet-500/10 ring-1 ring-violet-500/20 hover:bg-violet-500/20 text-violet-600 dark:text-violet-400", + badgeClass: + "bg-violet-500/10 text-violet-700 dark:text-violet-300 hover:bg-violet-500/20 border-0 gap-1.5", + }, }; diff --git a/src/hooks/useHermes.ts b/src/hooks/useHermes.ts new file mode 100644 index 000000000..c963faebd --- /dev/null +++ b/src/hooks/useHermes.ts @@ -0,0 +1,131 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { hermesApi } from "@/lib/api/hermes"; +import { providersApi } from "@/lib/api/providers"; +import type { + HermesEnvConfig, + HermesAgentConfig, + HermesModelConfig, +} from "@/types"; + +/** + * Centralized query keys for all Hermes-related queries. + * Import this from any file that needs to invalidate Hermes caches. + */ +export const hermesKeys = { + all: ["hermes"] as const, + liveProviderIds: ["hermes", "liveProviderIds"] as const, + modelConfig: ["hermes", "modelConfig"] as const, + agentConfig: ["hermes", "agentConfig"] as const, + env: ["hermes", "env"] as const, + health: ["hermes", "health"] as const, +}; + +// ============================================================ +// Query hooks +// ============================================================ + +/** + * Query live provider IDs from Hermes config. + * Used by ProviderList to show "In Config" badge. + */ +export function useHermesLiveProviderIds(enabled: boolean) { + return useQuery({ + queryKey: hermesKeys.liveProviderIds, + queryFn: () => providersApi.getHermesLiveProviderIds(), + enabled, + }); +} + +/** + * Query model configuration. + */ +export function useHermesModelConfig(enabled: boolean) { + return useQuery({ + queryKey: hermesKeys.modelConfig, + queryFn: () => hermesApi.getModelConfig(), + enabled, + }); +} + +/** + * Query agent configuration. + */ +export function useHermesAgentConfig() { + return useQuery({ + queryKey: hermesKeys.agentConfig, + queryFn: () => hermesApi.getAgentConfig(), + staleTime: 30_000, + }); +} + +/** + * Query env configuration. + */ +export function useHermesEnv() { + return useQuery({ + queryKey: hermesKeys.env, + queryFn: () => hermesApi.getEnv(), + staleTime: 30_000, + }); +} + +/** + * Query config health warnings. + */ +export function useHermesHealth(enabled: boolean) { + return useQuery({ + queryKey: hermesKeys.health, + queryFn: () => hermesApi.scanHealth(), + staleTime: 30_000, + enabled, + }); +} + +// ============================================================ +// Mutation hooks +// ============================================================ + +/** + * Save model config. Invalidates modelConfig and health queries on success. + * Toast notifications are handled by the component. + */ +export function useSaveHermesModelConfig() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (config: HermesModelConfig) => hermesApi.setModelConfig(config), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: hermesKeys.modelConfig }); + queryClient.invalidateQueries({ queryKey: hermesKeys.health }); + }, + }); +} + +/** + * Save agent config. Invalidates agentConfig and health queries on success. + * Toast notifications are handled by the component. + */ +export function useSaveHermesAgentConfig() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (config: HermesAgentConfig) => hermesApi.setAgentConfig(config), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: hermesKeys.agentConfig }); + queryClient.invalidateQueries({ queryKey: hermesKeys.health }); + }, + }); +} + +/** + * Save env config. Invalidates env and health queries on success. + * Toast notifications are handled by the component. + */ +export function useSaveHermesEnv() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (env: HermesEnvConfig) => hermesApi.setEnv(env), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: hermesKeys.env }); + queryClient.invalidateQueries({ queryKey: hermesKeys.health }); + }, + }); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index c7f089cdd..790660aa5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -658,7 +658,8 @@ "codex": "Codex", "gemini": "Gemini", "opencode": "OpenCode", - "openclaw": "OpenClaw" + "openclaw": "OpenClaw", + "hermes": "Hermes" }, "sessionManager": { "title": "Session Manager", @@ -1308,7 +1309,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 156653fde..c7b19cbee 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -658,7 +658,8 @@ "codex": "Codex", "gemini": "Gemini", "opencode": "OpenCode", - "openclaw": "OpenClaw" + "openclaw": "OpenClaw", + "hermes": "Hermes" }, "sessionManager": { "title": "セッション管理", @@ -1308,7 +1309,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 f8a59d5da..6b18d5be8 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -658,7 +658,8 @@ "codex": "Codex", "gemini": "Gemini", "opencode": "OpenCode", - "openclaw": "OpenClaw" + "openclaw": "OpenClaw", + "hermes": "Hermes" }, "sessionManager": { "title": "会话管理", @@ -1309,7 +1310,8 @@ "codex": "Codex", "gemini": "Gemini", "opencode": "OpenCode", - "openclaw": "OpenClaw" + "openclaw": "OpenClaw", + "hermes": "Hermes" } }, "userLevelPath": "用户级 MCP 配置路径", diff --git a/src/lib/api/hermes.ts b/src/lib/api/hermes.ts new file mode 100644 index 000000000..26bd17895 --- /dev/null +++ b/src/lib/api/hermes.ts @@ -0,0 +1,92 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { + HermesModelConfig, + HermesAgentConfig, + HermesEnvConfig, + HermesHealthWarning, + HermesWriteOutcome, +} from "@/types"; + +/** + * Hermes Agent configuration API + * + * Manages Hermes config sections: + * - model (model selection and provider) + * - agent (agent behavior) + * - env (environment variables) + */ +export const hermesApi = { + // ============================================================ + // Model Configuration + // ============================================================ + + /** + * Get model configuration + */ + async getModelConfig(): Promise { + return await invoke("get_hermes_model_config"); + }, + + /** + * Set model configuration + */ + async setModelConfig(config: HermesModelConfig): Promise { + return await invoke("set_hermes_model_config", { config }); + }, + + // ============================================================ + // Agent Configuration + // ============================================================ + + /** + * Get agent configuration + */ + async getAgentConfig(): Promise { + return await invoke("get_hermes_agent_config"); + }, + + /** + * Set agent configuration + */ + async setAgentConfig(config: HermesAgentConfig): Promise { + return await invoke("set_hermes_agent_config", { config }); + }, + + // ============================================================ + // Env Configuration + // ============================================================ + + /** + * Get env configuration (.env file) + */ + async getEnv(): Promise { + return await invoke("get_hermes_env"); + }, + + /** + * Set env configuration (.env file) + */ + async setEnv(env: HermesEnvConfig): Promise { + return await invoke("set_hermes_env", { env }); + }, + + // ============================================================ + // Health + // ============================================================ + + /** + * Scan config health and return warnings + */ + async scanHealth(): Promise { + return await invoke("scan_hermes_config_health"); + }, + + /** + * Get live provider config by ID + */ + async getLiveProvider( + providerId: string, + ): Promise | null> { + return await invoke("get_hermes_live_provider", { providerId }); + }, +}; diff --git a/src/lib/api/providers.ts b/src/lib/api/providers.ts index 89b6b7c7f..ba7bd2e77 100644 --- a/src/lib/api/providers.ts +++ b/src/lib/api/providers.ts @@ -136,6 +136,14 @@ export const providersApi = { return await invoke("get_openclaw_live_provider_ids"); }, + /** + * 获取 Hermes live 配置中的供应商 ID 列表 + * 用于前端判断供应商是否已添加到 Hermes 配置 + */ + async getHermesLiveProviderIds(): Promise { + return await invoke("get_hermes_live_provider_ids"); + }, + /** * 从 OpenClaw live 配置导入供应商到数据库 * OpenClaw 特有功能:由于累加模式,用户可能已在 openclaw.json 中配置供应商 @@ -143,6 +151,14 @@ export const providersApi = { async importOpenClawFromLive(): Promise { return await invoke("import_openclaw_providers_from_live"); }, + + /** + * 从 Hermes live 配置导入供应商到数据库 + * Hermes 特有功能:由于累加模式,用户可能已在 Hermes 配置中配置供应商 + */ + async importHermesFromLive(): Promise { + return await invoke("import_hermes_providers_from_live"); + }, }; // ============================================================================ diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts index 456e57b67..3c5263073 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 1fff721fc..4ec68d6b2 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/lib/query/mutations.ts b/src/lib/query/mutations.ts index e5ab2815c..ed1ee04d3 100644 --- a/src/lib/query/mutations.ts +++ b/src/lib/query/mutations.ts @@ -8,6 +8,7 @@ import type { Provider, SessionMeta, Settings } from "@/types"; import { extractErrorMessage } from "@/utils/errorUtils"; import { generateUUID } from "@/utils/uuid"; import { openclawKeys } from "@/hooks/useOpenClaw"; +import { hermesKeys } from "@/hooks/useHermes"; export const useAddProviderMutation = (appId: AppId) => { const queryClient = useQueryClient(); @@ -22,7 +23,7 @@ export const useAddProviderMutation = (appId: AppId) => { ) => { let id: string; - if (appId === "opencode" || appId === "openclaw") { + if (appId === "opencode" || appId === "openclaw" || appId === "hermes") { if ( providerInput.category === "omo" || providerInput.category === "omo-slim" @@ -75,6 +76,12 @@ export const useAddProviderMutation = (appId: AppId) => { }); } + if (appId === "hermes") { + await queryClient.invalidateQueries({ + queryKey: hermesKeys.health, + }); + } + try { await providersApi.updateTrayMenu(); } catch (trayError) { @@ -127,6 +134,11 @@ export const useUpdateProviderMutation = (appId: AppId) => { queryKey: openclawKeys.health, }); } + if (appId === "hermes") { + await queryClient.invalidateQueries({ + queryKey: hermesKeys.health, + }); + } toast.success( t("notifications.updateSuccess", { defaultValue: "供应商更新成功", @@ -180,6 +192,12 @@ export const useDeleteProviderMutation = (appId: AppId) => { }); } + if (appId === "hermes") { + await queryClient.invalidateQueries({ + queryKey: hermesKeys.health, + }); + } + try { await providersApi.updateTrayMenu(); } catch (trayError) { @@ -244,6 +262,14 @@ export const useSwitchProviderMutation = (appId: AppId) => { queryKey: openclawKeys.health, }); } + if (appId === "hermes") { + await queryClient.invalidateQueries({ + queryKey: hermesKeys.liveProviderIds, + }); + await queryClient.invalidateQueries({ + queryKey: hermesKeys.health, + }); + } try { await providersApi.updateTrayMenu(); diff --git a/src/types.ts b/src/types.ts index 8379c217b..9c8e7b906 100644 --- a/src/types.ts +++ b/src/types.ts @@ -188,6 +188,7 @@ export interface VisibleApps { gemini: boolean; opencode: boolean; openclaw: boolean; + hermes: boolean; } // WebDAV 同步状态 @@ -281,6 +282,8 @@ export interface Settings { opencodeConfigDir?: string; // 覆盖 OpenClaw 配置目录(可选) openclawConfigDir?: string; + // 覆盖 Hermes 配置目录(可选) + hermesConfigDir?: string; // ===== 当前供应商 ID(设备级)===== // 当前 Claude 供应商 ID(优先于数据库 is_current) @@ -354,6 +357,7 @@ export interface McpApps { gemini: boolean; opencode: boolean; openclaw: boolean; + hermes: boolean; } // MCP 服务器条目(v3.7.0 统一结构) @@ -569,3 +573,38 @@ export interface OpenClawToolsConfig { deny?: string[]; [key: string]: unknown; // preserve unknown fields } + +// ============================================================================ +// Hermes Agent 专属配置 +// ============================================================================ + +export interface HermesModelConfig { + default?: string; + provider?: string; + base_url?: string; + context_length?: number; + max_tokens?: number; + [key: string]: unknown; +} + +export interface HermesAgentConfig { + max_turns?: number; + reasoning_effort?: string; + tool_use_enforcement?: string | boolean | string[]; + [key: string]: unknown; +} + +export interface HermesEnvConfig { + [key: string]: unknown; +} + +export interface HermesHealthWarning { + code: string; + message: string; + path?: string; +} + +export interface HermesWriteOutcome { + backupPath?: string; + warnings: HermesHealthWarning[]; +} diff --git a/src/types/proxy.ts b/src/types/proxy.ts index 29fac7fc5..601ef3b14 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 9768914d9..4e589d230 100644 --- a/tests/msw/state.ts +++ b/tests/msw/state.ts @@ -10,7 +10,7 @@ import type { type ProvidersByApp = Record>; type CurrentProviderState = Record; type McpConfigState = Record>; -type LiveProviderIdsByApp = Record<"opencode" | "openclaw", string[]>; +type LiveProviderIdsByApp = Record<"opencode" | "openclaw" | "hermes", string[]>; const createDefaultProviders = (): ProvidersByApp => ({ claude: { @@ -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(); @@ -81,6 +83,7 @@ let current = createDefaultCurrent(); let liveProviderIds: LiveProviderIdsByApp = { opencode: [], openclaw: [], + hermes: [], }; let settingsState: Settings = { showInTray: true, @@ -153,6 +156,7 @@ let mcpConfigs: McpConfigState = { gemini: false, opencode: false, openclaw: false, + hermes: false, }, server: { type: "stdio", @@ -171,6 +175,7 @@ let mcpConfigs: McpConfigState = { gemini: false, opencode: false, openclaw: false, + hermes: false, }, server: { type: "http", @@ -181,6 +186,7 @@ let mcpConfigs: McpConfigState = { gemini: {}, opencode: {}, openclaw: {}, + hermes: {}, }; const cloneProviders = (value: ProvidersByApp) => @@ -192,6 +198,7 @@ export const resetProviderState = () => { liveProviderIds = { opencode: [], openclaw: [], + hermes: [], }; sessionsState = createDefaultSessions(); sessionMessagesState = createDefaultSessionMessages(); @@ -216,6 +223,7 @@ export const resetProviderState = () => { gemini: false, opencode: false, openclaw: false, + hermes: false, }, server: { type: "stdio", @@ -234,6 +242,7 @@ export const resetProviderState = () => { gemini: false, opencode: false, openclaw: false, + hermes: false, }, server: { type: "http", @@ -244,6 +253,7 @@ export const resetProviderState = () => { gemini: {}, opencode: {}, openclaw: {}, + hermes: {}, }; }; @@ -252,12 +262,12 @@ export const getProviders = (appType: AppId) => export const getCurrentProviderId = (appType: AppId) => current[appType] ?? ""; -export const getLiveProviderIds = (appType: "opencode" | "openclaw") => [ +export const getLiveProviderIds = (appType: "opencode" | "openclaw" | "hermes") => [ ...liveProviderIds[appType], ]; export const setLiveProviderIds = ( - appType: "opencode" | "openclaw", + appType: "opencode" | "openclaw" | "hermes", ids: string[], ) => { liveProviderIds[appType] = [...ids]; From 240969d8c7ff5cbd1beb22f9253adcac459aa26b Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 15 Apr 2026 21:02:46 +0800 Subject: [PATCH 015/156] feat: add Hermes UI components, presets, and config panels (Phase 8) - Add 7 provider presets (OpenRouter, Anthropic, OpenAI, Google, DeepSeek, Together, Nous) - Create HermesFormFields + useHermesFormState for provider form integration - Create Model/Agent/Env config panels with save/load functionality - Create HermesHealthBanner for config warnings - Add hermes icon (violet winged H) to icon system - Integrate into App.tsx: 3 new view types (hermesModel/hermesAgent/hermesEnv), sidebar buttons (Brain/Bot/KeyRound), health banner, session support - Integrate into ProviderForm: presets, form state, key validation, rendering - Integrate into AddProviderDialog: universal tab exclusion, providerKey, base_url extraction - Add i18n keys for all Hermes UI (zh/en/ja) --- src-tauri/src/hermes_config.rs | 34 +-- src-tauri/src/mcp/hermes.rs | 7 +- src-tauri/src/services/mcp.rs | 6 +- src-tauri/src/services/provider/live.rs | 4 +- src-tauri/src/services/provider/mod.rs | 5 +- src/App.tsx | 104 +++++++- src/components/hermes/AgentPanel.tsx | 249 ++++++++++++++++++ src/components/hermes/EnvPanel.tsx | 128 +++++++++ src/components/hermes/HermesHealthBanner.tsx | 78 ++++++ src/components/hermes/ModelPanel.tsx | 202 ++++++++++++++ .../providers/AddProviderDialog.tsx | 9 +- .../providers/forms/HermesFormFields.tsx | 64 +++++ .../providers/forms/ProviderForm.tsx | 216 ++++++++++++++- src/components/providers/forms/hooks/index.ts | 1 + .../forms/hooks/useHermesFormState.ts | 138 ++++++++++ src/config/hermesProviderPresets.ts | 132 ++++++++++ src/hooks/useHermes.ts | 6 +- src/i18n/locales/en.json | 58 ++++ src/i18n/locales/ja.json | 58 ++++ src/i18n/locales/zh.json | 58 ++++ src/icons/extracted/index.ts | 1 + src/icons/extracted/metadata.ts | 7 + src/types.ts | 1 + 23 files changed, 1512 insertions(+), 54 deletions(-) create mode 100644 src/components/hermes/AgentPanel.tsx create mode 100644 src/components/hermes/EnvPanel.tsx create mode 100644 src/components/hermes/HermesHealthBanner.tsx create mode 100644 src/components/hermes/ModelPanel.tsx create mode 100644 src/components/providers/forms/HermesFormFields.tsx create mode 100644 src/components/providers/forms/hooks/useHermesFormState.ts create mode 100644 src/config/hermesProviderPresets.ts diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs index 49a74812a..50ca7b136 100644 --- a/src-tauri/src/hermes_config.rs +++ b/src-tauri/src/hermes_config.rs @@ -171,9 +171,7 @@ fn is_top_level_key_line(line: &str) -> bool { } if let Some(colon_pos) = line.find(':') { let after_colon = &line[colon_pos + 1..]; - after_colon.is_empty() - || after_colon.starts_with(' ') - || after_colon.starts_with('\t') + after_colon.is_empty() || after_colon.starts_with(' ') || after_colon.starts_with('\t') } else { false } @@ -221,10 +219,7 @@ fn find_yaml_section_range(raw: &str, section_key: &str) -> Option<(usize, usize /// ``` fn serialize_yaml_section(key: &str, value: &serde_yaml::Value) -> Result { let mut section = serde_yaml::Mapping::new(); - section.insert( - serde_yaml::Value::String(key.to_string()), - value.clone(), - ); + section.insert(serde_yaml::Value::String(key.to_string()), value.clone()); let yaml_str = serde_yaml::to_string(&serde_yaml::Value::Mapping(section)) .map_err(|e| AppError::Config(format!("Failed to serialize YAML section '{key}': {e}")))?; Ok(yaml_str) @@ -367,7 +362,10 @@ fn write_yaml_section_to_config_locked( let warnings = scan_hermes_health_internal(&new_raw); - log::debug!("Hermes config section '{section_key}' written to {:?}", config_path); + log::debug!( + "Hermes config section '{section_key}' written to {:?}", + config_path + ); Ok(HermesWriteOutcome { backup_path: backup_path.map(|p| p.display().to_string()), warnings, @@ -438,11 +436,10 @@ pub fn set_provider( ); } - if let Some(existing) = providers.iter_mut().find(|p| { - p.get("name") - .and_then(|n| n.as_str()) - == Some(name) - }) { + if let Some(existing) = providers + .iter_mut() + .find(|p| p.get("name").and_then(|n| n.as_str()) == Some(name)) + { *existing = yaml_val; } else { providers.push(yaml_val); @@ -467,11 +464,7 @@ pub fn remove_provider(name: &str) -> Result { .unwrap_or_default(); let original_len = providers.len(); - providers.retain(|p| { - p.get("name") - .and_then(|n| n.as_str()) - != Some(name) - }); + providers.retain(|p| p.get("name").and_then(|n| n.as_str()) != Some(name)); if providers.len() == original_len { return Ok(HermesWriteOutcome::default()); @@ -690,9 +683,8 @@ fn scan_hermes_health_internal(content: &str) -> Vec { if !providers.is_sequence() { warnings.push(HermesHealthWarning { code: "custom_providers_not_list".to_string(), - message: - "custom_providers should be a YAML list (sequence), not a mapping" - .to_string(), + message: "custom_providers should be a YAML list (sequence), not a mapping" + .to_string(), path: Some("custom_providers".to_string()), }); } diff --git a/src-tauri/src/mcp/hermes.rs b/src-tauri/src/mcp/hermes.rs index 69279b765..c2788212f 100644 --- a/src-tauri/src/mcp/hermes.rs +++ b/src-tauri/src/mcp/hermes.rs @@ -83,8 +83,7 @@ fn convert_to_hermes_format(spec: &Value) -> Result { result.insert("url".into(), url.clone()); } if let Some(headers) = obj.get("headers") { - if headers.is_object() - && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true) + if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true) { result.insert("headers".into(), headers.clone()); } @@ -142,9 +141,7 @@ fn convert_from_hermes_format(id: &str, spec: &Value) -> Result result.insert("url".into(), url.clone()); } if let Some(headers) = obj.get("headers") { - if headers.is_object() - && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true) - { + if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true) { result.insert("headers".into(), headers.clone()); } } diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index 37e04c1c6..a0026dd87 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -132,11 +132,7 @@ impl McpService { 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, - )?; + mcp::sync_single_server_to_hermes(&Default::default(), &server.id, &server.server)?; } } Ok(()) diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index e50fd1023..2127b51d3 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -1393,9 +1393,7 @@ pub fn remove_hermes_provider_from_live(provider_id: &str) -> Result<(), AppErro // Check if Hermes config directory exists if !hermes_config::get_hermes_dir().exists() { - log::debug!( - "Hermes config directory doesn't exist, skipping removal of '{provider_id}'" - ); + log::debug!("Hermes config directory doesn't exist, skipping removal of '{provider_id}'"); return Ok(()); } diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index cae1cd5b9..a7df8892e 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -21,9 +21,8 @@ use crate::store::AppState; // Re-export sub-module functions for external access pub use live::{ - import_default_config, import_hermes_providers_from_live, - import_openclaw_providers_from_live, import_opencode_providers_from_live, read_live_settings, - sync_current_to_live, + import_default_config, import_hermes_providers_from_live, import_openclaw_providers_from_live, + import_opencode_providers_from_live, read_live_settings, sync_current_to_live, }; // Internal re-exports (pub(crate)) diff --git a/src/App.tsx b/src/App.tsx index 8d2f04f71..b331aaea1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,8 @@ import { KeyRound, Shield, Cpu, + Brain, + Bot, } from "lucide-react"; import { getCurrentWindow } from "@tauri-apps/api/window"; import type { Provider, VisibleApps } from "@/types"; @@ -39,7 +41,7 @@ import { import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env"; import { useProviderActions } from "@/hooks/useProviderActions"; import { openclawKeys, useOpenClawHealth } from "@/hooks/useOpenClaw"; -import { hermesKeys } from "@/hooks/useHermes"; +import { hermesKeys, useHermesHealth } from "@/hooks/useHermes"; import { useProxyStatus } from "@/hooks/useProxyStatus"; import { useAutoCompact } from "@/hooks/useAutoCompact"; import { useLastValidValue } from "@/hooks/useLastValidValue"; @@ -83,6 +85,10 @@ import EnvPanel from "@/components/openclaw/EnvPanel"; import ToolsPanel from "@/components/openclaw/ToolsPanel"; import AgentsDefaultsPanel from "@/components/openclaw/AgentsDefaultsPanel"; import OpenClawHealthBanner from "@/components/openclaw/OpenClawHealthBanner"; +import HermesModelPanel from "@/components/hermes/ModelPanel"; +import HermesAgentPanel from "@/components/hermes/AgentPanel"; +import HermesEnvPanel from "@/components/hermes/EnvPanel"; +import HermesHealthBanner from "@/components/hermes/HermesHealthBanner"; type View = | "providers" @@ -97,7 +103,10 @@ type View = | "workspace" | "openclawEnv" | "openclawTools" - | "openclawAgents"; + | "openclawAgents" + | "hermesModel" + | "hermesAgent" + | "hermesEnv"; interface WebDavSyncStatusUpdatedPayload { source?: string; @@ -141,6 +150,9 @@ const VALID_VIEWS: View[] = [ "openclawEnv", "openclawTools", "openclawAgents", + "hermesModel", + "hermesAgent", + "hermesEnv", ]; const getInitialView = (): View => { @@ -203,7 +215,8 @@ function App() { activeApp !== "codex" && activeApp !== "opencode" && activeApp !== "openclaw" && - activeApp !== "gemini" + activeApp !== "gemini" && + activeApp !== "hermes" ) { setCurrentView("providers"); } @@ -259,13 +272,21 @@ function App() { currentView === "openclawAgents"); const { data: openclawHealthWarnings = [] } = useOpenClawHealth(isOpenClawView); + const isHermesView = + activeApp === "hermes" && + (currentView === "providers" || + currentView === "hermesModel" || + currentView === "hermesAgent" || + currentView === "hermesEnv"); + const { data: hermesHealthWarnings = [] } = useHermesHealth(isHermesView); const hasSkillsSupport = true; const hasSessionSupport = activeApp === "claude" || activeApp === "codex" || activeApp === "opencode" || activeApp === "openclaw" || - activeApp === "gemini"; + activeApp === "gemini" || + activeApp === "hermes"; const { addProvider, @@ -940,6 +961,12 @@ function App() { return ; case "openclawAgents": return ; + case "hermesModel": + return ; + case "hermesAgent": + return ; + case "hermesEnv": + return ; default: return (
@@ -971,7 +998,9 @@ function App() { setConfirmAction({ provider, action: "delete" }) } onRemoveFromConfig={ - activeApp === "opencode" || activeApp === "openclaw" + activeApp === "opencode" || + activeApp === "openclaw" || + activeApp === "hermes" ? (provider) => setConfirmAction({ provider, action: "remove" }) : undefined @@ -1153,6 +1182,9 @@ function App() { {currentView === "openclawTools" && t("openclaw.tools.title")} {currentView === "openclawAgents" && t("openclaw.agents.title")} + {currentView === "hermesModel" && t("hermes.model.title")} + {currentView === "hermesAgent" && t("hermes.agent.title")} + {currentView === "hermesEnv" && t("hermes.env.title")}
) : ( @@ -1213,7 +1245,8 @@ function App() {
{currentView === "providers" && activeApp !== "opencode" && - activeApp !== "openclaw" && ( + activeApp !== "openclaw" && + activeApp !== "hermes" && (
- {activeApp === "openclaw" ? ( + {activeApp === "hermes" ? ( + <> + + + + + + + ) : activeApp === "openclaw" ? ( <> +
+
+ ); +}; + +export default AgentPanel; diff --git a/src/components/hermes/EnvPanel.tsx b/src/components/hermes/EnvPanel.tsx new file mode 100644 index 000000000..b8ce9f735 --- /dev/null +++ b/src/components/hermes/EnvPanel.tsx @@ -0,0 +1,128 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Save } from "lucide-react"; +import { toast } from "sonner"; +import { useHermesEnv, useSaveHermesEnv } from "@/hooks/useHermes"; +import { extractErrorMessage } from "@/utils/errorUtils"; +import { Button } from "@/components/ui/button"; +import JsonEditor from "@/components/JsonEditor"; + +function parseEnvEditorValue(raw: string): Record { + const trimmed = raw.trim(); + if (!trimmed) throw new Error("HERMES_ENV_EMPTY"); + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + throw new Error("HERMES_ENV_INVALID_JSON"); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("HERMES_ENV_OBJECT_REQUIRED"); + } + return parsed as Record; +} + +const EnvPanel: React.FC = () => { + const { t } = useTranslation(); + const { data: envData, isLoading } = useHermesEnv(); + const saveEnvMutation = useSaveHermesEnv(); + const [editorValue, setEditorValue] = useState("{}"); + const [isDarkMode, setIsDarkMode] = useState(false); + + useEffect(() => { + const nextValue = + envData && Object.keys(envData).length > 0 + ? JSON.stringify(envData, null, 2) + : "{}"; + setEditorValue(nextValue); + }, [envData]); + + useEffect(() => { + setIsDarkMode(document.documentElement.classList.contains("dark")); + + const observer = new MutationObserver(() => { + setIsDarkMode(document.documentElement.classList.contains("dark")); + }); + + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + + return () => observer.disconnect(); + }, []); + + const handleSave = async () => { + try { + const env = parseEnvEditorValue(editorValue); + await saveEnvMutation.mutateAsync(env); + toast.success(t("hermes.env.saveSuccess")); + } catch (error) { + const detail = extractErrorMessage(error); + let description = detail || undefined; + if (detail === "HERMES_ENV_EMPTY") { + description = t("hermes.env.empty", { + defaultValue: + "Hermes env cannot be empty. Use {} for an empty object.", + }); + } else if (detail === "HERMES_ENV_INVALID_JSON") { + description = t("hermes.env.invalidJson", { + defaultValue: "Hermes env must be valid JSON.", + }); + } else if (detail === "HERMES_ENV_OBJECT_REQUIRED") { + description = t("hermes.env.objectRequired", { + defaultValue: "Hermes env must be a JSON object.", + }); + } + toast.error(t("hermes.env.saveFailed"), { + description, + }); + } + }; + + if (isLoading) { + return ( +
+
+ {t("common.loading")} +
+
+ ); + } + + return ( +
+

+ {t("hermes.env.description")} +

+

+ {t("hermes.env.editorHint", { + defaultValue: + "Edit the Hermes .env file as a JSON key-value map. Keys become environment variable names.", + })} +

+ + + +
+ +
+
+ ); +}; + +export default EnvPanel; diff --git a/src/components/hermes/HermesHealthBanner.tsx b/src/components/hermes/HermesHealthBanner.tsx new file mode 100644 index 000000000..6907c6138 --- /dev/null +++ b/src/components/hermes/HermesHealthBanner.tsx @@ -0,0 +1,78 @@ +import React, { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { TriangleAlert } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import type { HermesHealthWarning } from "@/types"; + +interface HermesHealthBannerProps { + warnings: HermesHealthWarning[]; +} + +function getWarningText( + code: string, + fallback: string, + t: ReturnType["t"], +) { + switch (code) { + case "config_parse_failed": + return t("hermes.health.parseFailed", { + defaultValue: + "config.yaml could not be parsed as valid YAML. Fix the file before editing it here.", + }); + case "config_not_found": + return t("hermes.health.configNotFound", { + defaultValue: + "Hermes config.yaml not found. Create it at ~/.hermes/config.yaml or configure the path in settings.", + }); + case "env_parse_failed": + return t("hermes.health.envParseFailed", { + defaultValue: "The .env file could not be parsed.", + }); + default: + return fallback; + } +} + +const HermesHealthBanner: React.FC = ({ + warnings, +}) => { + const { t } = useTranslation(); + + const items = useMemo( + () => + warnings.map((warning) => ({ + ...warning, + text: getWarningText(warning.code, warning.message, t), + })), + [t, warnings], + ); + + if (warnings.length === 0) { + return null; + } + + return ( +
+ + + + {t("hermes.health.title", { + defaultValue: "Hermes config warnings detected", + })} + + +
    + {items.map((warning) => ( +
  • + {warning.text} + {warning.path ? ` (${warning.path})` : ""} +
  • + ))} +
+
+
+
+ ); +}; + +export default HermesHealthBanner; diff --git a/src/components/hermes/ModelPanel.tsx b/src/components/hermes/ModelPanel.tsx new file mode 100644 index 000000000..08eb2fbf5 --- /dev/null +++ b/src/components/hermes/ModelPanel.tsx @@ -0,0 +1,202 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Save } from "lucide-react"; +import { toast } from "sonner"; +import { + useHermesModelConfig, + useSaveHermesModelConfig, +} from "@/hooks/useHermes"; +import { extractErrorMessage } from "@/utils/errorUtils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { HermesModelConfig } from "@/types"; + +const ModelPanel: React.FC = () => { + const { t } = useTranslation(); + const { data: modelData, isLoading } = useHermesModelConfig(true); + const saveModelMutation = useSaveHermesModelConfig(); + + const [defaultModel, setDefaultModel] = useState(""); + const [provider, setProvider] = useState(""); + const [baseUrl, setBaseUrl] = useState(""); + const [contextLength, setContextLength] = useState(""); + const [maxTokens, setMaxTokens] = useState(""); + + // Preserve unknown fields from the original config + const [extra, setExtra] = useState>({}); + + useEffect(() => { + if (modelData === undefined) return; + if (modelData) { + setDefaultModel(modelData.default ?? ""); + setProvider(modelData.provider ?? ""); + setBaseUrl(modelData.base_url ?? ""); + setContextLength( + modelData.context_length != null + ? String(modelData.context_length) + : "", + ); + setMaxTokens( + modelData.max_tokens != null ? String(modelData.max_tokens) : "", + ); + // Collect unknown fields + const { + default: _d, + provider: _p, + base_url: _b, + context_length: _c, + max_tokens: _m, + ...rest + } = modelData; + setExtra(rest); + } else { + setDefaultModel(""); + setProvider(""); + setBaseUrl(""); + setContextLength(""); + setMaxTokens(""); + setExtra({}); + } + }, [modelData]); + + const handleSave = async () => { + try { + const config: HermesModelConfig = { + ...extra, + }; + if (defaultModel.trim()) config.default = defaultModel.trim(); + if (provider.trim()) config.provider = provider.trim(); + if (baseUrl.trim()) config.base_url = baseUrl.trim(); + + const cl = parseInt(contextLength); + if (!isNaN(cl) && cl > 0) config.context_length = cl; + + const mt = parseInt(maxTokens); + if (!isNaN(mt) && mt > 0) config.max_tokens = mt; + + await saveModelMutation.mutateAsync(config); + toast.success(t("hermes.model.saveSuccess")); + } catch (error) { + toast.error(t("hermes.model.saveFailed"), { + description: extractErrorMessage(error), + }); + } + }; + + if (isLoading) { + return ( +
+
+ {t("common.loading")} +
+
+ ); + } + + return ( +
+

+ {t("hermes.model.description")} +

+ +
+
+
+ + setDefaultModel(e.target.value)} + placeholder="anthropic/claude-opus-4-6" + /> +

+ {t("hermes.model.defaultHint", { + defaultValue: + "The default model to use, e.g. anthropic/claude-opus-4-6", + })} +

+
+ +
+ + setProvider(e.target.value)} + placeholder="openrouter" + /> +

+ {t("hermes.model.providerHint", { + defaultValue: + "Provider name for model routing (e.g. openrouter, anthropic)", + })} +

+
+ +
+ + setBaseUrl(e.target.value)} + placeholder="https://api.example.com/v1" + /> +

+ {t("hermes.model.baseUrlHint", { + defaultValue: "Override the API endpoint URL for this model", + })} +

+
+ +
+ + setContextLength(e.target.value)} + placeholder="200000" + /> +
+ +
+ + setMaxTokens(e.target.value)} + placeholder="16384" + /> +
+
+
+ +
+ +
+
+ ); +}; + +export default ModelPanel; diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index b6e9075a7..c611d0acf 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -41,7 +41,8 @@ export function AddProviderDialog({ }: AddProviderDialogProps) { const { t } = useTranslation(); // OpenCode and OpenClaw don't support universal providers - const showUniversalTab = appId !== "opencode" && appId !== "openclaw"; + const showUniversalTab = + appId !== "opencode" && appId !== "openclaw" && appId !== "hermes"; const [activeTab, setActiveTab] = useState<"app-specific" | "universal">( "app-specific", ); @@ -106,7 +107,7 @@ export function AddProviderDialog({ // OpenCode/OpenClaw: pass providerKey for ID generation if ( - (appId === "opencode" || appId === "openclaw") && + (appId === "opencode" || appId === "openclaw" || appId === "hermes") && values.providerKey ) { providerData.providerKey = values.providerKey; @@ -203,6 +204,10 @@ export function AddProviderDialog({ if (parsedConfig.baseUrl) { addUrl(parsedConfig.baseUrl as string); } + } else if (appId === "hermes") { + if (parsedConfig.base_url) { + addUrl(parsedConfig.base_url as string); + } } const urls = Array.from(urlSet); diff --git a/src/components/providers/forms/HermesFormFields.tsx b/src/components/providers/forms/HermesFormFields.tsx new file mode 100644 index 000000000..0e8b5e5ab --- /dev/null +++ b/src/components/providers/forms/HermesFormFields.tsx @@ -0,0 +1,64 @@ +import { useTranslation } from "react-i18next"; +import { FormLabel } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { ApiKeySection } from "./shared"; +import type { ProviderCategory } from "@/types"; + +interface HermesFormFieldsProps { + baseUrl: string; + onBaseUrlChange: (value: string) => void; + apiKey: string; + onApiKeyChange: (value: string) => void; + category?: ProviderCategory; + shouldShowApiKeyLink: boolean; + websiteUrl: string; + isPartner?: boolean; + partnerPromotionKey?: string; +} + +export function HermesFormFields({ + baseUrl, + onBaseUrlChange, + apiKey, + onApiKeyChange, + category, + shouldShowApiKeyLink, + websiteUrl, + isPartner, + partnerPromotionKey, +}: HermesFormFieldsProps) { + const { t } = useTranslation(); + + return ( + <> + {/* Base URL */} +
+ + {t("hermes.form.baseUrl", { defaultValue: "API Endpoint" })} + + onBaseUrlChange(e.target.value)} + placeholder="https://api.example.com/v1" + /> +

+ {t("hermes.form.baseUrlHint", { + defaultValue: "The API endpoint URL for this provider.", + })} +

+
+ + {/* API Key */} + + + ); +} diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 6d414c769..fba7b2624 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -37,8 +37,13 @@ import { type OpenClawProviderPreset, type OpenClawSuggestedDefaults, } from "@/config/openclawProviderPresets"; +import { + hermesProviderPresets, + type HermesProviderPreset, +} from "@/config/hermesProviderPresets"; import { OpenCodeFormFields } from "./OpenCodeFormFields"; import { OpenClawFormFields } from "./OpenClawFormFields"; +import { HermesFormFields } from "./HermesFormFields"; import type { UniversalProviderPreset } from "@/config/universalProviderPresets"; import { applyTemplateValues, @@ -80,6 +85,7 @@ import { useOpencodeFormState, useOmoDraftState, useOpenclawFormState, + useHermesFormState, useCopilotAuth, useCodexOauth, } from "./hooks"; @@ -95,6 +101,7 @@ import { } from "./helpers/opencodeFormUtils"; import { resolveManagedAccountId } from "@/lib/authBinding"; import { useOpenClawLiveProviderIds } from "@/hooks/useOpenClaw"; +import { useHermesLiveProviderIds } from "@/hooks/useHermes"; type PresetEntry = { id: string; @@ -103,7 +110,8 @@ type PresetEntry = { | CodexProviderPreset | GeminiProviderPreset | OpenCodeProviderPreset - | OpenClawProviderPreset; + | OpenClawProviderPreset + | HermesProviderPreset; }; interface ProviderFormProps { @@ -449,6 +457,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) @@ -644,6 +657,18 @@ export function ProviderForm({ isLoading: isOpenclawLiveProviderIdsLoading, } = useOpenClawLiveProviderIds(appId === "openclaw"); + const hermesForm = useHermesFormState({ + initialData, + appId, + providerId, + onSettingsConfigChange: (config) => form.setValue("settingsConfig", config), + getSettingsConfig: () => form.getValues("settingsConfig"), + }); + const { + data: hermesLiveProviderIds = [], + isLoading: isHermesLiveProviderIdsLoading, + } = useHermesLiveProviderIds(appId === "hermes"); + const additiveExistingProviderKeys = useMemo(() => { if (appId === "opencode" && !isAnyOmoCategory) { return Array.from( @@ -666,10 +691,22 @@ export function ProviderForm({ ); } + if (appId === "hermes") { + return Array.from( + new Set( + [...hermesForm.existingHermesKeys, ...hermesLiveProviderIds].filter( + (key) => key !== providerId, + ), + ), + ); + } + return []; }, [ appId, existingOpencodeKeys, + hermesForm.existingHermesKeys, + hermesLiveProviderIds, isAnyOmoCategory, openclawForm.existingOpenclawKeys, openclawLiveProviderIds, @@ -685,11 +722,15 @@ export function ProviderForm({ if (appId === "openclaw") { return isOpenclawLiveProviderIdsLoading; } + if (appId === "hermes") { + return isHermesLiveProviderIdsLoading; + } return false; }, [ appId, isAnyOmoCategory, isEditMode, + isHermesLiveProviderIdsLoading, isOpenclawLiveProviderIdsLoading, isOpencodeLiveProviderIdsLoading, ]); @@ -702,9 +743,13 @@ export function ProviderForm({ if (appId === "openclaw") { return openclawLiveProviderIds.includes(providerId); } + if (appId === "hermes") { + return hermesLiveProviderIds.includes(providerId); + } return false; }, [ appId, + hermesLiveProviderIds, isAnyOmoCategory, isEditMode, openclawLiveProviderIds, @@ -796,6 +841,34 @@ export function ProviderForm({ } } + // Hermes: validate provider key + if (appId === "hermes") { + const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/; + if (!hermesForm.hermesProviderKey.trim()) { + toast.error(t("hermes.form.providerKeyRequired")); + return; + } + if (!keyPattern.test(hermesForm.hermesProviderKey)) { + toast.error(t("hermes.form.providerKeyInvalid")); + return; + } + if (isProviderKeyLockStateLoading) { + toast.error( + t("providerForm.providerKeyStatusLoading", { + defaultValue: "正在加载供应商标识状态,请稍后再试", + }), + ); + return; + } + if ( + !isProviderKeyLocked && + additiveExistingProviderKeys.includes(hermesForm.hermesProviderKey) + ) { + toast.error(t("hermes.form.providerKeyDuplicate")); + return; + } + } + // 非官方供应商必填校验:端点和 API Key // cloud_provider(如 Bedrock)通过模板变量处理认证,跳过通用校验 // GitHub Copilot 使用 OAuth 认证,不需要 API Key @@ -968,6 +1041,8 @@ export function ProviderForm({ } } else if (appId === "openclaw") { payload.providerKey = openclawForm.openclawProviderKey; + } else if (appId === "hermes") { + payload.providerKey = hermesForm.hermesProviderKey; } if (isAnyOmoCategory && !payload.presetCategory) { @@ -1181,6 +1256,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, @@ -1212,6 +1301,9 @@ export function ProviderForm({ if (appId === "openclaw") { openclawForm.resetOpenclawState(); } + if (appId === "hermes") { + hermesForm.resetHermesState(); + } return; } @@ -1316,6 +1408,23 @@ export function ProviderForm({ return; } + // Hermes preset handling + if (appId === "hermes") { + const preset = entry.preset as HermesProviderPreset; + const config = preset.settingsConfig; + + hermesForm.resetHermesState(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; + } + const preset = entry.preset as ProviderPreset; const config = applyTemplateValues( preset.settingsConfig, @@ -1508,6 +1617,79 @@ export function ProviderForm({

)}
+ ) : appId === "hermes" ? ( +
+ + + hermesForm.setHermesProviderKey( + e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""), + ) + } + placeholder={t("hermes.form.providerKeyPlaceholder", { + defaultValue: "my-provider", + })} + disabled={ + isProviderKeyLocked || isProviderKeyLockStateLoading + } + className={ + (additiveExistingProviderKeys.includes( + hermesForm.hermesProviderKey, + ) && + !isProviderKeyLocked) || + (hermesForm.hermesProviderKey.trim() !== "" && + !/^[a-z0-9]+(-[a-z0-9]+)*$/.test( + hermesForm.hermesProviderKey, + )) + ? "border-destructive" + : "" + } + /> + {additiveExistingProviderKeys.includes( + hermesForm.hermesProviderKey, + ) && + !isProviderKeyLocked && ( +

+ {t("hermes.form.providerKeyDuplicate")} +

+ )} + {hermesForm.hermesProviderKey.trim() !== "" && + !/^[a-z0-9]+(-[a-z0-9]+)*$/.test( + hermesForm.hermesProviderKey, + ) && ( +

+ {t("hermes.form.providerKeyInvalid")} +

+ )} + {!( + additiveExistingProviderKeys.includes( + hermesForm.hermesProviderKey, + ) && !isProviderKeyLocked + ) && + (hermesForm.hermesProviderKey.trim() === "" || + /^[a-z0-9]+(-[a-z0-9]+)*$/.test( + hermesForm.hermesProviderKey, + )) && ( +

+ {isProviderKeyLocked + ? t("hermes.form.providerKeyLockedHint", { + defaultValue: + "This provider is in Hermes config; key is locked.", + }) + : t("hermes.form.providerKeyHint", { + defaultValue: + "Lowercase letters, numbers, and hyphens only. Used as the provider name in config.yaml.", + })} +

+ )} +
) : undefined } /> @@ -1701,6 +1883,21 @@ export function ProviderForm({ /> )} + {/* Hermes 专属字段 */} + {appId === "hermes" && ( + + )} + {/* 配置编辑器:Codex、Claude、Gemini 分别使用不同的编辑器 */} {appId === "codex" ? ( <> @@ -1784,7 +1981,7 @@ export function ProviderForm({
{settingsConfigErrorField} - ) : appId === "openclaw" ? ( + ) : appId === "openclaw" || appId === "hermes" ? ( <>
diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index 246a5965c..8bbfb6e6c 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -759,7 +759,7 @@ export const providerPresets: ProviderPreset[] = [ ANTHROPIC_MODEL: "anthropic/claude-sonnet-4.6", ANTHROPIC_DEFAULT_HAIKU_MODEL: "anthropic/claude-haiku-4.5", ANTHROPIC_DEFAULT_SONNET_MODEL: "anthropic/claude-sonnet-4.6", - ANTHROPIC_DEFAULT_OPUS_MODEL: "anthropic/claude-opus-4.6", + ANTHROPIC_DEFAULT_OPUS_MODEL: "anthropic/claude-opus-4.7", }, }, category: "aggregator", @@ -778,7 +778,7 @@ export const providerPresets: ProviderPreset[] = [ ANTHROPIC_MODEL: "anthropic/claude-sonnet-4.6", ANTHROPIC_DEFAULT_HAIKU_MODEL: "anthropic/claude-haiku-4.5", ANTHROPIC_DEFAULT_SONNET_MODEL: "anthropic/claude-sonnet-4.6", - ANTHROPIC_DEFAULT_OPUS_MODEL: "anthropic/claude-opus-4.6", + ANTHROPIC_DEFAULT_OPUS_MODEL: "anthropic/claude-opus-4.7", }, }, category: "aggregator", @@ -809,10 +809,10 @@ export const providerPresets: ProviderPreset[] = [ settingsConfig: { env: { ANTHROPIC_BASE_URL: "https://api.githubcopilot.com", - ANTHROPIC_MODEL: "claude-opus-4.6", + ANTHROPIC_MODEL: "claude-opus-4.7", ANTHROPIC_DEFAULT_HAIKU_MODEL: "claude-haiku-4.5", ANTHROPIC_DEFAULT_SONNET_MODEL: "claude-sonnet-4.6", - ANTHROPIC_DEFAULT_OPUS_MODEL: "claude-opus-4.6", + ANTHROPIC_DEFAULT_OPUS_MODEL: "claude-opus-4.7", }, }, category: "third_party", diff --git a/src/config/openclawProviderPresets.ts b/src/config/openclawProviderPresets.ts index 53f8e5c33..12a375e7d 100644 --- a/src/config/openclawProviderPresets.ts +++ b/src/config/openclawProviderPresets.ts @@ -69,8 +69,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ api: "anthropic-messages", models: [ { - id: "claude-opus-4-6", - name: "Claude Opus 4.6", + id: "claude-opus-4-7", + name: "Claude Opus 4.7", contextWindow: 1000000, cost: { input: 5, output: 25 }, }, @@ -95,11 +95,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, suggestedDefaults: { model: { - primary: "shengsuanyun/claude-opus-4-6", + primary: "shengsuanyun/claude-opus-4-7", fallbacks: ["shengsuanyun/claude-sonnet-4-6"], }, modelCatalog: { - "shengsuanyun/claude-opus-4-6": { alias: "Opus" }, + "shengsuanyun/claude-opus-4-7": { alias: "Opus" }, "shengsuanyun/claude-sonnet-4-6": { alias: "Sonnet" }, }, }, @@ -696,8 +696,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ api: "anthropic-messages", models: [ { - id: "claude-opus-4-6", - name: "Claude Opus 4.6", + id: "claude-opus-4-7", + name: "Claude Opus 4.7", contextWindow: 1000000, cost: { input: 5, output: 25 }, }, @@ -721,11 +721,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, suggestedDefaults: { model: { - primary: "aihubmix/claude-opus-4-6", + primary: "aihubmix/claude-opus-4-7", fallbacks: ["aihubmix/claude-sonnet-4-6"], }, modelCatalog: { - "aihubmix/claude-opus-4-6": { alias: "Opus" }, + "aihubmix/claude-opus-4-7": { alias: "Opus" }, "aihubmix/claude-sonnet-4-6": { alias: "Sonnet" }, }, }, @@ -740,8 +740,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ api: "anthropic-messages", models: [ { - id: "claude-opus-4-6", - name: "Claude Opus 4.6", + id: "claude-opus-4-7", + name: "Claude Opus 4.7", contextWindow: 1000000, cost: { input: 5, output: 25 }, }, @@ -765,11 +765,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, suggestedDefaults: { model: { - primary: "dmxapi/claude-opus-4-6", + primary: "dmxapi/claude-opus-4-7", fallbacks: ["dmxapi/claude-sonnet-4-6"], }, modelCatalog: { - "dmxapi/claude-opus-4-6": { alias: "Opus" }, + "dmxapi/claude-opus-4-7": { alias: "Opus" }, "dmxapi/claude-sonnet-4-6": { alias: "Sonnet" }, }, }, @@ -784,8 +784,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ api: "openai-completions", models: [ { - id: "anthropic/claude-opus-4.6", - name: "Claude Opus 4.6", + id: "anthropic/claude-opus-4.7", + name: "Claude Opus 4.7", contextWindow: 1000000, cost: { input: 5, output: 25 }, }, @@ -809,11 +809,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, suggestedDefaults: { model: { - primary: "openrouter/anthropic/claude-opus-4.6", + primary: "openrouter/anthropic/claude-opus-4.7", fallbacks: ["openrouter/anthropic/claude-sonnet-4.6"], }, modelCatalog: { - "openrouter/anthropic/claude-opus-4.6": { alias: "Opus" }, + "openrouter/anthropic/claude-opus-4.7": { alias: "Opus" }, "openrouter/anthropic/claude-sonnet-4.6": { alias: "Sonnet" }, }, }, @@ -1070,8 +1070,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ api: "anthropic-messages", models: [ { - id: "claude-opus-4-6", - name: "claude-opus-4-6", + id: "claude-opus-4-7", + name: "claude-opus-4-7", contextWindow: 1000000, cost: { input: 5, output: 25 }, }, @@ -1100,11 +1100,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, suggestedDefaults: { model: { - primary: "pipellm/claude-opus-4-6", + primary: "pipellm/claude-opus-4-7", fallbacks: ["pipellm/claude-sonnet-4-6"], }, modelCatalog: { - "pipellm/claude-opus-4-6": { alias: "Opus" }, + "pipellm/claude-opus-4-7": { alias: "Opus" }, "pipellm/claude-sonnet-4-6": { alias: "Sonnet" }, "pipellm/claude-haiku-4-5-20251001": { alias: "Haiku" }, }, @@ -1122,8 +1122,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ api: "anthropic-messages", models: [ { - id: "claude-opus-4-6", - name: "Claude Opus 4.6", + id: "claude-opus-4-7", + name: "Claude Opus 4.7", contextWindow: 1000000, cost: { input: 5, output: 25 }, }, @@ -1148,11 +1148,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, suggestedDefaults: { model: { - primary: "packycode/claude-opus-4-6", + primary: "packycode/claude-opus-4-7", fallbacks: ["packycode/claude-sonnet-4-6"], }, modelCatalog: { - "packycode/claude-opus-4-6": { alias: "Opus" }, + "packycode/claude-opus-4-7": { alias: "Opus" }, "packycode/claude-sonnet-4-6": { alias: "Sonnet" }, }, }, @@ -1167,8 +1167,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ api: "anthropic-messages", models: [ { - id: "claude-opus-4-6", - name: "Claude Opus 4.6", + id: "claude-opus-4-7", + name: "Claude Opus 4.7", contextWindow: 1000000, cost: { input: 5, output: 25 }, }, @@ -1194,11 +1194,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, suggestedDefaults: { model: { - primary: "cubence/claude-opus-4-6", + primary: "cubence/claude-opus-4-7", fallbacks: ["cubence/claude-sonnet-4-6"], }, modelCatalog: { - "cubence/claude-opus-4-6": { alias: "Opus" }, + "cubence/claude-opus-4-7": { alias: "Opus" }, "cubence/claude-sonnet-4-6": { alias: "Sonnet" }, }, }, @@ -1213,8 +1213,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ api: "anthropic-messages", models: [ { - id: "claude-opus-4-6", - name: "Claude Opus 4.6", + id: "claude-opus-4-7", + name: "Claude Opus 4.7", contextWindow: 1000000, cost: { input: 5, output: 25 }, }, @@ -1240,11 +1240,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, suggestedDefaults: { model: { - primary: "aigocode/claude-opus-4-6", + primary: "aigocode/claude-opus-4-7", fallbacks: ["aigocode/claude-sonnet-4-6"], }, modelCatalog: { - "aigocode/claude-opus-4-6": { alias: "Opus" }, + "aigocode/claude-opus-4-7": { alias: "Opus" }, "aigocode/claude-sonnet-4-6": { alias: "Sonnet" }, }, }, @@ -1259,8 +1259,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ api: "anthropic-messages", models: [ { - id: "claude-opus-4-6", - name: "Claude Opus 4.6", + id: "claude-opus-4-7", + name: "Claude Opus 4.7", contextWindow: 1000000, cost: { input: 5, output: 25 }, }, @@ -1286,11 +1286,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, suggestedDefaults: { model: { - primary: "rightcode/claude-opus-4-6", + primary: "rightcode/claude-opus-4-7", fallbacks: ["rightcode/claude-sonnet-4-6"], }, modelCatalog: { - "rightcode/claude-opus-4-6": { alias: "Opus" }, + "rightcode/claude-opus-4-7": { alias: "Opus" }, "rightcode/claude-sonnet-4-6": { alias: "Sonnet" }, }, }, @@ -1305,8 +1305,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ api: "anthropic-messages", models: [ { - id: "claude-opus-4-6", - name: "Claude Opus 4.6", + id: "claude-opus-4-7", + name: "Claude Opus 4.7", contextWindow: 1000000, cost: { input: 5, output: 25 }, }, @@ -1332,11 +1332,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, suggestedDefaults: { model: { - primary: "aicodemirror/claude-opus-4-6", + primary: "aicodemirror/claude-opus-4-7", fallbacks: ["aicodemirror/claude-sonnet-4-6"], }, modelCatalog: { - "aicodemirror/claude-opus-4-6": { alias: "Opus" }, + "aicodemirror/claude-opus-4-7": { alias: "Opus" }, "aicodemirror/claude-sonnet-4-6": { alias: "Sonnet" }, }, }, @@ -1351,8 +1351,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ api: "anthropic-messages", models: [ { - id: "claude-opus-4-6", - name: "Claude Opus 4.6", + id: "claude-opus-4-7", + name: "Claude Opus 4.7", contextWindow: 1000000, cost: { input: 5, output: 25 }, }, @@ -1378,11 +1378,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, suggestedDefaults: { model: { - primary: "aicoding/claude-opus-4-6", + primary: "aicoding/claude-opus-4-7", fallbacks: ["aicoding/claude-sonnet-4-6"], }, modelCatalog: { - "aicoding/claude-opus-4-6": { alias: "Opus" }, + "aicoding/claude-opus-4-7": { alias: "Opus" }, "aicoding/claude-sonnet-4-6": { alias: "Sonnet" }, }, }, @@ -1397,8 +1397,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ api: "anthropic-messages", models: [ { - id: "claude-opus-4-6", - name: "Claude Opus 4.6", + id: "claude-opus-4-7", + name: "Claude Opus 4.7", contextWindow: 1000000, cost: { input: 5, output: 25 }, }, @@ -1424,11 +1424,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, suggestedDefaults: { model: { - primary: "crazyrouter/claude-opus-4-6", + primary: "crazyrouter/claude-opus-4-7", fallbacks: ["crazyrouter/claude-sonnet-4-6"], }, modelCatalog: { - "crazyrouter/claude-opus-4-6": { alias: "Opus" }, + "crazyrouter/claude-opus-4-7": { alias: "Opus" }, "crazyrouter/claude-sonnet-4-6": { alias: "Sonnet" }, }, }, @@ -1443,8 +1443,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ api: "anthropic-messages", models: [ { - id: "claude-opus-4-6", - name: "Claude Opus 4.6", + id: "claude-opus-4-7", + name: "Claude Opus 4.7", contextWindow: 1000000, cost: { input: 5, output: 25 }, }, @@ -1470,11 +1470,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, suggestedDefaults: { model: { - primary: "sssaicode/claude-opus-4-6", + primary: "sssaicode/claude-opus-4-7", fallbacks: ["sssaicode/claude-sonnet-4-6"], }, modelCatalog: { - "sssaicode/claude-opus-4-6": { alias: "Opus" }, + "sssaicode/claude-opus-4-7": { alias: "Opus" }, "sssaicode/claude-sonnet-4-6": { alias: "Sonnet" }, }, }, @@ -1491,8 +1491,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ api: "anthropic-messages", models: [ { - id: "claude-opus-4-6", - name: "Claude Opus 4.6", + id: "claude-opus-4-7", + name: "Claude Opus 4.7", contextWindow: 1000000, cost: { input: 5, output: 25 }, }, @@ -1512,10 +1512,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, suggestedDefaults: { model: { - primary: "compshare/claude-opus-4-6", + primary: "compshare/claude-opus-4-7", }, modelCatalog: { - "compshare/claude-opus-4-6": { alias: "Opus" }, + "compshare/claude-opus-4-7": { alias: "Opus" }, }, }, }, @@ -1529,8 +1529,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ api: "anthropic-messages", models: [ { - id: "claude-opus-4-6", - name: "Claude Opus 4.6", + id: "claude-opus-4-7", + name: "Claude Opus 4.7", contextWindow: 1000000, cost: { input: 5, output: 25 }, }, @@ -1550,10 +1550,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, suggestedDefaults: { model: { - primary: "micu/claude-opus-4-6", + primary: "micu/claude-opus-4-7", }, modelCatalog: { - "micu/claude-opus-4-6": { alias: "Opus" }, + "micu/claude-opus-4-7": { alias: "Opus" }, }, }, }, @@ -1567,8 +1567,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ api: "anthropic-messages", models: [ { - id: "claude-opus-4-6", - name: "Claude Opus 4.6", + id: "claude-opus-4-7", + name: "Claude Opus 4.7", contextWindow: 1000000, cost: { input: 5, output: 25 }, }, @@ -1588,10 +1588,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, suggestedDefaults: { model: { - primary: "ctok/claude-opus-4-6", + primary: "ctok/claude-opus-4-7", }, modelCatalog: { - "ctok/claude-opus-4-6": { alias: "Opus" }, + "ctok/claude-opus-4-7": { alias: "Opus" }, }, }, }, @@ -1604,8 +1604,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ api: "anthropic-messages", models: [ { - id: "claude-opus-4-6", - name: "Claude Opus 4.6", + id: "claude-opus-4-7", + name: "Claude Opus 4.7", contextWindow: 1000000, cost: { input: 5, output: 25 }, }, @@ -1630,11 +1630,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, suggestedDefaults: { model: { - primary: "lionccapi/claude-opus-4-6", + primary: "lionccapi/claude-opus-4-7", fallbacks: ["lionccapi/claude-sonnet-4-6"], }, modelCatalog: { - "lionccapi/claude-opus-4-6": { alias: "Opus" }, + "lionccapi/claude-opus-4-7": { alias: "Opus" }, "lionccapi/claude-sonnet-4-6": { alias: "Sonnet" }, }, }, @@ -1712,10 +1712,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ api: "bedrock-converse-stream", models: [ { - id: "anthropic.claude-opus-4-6-20250514-v1:0", - name: "Claude Opus 4.6", + id: "anthropic.claude-opus-4-7", + name: "Claude Opus 4.7", contextWindow: 1000000, - cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, }, { id: "anthropic.claude-sonnet-4-6", diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts index 8ec12b99c..1e70a2a1a 100644 --- a/src/config/opencodeProviderPresets.ts +++ b/src/config/opencodeProviderPresets.ts @@ -166,8 +166,8 @@ export const OPENCODE_PRESET_MODEL_VARIANTS: Record< ], "@ai-sdk/amazon-bedrock": [ { - id: "global.anthropic.claude-opus-4-6-v1", - name: "Claude Opus 4.6", + id: "global.anthropic.claude-opus-4-7", + name: "Claude Opus 4.7", contextLimit: 1000000, outputLimit: 128000, modalities: { input: ["text", "image", "pdf"], output: ["text"] }, @@ -234,8 +234,8 @@ export const OPENCODE_PRESET_MODEL_VARIANTS: Record< }, }, { - id: "claude-opus-4-6", - name: "Claude Opus 4.6", + id: "claude-opus-4-7", + name: "Claude Opus 4.7", contextLimit: 1000000, outputLimit: 128000, modalities: { input: ["text", "image", "pdf"], output: ["text"] }, @@ -310,7 +310,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ setCacheKey: true, }, models: { - "claude-opus-4-6": { name: "Claude Opus 4.6" }, + "claude-opus-4-7": { name: "Claude Opus 4.7" }, "claude-sonnet-4-6": { name: "Claude Sonnet 4.6" }, }, }, @@ -878,7 +878,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, models: { "claude-sonnet-4-6": { name: "Claude Sonnet 4.6" }, - "claude-opus-4-6": { name: "Claude Opus 4.6" }, + "claude-opus-4-7": { name: "Claude Opus 4.7" }, }, }, category: "aggregator", @@ -906,7 +906,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, models: { "claude-sonnet-4-6": { name: "Claude Sonnet 4.6" }, - "claude-opus-4-6": { name: "Claude Opus 4.6" }, + "claude-opus-4-7": { name: "Claude Opus 4.7" }, }, }, category: "aggregator", @@ -934,7 +934,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, models: { "anthropic/claude-sonnet-4.6": { name: "Claude Sonnet 4.6" }, - "anthropic/claude-opus-4.6": { name: "Claude Opus 4.6" }, + "anthropic/claude-opus-4.7": { name: "Claude Opus 4.7" }, }, }, category: "aggregator", @@ -1046,7 +1046,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ setCacheKey: true, }, models: { - "claude-opus-4-6": { name: "claude-opus-4-6" }, + "claude-opus-4-7": { name: "claude-opus-4-7" }, "claude-sonnet-4-6": { name: "claude-sonnet-4-6" }, "claude-haiku-4-5-20251001": { name: "claude-haiku-4-5-20251001" }, }, @@ -1076,7 +1076,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, models: { "claude-sonnet-4-6": { name: "Claude Sonnet 4.6" }, - "claude-opus-4-6": { name: "Claude Opus 4.6" }, + "claude-opus-4-7": { name: "Claude Opus 4.7" }, }, }, category: "third_party", @@ -1105,7 +1105,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, models: { "claude-sonnet-4-6": { name: "Claude Sonnet 4.6" }, - "claude-opus-4-6": { name: "Claude Opus 4.6" }, + "claude-opus-4-7": { name: "Claude Opus 4.7" }, }, }, category: "third_party", @@ -1135,7 +1135,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, models: { "claude-sonnet-4-6": { name: "Claude Sonnet 4.6" }, - "claude-opus-4-6": { name: "Claude Opus 4.6" }, + "claude-opus-4-7": { name: "Claude Opus 4.7" }, }, }, category: "third_party", @@ -1194,7 +1194,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, models: { "claude-sonnet-4.6": { name: "Claude Sonnet 4.6" }, - "claude-opus-4.6": { name: "Claude Opus 4.6" }, + "claude-opus-4.7": { name: "Claude Opus 4.7" }, }, }, category: "third_party", @@ -1224,7 +1224,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, models: { "claude-sonnet-4-6": { name: "Claude Sonnet 4.6" }, - "claude-opus-4-6": { name: "Claude Opus 4.6" }, + "claude-opus-4-7": { name: "Claude Opus 4.7" }, }, }, category: "third_party", @@ -1254,7 +1254,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, models: { "claude-sonnet-4-6": { name: "Claude Sonnet 4.6" }, - "claude-opus-4-6": { name: "Claude Opus 4.6" }, + "claude-opus-4-7": { name: "Claude Opus 4.7" }, }, }, category: "third_party", @@ -1284,7 +1284,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, models: { "claude-sonnet-4-6": { name: "Claude Sonnet 4.6" }, - "claude-opus-4-6": { name: "Claude Opus 4.6" }, + "claude-opus-4-7": { name: "Claude Opus 4.7" }, }, }, category: "third_party", @@ -1313,7 +1313,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ setCacheKey: true, }, models: { - "claude-opus-4-6": { name: "Claude Opus 4.6" }, + "claude-opus-4-7": { name: "Claude Opus 4.7" }, "claude-sonnet-4-6": { name: "Claude Sonnet 4.6" }, }, }, @@ -1343,7 +1343,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ setCacheKey: true, }, models: { - "claude-opus-4-6": { name: "Claude Opus 4.6" }, + "claude-opus-4-7": { name: "Claude Opus 4.7" }, "claude-sonnet-4-6": { name: "Claude Sonnet 4.6" }, }, }, @@ -1372,7 +1372,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ setCacheKey: true, }, models: { - "claude-opus-4-6": { name: "Claude Opus 4.6" }, + "claude-opus-4-7": { name: "Claude Opus 4.7" }, "claude-sonnet-4-6": { name: "Claude Sonnet 4.6" }, }, }, @@ -1431,7 +1431,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ setCacheKey: true, }, models: { - "global.anthropic.claude-opus-4-6-v1": { name: "Claude Opus 4.6" }, + "global.anthropic.claude-opus-4-7": { name: "Claude Opus 4.7" }, "global.anthropic.claude-sonnet-4-6": { name: "Claude Sonnet 4.6", }, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 6d983efd9..965ba823b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1646,7 +1646,7 @@ "title": "Model Config", "description": "Configure the default model settings in config.yaml's model section.", "default": "Default Model", - "defaultHint": "The default model to use, e.g. anthropic/claude-opus-4-6", + "defaultHint": "The default model to use, e.g. anthropic/claude-opus-4-7", "provider": "Provider", "providerHint": "Provider name for model routing (e.g. openrouter, anthropic)", "baseUrl": "Base URL", @@ -2448,7 +2448,7 @@ "artistry": "Highly creative and artistic task category that inspires novel ideas and creative solutions. Defaults to Gemini 3 Pro max variant.", "quick": "Lightweight task category for single-file modifications, typo fixes, and simple adjustments. Defaults to Claude Haiku 4.5 fast model.", "unspecifiedLow": "Uncategorized low-effort task category for tasks that don't fit other categories with small workload. Defaults to Claude Sonnet 4.5.", - "unspecifiedHigh": "Uncategorized high-effort task category for tasks that don't fit other categories with large workload. Defaults to Claude Opus 4.6 max variant.", + "unspecifiedHigh": "Uncategorized high-effort task category for tasks that don't fit other categories with large workload. Defaults to Claude Opus 4.7 max variant.", "writing": "Writing category for documentation, prose and technical writing. Defaults to Gemini 3 Flash fast generation model." }, "slimAgentDesc": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 4026212d6..b3e1186e8 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1646,7 +1646,7 @@ "title": "モデル設定", "description": "config.yaml の model セクションのデフォルトモデル設定を構成します。", "default": "デフォルトモデル", - "defaultHint": "使用するデフォルトモデル(例: anthropic/claude-opus-4-6)", + "defaultHint": "使用するデフォルトモデル(例: anthropic/claude-opus-4-7)", "provider": "プロバイダー", "providerHint": "モデルルーティングのプロバイダー名(例: openrouter、anthropic)", "baseUrl": "Base URL", @@ -2448,7 +2448,7 @@ "artistry": "高度にクリエイティブで芸術的なタスクカテゴリ。斬新なアイデアとクリエイティブなソリューションを促進。デフォルトで Gemini 3 Pro の max バリアントを使用。", "quick": "軽量タスクカテゴリ。単一ファイルの修正、タイポ修正、簡単な調整などの些細な作業に使用。デフォルトで Claude Haiku 4.5 高速モデルを使用。", "unspecifiedLow": "未分類の低作業量タスクカテゴリ。他のカテゴリに該当せず作業量が小さいタスクに適用。デフォルトで Claude Sonnet 4.5 を使用。", - "unspecifiedHigh": "未分類の高作業量タスクカテゴリ。他のカテゴリに該当せず作業量が大きいタスクに適用。デフォルトで Claude Opus 4.6 の max バリアントを使用。", + "unspecifiedHigh": "未分類の高作業量タスクカテゴリ。他のカテゴリに該当せず作業量が大きいタスクに適用。デフォルトで Claude Opus 4.7 の max バリアントを使用。", "writing": "ライティングカテゴリ。ドキュメント、散文、技術文書に特化。デフォルトで Gemini 3 Flash 高速生成モデルを使用。" }, "slimAgentDesc": { diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 8337a67cb..0fd198e0f 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1647,7 +1647,7 @@ "title": "模型配置", "description": "配置 config.yaml 中 model 节的默认模型设置。", "default": "默认模型", - "defaultHint": "使用的默认模型,如 anthropic/claude-opus-4-6", + "defaultHint": "使用的默认模型,如 anthropic/claude-opus-4-7", "provider": "供应商", "providerHint": "模型路由的供应商名称(如 openrouter、anthropic)", "baseUrl": "Base URL", @@ -2449,7 +2449,7 @@ "artistry": "高度创意与艺术性任务类别,激发新颖想法和创造性解决方案,默认使用 Gemini 3 Pro 的最大变体。", "quick": "轻量任务类别,用于单文件修改、错别字修复、简单调整等琐碎工作,默认使用 Claude Haiku 4.5 快速模型。", "unspecifiedLow": "未归类低工作量任务类别,适用于不适合其他类别且工作量较小的任务,默认使用 Claude Sonnet 4.5。", - "unspecifiedHigh": "未归类高工作量任务类别,适用于不适合其他类别且工作量较大的任务,默认使用 Claude Opus 4.6 的最大变体。", + "unspecifiedHigh": "未归类高工作量任务类别,适用于不适合其他类别且工作量较大的任务,默认使用 Claude Opus 4.7 的最大变体。", "writing": "写作类别,专注于文档、散文和技术写作,默认使用 Gemini 3 Flash 快速生成模型。" }, "slimAgentDesc": { diff --git a/src/types/omo.ts b/src/types/omo.ts index 2ce50efa6..665306c88 100644 --- a/src/types/omo.ts +++ b/src/types/omo.ts @@ -29,7 +29,7 @@ export const OMO_BUILTIN_AGENTS: OmoAgentDef[] = [ display: "Sisyphus", descKey: "omo.agentDesc.sisyphus", tooltipKey: "omo.agentTooltip.sisyphus", - recommended: "claude-opus-4-6", + recommended: "claude-opus-4-7", group: "main", }, { @@ -45,7 +45,7 @@ export const OMO_BUILTIN_AGENTS: OmoAgentDef[] = [ display: "Prometheus", descKey: "omo.agentDesc.prometheus", tooltipKey: "omo.agentTooltip.prometheus", - recommended: "claude-opus-4-6", + recommended: "claude-opus-4-7", group: "main", }, { @@ -93,7 +93,7 @@ export const OMO_BUILTIN_AGENTS: OmoAgentDef[] = [ display: "Metis", descKey: "omo.agentDesc.metis", tooltipKey: "omo.agentTooltip.metis", - recommended: "claude-opus-4-6", + recommended: "claude-opus-4-7", group: "sub", }, { @@ -161,7 +161,7 @@ export const OMO_BUILTIN_CATEGORIES: OmoCategoryDef[] = [ display: "Unspecified High", descKey: "omo.categoryDesc.unspecifiedHigh", tooltipKey: "omo.categoryTooltip.unspecifiedHigh", - recommended: "claude-opus-4-6", + recommended: "claude-opus-4-7", }, { key: "writing", @@ -280,7 +280,7 @@ export const OMO_BACKGROUND_TASK_PLACEHOLDER = `{ "google": 10 }, "modelConcurrency": { - "anthropic/claude-opus-4-6": 2, + "anthropic/claude-opus-4-7": 2, "google/gemini-3-flash": 10 } }`; @@ -319,7 +319,7 @@ export const OMO_SLIM_BUILTIN_AGENTS: OmoAgentDef[] = [ display: "Orchestrator", descKey: "omo.slimAgentDesc.orchestrator", tooltipKey: "omo.slimAgentTooltip.orchestrator", - recommended: "claude-opus-4-6", + recommended: "claude-opus-4-7", group: "main", }, { diff --git a/tests/config/opencodeProviderPresets.test.ts b/tests/config/opencodeProviderPresets.test.ts index c54cea5f4..0a4223be9 100644 --- a/tests/config/opencodeProviderPresets.test.ts +++ b/tests/config/opencodeProviderPresets.test.ts @@ -20,7 +20,7 @@ describe("AWS Bedrock OpenCode Provider Presets", () => { expect(variants.length).toBeGreaterThan(0); const opusModel = variants.find((v) => - v.id.includes("anthropic.claude-opus-4-6"), + v.id.includes("anthropic.claude-opus-4-7"), ); expect(opusModel).toBeDefined(); }); diff --git a/tests/config/therouterProviderPresets.test.ts b/tests/config/therouterProviderPresets.test.ts index 97b77a24c..4c00e1798 100644 --- a/tests/config/therouterProviderPresets.test.ts +++ b/tests/config/therouterProviderPresets.test.ts @@ -25,7 +25,7 @@ describe("TheRouter provider presets", () => { "anthropic/claude-sonnet-4.6", ); expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe( - "anthropic/claude-opus-4.6", + "anthropic/claude-opus-4.7", ); }); From 5d3cd7eb8581f64636d0c855138fb785db31dadc Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 17 Apr 2026 09:31:48 +0800 Subject: [PATCH 023/156] fix(icons): replace placeholder Hermes icon with Nous brand artwork The previous Hermes icon was an inline purple "H" SVG unrelated to the real Hermes Agent brand. Replace it with the official Nous Research avatar sourced from hermes-agent/landingpage/icon-512.png, routed through iconUrls as a Vite URL import. The PNG is post-processed to strip the 4px black rectangle border (cropped inward by 6px and pasted back onto a 512x512 transparent canvas). Also switch defaultColor to black to match the monochrome artwork when the asset falls back to an initial glyph. --- src/icons/extracted/hermes.png | Bin 0 -> 128217 bytes src/icons/extracted/index.ts | 3 ++- src/icons/extracted/metadata.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 src/icons/extracted/hermes.png diff --git a/src/icons/extracted/hermes.png b/src/icons/extracted/hermes.png new file mode 100644 index 0000000000000000000000000000000000000000..2d3fa567047f98dcffe0edbbe345626a6ea16fe2 GIT binary patch literal 128217 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 Q1Okr96>}qsq4R_P0RuDgWdHyG literal 0 HcmV?d00001 diff --git a/src/icons/extracted/index.ts b/src/icons/extracted/index.ts index cea42e462..c17d4b1f7 100644 --- a/src/icons/extracted/index.ts +++ b/src/icons/extracted/index.ts @@ -3,6 +3,7 @@ import _dds from "./dds.svg?url"; import _eflowcode from "./eflowcode.png"; +import _hermes from "./hermes.png"; import _pipellm from "./pipellm.png"; import _shengsuanyun from "./shengsuanyun.svg?url"; @@ -76,12 +77,12 @@ export const icons: Record = { novita: `Novita`, nvidia: `Nvidia`, bailian: `BaiLian`, - hermes: `Hermes`, }; export const iconUrls: Record = { dds: _dds, eflowcode: _eflowcode, + hermes: _hermes, pipellm: _pipellm, shengsuanyun: _shengsuanyun, }; diff --git a/src/icons/extracted/metadata.ts b/src/icons/extracted/metadata.ts index 1f587f9ca..bbf5afd34 100644 --- a/src/icons/extracted/metadata.ts +++ b/src/icons/extracted/metadata.ts @@ -273,7 +273,7 @@ export const iconMetadata: Record = { displayName: "Hermes", category: "ai-provider", keywords: ["hermes", "agent", "nous", "nousresearch"], - defaultColor: "#7C3AED", + defaultColor: "#000000", }, packycode: { name: "packycode", From 615c430dd30d9437edbcd25cd46f1b415fa6f5a2 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 17 Apr 2026 11:12:10 +0800 Subject: [PATCH 024/156] docs(readme): trim SSSAiCode sponsor blurb and sync across locales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the ¥0.5/$ pricing claim and monthly/paygo line from the SSSAiCode entry in all three READMEs, keeping only the fast-invoicing mention. Also collapse a duplicate blank line after the LemonData row in the JA README to match the ZH version. --- README.md | 2 +- README_JA.md | 3 +-- README_ZH.md | 3 +-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 19bcf0a76..87e44d47f 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric SSSAiCode -Thanks to SSSAiCode for sponsoring this project! SSSAiCode is a stable and reliable API relay service, dedicated to providing stable, reliable, and affordable Claude and Codex model services, offering high cost-effective official Claude service at just ¥0.5/$ equivalent, supporting monthly and pay-as-you-go billing plans with same-day fast invoicing. SSSAiCode offers a special deal for CC Switch users: register via this link to enjoy $10 extra credit on every top-up! +Thanks to SSSAiCode for sponsoring this project! SSSAiCode is a stable and reliable API relay service, dedicated to providing stable, reliable, and affordable Claude and Codex model services, with same-day fast invoicing. SSSAiCode offers a special deal for CC Switch users: register via this link to enjoy $10 extra credit on every top-up! diff --git a/README_JA.md b/README_JA.md index 3e10dd639..0befabdba 100644 --- a/README_JA.md +++ b/README_JA.md @@ -91,7 +91,7 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / SSSAiCode -SSSAiCode のご支援に感謝します!SSSAiCode は安定性と信頼性に優れた API 中継サービスで、安定的で信頼性が高く、手頃な価格の Claude・Codex モデルサービスを提供しています。高コストパフォーマンスの公式 Claude サービスを 0.5¥/$ 換算で提供、月額制・Paygo など多様な課金方式に対応し、当日の迅速な請求書発行をサポート。CC Switch ユーザー向けの特別特典:こちらのリンクから登録すると、毎回のチャージで $10 の追加ボーナスを受けられます! +SSSAiCode のご支援に感謝します!SSSAiCode は安定性と信頼性に優れた API 中継サービスで、安定的で信頼性が高く、手頃な価格の Claude・Codex モデルサービスを提供しています。当日の迅速な請求書発行をサポート。CC Switch ユーザー向けの特別特典:こちらのリンクから登録すると、毎回のチャージで $10 の追加ボーナスを受けられます! @@ -103,7 +103,6 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / LemonData のご支援に感謝します!LemonData は高性能 AI API アグリゲーションプラットフォームで、GPT、Claude、Gemini、DeepSeek など 300 以上のモデルに 1 つの API キーでアクセス可能。全モデルが公式価格の 30〜70% オフで自動フェイルオーバー、スマートルーティング、無制限同時接続に対応。新規ユーザーは登録だけで即座に $1 の無料クレジットを獲得 — こちらのリンクから登録してボーナスを獲得し、すぐに開発を始めましょう! - CTok CTok.ai のご支援に感謝します!CTok.ai はワンストップ AI プログラミングツールサービスプラットフォームの構築に取り組んでいます。Claude Code のプロフェッショナルプランと技術コミュニティサービスを提供し、Google Gemini や OpenAI Codex にも対応しています。丁寧に設計されたプランと専門的な技術コミュニティを通じて、開発者に安定したサービス保証と継続的な技術サポートを提供し、AI アシストプログラミングを真の生産性ツールにします。こちらから登録してください! diff --git a/README_ZH.md b/README_ZH.md index 386d32d36..8b2206a10 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -90,7 +90,7 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更 SSSAiCode -感谢 SSSAiCode 赞助了本项目!SSSAiCode 是一家稳定可靠的API中转站,致力于提供稳定、可靠、平价的Claude、CodeX模型服务,提供高性价比折合0.5¥/$的官方Claude服务,支持包月、Paygo多种计费方式、支持当日快速开票,SSSAiCode为本软件的用户提供特别优惠,使用此链接注册每次充值均可享受10$的额外奖励! +感谢 SSSAiCode 赞助了本项目!SSSAiCode 是一家稳定可靠的API中转站,致力于提供稳定、可靠、平价的Claude、CodeX模型服务,支持当日快速开票,SSSAiCode为本软件的用户提供特别优惠,使用此链接注册每次充值均可享受10$的额外奖励! @@ -102,7 +102,6 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更 感谢 LemonData 赞助了本项目!LemonData 是一个高性能 AI API 聚合平台——一个 API Key 即可访问 GPT、Claude、Gemini、DeepSeek 等 300+ 模型。所有模型定价为官方价格的 30%-70%,支持自动故障转移、智能路由和无限并发。新用户注册即获 $1 免费额度——通过此链接注册即可领取奖励,立即开始开发! - CTok 感谢 CTok.ai 赞助了本项目!CTok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击这里注册! From 63aa3105768db4c9baaaf81185668ddbda3643a2 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 18 Apr 2026 17:37:48 +0800 Subject: [PATCH 025/156] feat(copilot): strip thinking blocks before forwarding to save premium quota MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot routes through OpenAI-compatible endpoints that reject Anthropic's thinking and redacted_thinking blocks. Previously the request would fail upstream, burning one premium interaction, and only then trigger thinking_rectifier to retry. This adds a proactive strip_thinking_blocks pass in the Copilot optimization pipeline (step 3.5, after tool_result merging). Signature fields and top-level thinking are left alone — those are the reactive rectifier's job on the error path. Also fixes a default-value inconsistency where CopilotOptimizerConfig's Default impl used "gpt-4o-mini" while the serde default function returned "gpt-5-mini" (aligned to gpt-5-mini, matching the reference implementation). Aligned with yuegongzi/copilot-api's /v1/messages handler behavior. --- src-tauri/src/proxy/copilot_optimizer.rs | 169 +++++++++++++++++++++++ src-tauri/src/proxy/forwarder.rs | 6 + src-tauri/src/proxy/types.rs | 11 +- 3 files changed, 184 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/proxy/copilot_optimizer.rs b/src-tauri/src/proxy/copilot_optimizer.rs index a58d1ef26..b28cae6db 100644 --- a/src-tauri/src/proxy/copilot_optimizer.rs +++ b/src-tauri/src/proxy/copilot_optimizer.rs @@ -443,6 +443,41 @@ pub fn sanitize_orphan_tool_results(mut body: Value) -> Value { body } +/// 请求前主动剥离所有 assistant 消息里的 thinking / redacted_thinking block +/// +/// Copilot 的三条目标端点(`/chat/completions`、`/v1/responses`、`/v1/chat/completions`) +/// 均为 OpenAI 兼容格式,不识别 Anthropic 的 thinking block。若原样转发,上游会 +/// 拒绝并返回 invalid_request_error —— 届时 `thinking_rectifier` 才做反应式清理并 +/// 重试。那次已经失败的请求依旧消耗一次 premium quota,所以此处提前剥离。 +/// +/// 与 `thinking_rectifier::rectify_anthropic_request` 的区别: +/// - 本函数只剥 thinking / redacted_thinking 两类 block,不触碰 signature,也不 +/// 移除顶层 thinking 字段——那些是错误路径上的激进整流,常规路径不需要。 +/// - 保持与 `merge_tool_results` / `sanitize_orphan_tool_results` 一致的"消费 body、 +/// 返回新 body"签名,便于接入 forwarder 管道。 +pub fn strip_thinking_blocks(mut body: Value) -> Value { + let Some(messages) = body.get_mut("messages").and_then(|m| m.as_array_mut()) else { + return body; + }; + + for msg in messages.iter_mut() { + if msg.get("role").and_then(|r| r.as_str()) != Some("assistant") { + continue; + } + let Some(content) = msg.get_mut("content").and_then(|c| c.as_array_mut()) else { + continue; + }; + content.retain(|block| { + !matches!( + block.get("type").and_then(|t| t.as_str()), + Some("thinking") | Some("redacted_thinking") + ) + }); + } + + body +} + // ─── 内部辅助 ───────────────────────────────── /// 从请求体的 `system` 字段提取文本(处理 string/array 两种格式)。 @@ -1371,4 +1406,138 @@ mod tests { assert_eq!(content[0]["type"], "text"); assert_eq!(content[1]["type"], "text"); } + + // === strip_thinking_blocks 测试 === + + #[test] + fn test_strip_thinking_removes_assistant_thinking_blocks() { + let body = serde_json::json!({ + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "hi"}]}, + {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "let me ponder", "signature": "sig"}, + {"type": "redacted_thinking", "data": "opaque"}, + {"type": "text", "text": "hello"}, + {"type": "tool_use", "id": "t1", "name": "read", "input": {}} + ]} + ] + }); + let result = strip_thinking_blocks(body); + let content = result["messages"][1]["content"].as_array().unwrap(); + assert_eq!(content.len(), 2); + assert_eq!(content[0]["type"], "text"); + assert_eq!(content[1]["type"], "tool_use"); + } + + #[test] + fn test_strip_thinking_leaves_user_messages_untouched() { + // 仅处理 assistant,user 的 thinking 块(极少见,但可能)不动 + let body = serde_json::json!({ + "messages": [ + {"role": "user", "content": [ + {"type": "thinking", "thinking": "x"}, + {"type": "text", "text": "hi"} + ]} + ] + }); + let result = strip_thinking_blocks(body); + let content = result["messages"][0]["content"].as_array().unwrap(); + assert_eq!(content.len(), 2); + } + + #[test] + fn test_strip_thinking_handles_missing_messages() { + let body = serde_json::json!({ "model": "claude-3-5-sonnet" }); + let result = strip_thinking_blocks(body.clone()); + assert_eq!(result, body); + } + + #[test] + fn test_strip_thinking_leaves_empty_content_array() { + // 仅含 thinking 的 assistant 消息剥完后 content 为空——保留上游自处理 + let body = serde_json::json!({ + "messages": [ + {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "solo"} + ]} + ] + }); + let result = strip_thinking_blocks(body); + let content = result["messages"][0]["content"].as_array().unwrap(); + assert_eq!(content.len(), 0); + } + + #[test] + fn test_strip_thinking_preserves_signature_on_non_thinking_blocks() { + // signature 留给 thinking_rectifier 在错误路径处理,此处不动 + let body = serde_json::json!({ + "messages": [ + {"role": "assistant", "content": [ + {"type": "tool_use", "id": "t1", "name": "x", "input": {}, "signature": "s"} + ]} + ] + }); + let result = strip_thinking_blocks(body); + let block = &result["messages"][0]["content"][0]; + assert_eq!(block["signature"], "s"); + } + + #[test] + fn test_strip_thinking_multiple_assistant_turns() { + let body = serde_json::json!({ + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "q1"}]}, + {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "a"}, + {"type": "text", "text": "r1"} + ]}, + {"role": "user", "content": [{"type": "text", "text": "q2"}]}, + {"role": "assistant", "content": [ + {"type": "redacted_thinking", "data": "x"}, + {"type": "text", "text": "r2"} + ]} + ] + }); + let result = strip_thinking_blocks(body); + let a1 = result["messages"][1]["content"].as_array().unwrap(); + let a2 = result["messages"][3]["content"].as_array().unwrap(); + assert_eq!(a1.len(), 1); + assert_eq!(a1[0]["text"], "r1"); + assert_eq!(a2.len(), 1); + assert_eq!(a2[0]["text"], "r2"); + } + + #[test] + fn test_strip_thinking_ignores_string_content() { + // assistant.content 是字符串而非 block 数组 — 历史请求或极简客户端会这样 + // 不应崩溃,也不应转换结构 + let body = serde_json::json!({ + "messages": [ + {"role": "assistant", "content": "plain text response"} + ] + }); + let result = strip_thinking_blocks(body.clone()); + assert_eq!(result, body); + } + + #[test] + fn test_strip_thinking_preserves_block_order() { + let body = serde_json::json!({ + "messages": [ + {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "pre"}, + {"type": "text", "text": "A"}, + {"type": "tool_use", "id": "t1", "name": "x", "input": {}}, + {"type": "redacted_thinking", "data": "mid"}, + {"type": "text", "text": "B"} + ]} + ] + }); + let result = strip_thinking_blocks(body); + let content = result["messages"][0]["content"].as_array().unwrap(); + assert_eq!(content.len(), 3); + assert_eq!(content[0]["text"], "A"); + assert_eq!(content[1]["type"], "tool_use"); + assert_eq!(content[2]["text"], "B"); + } } diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 7c9fbc124..f31bafcb7 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -821,6 +821,12 @@ impl RequestForwarder { mapped_body = super::copilot_optimizer::merge_tool_results(mapped_body); } + // 3.5. 主动剥离 thinking block — Copilot 走 OpenAI 兼容端点不识别该块 + // 避免上游拒绝后由 rectifier 反应式重试(首次请求已消耗 quota) + if self.copilot_optimizer_config.strip_thinking { + mapped_body = super::copilot_optimizer::strip_thinking_blocks(mapped_body); + } + // 4. Warmup 小模型降级 if self.copilot_optimizer_config.warmup_downgrade && classification.is_warmup { log::info!( diff --git a/src-tauri/src/proxy/types.rs b/src-tauri/src/proxy/types.rs index 744e596f7..68474400b 100644 --- a/src-tauri/src/proxy/types.rs +++ b/src-tauri/src/proxy/types.rs @@ -298,9 +298,15 @@ pub struct CopilotOptimizerConfig { /// Warmup 小模型降级(默认开启 — 与参考实现对齐,避免探针请求消耗 premium quota) #[serde(default = "default_true")] pub warmup_downgrade: bool, - /// Warmup 降级使用的模型(默认 "gpt-4o-mini") + /// Warmup 降级使用的模型(默认 "gpt-5-mini") #[serde(default = "default_warmup_model")] pub warmup_model: String, + /// 请求前主动剥离 assistant 消息里的 thinking / redacted_thinking block + /// + /// Copilot 走 OpenAI 兼容端点,thinking block 会被上游拒绝并触发 rectifier 反应式 + /// 重试,那时第一次请求已经消耗了一次 premium quota。主动剥离避免这次浪费。 + #[serde(default = "default_true")] + pub strip_thinking: bool, } fn default_warmup_model() -> String { @@ -317,7 +323,8 @@ impl Default for CopilotOptimizerConfig { deterministic_request_id: true, subagent_detection: true, warmup_downgrade: true, - warmup_model: "gpt-4o-mini".to_string(), + warmup_model: "gpt-5-mini".to_string(), + strip_thinking: true, } } } From f935bac633416867f7b5bee1a53e2947dbe02759 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 18 Apr 2026 22:08:23 +0800 Subject: [PATCH 026/156] feat(hermes): bind per-provider models to top-level model: on switch Hermes custom_providers entries now carry an ordered models array (id / context_length / max_tokens) plus suggestedDefaults. The backend serializes the array to the YAML dict shape Hermes expects on write and inverts it on read, preserving insertion order via the preserve_order feature on serde_json. When a user switches providers, switch_normal calls apply_switch_defaults so the top-level model.default / model.provider follow the selected provider's first model. Previously switching a Hermes provider only shuffled custom_providers[] and left Hermes pointing at whatever model.provider was set before. Seven existing Hermes presets now ship with a curated models list so switching lands on a working default without a detour through the Model panel. --- src-tauri/src/hermes_config.rs | 352 ++++++++++++++++++++++++- src-tauri/src/services/provider/mod.rs | 19 ++ src/config/hermesProviderPresets.ts | 193 ++++++++++++++ 3 files changed, 560 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs index 7904746d4..88a58190a 100644 --- a/src-tauri/src/hermes_config.rs +++ b/src-tauri/src/hermes_config.rs @@ -89,7 +89,7 @@ pub struct HermesWriteOutcome { } /// Hermes model section config -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct HermesModelConfig { #[serde(skip_serializing_if = "Option::is_none")] pub default: Option, @@ -376,10 +376,100 @@ fn write_yaml_section_to_config_locked( // Provider Functions // ============================================================================ +/// Convert a provider's `models` field from a UI-friendly array to the YAML +/// dict shape that Hermes expects. +/// +/// Input (from CC Switch UI / database): +/// ```json +/// "models": [{ "id": "foo", "context_length": 200000 }, { "id": "bar" }] +/// ``` +/// +/// Output (what we write to YAML): +/// ```json +/// "models": { "foo": { "context_length": 200000 }, "bar": {} } +/// ``` +/// +/// Entries with a missing or empty `id` are dropped. The top-level `id` key +/// is stripped from each value since it now lives on the parent as the map +/// key. Insertion order is preserved (serde_json uses IndexMap under the +/// `preserve_order` feature). +fn models_array_to_dict(array: Vec) -> serde_json::Value { + let mut map = serde_json::Map::new(); + for item in array { + let serde_json::Value::Object(mut obj) = item else { + continue; + }; + let Some(id) = obj + .remove("id") + .and_then(|v| v.as_str().map(|s| s.trim().to_string())) + .filter(|s| !s.is_empty()) + else { + continue; + }; + map.insert(id, serde_json::Value::Object(obj)); + } + serde_json::Value::Object(map) +} + +/// Inverse of [`models_array_to_dict`]. Converts the YAML dict shape back to +/// the UI-friendly ordered array, re-injecting `id` as an object field. +fn models_dict_to_array(dict: serde_json::Map) -> serde_json::Value { + let mut out = Vec::with_capacity(dict.len()); + for (id, value) in dict { + let mut obj = match value { + serde_json::Value::Object(obj) => obj, + serde_json::Value::Null => serde_json::Map::new(), + other => { + log::warn!("Unexpected Hermes model entry for '{id}': {other:?}, skipping"); + continue; + } + }; + obj.insert("id".to_string(), serde_json::Value::String(id)); + out.push(serde_json::Value::Object(obj)); + } + serde_json::Value::Array(out) +} + +/// If `config.models` is a JSON array, convert it in-place to the dict shape. +/// No-op when `models` is absent or already a dict. +fn normalize_provider_models_for_write(config: &mut serde_json::Value) { + let Some(obj) = config.as_object_mut() else { + return; + }; + let Some(models_val) = obj.get_mut("models") else { + return; + }; + if models_val.is_array() { + let taken = std::mem::take(models_val); + if let serde_json::Value::Array(arr) = taken { + *models_val = models_array_to_dict(arr); + } + } +} + +/// If `config.models` is a JSON dict, convert it in-place to the ordered array +/// shape. No-op when `models` is absent or already an array. +fn denormalize_provider_models_for_read(config: &mut serde_json::Value) { + let Some(obj) = config.as_object_mut() else { + return; + }; + let Some(models_val) = obj.get_mut("models") else { + return; + }; + if models_val.is_object() { + let taken = std::mem::take(models_val); + if let serde_json::Value::Object(map) = taken { + *models_val = models_dict_to_array(map); + } + } +} + /// Get all custom providers as a JSON map keyed by provider name. /// /// The `custom_providers` section is a YAML sequence where each item has a -/// `name` field. This function converts it to a map for CC Switch consumption. +/// `name` field. This function converts it to a map for CC Switch consumption, +/// and additionally transforms each entry's `models` field from the +/// YAML dict shape to the UI-friendly ordered array shape. pub fn get_providers() -> Result, AppError> { let config = read_hermes_config()?; let providers = config @@ -390,7 +480,8 @@ pub fn get_providers() -> Result, App for item in seq { if let Some(name) = item.get("name").and_then(|n| n.as_str()) { match yaml_to_json(item) { - Ok(json_val) => { + Ok(mut json_val) => { + denormalize_provider_models_for_read(&mut json_val); map.insert(name.to_string(), json_val); } Err(e) => { @@ -428,7 +519,11 @@ pub fn set_provider( .cloned() .unwrap_or_default(); - let mut yaml_val: serde_yaml::Value = json_to_yaml(&provider_config)?; + // Normalize `models` from UI array to Hermes YAML dict before serializing. + let mut normalized = provider_config; + normalize_provider_models_for_write(&mut normalized); + + let mut yaml_val: serde_yaml::Value = json_to_yaml(&normalized)?; if let serde_yaml::Value::Mapping(ref mut m) = yaml_val { m.insert( serde_yaml::Value::String("name".to_string()), @@ -498,6 +593,38 @@ pub fn set_model_config(model: &HermesModelConfig) -> Result Result, AppError> { + let Some(first_model_id) = settings_config + .get("models") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|m| m.get("id")) + .and_then(|id| id.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + else { + return Ok(None); + }; + + let current = get_model_config()?.unwrap_or_default(); + let merged = HermesModelConfig { + default: Some(first_model_id), + provider: Some(provider_id.to_string()), + ..current + }; + Ok(Some(set_model_config(&merged)?)) +} + // ============================================================================ // Agent Config Functions // ============================================================================ @@ -1208,4 +1335,221 @@ custom_providers: let back = yaml_to_json(&yaml).unwrap(); assert_eq!(json, back); } + + // ---- models array ↔ dict transforms ---- + + #[test] + fn models_array_to_dict_strips_id_and_preserves_order() { + let arr = vec![ + serde_json::json!({ "id": "foo", "context_length": 100 }), + serde_json::json!({ "id": "bar", "max_tokens": 2000 }), + serde_json::json!({ "id": "baz" }), + ]; + let dict = models_array_to_dict(arr); + let obj = dict.as_object().unwrap(); + let keys: Vec<&String> = obj.keys().collect(); + assert_eq!(keys, vec!["foo", "bar", "baz"]); + assert_eq!(obj["foo"]["context_length"], 100); + assert_eq!(obj["bar"]["max_tokens"], 2000); + assert!(obj["baz"].as_object().unwrap().is_empty()); + // id must not leak into values + assert!(obj["foo"].get("id").is_none()); + } + + #[test] + fn models_array_to_dict_drops_empty_and_missing_ids() { + let arr = vec![ + serde_json::json!({ "id": "", "context_length": 1 }), + serde_json::json!({ "id": " ", "context_length": 2 }), + serde_json::json!({ "context_length": 3 }), + serde_json::json!({ "id": "kept" }), + ]; + let dict = models_array_to_dict(arr); + let obj = dict.as_object().unwrap(); + assert_eq!(obj.len(), 1); + assert!(obj.contains_key("kept")); + } + + #[test] + fn models_dict_to_array_reinjects_id_and_preserves_order() { + let mut map = serde_json::Map::new(); + map.insert( + "alpha".to_string(), + serde_json::json!({ "context_length": 10 }), + ); + map.insert("beta".to_string(), serde_json::json!({ "max_tokens": 20 })); + map.insert("gamma".to_string(), serde_json::Value::Null); + let arr = models_dict_to_array(map); + let list = arr.as_array().unwrap(); + assert_eq!(list.len(), 3); + assert_eq!(list[0]["id"], "alpha"); + assert_eq!(list[0]["context_length"], 10); + assert_eq!(list[1]["id"], "beta"); + assert_eq!(list[2]["id"], "gamma"); + } + + #[test] + #[serial] + fn provider_with_models_array_writes_dict_to_yaml() { + with_test_home(|| { + let config = serde_json::json!({ + "base_url": "https://api.example.com/v1", + "api_key": "sk-test", + "api_mode": "chat_completions", + "models": [ + { "id": "model-a", "context_length": 200000, "max_tokens": 32000 }, + { "id": "model-b", "context_length": 100000 }, + ] + }); + set_provider("demo", config).unwrap(); + + // Read raw YAML to verify the on-disk shape is a dict. + let raw = fs::read_to_string(get_hermes_config_path()).unwrap(); + let yaml: serde_yaml::Value = serde_yaml::from_str(&raw).unwrap(); + let providers = yaml + .get("custom_providers") + .and_then(|v| v.as_sequence()) + .unwrap(); + let provider = &providers[0]; + let models = provider.get("models").and_then(|v| v.as_mapping()).unwrap(); + assert_eq!(models.len(), 2); + assert!(models.contains_key(serde_yaml::Value::String("model-a".into()))); + assert!(models.contains_key(serde_yaml::Value::String("model-b".into()))); + let model_a = models + .get(serde_yaml::Value::String("model-a".into())) + .unwrap(); + assert_eq!( + model_a + .get("context_length") + .and_then(|v| v.as_u64()) + .unwrap(), + 200000 + ); + // id should not leak into each model value + assert!(model_a.get("id").is_none()); + }); + } + + #[test] + #[serial] + fn provider_models_roundtrip_array_dict_array_preserves_order() { + with_test_home(|| { + let input = serde_json::json!({ + "base_url": "https://api.example.com/v1", + "api_key": "sk-test", + "models": [ + { "id": "first", "context_length": 1 }, + { "id": "second", "context_length": 2 }, + { "id": "third", "context_length": 3 }, + ] + }); + set_provider("order", input).unwrap(); + + let providers = get_providers().unwrap(); + let provider = providers.get("order").unwrap(); + let models = provider.get("models").and_then(|v| v.as_array()).unwrap(); + let ids: Vec<&str> = models + .iter() + .map(|m| m.get("id").and_then(|v| v.as_str()).unwrap()) + .collect(); + assert_eq!(ids, vec!["first", "second", "third"]); + assert_eq!(models[0].get("context_length").unwrap(), 1); + }); + } + + #[test] + #[serial] + fn provider_without_models_is_unaffected() { + with_test_home(|| { + let input = serde_json::json!({ + "base_url": "https://api.example.com/v1", + "api_key": "sk-test" + }); + set_provider("simple", input).unwrap(); + let providers = get_providers().unwrap(); + let provider = providers.get("simple").unwrap(); + assert!(provider.get("models").is_none()); + }); + } + + // ---- apply_switch_defaults ---- + + #[test] + #[serial] + fn apply_switch_defaults_sets_default_and_provider() { + with_test_home(|| { + let settings = serde_json::json!({ + "base_url": "https://api.example.com/v1", + "models": [ + { "id": "primary-model", "context_length": 200000 }, + { "id": "fallback", "context_length": 100000 }, + ] + }); + let outcome = apply_switch_defaults("demo", &settings).unwrap(); + assert!(outcome.is_some()); + + let model = get_model_config().unwrap().unwrap(); + assert_eq!(model.default.as_deref(), Some("primary-model")); + assert_eq!(model.provider.as_deref(), Some("demo")); + }); + } + + #[test] + #[serial] + fn apply_switch_defaults_preserves_user_context_length() { + with_test_home(|| { + // User previously set a custom context_length via the Model panel. + let initial = HermesModelConfig { + default: Some("old-model".to_string()), + provider: Some("old-provider".to_string()), + base_url: Some("https://user-override.example.com".to_string()), + context_length: Some(131072), + max_tokens: Some(16384), + extra: HashMap::new(), + }; + set_model_config(&initial).unwrap(); + + let settings = serde_json::json!({ + "models": [{ "id": "new-model" }] + }); + apply_switch_defaults("new-provider", &settings).unwrap(); + + let model = get_model_config().unwrap().unwrap(); + assert_eq!(model.default.as_deref(), Some("new-model")); + assert_eq!(model.provider.as_deref(), Some("new-provider")); + // User-customized fields must survive the switch. + assert_eq!( + model.base_url.as_deref(), + Some("https://user-override.example.com") + ); + assert_eq!(model.context_length, Some(131072)); + assert_eq!(model.max_tokens, Some(16384)); + }); + } + + #[test] + #[serial] + fn apply_switch_defaults_noop_when_no_models_declared() { + with_test_home(|| { + let settings = serde_json::json!({ + "base_url": "https://api.example.com/v1" + }); + let outcome = apply_switch_defaults("bare", &settings).unwrap(); + assert!(outcome.is_none()); + assert!(get_model_config().unwrap().is_none()); + }); + } + + #[test] + #[serial] + fn apply_switch_defaults_noop_on_empty_model_id() { + with_test_home(|| { + let settings = serde_json::json!({ + "models": [{ "id": " " }, { "id": "real" }] + }); + let outcome = apply_switch_defaults("edge", &settings).unwrap(); + // First entry has whitespace-only id → treated as missing, no-op. + assert!(outcome.is_none()); + }); + } } diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index a7df8892e..163989498 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -1523,6 +1523,25 @@ impl ProviderService { // Sync to live (write_gemini_live handles security flag internally for Gemini) write_live_with_common_config(state.db.as_ref(), &app_type, provider)?; + // Hermes is additive, so "switching" doesn't overwrite a live config file + // — we instead update the top-level `model:` section to point at this + // provider's first declared model. Without this, clicking "switch" would + // only shuffle entries in custom_providers[] while Hermes keeps using + // whatever `model.provider` was set before. + if matches!(app_type, AppType::Hermes) { + if let Err(e) = + crate::hermes_config::apply_switch_defaults(&provider.id, &provider.settings_config) + { + log::warn!( + "Failed to update Hermes model defaults after switching to '{}': {e}", + provider.id + ); + result + .warnings + .push(format!("hermes_model_defaults_failed:{}", provider.id)); + } + } + // For additive-mode providers that were DB-only (live_config_managed == Some(false)), // flip the flag to true now that the provider has been successfully written to the live // file. This ensures sync_all_providers_to_live() will include it on future syncs. diff --git a/src/config/hermesProviderPresets.ts b/src/config/hermesProviderPresets.ts index 614afaf78..c586b257e 100644 --- a/src/config/hermesProviderPresets.ts +++ b/src/config/hermesProviderPresets.ts @@ -5,6 +5,48 @@ import type { ProviderCategory } from "../types"; import type { PresetTheme, TemplateValueConfig } from "./claudeProviderPresets"; +/** + * A model entry under a Hermes custom_provider. + * + * Serialized to YAML as a dict keyed by `id`: + * + * ```yaml + * models: + * anthropic/claude-opus-4-7: + * context_length: 200000 + * max_tokens: 32000 + * ``` + */ +export interface HermesModel { + /** Model ID — becomes the YAML key and the value written to top-level model.default. */ + id: string; + /** Optional display label (UI only, not serialized to YAML). */ + name?: string; + /** Override the auto-detected context window. */ + context_length?: number; + /** Response-length cap. */ + max_tokens?: number; +} + +/** + * Top-level `model:` defaults suggested by a preset. + * + * Written to the YAML `model:` section when the user switches to this provider. + * Per-model `context_length` / `max_tokens` live on the individual `HermesModel` + * entries and flow through `custom_providers[].models`, not this object. + */ +export interface HermesSuggestedDefaults { + model: { + /** Model ID for `model.default`. Typically equals `models[0].id`. */ + default: string; + /** Value for `model.provider`. Omit to use the custom_provider name. */ + provider?: string; + }; +} + +/** Hermes custom_provider protocol mode (optional; auto-detected when omitted). */ +export type HermesApiMode = "chat_completions" | "anthropic_messages"; + export interface HermesProviderPreset { name: string; nameKey?: string; @@ -20,12 +62,17 @@ export interface HermesProviderPreset { icon?: string; iconColor?: string; isCustomTemplate?: boolean; + /** Optional top-level `model:` defaults written on switch. */ + suggestedDefaults?: HermesSuggestedDefaults; } export interface HermesProviderSettingsConfig { name: string; base_url?: string; api_key?: string; + api_mode?: HermesApiMode; + /** UI-side ordered list; serialized to YAML as a dict keyed by id. */ + models?: HermesModel[]; [key: string]: unknown; } @@ -39,10 +86,38 @@ export const hermesProviderPresets: HermesProviderPreset[] = [ name: "openrouter", base_url: "https://openrouter.ai/api/v1", api_key: "", + api_mode: "chat_completions", + models: [ + { + id: "anthropic/claude-opus-4-7", + name: "Claude Opus 4.7", + context_length: 200000, + max_tokens: 32000, + }, + { + id: "anthropic/claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + context_length: 200000, + max_tokens: 32000, + }, + { + id: "openai/gpt-5", + name: "GPT-5", + context_length: 400000, + }, + { + id: "google/gemini-3-pro", + name: "Gemini 3 Pro", + context_length: 1000000, + }, + ], }, category: "aggregator", icon: "openrouter", iconColor: "#6366F1", + suggestedDefaults: { + model: { default: "anthropic/claude-opus-4-7", provider: "openrouter" }, + }, }, { name: "Anthropic", @@ -53,11 +128,35 @@ export const hermesProviderPresets: HermesProviderPreset[] = [ name: "anthropic", base_url: "https://api.anthropic.com", api_key: "", + api_mode: "anthropic_messages", + models: [ + { + id: "claude-opus-4-7", + name: "Claude Opus 4.7", + context_length: 200000, + max_tokens: 32000, + }, + { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + context_length: 200000, + max_tokens: 32000, + }, + { + id: "claude-haiku-4-5-20251001", + name: "Claude Haiku 4.5", + context_length: 200000, + max_tokens: 16000, + }, + ], }, isOfficial: true, category: "official", icon: "anthropic", iconColor: "#D4915D", + suggestedDefaults: { + model: { default: "claude-opus-4-7", provider: "anthropic" }, + }, }, { name: "OpenAI", @@ -68,11 +167,32 @@ export const hermesProviderPresets: HermesProviderPreset[] = [ name: "openai", base_url: "https://api.openai.com/v1", api_key: "", + api_mode: "chat_completions", + models: [ + { + id: "gpt-5", + name: "GPT-5", + context_length: 400000, + }, + { + id: "gpt-5-codex", + name: "GPT-5 Codex", + context_length: 400000, + }, + { + id: "o3-mini", + name: "o3-mini", + context_length: 200000, + }, + ], }, isOfficial: true, category: "official", icon: "openai", iconColor: "#000000", + suggestedDefaults: { + model: { default: "gpt-5", provider: "openai" }, + }, }, { name: "Google AI", @@ -82,11 +202,26 @@ export const hermesProviderPresets: HermesProviderPreset[] = [ settingsConfig: { name: "google", api_key: "", + models: [ + { + id: "gemini-3-pro", + name: "Gemini 3 Pro", + context_length: 1000000, + }, + { + id: "gemini-3-flash", + name: "Gemini 3 Flash", + context_length: 1000000, + }, + ], }, isOfficial: true, category: "official", icon: "gemini", iconColor: "#4285F4", + suggestedDefaults: { + model: { default: "gemini-3-pro", provider: "google" }, + }, }, { name: "DeepSeek", @@ -97,10 +232,28 @@ export const hermesProviderPresets: HermesProviderPreset[] = [ name: "deepseek", base_url: "https://api.deepseek.com", api_key: "", + api_mode: "chat_completions", + models: [ + { + id: "deepseek-chat", + name: "DeepSeek V3.2", + context_length: 64000, + max_tokens: 8000, + }, + { + id: "deepseek-reasoner", + name: "DeepSeek R1", + context_length: 64000, + max_tokens: 8000, + }, + ], }, category: "cn_official", icon: "deepseek", iconColor: "#4D6BFE", + suggestedDefaults: { + model: { default: "deepseek-chat", provider: "deepseek" }, + }, }, { name: "Together AI", @@ -111,10 +264,34 @@ export const hermesProviderPresets: HermesProviderPreset[] = [ name: "together", base_url: "https://api.together.xyz/v1", api_key: "", + api_mode: "chat_completions", + models: [ + { + id: "Qwen/Qwen3-Coder-480B-A35B-Instruct", + name: "Qwen3 Coder 480B", + context_length: 262144, + }, + { + id: "deepseek-ai/DeepSeek-V3.2", + name: "DeepSeek V3.2", + context_length: 64000, + }, + { + id: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", + name: "Llama 4 Maverick", + context_length: 131072, + }, + ], }, category: "aggregator", icon: "together", iconColor: "#0F6FFF", + suggestedDefaults: { + model: { + default: "Qwen/Qwen3-Coder-480B-A35B-Instruct", + provider: "together", + }, + }, }, { name: "Nous Research", @@ -123,10 +300,26 @@ export const hermesProviderPresets: HermesProviderPreset[] = [ name: "nous", base_url: "https://inference.nous.hermes.dev/v1", api_key: "", + api_mode: "chat_completions", + models: [ + { + id: "Hermes-4-405B", + name: "Hermes 4 405B", + context_length: 131072, + }, + { + id: "Hermes-4-70B", + name: "Hermes 4 70B", + context_length: 131072, + }, + ], }, isOfficial: true, category: "official", icon: "hermes", iconColor: "#7C3AED", + suggestedDefaults: { + model: { default: "Hermes-4-405B", provider: "nous" }, + }, }, ]; From 497a543c1b4203b1540511324b9e5dbbaaff47a4 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 18 Apr 2026 22:28:22 +0800 Subject: [PATCH 027/156] feat(hermes): add API mode dropdown and per-provider model editor in form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Hermes provider form previously only exposed Base URL and API Key, forcing users to drop into the Model panel to hand-edit model IDs after adding a provider. Following OpenClaw's shape, the form now carries: - An API Mode selector (auto-detect / chat_completions / anthropic_messages). "auto" is a UI-only sentinel — selecting it deletes api_mode from the config so the YAML doesn't leak a redundant field. - A model list editor where the first row is badged as the default and each row has a collapsible Advanced panel for context_length and max_tokens. Adding/removing rows uses a UUID-keyed ref so typing in one input doesn't drop focus when another row is added. - A Fetch Models button that pulls /v1/models from the configured endpoint and exposes the catalog in a per-row dropdown, identical to OpenClaw's flow. The vendor grouping is memoized so keystrokes don't trigger a reduce+sort per model row — Radix DropdownMenuContent does not lazy-mount, so the inner JSX evaluates on every render regardless of whether the menu is open. Three-locale i18n keys are added together (zh/en/ja). --- .../providers/forms/HermesFormFields.tsx | 407 +++++++++++++++++- .../providers/forms/ProviderForm.tsx | 4 + .../forms/hooks/useHermesFormState.ts | 73 +++- src/config/hermesProviderPresets.ts | 25 ++ src/i18n/locales/en.json | 20 +- src/i18n/locales/ja.json | 20 +- src/i18n/locales/zh.json | 20 +- 7 files changed, 555 insertions(+), 14 deletions(-) diff --git a/src/components/providers/forms/HermesFormFields.tsx b/src/components/providers/forms/HermesFormFields.tsx index 0e8b5e5ab..e74b5365b 100644 --- a/src/components/providers/forms/HermesFormFields.tsx +++ b/src/components/providers/forms/HermesFormFields.tsx @@ -1,7 +1,48 @@ import { useTranslation } from "react-i18next"; +import { useState, useRef, useCallback, useMemo } from "react"; import { FormLabel } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { toast } from "sonner"; +import { + Download, + Plus, + Trash2, + ChevronDown, + ChevronRight, + Loader2, +} from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { ApiKeySection } from "./shared"; +import { + fetchModelsForConfig, + showFetchModelsError, + type FetchedModel, +} from "@/lib/api/model-fetch"; +import { + hermesApiModes, + type HermesApiModeChoice, + type HermesModel, +} from "@/config/hermesProviderPresets"; import type { ProviderCategory } from "@/types"; interface HermesFormFieldsProps { @@ -14,6 +55,10 @@ interface HermesFormFieldsProps { websiteUrl: string; isPartner?: boolean; partnerPromotionKey?: string; + apiMode: HermesApiModeChoice; + onApiModeChange: (mode: HermesApiModeChoice) => void; + models: HermesModel[]; + onModelsChange: (models: HermesModel[]) => void; } export function HermesFormFields({ @@ -26,15 +71,140 @@ export function HermesFormFields({ websiteUrl, isPartner, partnerPromotionKey, + apiMode, + onApiModeChange, + models, + onModelsChange, }: HermesFormFieldsProps) { const { t } = useTranslation(); + const [expandedModels, setExpandedModels] = useState>( + {}, + ); + const [fetchedModels, setFetchedModels] = useState([]); + const [isFetchingModels, setIsFetchingModels] = useState(false); + + // Stable list keys: a manual ref rather than UUID-in-state so adding/removing + // rows doesn't re-mount unrelated inputs (would drop focus mid-typing). + const modelKeysRef = useRef([]); + while (modelKeysRef.current.length < models.length) { + modelKeysRef.current.push(crypto.randomUUID()); + } + if (modelKeysRef.current.length > models.length) { + modelKeysRef.current.length = models.length; + } + const modelKeys = modelKeysRef.current; + + // Group fetched models by vendor once — Radix DropdownMenuContent doesn't + // lazy-mount, so computing this in JSX would re-run per model row per render. + const groupedFetchedModels = useMemo( + () => + Object.entries( + fetchedModels.reduce( + (acc, m) => { + const v = m.ownedBy || "Other"; + if (!acc[v]) acc[v] = []; + acc[v].push(m); + return acc; + }, + {} as Record, + ), + ).sort(([a], [b]) => a.localeCompare(b)), + [fetchedModels], + ); + + const toggleModelAdvanced = (index: number) => { + setExpandedModels((prev) => ({ ...prev, [index]: !prev[index] })); + }; + + const handleAddModel = () => { + modelKeysRef.current.push(crypto.randomUUID()); + onModelsChange([ + ...models, + { id: "", name: "", context_length: undefined, max_tokens: undefined }, + ]); + }; + + const handleFetchModels = useCallback(() => { + if (!baseUrl || !apiKey) { + showFetchModelsError(null, t, { + hasApiKey: !!apiKey, + hasBaseUrl: !!baseUrl, + }); + return; + } + setIsFetchingModels(true); + fetchModelsForConfig(baseUrl, apiKey) + .then((fetched) => { + setFetchedModels(fetched); + if (fetched.length === 0) { + toast.info(t("providerForm.fetchModelsEmpty")); + } else { + toast.success( + t("providerForm.fetchModelsSuccess", { count: fetched.length }), + ); + } + }) + .catch((err) => { + console.warn("[ModelFetch] Failed:", err); + showFetchModelsError(err, t); + }) + .finally(() => setIsFetchingModels(false)); + }, [baseUrl, apiKey, t]); + + const handleRemoveModel = (index: number) => { + modelKeysRef.current.splice(index, 1); + const next = [...models]; + next.splice(index, 1); + onModelsChange(next); + setExpandedModels((prev) => { + const updated = { ...prev }; + delete updated[index]; + return updated; + }); + }; + + const handleModelChange = ( + index: number, + field: keyof HermesModel, + value: unknown, + ) => { + const next = [...models]; + next[index] = { ...next[index], [field]: value }; + onModelsChange(next); + }; return ( <> - {/* Base URL */} +
+ + {t("hermes.form.apiMode", { defaultValue: "API 模式" })} + + +

+ {t("hermes.form.apiModeHint", { + defaultValue: + "供应商 API 协议。Auto 表示让 Hermes 按端点自动判断。", + })} +

+
+
- {t("hermes.form.baseUrl", { defaultValue: "API Endpoint" })} + {t("hermes.form.baseUrl", { defaultValue: "API 端点" })}

{t("hermes.form.baseUrlHint", { - defaultValue: "The API endpoint URL for this provider.", + defaultValue: "供应商的 API 端点地址。", })}

- {/* API Key */} + +
+
+ + {t("hermes.form.models", { defaultValue: "模型列表" })} + +
+ + +
+
+ + {models.length === 0 ? ( +

+ {t("hermes.form.noModels", { + defaultValue: "暂无模型配置。切换到此供应商时将无默认模型。", + })} +

+ ) : ( +
+ {models.map((model, index) => ( +
+ {/* Role badge — first entry is the default written to model.default on switch */} +
+ + {index === 0 + ? t("hermes.form.primaryModel", { + defaultValue: "默认模型", + }) + : t("hermes.form.fallbackModel", { + defaultValue: "备选模型", + })} + +
+ +
+
+ +
+ + handleModelChange(index, "id", e.target.value) + } + placeholder={t("hermes.form.modelIdPlaceholder", { + defaultValue: "anthropic/claude-opus-4-7", + })} + className="flex-1" + /> + {fetchedModels.length > 0 && ( + + + + + + {groupedFetchedModels.map( + ([vendor, vModels], vi) => ( +
+ {vi > 0 && } + + {vendor} + + {vModels.map((m) => ( + + handleModelChange(index, "id", m.id) + } + > + {m.id} + + ))} +
+ ), + )} +
+
+ )} +
+
+
+ + + handleModelChange(index, "name", e.target.value) + } + placeholder={t("hermes.form.modelNamePlaceholder", { + defaultValue: "Claude Opus 4.7", + })} + /> +
+ +
+ + toggleModelAdvanced(index)} + > + + + + +
+
+ + + handleModelChange( + index, + "context_length", + e.target.value + ? parseInt(e.target.value) + : undefined, + ) + } + placeholder="200000" + /> +
+
+ + + handleModelChange( + index, + "max_tokens", + e.target.value + ? parseInt(e.target.value) + : undefined, + ) + } + placeholder="32000" + /> +
+
+
+
+
+ ))} +
+ )} + +

+ {t("hermes.form.modelsHint", { + defaultValue: + "切换到此供应商时,第一个模型会写入顶层 model.default。", + })} +

+
); } diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 63bf2a0eb..398f76ae2 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -1898,6 +1898,10 @@ export function ProviderForm({ websiteUrl={hermesWebsiteUrl} isPartner={isHermesPartner} partnerPromotionKey={hermesPartnerPromotionKey} + apiMode={hermesForm.hermesApiMode} + onApiModeChange={hermesForm.handleHermesApiModeChange} + models={hermesForm.hermesModels} + onModelsChange={hermesForm.handleHermesModelsChange} /> )} diff --git a/src/components/providers/forms/hooks/useHermesFormState.ts b/src/components/providers/forms/hooks/useHermesFormState.ts index 09f5d135e..d788e367b 100644 --- a/src/components/providers/forms/hooks/useHermesFormState.ts +++ b/src/components/providers/forms/hooks/useHermesFormState.ts @@ -1,6 +1,11 @@ import { useState, useCallback, useMemo } from "react"; import type { AppId } from "@/lib/api"; import { useProvidersQuery } from "@/lib/query/queries"; +import type { + HermesApiModeChoice, + HermesModel, + HermesProviderSettingsConfig, +} from "@/config/hermesProviderPresets"; interface UseHermesFormStateParams { initialData?: { @@ -29,14 +34,14 @@ export interface HermesFormState { setHermesProviderKey: (key: string) => void; hermesBaseUrl: string; hermesApiKey: string; + hermesApiMode: HermesApiModeChoice; + hermesModels: HermesModel[]; existingHermesKeys: string[]; handleHermesBaseUrlChange: (baseUrl: string) => void; handleHermesApiKeyChange: (apiKey: string) => void; - resetHermesState: (config?: { - name?: string; - base_url?: string; - api_key?: string; - }) => void; + handleHermesApiModeChange: (mode: HermesApiModeChoice) => void; + handleHermesModelsChange: (models: HermesModel[]) => void; + resetHermesState: (config?: Partial) => void; } function parseHermesField( @@ -48,7 +53,10 @@ function parseHermesField( if (initialData?.settingsConfig) { return (initialData.settingsConfig[field] as T) || fallback; } - return ((HERMES_DEFAULT_CONFIG_OBJ as Record)[field] as T) || fallback; + return ( + ((HERMES_DEFAULT_CONFIG_OBJ as Record)[field] as T) || + fallback + ); } catch { return fallback; } @@ -84,6 +92,23 @@ export function useHermesFormState({ return parseHermesField(initialData, "api_key", ""); }); + const [hermesApiMode, setHermesApiMode] = useState( + () => { + if (appId !== "hermes") return "auto"; + const stored = parseHermesField( + initialData, + "api_mode", + "", + ); + return stored || "auto"; + }, + ); + + const [hermesModels, setHermesModels] = useState(() => { + if (appId !== "hermes") return []; + return parseHermesField(initialData, "models", []); + }); + const updateHermesConfig = useCallback( (updater: (config: Record) => void) => { try { @@ -117,11 +142,41 @@ export function useHermesFormState({ [updateHermesConfig], ); + const handleHermesApiModeChange = useCallback( + (mode: HermesApiModeChoice) => { + setHermesApiMode(mode); + updateHermesConfig((config) => { + if (mode === "auto") { + delete config.api_mode; + } else { + config.api_mode = mode; + } + }); + }, + [updateHermesConfig], + ); + + const handleHermesModelsChange = useCallback( + (models: HermesModel[]) => { + setHermesModels(models); + updateHermesConfig((config) => { + if (models.length === 0) { + delete config.models; + } else { + config.models = models; + } + }); + }, + [updateHermesConfig], + ); + const resetHermesState = useCallback( - (config?: { name?: string; base_url?: string; api_key?: string }) => { + (config?: Partial) => { setHermesProviderKey(""); setHermesBaseUrl(config?.base_url || ""); setHermesApiKey(config?.api_key || ""); + setHermesApiMode(config?.api_mode ?? "auto"); + setHermesModels(config?.models ?? []); }, [], ); @@ -131,9 +186,13 @@ export function useHermesFormState({ setHermesProviderKey, hermesBaseUrl, hermesApiKey, + hermesApiMode, + hermesModels, existingHermesKeys, handleHermesBaseUrlChange, handleHermesApiKeyChange, + handleHermesApiModeChange, + handleHermesModelsChange, resetHermesState, }; } diff --git a/src/config/hermesProviderPresets.ts b/src/config/hermesProviderPresets.ts index c586b257e..4cc5f7241 100644 --- a/src/config/hermesProviderPresets.ts +++ b/src/config/hermesProviderPresets.ts @@ -47,6 +47,31 @@ export interface HermesSuggestedDefaults { /** Hermes custom_provider protocol mode (optional; auto-detected when omitted). */ export type HermesApiMode = "chat_completions" | "anthropic_messages"; +/** + * Form-facing value used by the API Mode dropdown. + * + * `auto` is the UI-only sentinel for "omit `api_mode` and let Hermes detect the + * protocol from the endpoint". When serialized to `settings_config`, `auto` + * becomes `undefined` so the YAML doesn't include `api_mode` at all. + */ +export type HermesApiModeChoice = "auto" | HermesApiMode; + +/** + * Dropdown options for the API Mode selector. `labelKey` is looked up in i18n; + * `value` of "auto" means "don't write `api_mode` to the config". + */ +export const hermesApiModes: Array<{ + value: HermesApiModeChoice; + labelKey: string; +}> = [ + { value: "auto", labelKey: "hermes.form.apiModeAuto" }, + { value: "chat_completions", labelKey: "hermes.form.apiModeChatCompletions" }, + { + value: "anthropic_messages", + labelKey: "hermes.form.apiModeAnthropicMessages", + }, +]; + export interface HermesProviderPreset { name: string; nameKey?: string; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 965ba823b..e50bf4e5b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1640,7 +1640,25 @@ "providerKeyLockedHint": "This provider is in Hermes config; key is locked.", "providerKeyRequired": "Provider key is required", "providerKeyInvalid": "Provider key can only contain lowercase letters, numbers, and hyphens", - "providerKeyDuplicate": "This provider key already exists" + "providerKeyDuplicate": "This provider key already exists", + "apiMode": "API Mode", + "apiModeHint": "Provider API protocol. Auto lets Hermes detect it from the endpoint.", + "apiModeAuto": "Auto-detect", + "apiModeChatCompletions": "OpenAI Chat Completions", + "apiModeAnthropicMessages": "Anthropic Messages", + "models": "Models", + "addModel": "Add model", + "noModels": "No models configured. Switching to this provider won't change the default model.", + "modelId": "Model ID", + "modelIdPlaceholder": "anthropic/claude-opus-4-7", + "modelName": "Display name", + "modelNamePlaceholder": "Claude Opus 4.7", + "contextLength": "Context length", + "maxTokens": "Max output tokens", + "advancedOptions": "Advanced options", + "modelsHint": "On switch, the first model is written to top-level model.default.", + "primaryModel": "Default", + "fallbackModel": "Alternate" }, "model": { "title": "Model Config", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index b3e1186e8..3beed5464 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1640,7 +1640,25 @@ "providerKeyLockedHint": "このプロバイダーは Hermes 設定に追加済みのため、キーは変更できません。", "providerKeyRequired": "プロバイダーキーは必須です", "providerKeyInvalid": "プロバイダーキーは小文字、数字、ハイフンのみ使用できます", - "providerKeyDuplicate": "このプロバイダーキーは既に存在します" + "providerKeyDuplicate": "このプロバイダーキーは既に存在します", + "apiMode": "API モード", + "apiModeHint": "プロバイダーの API プロトコル。Auto はエンドポイントから自動判定します。", + "apiModeAuto": "自動検出", + "apiModeChatCompletions": "OpenAI Chat Completions", + "apiModeAnthropicMessages": "Anthropic Messages", + "models": "モデル一覧", + "addModel": "モデルを追加", + "noModels": "モデル未設定。このプロバイダーに切り替えてもデフォルトモデルは更新されません。", + "modelId": "モデル ID", + "modelIdPlaceholder": "anthropic/claude-opus-4-7", + "modelName": "表示名", + "modelNamePlaceholder": "Claude Opus 4.7", + "contextLength": "コンテキスト長", + "maxTokens": "最大出力トークン", + "advancedOptions": "詳細オプション", + "modelsHint": "切り替え時、最初のモデルが model.default に書き込まれます。", + "primaryModel": "デフォルト", + "fallbackModel": "予備" }, "model": { "title": "モデル設定", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 0fd198e0f..3abb2afcc 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1641,7 +1641,25 @@ "providerKeyLockedHint": "该供应商已添加到 Hermes 配置中,标识不可修改。", "providerKeyRequired": "供应商标识不能为空", "providerKeyInvalid": "供应商标识只能包含小写字母、数字和连字符", - "providerKeyDuplicate": "该供应商标识已存在" + "providerKeyDuplicate": "该供应商标识已存在", + "apiMode": "API 模式", + "apiModeHint": "供应商 API 协议。Auto 表示让 Hermes 按端点自动判断。", + "apiModeAuto": "自动检测", + "apiModeChatCompletions": "OpenAI Chat Completions", + "apiModeAnthropicMessages": "Anthropic Messages", + "models": "模型列表", + "addModel": "添加模型", + "noModels": "暂无模型配置。切换到此供应商时将不会更新默认模型。", + "modelId": "模型 ID", + "modelIdPlaceholder": "anthropic/claude-opus-4-7", + "modelName": "显示名称", + "modelNamePlaceholder": "Claude Opus 4.7", + "contextLength": "上下文长度", + "maxTokens": "最大输出 Tokens", + "advancedOptions": "高级选项", + "modelsHint": "切换到此供应商时,第一个模型会写入顶层 model.default。", + "primaryModel": "默认模型", + "fallbackModel": "备选模型" }, "model": { "title": "模型配置", From 5056978d80d79514e3115696309c74320fe6c503 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 19 Apr 2026 00:12:43 +0800 Subject: [PATCH 028/156] fix(hermes): persist providers under custom_providers so api_mode/model survive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Writing to the v12+ `providers:` dict broke every anthropic_messages provider. Hermes `runtime_provider.py::_get_named_custom_provider` has a bug in its `providers:` branch: the returned entry drops `api_mode`, `transport`, `models`, and singular `model:`, and `_resolve_named_custom_runtime` then falls back to `chat_completions` — so an Anthropic-format endpoint receives OpenAI-format requests and returns 404. Keep using the legacy `custom_providers:` list; its normalization path (`_normalize_custom_provider_entry`) preserves every field. In addition, write a singular `model:` alongside the plural `models:` dict so the Hermes runtime and `/model` picker see the default model id. Also keep the `apply_switch_defaults` fix from the prior attempt: `model.provider` is always updated, and `model.default` is only overwritten when the new provider declares at least one model — so switching to an incomplete provider no longer silently no-ops. --- src-tauri/src/hermes_config.rs | 149 +++++++++++++++++++++++++-------- 1 file changed, 115 insertions(+), 34 deletions(-) diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs index 88a58190a..4f7d799e8 100644 --- a/src-tauri/src/hermes_config.rs +++ b/src-tauri/src/hermes_config.rs @@ -19,6 +19,10 @@ //! - name: openrouter //! base_url: https://openrouter.ai/api/v1 //! api_key: sk-or-... +//! model: anthropic/claude-opus-4-7 +//! models: +//! anthropic/claude-opus-4-7: +//! context_length: 200000 //! //! mcp_servers: //! filesystem: @@ -466,10 +470,19 @@ fn denormalize_provider_models_for_read(config: &mut serde_json::Value) { /// Get all custom providers as a JSON map keyed by provider name. /// -/// The `custom_providers` section is a YAML sequence where each item has a -/// `name` field. This function converts it to a map for CC Switch consumption, -/// and additionally transforms each entry's `models` field from the -/// YAML dict shape to the UI-friendly ordered array shape. +/// Reads the `custom_providers:` YAML sequence where each item has a `name` +/// field, and converts it to a map for CC Switch consumption. Each entry's +/// `models` field is converted from the YAML dict shape back to the +/// UI-friendly ordered array shape. +/// +/// We intentionally use the legacy `custom_providers:` list (not the v12+ +/// `providers:` dict) because Hermes's runtime_provider.py +/// `_get_named_custom_provider` has a bug in its `providers:` branch: the +/// returned entry dict drops `api_mode` / `transport` / `models` / +/// singular `model:`. That leaves `_resolve_named_custom_runtime` to fall +/// back to `chat_completions`, breaking every anthropic_messages provider. +/// The legacy `custom_providers:` branch goes through +/// `_normalize_custom_provider_entry` and preserves all fields correctly. pub fn get_providers() -> Result, AppError> { let config = read_hermes_config()?; let providers = config @@ -503,9 +516,17 @@ pub fn get_provider(name: &str) -> Result, AppError> { /// Set (upsert) a custom provider by name. /// -/// If a provider with the same name exists, it is replaced; otherwise a new -/// entry is appended. The entire read-modify-write is done under the write -/// lock to prevent TOCTOU races. +/// Upserts into the `custom_providers:` YAML sequence (matched by `name`). +/// The entry includes: +/// - `name:` field matching the provider id +/// - singular `model:` field set to the first model id from the `models:` +/// dict — the Hermes runtime and `/model` picker both read this field +/// (runtime_provider.py reads it via `_normalize_custom_provider_entry`; +/// main.py:1436/1450 uses it for picker hints) +/// - plural `models:` dict carrying per-model `context_length` etc. +/// +/// The entire read-modify-write is done under the write lock to prevent +/// TOCTOU races. pub fn set_provider( name: &str, provider_config: serde_json::Value, @@ -523,12 +544,28 @@ pub fn set_provider( let mut normalized = provider_config; normalize_provider_models_for_write(&mut normalized); + // Extract the first model id (now a key in the normalized dict) so we can + // propagate it to the singular `model:` field Hermes reads. + let first_model_id = normalized + .get("models") + .and_then(|v| v.as_object()) + .and_then(|obj| obj.keys().next()) + .cloned(); + let mut yaml_val: serde_yaml::Value = json_to_yaml(&normalized)?; if let serde_yaml::Value::Mapping(ref mut m) = yaml_val { m.insert( serde_yaml::Value::String("name".to_string()), serde_yaml::Value::String(name.to_string()), ); + if let Some(model_id) = first_model_id { + m.insert( + serde_yaml::Value::String("model".to_string()), + serde_yaml::Value::String(model_id), + ); + } else { + m.remove(serde_yaml::Value::String("model".to_string())); + } } if let Some(existing) = providers @@ -546,8 +583,9 @@ pub fn set_provider( /// Remove a custom provider by name. /// -/// The entire read-modify-write is done under the write lock to prevent -/// TOCTOU races. +/// Filters out the matching entry from the `custom_providers:` sequence. +/// No-op if the section is missing or no entry matches. The entire +/// read-modify-write is done under the write lock to prevent TOCTOU races. pub fn remove_provider(name: &str) -> Result { let _guard = hermes_write_lock().lock()?; @@ -595,34 +633,37 @@ pub fn set_model_config(model: &HermesModelConfig) -> Result Result, AppError> { - let Some(first_model_id) = settings_config +) -> Result { + let first_model_id = settings_config .get("models") .and_then(|v| v.as_array()) .and_then(|arr| arr.first()) .and_then(|m| m.get("id")) .and_then(|id| id.as_str()) .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - else { - return Ok(None); - }; + .filter(|s| !s.is_empty()); let current = get_model_config()?.unwrap_or_default(); let merged = HermesModelConfig { - default: Some(first_model_id), + default: first_model_id.or(current.default.clone()), provider: Some(provider_id.to_string()), ..current }; - Ok(Some(set_model_config(&merged)?)) + set_model_config(&merged) } // ============================================================================ @@ -1311,7 +1352,7 @@ model: let yaml = "\ model: default: gpt-4 - provider: openai + provider: openrouter custom_providers: - name: openrouter base_url: https://openrouter.ai/api/v1 @@ -1403,7 +1444,7 @@ custom_providers: }); set_provider("demo", config).unwrap(); - // Read raw YAML to verify the on-disk shape is a dict. + // Read raw YAML to verify the on-disk shape is a sequence under `custom_providers:`. let raw = fs::read_to_string(get_hermes_config_path()).unwrap(); let yaml: serde_yaml::Value = serde_yaml::from_str(&raw).unwrap(); let providers = yaml @@ -1411,6 +1452,17 @@ custom_providers: .and_then(|v| v.as_sequence()) .unwrap(); let provider = &providers[0]; + assert_eq!( + provider.get("name").and_then(|v| v.as_str()), + Some("demo"), + "entry should carry a name field" + ); + assert_eq!( + provider.get("model").and_then(|v| v.as_str()), + Some("model-a"), + "entry should carry a singular `model:` field set to the first model id \ + so Hermes runtime/picker reads it" + ); let models = provider.get("models").and_then(|v| v.as_mapping()).unwrap(); assert_eq!(models.len(), 2); assert!(models.contains_key(serde_yaml::Value::String("model-a".into()))); @@ -1469,6 +1521,10 @@ custom_providers: let providers = get_providers().unwrap(); let provider = providers.get("simple").unwrap(); assert!(provider.get("models").is_none()); + assert!( + provider.get("model").is_none(), + "singular `model:` should not appear when no models are declared" + ); }); } @@ -1485,8 +1541,7 @@ custom_providers: { "id": "fallback", "context_length": 100000 }, ] }); - let outcome = apply_switch_defaults("demo", &settings).unwrap(); - assert!(outcome.is_some()); + apply_switch_defaults("demo", &settings).unwrap(); let model = get_model_config().unwrap().unwrap(); assert_eq!(model.default.as_deref(), Some("primary-model")); @@ -1529,27 +1584,53 @@ custom_providers: #[test] #[serial] - fn apply_switch_defaults_noop_when_no_models_declared() { + fn apply_switch_defaults_updates_provider_even_without_models() { with_test_home(|| { + // Seed an existing `model:` section — the user was already running + // some provider before this switch. + let initial = HermesModelConfig { + default: Some("legacy-default".to_string()), + provider: Some("legacy-provider".to_string()), + ..Default::default() + }; + set_model_config(&initial).unwrap(); + + // New provider has no `models` list — previously this would no-op + // and leave `model.provider` pointing at the legacy provider, + // causing "switch succeeds but has no effect" bug. let settings = serde_json::json!({ "base_url": "https://api.example.com/v1" }); - let outcome = apply_switch_defaults("bare", &settings).unwrap(); - assert!(outcome.is_none()); - assert!(get_model_config().unwrap().is_none()); + apply_switch_defaults("bare", &settings).unwrap(); + + let model = get_model_config().unwrap().unwrap(); + assert_eq!(model.provider.as_deref(), Some("bare")); + assert_eq!(model.default.as_deref(), Some("legacy-default")); }); } #[test] #[serial] - fn apply_switch_defaults_noop_on_empty_model_id() { + fn apply_switch_defaults_keeps_old_default_when_first_model_id_is_blank() { with_test_home(|| { + let initial = HermesModelConfig { + default: Some("prev-default".to_string()), + provider: Some("prev-provider".to_string()), + ..Default::default() + }; + set_model_config(&initial).unwrap(); + let settings = serde_json::json!({ "models": [{ "id": " " }, { "id": "real" }] }); - let outcome = apply_switch_defaults("edge", &settings).unwrap(); - // First entry has whitespace-only id → treated as missing, no-op. - assert!(outcome.is_none()); + apply_switch_defaults("edge", &settings).unwrap(); + + let model = get_model_config().unwrap().unwrap(); + // Provider always updates. + assert_eq!(model.provider.as_deref(), Some("edge")); + // First entry's id is whitespace-only → blank → fall back to old default + // (we intentionally don't scan past the first entry for a default). + assert_eq!(model.default.as_deref(), Some("prev-default")); }); } } From 828ec2ce07fb5a0eb81ef7920631b4f39a558e2c Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 19 Apr 2026 09:32:43 +0800 Subject: [PATCH 029/156] fix(hermes): show active provider and wire add/enable/remove actions Switching a Hermes provider previously only fired a toast because the frontend treated Hermes as non-additive (unlike backend AppType::is_additive_mode, which lists OpenCode | OpenClaw | Hermes) and relied on the unused is_current DB flag for highlighting. Align the UI model with the backend: - Include Hermes in ProviderActions' isAdditiveMode so the main button switches between "Add" and "Remove". - Drive the "current" highlight from model.provider (via useHermesModelConfig) instead of the DB is_current field; model.provider is Hermes's real SSOT for the active provider. - Reuse OpenClaw's set-as-default button slot to expose an "Enable" action on Hermes that calls switchProvider, so providers already in config can be activated without re-adding. switch_normal + apply_switch_defaults already atomically update custom_providers and model.provider, so no backend change is needed. - Invalidate liveProviderIds + modelConfig + health in parallel after add/update/delete/switch via a new invalidateHermesProviderCaches helper, replacing four copies of three sequential awaits. --- src/App.tsx | 6 ++- src/components/providers/ProviderActions.tsx | 55 ++++++++++++-------- src/components/providers/ProviderCard.tsx | 6 ++- src/components/providers/ProviderList.tsx | 23 ++++++-- src/hooks/useHermes.ts | 20 ++++++- src/lib/query/mutations.ts | 21 ++------ 6 files changed, 86 insertions(+), 45 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index b331aaea1..d1dee843f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1021,7 +1021,11 @@ function App() { } onCreate={() => setIsAddOpen(true)} onSetAsDefault={ - activeApp === "openclaw" ? setAsDefaultModel : undefined + activeApp === "openclaw" + ? setAsDefaultModel + : activeApp === "hermes" + ? switchProvider + : undefined } /> diff --git a/src/components/providers/ProviderActions.tsx b/src/components/providers/ProviderActions.tsx index 344a71da8..50872decf 100644 --- a/src/components/providers/ProviderActions.tsx +++ b/src/components/providers/ProviderActions.tsx @@ -70,9 +70,11 @@ export function ProviderActions({ const { t } = useTranslation(); const iconButtonClass = "h-8 w-8 p-1"; - // 累加模式应用(OpenCode 非 OMO 和 OpenClaw) + // 累加模式应用(OpenCode 非 OMO / OpenClaw / Hermes) const isAdditiveMode = - (appId === "opencode" && !isOmo) || appId === "openclaw"; + (appId === "opencode" && !isOmo) || + appId === "openclaw" || + appId === "hermes"; // 故障转移模式下的按钮逻辑(累加模式和 OMO 应用不支持故障转移) const isFailoverMode = @@ -207,25 +209,36 @@ export function ProviderActions({ return (
- {appId === "openclaw" && isInConfig && onSetAsDefault && ( - - )} + {(appId === "openclaw" || appId === "hermes") && + isInConfig && + onSetAsDefault && + (() => { + const activeLabel = + appId === "hermes" + ? t("provider.inUse", { defaultValue: "已在用" }) + : t("provider.isDefault", { defaultValue: "当前默认" }); + const inactiveLabel = + appId === "hermes" + ? t("provider.enable", { defaultValue: "启用" }) + : t("provider.setAsDefault", { defaultValue: "设为默认" }); + return ( + + ); + })()} - - + ) : activeApp === "openclaw" ? ( <> diff --git a/src/components/hermes/AgentPanel.tsx b/src/components/hermes/AgentPanel.tsx deleted file mode 100644 index a90fde681..000000000 --- a/src/components/hermes/AgentPanel.tsx +++ /dev/null @@ -1,249 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Save } from "lucide-react"; -import { toast } from "sonner"; -import { - useHermesAgentConfig, - useSaveHermesAgentConfig, -} from "@/hooks/useHermes"; -import { extractErrorMessage } from "@/utils/errorUtils"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import type { HermesAgentConfig } from "@/types"; - -const UNSET_SENTINEL = "__unset__"; - -const REASONING_EFFORT_OPTIONS = [ - { value: UNSET_SENTINEL, labelKey: "hermes.agent.notSet" }, - { value: "none", label: "None" }, - { value: "minimal", label: "Minimal" }, - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - { value: "xhigh", label: "Extra High" }, -] as const; - -const APPROVALS_MODE_OPTIONS = [ - { value: UNSET_SENTINEL, labelKey: "hermes.agent.notSet" }, - { value: "manual", label: "Manual" }, - { value: "smart", label: "Smart" }, - { value: "off", label: "Off" }, -] as const; - -const AgentPanel: React.FC = () => { - const { t } = useTranslation(); - const { data: agentData, isLoading } = useHermesAgentConfig(); - const saveAgentMutation = useSaveHermesAgentConfig(); - - const [maxTurns, setMaxTurns] = useState(""); - const [reasoningEffort, setReasoningEffort] = useState(UNSET_SENTINEL); - const [toolUseEnforcement, setToolUseEnforcement] = useState(""); - const [approvalsMode, setApprovalsMode] = useState(UNSET_SENTINEL); - - // Preserve unknown fields - const [extra, setExtra] = useState>({}); - - useEffect(() => { - if (agentData === undefined) return; - if (agentData) { - setMaxTurns( - agentData.max_turns != null ? String(agentData.max_turns) : "", - ); - setReasoningEffort(agentData.reasoning_effort ?? UNSET_SENTINEL); - setToolUseEnforcement( - agentData.tool_use_enforcement != null - ? typeof agentData.tool_use_enforcement === "string" - ? agentData.tool_use_enforcement - : JSON.stringify(agentData.tool_use_enforcement) - : "", - ); - setApprovalsMode(agentData.approvals_mode ?? UNSET_SENTINEL); - const { - max_turns: _mt, - reasoning_effort: _re, - tool_use_enforcement: _tu, - approvals_mode: _am, - ...rest - } = agentData; - setExtra(rest); - } else { - setMaxTurns(""); - setReasoningEffort(UNSET_SENTINEL); - setToolUseEnforcement(""); - setApprovalsMode(UNSET_SENTINEL); - setExtra({}); - } - }, [agentData]); - - const handleSave = async () => { - try { - const config: HermesAgentConfig = { - ...extra, - }; - - const mt = parseInt(maxTurns); - if (!isNaN(mt) && mt > 0) config.max_turns = mt; - - if (reasoningEffort !== UNSET_SENTINEL) { - config.reasoning_effort = reasoningEffort; - } - - if (toolUseEnforcement.trim()) { - // Try parsing as JSON (for boolean/array values) - try { - config.tool_use_enforcement = JSON.parse(toolUseEnforcement.trim()); - } catch { - config.tool_use_enforcement = toolUseEnforcement.trim(); - } - } - - if (approvalsMode !== UNSET_SENTINEL) { - config.approvals_mode = approvalsMode; - } - - await saveAgentMutation.mutateAsync(config); - toast.success(t("hermes.agent.saveSuccess")); - } catch (error) { - toast.error(t("hermes.agent.saveFailed"), { - description: extractErrorMessage(error), - }); - } - }; - - if (isLoading) { - return ( -
-
- {t("common.loading")} -
-
- ); - } - - return ( -
-

- {t("hermes.agent.description")} -

- -
-
-
- - setMaxTurns(e.target.value)} - placeholder="100" - /> -

- {t("hermes.agent.maxTurnsHint", { - defaultValue: "Maximum number of agent turns per session", - })} -

-
- -
- - -

- {t("hermes.agent.reasoningEffortHint", { - defaultValue: - "Controls the depth of reasoning: none, minimal, low, medium, high, xhigh", - })} -

-
- -
- - setToolUseEnforcement(e.target.value)} - placeholder="auto" - /> -

- {t("hermes.agent.toolUseHint", { - defaultValue: - 'Values: "auto", true, false, or a JSON array of tool names', - })} -

-
- -
- - -

- {t("hermes.agent.approvalsModeHint", { - defaultValue: - "Controls tool call approval: manual (always ask), smart (auto-approve safe), off (never ask)", - })} -

-
-
-
- -
- -
-
- ); -}; - -export default AgentPanel; diff --git a/src/components/hermes/EnvPanel.tsx b/src/components/hermes/EnvPanel.tsx deleted file mode 100644 index b8ce9f735..000000000 --- a/src/components/hermes/EnvPanel.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Save } from "lucide-react"; -import { toast } from "sonner"; -import { useHermesEnv, useSaveHermesEnv } from "@/hooks/useHermes"; -import { extractErrorMessage } from "@/utils/errorUtils"; -import { Button } from "@/components/ui/button"; -import JsonEditor from "@/components/JsonEditor"; - -function parseEnvEditorValue(raw: string): Record { - const trimmed = raw.trim(); - if (!trimmed) throw new Error("HERMES_ENV_EMPTY"); - let parsed: unknown; - try { - parsed = JSON.parse(trimmed); - } catch { - throw new Error("HERMES_ENV_INVALID_JSON"); - } - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { - throw new Error("HERMES_ENV_OBJECT_REQUIRED"); - } - return parsed as Record; -} - -const EnvPanel: React.FC = () => { - const { t } = useTranslation(); - const { data: envData, isLoading } = useHermesEnv(); - const saveEnvMutation = useSaveHermesEnv(); - const [editorValue, setEditorValue] = useState("{}"); - const [isDarkMode, setIsDarkMode] = useState(false); - - useEffect(() => { - const nextValue = - envData && Object.keys(envData).length > 0 - ? JSON.stringify(envData, null, 2) - : "{}"; - setEditorValue(nextValue); - }, [envData]); - - useEffect(() => { - setIsDarkMode(document.documentElement.classList.contains("dark")); - - const observer = new MutationObserver(() => { - setIsDarkMode(document.documentElement.classList.contains("dark")); - }); - - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ["class"], - }); - - return () => observer.disconnect(); - }, []); - - const handleSave = async () => { - try { - const env = parseEnvEditorValue(editorValue); - await saveEnvMutation.mutateAsync(env); - toast.success(t("hermes.env.saveSuccess")); - } catch (error) { - const detail = extractErrorMessage(error); - let description = detail || undefined; - if (detail === "HERMES_ENV_EMPTY") { - description = t("hermes.env.empty", { - defaultValue: - "Hermes env cannot be empty. Use {} for an empty object.", - }); - } else if (detail === "HERMES_ENV_INVALID_JSON") { - description = t("hermes.env.invalidJson", { - defaultValue: "Hermes env must be valid JSON.", - }); - } else if (detail === "HERMES_ENV_OBJECT_REQUIRED") { - description = t("hermes.env.objectRequired", { - defaultValue: "Hermes env must be a JSON object.", - }); - } - toast.error(t("hermes.env.saveFailed"), { - description, - }); - } - }; - - if (isLoading) { - return ( -
-
- {t("common.loading")} -
-
- ); - } - - return ( -
-

- {t("hermes.env.description")} -

-

- {t("hermes.env.editorHint", { - defaultValue: - "Edit the Hermes .env file as a JSON key-value map. Keys become environment variable names.", - })} -

- - - -
- -
-
- ); -}; - -export default EnvPanel; diff --git a/src/components/hermes/HermesHealthBanner.tsx b/src/components/hermes/HermesHealthBanner.tsx index 6907c6138..bf6875c6f 100644 --- a/src/components/hermes/HermesHealthBanner.tsx +++ b/src/components/hermes/HermesHealthBanner.tsx @@ -1,7 +1,9 @@ import React, { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { TriangleAlert } from "lucide-react"; +import { ExternalLink, TriangleAlert } from "lucide-react"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { useOpenHermesWebUI } from "@/hooks/useHermes"; import type { HermesHealthWarning } from "@/types"; interface HermesHealthBannerProps { @@ -37,6 +39,7 @@ const HermesHealthBanner: React.FC = ({ warnings, }) => { const { t } = useTranslation(); + const openHermesWebUI = useOpenHermesWebUI(); const items = useMemo( () => @@ -55,10 +58,21 @@ const HermesHealthBanner: React.FC = ({
- - {t("hermes.health.title", { - defaultValue: "Hermes config warnings detected", - })} + + + {t("hermes.health.title", { + defaultValue: "Hermes config warnings detected", + })} + +
    diff --git a/src/components/hermes/ModelPanel.tsx b/src/components/hermes/ModelPanel.tsx deleted file mode 100644 index 4c5aeb762..000000000 --- a/src/components/hermes/ModelPanel.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Save } from "lucide-react"; -import { toast } from "sonner"; -import { - useHermesModelConfig, - useSaveHermesModelConfig, -} from "@/hooks/useHermes"; -import { extractErrorMessage } from "@/utils/errorUtils"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import type { HermesModelConfig } from "@/types"; - -const ModelPanel: React.FC = () => { - const { t } = useTranslation(); - const { data: modelData, isLoading } = useHermesModelConfig(true); - const saveModelMutation = useSaveHermesModelConfig(); - - const [defaultModel, setDefaultModel] = useState(""); - const [provider, setProvider] = useState(""); - const [baseUrl, setBaseUrl] = useState(""); - const [contextLength, setContextLength] = useState(""); - const [maxTokens, setMaxTokens] = useState(""); - - // Preserve unknown fields from the original config - const [extra, setExtra] = useState>({}); - - useEffect(() => { - if (modelData === undefined) return; - if (modelData) { - setDefaultModel(modelData.default ?? ""); - setProvider(modelData.provider ?? ""); - setBaseUrl(modelData.base_url ?? ""); - setContextLength( - modelData.context_length != null - ? String(modelData.context_length) - : "", - ); - setMaxTokens( - modelData.max_tokens != null ? String(modelData.max_tokens) : "", - ); - // Collect unknown fields - const { - default: _d, - provider: _p, - base_url: _b, - context_length: _c, - max_tokens: _m, - ...rest - } = modelData; - setExtra(rest); - } else { - setDefaultModel(""); - setProvider(""); - setBaseUrl(""); - setContextLength(""); - setMaxTokens(""); - setExtra({}); - } - }, [modelData]); - - const handleSave = async () => { - try { - const config: HermesModelConfig = { - ...extra, - }; - if (defaultModel.trim()) config.default = defaultModel.trim(); - if (provider.trim()) config.provider = provider.trim(); - if (baseUrl.trim()) config.base_url = baseUrl.trim(); - - const cl = parseInt(contextLength); - if (!isNaN(cl) && cl > 0) config.context_length = cl; - - const mt = parseInt(maxTokens); - if (!isNaN(mt) && mt > 0) config.max_tokens = mt; - - await saveModelMutation.mutateAsync(config); - toast.success(t("hermes.model.saveSuccess")); - } catch (error) { - toast.error(t("hermes.model.saveFailed"), { - description: extractErrorMessage(error), - }); - } - }; - - if (isLoading) { - return ( -
    -
    - {t("common.loading")} -
    -
    - ); - } - - return ( -
    -

    - {t("hermes.model.description")} -

    - -
    -
    -
    - - setDefaultModel(e.target.value)} - placeholder="anthropic/claude-opus-4-7" - /> -

    - {t("hermes.model.defaultHint", { - defaultValue: - "The default model to use, e.g. anthropic/claude-opus-4-7", - })} -

    -
    - -
    - - setProvider(e.target.value)} - placeholder="openrouter" - /> -

    - {t("hermes.model.providerHint", { - defaultValue: - "Provider name for model routing (e.g. openrouter, anthropic)", - })} -

    -
    - -
    - - setBaseUrl(e.target.value)} - placeholder="https://api.example.com/v1" - /> -

    - {t("hermes.model.baseUrlHint", { - defaultValue: "Override the API endpoint URL for this model", - })} -

    -
    - -
    - - setContextLength(e.target.value)} - placeholder="200000" - /> -
    - -
    - - setMaxTokens(e.target.value)} - placeholder="16384" - /> -
    -
    -
    - -
    - -
    -
    - ); -}; - -export default ModelPanel; diff --git a/src/hooks/useHermes.ts b/src/hooks/useHermes.ts index 63ee17fd1..63599dd9c 100644 --- a/src/hooks/useHermes.ts +++ b/src/hooks/useHermes.ts @@ -1,16 +1,17 @@ -import { - useMutation, - useQuery, - useQueryClient, - type QueryClient, -} from "@tanstack/react-query"; +import { useCallback } from "react"; +import { useQuery, type QueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; import { hermesApi } from "@/lib/api/hermes"; import { providersApi } from "@/lib/api/providers"; -import type { - HermesEnvConfig, - HermesAgentConfig, - HermesModelConfig, -} from "@/types"; +import { extractErrorMessage } from "@/utils/errorUtils"; + +/** + * Error code returned by the Rust `open_hermes_web_ui` command when probing + * `/api/status` fails. Must match the string constant in + * `src-tauri/src/commands/hermes.rs`. + */ +export const HERMES_WEB_OFFLINE_ERROR = "hermes_web_offline"; /** * Centralized query keys for all Hermes-related queries. @@ -20,8 +21,6 @@ export const hermesKeys = { all: ["hermes"] as const, liveProviderIds: ["hermes", "liveProviderIds"] as const, modelConfig: ["hermes", "modelConfig"] as const, - agentConfig: ["hermes", "agentConfig"] as const, - env: ["hermes", "env"] as const, health: ["hermes", "health"] as const, }; @@ -42,10 +41,6 @@ export function invalidateHermesProviderCaches(queryClient: QueryClient) { // Query hooks // ============================================================ -/** - * Query live provider IDs from Hermes config. - * Used by ProviderList to show "In Config" badge. - */ export function useHermesLiveProviderIds(enabled: boolean) { return useQuery({ queryKey: hermesKeys.liveProviderIds, @@ -54,9 +49,6 @@ export function useHermesLiveProviderIds(enabled: boolean) { }); } -/** - * Query model configuration. - */ export function useHermesModelConfig(enabled: boolean) { return useQuery({ queryKey: hermesKeys.modelConfig, @@ -65,33 +57,6 @@ export function useHermesModelConfig(enabled: boolean) { }); } -/** - * Query agent configuration. - */ -export function useHermesAgentConfig(enabled = true) { - return useQuery({ - queryKey: hermesKeys.agentConfig, - queryFn: () => hermesApi.getAgentConfig(), - staleTime: 30_000, - enabled, - }); -} - -/** - * Query env configuration. - */ -export function useHermesEnv(enabled = true) { - return useQuery({ - queryKey: hermesKeys.env, - queryFn: () => hermesApi.getEnv(), - staleTime: 30_000, - enabled, - }); -} - -/** - * Query config health warnings. - */ export function useHermesHealth(enabled: boolean) { return useQuery({ queryKey: hermesKeys.health, @@ -106,46 +71,27 @@ export function useHermesHealth(enabled: boolean) { // ============================================================ /** - * Save model config. Invalidates modelConfig and health queries on success. - * Toast notifications are handled by the component. + * Returns a handler that probes the local Hermes Web UI, opens it in the + * system browser, and surfaces a localized toast on failure. Callers only + * need to wire the returned function to a click handler. */ -export function useSaveHermesModelConfig() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (config: HermesModelConfig) => hermesApi.setModelConfig(config), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: hermesKeys.modelConfig }); - queryClient.invalidateQueries({ queryKey: hermesKeys.health }); +export function useOpenHermesWebUI() { + const { t } = useTranslation(); + return useCallback( + async (path?: string) => { + try { + await hermesApi.openWebUI(path); + } catch (error) { + const detail = extractErrorMessage(error); + if (detail === HERMES_WEB_OFFLINE_ERROR) { + toast.error(t("hermes.webui.offline")); + } else { + toast.error(t("hermes.webui.openFailed"), { + description: detail || undefined, + }); + } + } }, - }); -} - -/** - * Save agent config. Invalidates agentConfig and health queries on success. - * Toast notifications are handled by the component. - */ -export function useSaveHermesAgentConfig() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (config: HermesAgentConfig) => hermesApi.setAgentConfig(config), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: hermesKeys.agentConfig }); - queryClient.invalidateQueries({ queryKey: hermesKeys.health }); - }, - }); -} - -/** - * Save env config. Invalidates env and health queries on success. - * Toast notifications are handled by the component. - */ -export function useSaveHermesEnv() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (env: HermesEnvConfig) => hermesApi.setEnv(env), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: hermesKeys.env }); - queryClient.invalidateQueries({ queryKey: hermesKeys.health }); - }, - }); + [t], + ); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index d53be2af9..a2a21aa5c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1664,44 +1664,11 @@ "primaryModel": "Default", "fallbackModel": "Alternate" }, - "model": { - "title": "Model Config", - "description": "Configure the default model settings in config.yaml's model section.", - "default": "Default Model", - "defaultHint": "The default model to use, e.g. anthropic/claude-opus-4-7", - "provider": "Provider", - "providerHint": "Provider name for model routing (e.g. openrouter, anthropic)", - "baseUrl": "Base URL", - "baseUrlHint": "Override the API endpoint URL for this model", - "contextLength": "Context Length", - "maxTokens": "Max Tokens", - "saveSuccess": "Model config saved", - "saveFailed": "Failed to save model config" - }, - "agent": { - "title": "Agent Config", - "description": "Configure the agent behavior settings in config.yaml's agent section.", - "maxTurns": "Max Turns", - "maxTurnsHint": "Maximum number of agent turns per session", - "reasoningEffort": "Reasoning Effort", - "reasoningEffortHint": "Controls the depth of reasoning: none, minimal, low, medium, high, xhigh", - "toolUseEnforcement": "Tool Use Enforcement", - "toolUseHint": "Values: \"auto\", true, false, or a JSON array of tool names", - "approvalsMode": "Approvals Mode", - "approvalsModeHint": "Controls tool call approval: manual (always ask), smart (auto-approve safe), off (never ask)", - "notSet": "Not set", - "saveSuccess": "Agent config saved", - "saveFailed": "Failed to save agent config" - }, - "env": { - "title": "Env Config", - "description": "Edit the environment variables in Hermes .env file.", - "editorHint": "Edit the Hermes .env file as a JSON key-value map. Keys become environment variable names.", - "saveSuccess": "Env config saved", - "saveFailed": "Failed to save env config", - "empty": "Hermes env cannot be empty. Use {} for an empty object.", - "invalidJson": "Hermes env must be valid JSON.", - "objectRequired": "Hermes env must be a JSON object." + "webui": { + "open": "Open Hermes Web UI", + "offline": "Hermes Web UI is not running. Start it with `hermes web` first.", + "openFailed": "Failed to open Hermes Web UI", + "fixInWebUI": "Fix in Hermes Web UI" }, "health": { "title": "Hermes config warnings detected", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index c9b59ab4b..50925fcea 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1664,44 +1664,11 @@ "primaryModel": "デフォルト", "fallbackModel": "予備" }, - "model": { - "title": "モデル設定", - "description": "config.yaml の model セクションのデフォルトモデル設定を構成します。", - "default": "デフォルトモデル", - "defaultHint": "使用するデフォルトモデル(例: anthropic/claude-opus-4-7)", - "provider": "プロバイダー", - "providerHint": "モデルルーティングのプロバイダー名(例: openrouter、anthropic)", - "baseUrl": "Base URL", - "baseUrlHint": "このモデルの API エンドポイント URL を上書き", - "contextLength": "コンテキスト長", - "maxTokens": "最大トークン数", - "saveSuccess": "モデル設定を保存しました", - "saveFailed": "モデル設定の保存に失敗しました" - }, - "agent": { - "title": "エージェント設定", - "description": "config.yaml の agent セクションの動作設定を構成します。", - "maxTurns": "最大ターン数", - "maxTurnsHint": "セッションごとの最大エージェントターン数", - "reasoningEffort": "推論レベル", - "reasoningEffortHint": "推論の深さを制御: none、minimal、low、medium、high、xhigh", - "toolUseEnforcement": "ツール使用ポリシー", - "toolUseHint": "値: \"auto\"、true、false、またはツール名の JSON 配列", - "approvalsMode": "承認モード", - "approvalsModeHint": "ツール呼び出しの承認を制御: manual(常に確認)、smart(安全な操作を自動承認)、off(確認なし)", - "notSet": "未設定", - "saveSuccess": "エージェント設定を保存しました", - "saveFailed": "エージェント設定の保存に失敗しました" - }, - "env": { - "title": "環境変数", - "description": "Hermes .env ファイルの環境変数を編集します。", - "editorHint": "Hermes .env ファイルを JSON キー・バリュー形式で編集します。キーが環境変数名になります。", - "saveSuccess": "環境変数を保存しました", - "saveFailed": "環境変数の保存に失敗しました", - "empty": "Hermes 環境変数は空にできません。空のオブジェクトには {} を使用してください。", - "invalidJson": "Hermes 環境変数は有効な JSON である必要があります。", - "objectRequired": "Hermes 環境変数は JSON オブジェクトである必要があります。" + "webui": { + "open": "Hermes Web UI を開く", + "offline": "Hermes Web UI が起動していません。まず `hermes web` を実行してください。", + "openFailed": "Hermes Web UI を開けませんでした", + "fixInWebUI": "Hermes Web UI で修正" }, "health": { "title": "Hermes 設定の警告を検出", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index aa989e8f4..b0edf8d9c 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1664,44 +1664,11 @@ "primaryModel": "默认模型", "fallbackModel": "备选模型" }, - "model": { - "title": "模型配置", - "description": "配置 config.yaml 中 model 节的默认模型设置。", - "default": "默认模型", - "defaultHint": "使用的默认模型,如 anthropic/claude-opus-4-7", - "provider": "供应商", - "providerHint": "模型路由的供应商名称(如 openrouter、anthropic)", - "baseUrl": "Base URL", - "baseUrlHint": "覆盖此模型的 API 端点 URL", - "contextLength": "上下文长度", - "maxTokens": "最大 Tokens", - "saveSuccess": "模型配置已保存", - "saveFailed": "保存模型配置失败" - }, - "agent": { - "title": "Agent 配置", - "description": "配置 config.yaml 中 agent 节的行为设置。", - "maxTurns": "最大轮次", - "maxTurnsHint": "每个会话的最大 Agent 轮次", - "reasoningEffort": "推理深度", - "reasoningEffortHint": "控制推理深度:none、minimal、low、medium、high、xhigh", - "toolUseEnforcement": "工具使用策略", - "toolUseHint": "值:\"auto\"、true、false 或工具名称的 JSON 数组", - "approvalsMode": "审批模式", - "approvalsModeHint": "控制工具调用审批:manual(始终询问)、smart(自动审批安全操作)、off(从不询问)", - "notSet": "未设置", - "saveSuccess": "Agent 配置已保存", - "saveFailed": "保存 Agent 配置失败" - }, - "env": { - "title": "环境变量", - "description": "编辑 Hermes .env 文件中的环境变量。", - "editorHint": "以 JSON 键值对格式编辑 Hermes .env 文件。键名将作为环境变量名。", - "saveSuccess": "环境变量已保存", - "saveFailed": "保存环境变量失败", - "empty": "Hermes 环境变量不能为空。使用 {} 表示空对象。", - "invalidJson": "Hermes 环境变量必须是有效的 JSON。", - "objectRequired": "Hermes 环境变量必须是 JSON 对象。" + "webui": { + "open": "打开 Hermes Web UI", + "offline": "Hermes Web UI 未启动,请先运行 `hermes web` 启动服务。", + "openFailed": "打开 Hermes Web UI 失败", + "fixInWebUI": "在 Hermes Web UI 修复" }, "health": { "title": "检测到 Hermes 配置警告", diff --git a/src/lib/api/hermes.ts b/src/lib/api/hermes.ts index 26bd17895..f5cbb504a 100644 --- a/src/lib/api/hermes.ts +++ b/src/lib/api/hermes.ts @@ -1,92 +1,30 @@ import { invoke } from "@tauri-apps/api/core"; -import type { - HermesModelConfig, - HermesAgentConfig, - HermesEnvConfig, - HermesHealthWarning, - HermesWriteOutcome, -} from "@/types"; +import type { HermesModelConfig, HermesHealthWarning } from "@/types"; /** - * Hermes Agent configuration API + * Hermes Agent configuration API (CC Switch side). * - * Manages Hermes config sections: - * - model (model selection and provider) - * - agent (agent behavior) - * - env (environment variables) + * CC Switch intentionally keeps its Hermes surface minimal — deep configuration + * (model, agent behavior, env vars, skills, cron, logs, analytics) lives in + * the Hermes Web UI at http://127.0.0.1:9119. CC Switch only reads the `model` + * section to highlight the active provider, scans config health, and launches + * the Hermes Web UI for everything else. Writes to `model` happen implicitly + * via `apply_switch_defaults` when the user switches providers. */ export const hermesApi = { - // ============================================================ - // Model Configuration - // ============================================================ - - /** - * Get model configuration - */ async getModelConfig(): Promise { return await invoke("get_hermes_model_config"); }, - /** - * Set model configuration - */ - async setModelConfig(config: HermesModelConfig): Promise { - return await invoke("set_hermes_model_config", { config }); - }, - - // ============================================================ - // Agent Configuration - // ============================================================ - - /** - * Get agent configuration - */ - async getAgentConfig(): Promise { - return await invoke("get_hermes_agent_config"); - }, - - /** - * Set agent configuration - */ - async setAgentConfig(config: HermesAgentConfig): Promise { - return await invoke("set_hermes_agent_config", { config }); - }, - - // ============================================================ - // Env Configuration - // ============================================================ - - /** - * Get env configuration (.env file) - */ - async getEnv(): Promise { - return await invoke("get_hermes_env"); - }, - - /** - * Set env configuration (.env file) - */ - async setEnv(env: HermesEnvConfig): Promise { - return await invoke("set_hermes_env", { env }); - }, - - // ============================================================ - // Health - // ============================================================ - - /** - * Scan config health and return warnings - */ async scanHealth(): Promise { return await invoke("scan_hermes_config_health"); }, /** - * Get live provider config by ID + * Probe the local Hermes Web UI and open it in the system browser. + * Optional `path` lets callers deep-link to specific pages like `/config`. */ - async getLiveProvider( - providerId: string, - ): Promise | null> { - return await invoke("get_hermes_live_provider", { providerId }); + async openWebUI(path?: string): Promise { + await invoke("open_hermes_web_ui", { path: path ?? null }); }, }; diff --git a/src/types.ts b/src/types.ts index ad675e183..b868faa74 100644 --- a/src/types.ts +++ b/src/types.ts @@ -587,18 +587,6 @@ export interface HermesModelConfig { [key: string]: unknown; } -export interface HermesAgentConfig { - max_turns?: number; - reasoning_effort?: string; - tool_use_enforcement?: string | boolean | string[]; - approvals_mode?: string; - [key: string]: unknown; -} - -export interface HermesEnvConfig { - [key: string]: unknown; -} - export interface HermesHealthWarning { code: string; message: string; From acc6d795e4a09c43b5f4c3c9d10bbe3781d7caa8 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 19 Apr 2026 21:49:27 +0800 Subject: [PATCH 036/156] feat(hermes): replace Prompts entry with Memory panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes has no slash-prompt concept (templates live as Skills), so the Prompts tab for the Hermes app was always empty. Swap the toolbar Book button for a Brain button that opens a new Memory panel editing ~/.hermes/memories/{MEMORY,USER}.md — Hermes' first-class memory store which its Web UI exposes only as on/off toggles, never as an editor. The panel shows each file in its own tab with a character-budget bar read from config.yaml's nested memory.* section (memory_char_limit / user_char_limit, default 2200 / 1375). Edits are written atomically; Hermes picks them up on the next session start per MemoryStore. Also extract useDarkMode to src/hooks/useDarkMode.ts — the codebase already repeats the same MutationObserver pattern in 12+ places; this PR introduces the shared hook and uses it once, leaving the migration of the other copies to a follow-up. --- src-tauri/src/commands/hermes.rs | 19 ++ src-tauri/src/hermes_config.rs | 204 ++++++++++++++++++++ src-tauri/src/lib.rs | 3 + src/App.tsx | 15 +- src/components/hermes/HermesMemoryPanel.tsx | 155 +++++++++++++++ src/hooks/useDarkMode.ts | 29 +++ src/hooks/useHermes.ts | 57 +++++- src/i18n/locales/en.json | 13 ++ src/i18n/locales/ja.json | 13 ++ src/i18n/locales/zh.json | 13 ++ src/lib/api/hermes.ts | 28 ++- src/types.ts | 9 + 12 files changed, 552 insertions(+), 6 deletions(-) create mode 100644 src/components/hermes/HermesMemoryPanel.tsx create mode 100644 src/hooks/useDarkMode.ts diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 1034fc495..c6af00a4a 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -57,6 +57,25 @@ pub fn get_hermes_model_config() -> Result Result { + hermes_config::read_memory(kind).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn set_hermes_memory(kind: hermes_config::MemoryKind, content: String) -> Result<(), String> { + hermes_config::write_memory(kind, &content).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn get_hermes_memory_limits() -> Result { + hermes_config::read_memory_limits().map_err(|e| e.to_string()) +} + // ============================================================================ // Hermes Web UI launcher // ============================================================================ diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs index 431e616d9..d2e438ef7 100644 --- a/src-tauri/src/hermes_config.rs +++ b/src-tauri/src/hermes_config.rs @@ -763,6 +763,111 @@ pub(crate) fn json_to_yaml(json: &serde_json::Value) -> Result &'static str { + match self { + Self::Memory => "MEMORY.md", + Self::User => "USER.md", + } + } +} + +fn memories_dir() -> PathBuf { + get_hermes_dir().join("memories") +} + +/// Read a Hermes memory file as a markdown blob. Returns an empty string +/// when the file doesn't exist yet (first-run case). +pub fn read_memory(kind: MemoryKind) -> Result { + let path = memories_dir().join(kind.filename()); + match fs::read_to_string(&path) { + Ok(content) => Ok(content), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), + Err(e) => Err(AppError::io(&path, e)), + } +} + +/// Atomically replace a Hermes memory file. `atomic_write` creates parent +/// directories as needed, so `~/.hermes/memories/` is materialized on first +/// write without a separate `create_dir_all` call. +pub fn write_memory(kind: MemoryKind, content: &str) -> Result<(), AppError> { + let path = memories_dir().join(kind.filename()); + atomic_write(&path, content.as_bytes()) +} + +/// Character budget + enable flags for the two memory blobs, as configured +/// in Hermes' `config.yaml`. Defaults mirror `~/.hermes`'s own defaults so +/// callers get a usable budget bar even before the user edits config.yaml. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HermesMemoryLimits { + pub memory: usize, + pub user: usize, + pub memory_enabled: bool, + pub user_enabled: bool, +} + +impl Default for HermesMemoryLimits { + fn default() -> Self { + Self { + memory: 2200, + user: 1375, + memory_enabled: true, + user_enabled: true, + } + } +} + +/// Read memory budgets + toggles from `config.yaml`. Missing/unparsable +/// fields fall back to `HermesMemoryLimits::default()` rather than erroring, +/// so an empty or partially-populated config still yields a usable UI. +pub fn read_memory_limits() -> Result { + let mut out = HermesMemoryLimits::default(); + let config = read_hermes_config()?; + let Some(memory) = config.get("memory") else { + return Ok(out); + }; + + if let Some(v) = memory.get("memory_char_limit").and_then(|v| v.as_u64()) { + out.memory = v as usize; + } + if let Some(v) = memory.get("user_char_limit").and_then(|v| v.as_u64()) { + out.user = v as usize; + } + if let Some(v) = memory.get("memory_enabled").and_then(|v| v.as_bool()) { + out.memory_enabled = v; + } + if let Some(v) = memory.get("user_profile_enabled").and_then(|v| v.as_bool()) { + out.user_enabled = v; + } + + Ok(out) +} + // ============================================================================ // Tests // ============================================================================ @@ -1349,4 +1454,103 @@ custom_providers: assert_eq!(model.default.as_deref(), Some("prev-default")); }); } + + // ---- memory file tests ---- + + #[test] + #[serial] + fn read_memory_returns_empty_when_file_missing() { + with_test_home(|| { + let memory = read_memory(MemoryKind::Memory).unwrap(); + let user = read_memory(MemoryKind::User).unwrap(); + assert!(memory.is_empty()); + assert!(user.is_empty()); + }); + } + + #[test] + #[serial] + fn write_then_read_memory_round_trip() { + with_test_home(|| { + let blob = "> note\n§\nfirst entry\n§\nsecond entry\n"; + write_memory(MemoryKind::Memory, blob).unwrap(); + assert_eq!(read_memory(MemoryKind::Memory).unwrap(), blob); + + // Writing USER.md doesn't clobber MEMORY.md. + write_memory(MemoryKind::User, "user profile").unwrap(); + assert_eq!(read_memory(MemoryKind::Memory).unwrap(), blob); + assert_eq!(read_memory(MemoryKind::User).unwrap(), "user profile"); + }); + } + + #[test] + #[serial] + fn memory_limits_fall_back_to_defaults_when_config_missing() { + with_test_home(|| { + let limits = read_memory_limits().unwrap(); + let defaults = HermesMemoryLimits::default(); + assert_eq!(limits.memory, defaults.memory); + assert_eq!(limits.user, defaults.user); + assert_eq!(limits.memory_enabled, defaults.memory_enabled); + assert_eq!(limits.user_enabled, defaults.user_enabled); + }); + } + + #[test] + #[serial] + fn memory_limits_read_from_config_yaml() { + with_test_home(|| { + let yaml = "\ +memory: + memory_char_limit: 4096 + user_char_limit: 2048 + memory_enabled: false + user_profile_enabled: true +"; + let config_path = get_hermes_config_path(); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, yaml).unwrap(); + + let limits = read_memory_limits().unwrap(); + assert_eq!(limits.memory, 4096); + assert_eq!(limits.user, 2048); + assert!(!limits.memory_enabled); + assert!(limits.user_enabled); + }); + } + + #[test] + #[serial] + fn memory_limits_ignore_top_level_keys() { + // Regression guard: Hermes nests memory settings under `memory:`, so + // identically-named keys at the top level must be ignored rather than + // silently consumed. + with_test_home(|| { + let yaml = "\ +memory_char_limit: 9999 +user_char_limit: 9999 +memory_enabled: false +user_profile_enabled: false +"; + let config_path = get_hermes_config_path(); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, yaml).unwrap(); + + let limits = read_memory_limits().unwrap(); + let defaults = HermesMemoryLimits::default(); + assert_eq!(limits.memory, defaults.memory); + assert_eq!(limits.user, defaults.user); + assert_eq!(limits.memory_enabled, defaults.memory_enabled); + assert_eq!(limits.user_enabled, defaults.user_enabled); + }); + } + + #[test] + fn memory_kind_deserializes_from_lowercase_strings() { + let memory: MemoryKind = serde_json::from_str("\"memory\"").unwrap(); + let user: MemoryKind = serde_json::from_str("\"user\"").unwrap(); + assert_eq!(memory, MemoryKind::Memory); + assert_eq!(user, MemoryKind::User); + assert!(serde_json::from_str::("\"bogus\"").is_err()); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 42d06b0b4..2040c3a20 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1258,6 +1258,9 @@ pub fn run() { commands::scan_hermes_config_health, commands::get_hermes_model_config, commands::open_hermes_web_ui, + commands::get_hermes_memory, + commands::set_hermes_memory, + commands::get_hermes_memory_limits, // Global upstream proxy commands::get_global_proxy_url, commands::set_global_proxy_url, diff --git a/src/App.tsx b/src/App.tsx index 741d83d2f..01a1e105d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import { Minimize2, X, Book, + Brain, Wrench, RefreshCw, History, @@ -89,6 +90,7 @@ import ToolsPanel from "@/components/openclaw/ToolsPanel"; import AgentsDefaultsPanel from "@/components/openclaw/AgentsDefaultsPanel"; import OpenClawHealthBanner from "@/components/openclaw/OpenClawHealthBanner"; import HermesHealthBanner from "@/components/hermes/HermesHealthBanner"; +import HermesMemoryPanel from "@/components/hermes/HermesMemoryPanel"; type View = | "providers" @@ -103,7 +105,8 @@ type View = | "workspace" | "openclawEnv" | "openclawTools" - | "openclawAgents"; + | "openclawAgents" + | "hermesMemory"; interface WebDavSyncStatusUpdatedPayload { source?: string; @@ -147,6 +150,7 @@ const VALID_VIEWS: View[] = [ "openclawEnv", "openclawTools", "openclawAgents", + "hermesMemory", ]; const getInitialView = (): View => { @@ -909,6 +913,8 @@ function App() { appId={activeApp} /> ); + case "hermesMemory": + return ; case "skills": return (
) : ( @@ -1384,11 +1391,11 @@ function App() { +
+
+
+ ); +}; + +const HermesMemoryPanel: React.FC = () => { + const { t } = useTranslation(); + const [activeTab, setActiveTab] = useState("memory"); + const openHermesWebUI = useOpenHermesWebUI(); + const { data: limits } = useHermesMemoryLimits(true); + + const memoryLimit = limits?.memory ?? 2200; + const userLimit = limits?.user ?? 1375; + const memoryEnabled = limits?.memoryEnabled ?? true; + const userEnabled = limits?.userEnabled ?? true; + + return ( +
+ setActiveTab(v as HermesMemoryKind)} + className="flex-1 flex flex-col" + > +
+ + + {t("hermes.memory.agentTab")} + + {t("hermes.memory.userTab")} + + +
+ + + + + + + +
+
+ ); +}; + +export default HermesMemoryPanel; diff --git a/src/hooks/useDarkMode.ts b/src/hooks/useDarkMode.ts new file mode 100644 index 000000000..a3db69c5f --- /dev/null +++ b/src/hooks/useDarkMode.ts @@ -0,0 +1,29 @@ +import { useEffect, useState } from "react"; + +/** + * Subscribe to the presence of `class="dark"` on ``. The theme-provider + * toggles this class whenever the effective theme changes (including when the + * OS changes theme while the app is in "system" mode), so observing the class + * is the simplest way to drive components that need a boolean `darkMode` prop + * (e.g. CodeMirror-based editors that don't consume the theme context). + */ +export function useDarkMode(): boolean { + const [isDark, setIsDark] = useState(() => + typeof document !== "undefined" + ? document.documentElement.classList.contains("dark") + : false, + ); + + useEffect(() => { + const observer = new MutationObserver(() => { + setIsDark(document.documentElement.classList.contains("dark")); + }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + return () => observer.disconnect(); + }, []); + + return isDark; +} diff --git a/src/hooks/useHermes.ts b/src/hooks/useHermes.ts index 63599dd9c..1576224c0 100644 --- a/src/hooks/useHermes.ts +++ b/src/hooks/useHermes.ts @@ -1,9 +1,15 @@ import { useCallback } from "react"; -import { useQuery, type QueryClient } from "@tanstack/react-query"; +import { + useMutation, + useQuery, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { hermesApi } from "@/lib/api/hermes"; import { providersApi } from "@/lib/api/providers"; +import type { HermesMemoryKind } from "@/types"; import { extractErrorMessage } from "@/utils/errorUtils"; /** @@ -22,6 +28,8 @@ export const hermesKeys = { liveProviderIds: ["hermes", "liveProviderIds"] as const, modelConfig: ["hermes", "modelConfig"] as const, health: ["hermes", "health"] as const, + memory: (kind: HermesMemoryKind) => ["hermes", "memory", kind] as const, + memoryLimits: ["hermes", "memoryLimits"] as const, }; /** @@ -66,10 +74,57 @@ export function useHermesHealth(enabled: boolean) { }); } +export function useHermesMemory(kind: HermesMemoryKind, enabled: boolean) { + return useQuery({ + queryKey: hermesKeys.memory(kind), + queryFn: () => hermesApi.getMemory(kind), + enabled, + }); +} + +export function useHermesMemoryLimits(enabled: boolean) { + return useQuery({ + queryKey: hermesKeys.memoryLimits, + queryFn: () => hermesApi.getMemoryLimits(), + staleTime: 60_000, + enabled, + }); +} + // ============================================================ // Mutation hooks // ============================================================ +/** + * Save a Hermes memory file atomically and refresh the corresponding query. + * Error toasts are emitted here so caller components don't need their own + * try/catch; success toasts are intentionally left to the caller (to pick + * the right localized message per tab). + */ +export function useSaveHermesMemory() { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + return useMutation({ + mutationFn: ({ + kind, + content, + }: { + kind: HermesMemoryKind; + content: string; + }) => hermesApi.setMemory(kind, content), + onSuccess: async (_data, variables) => { + await queryClient.invalidateQueries({ + queryKey: hermesKeys.memory(variables.kind), + }); + }, + onError: (error) => { + toast.error(t("hermes.memory.saveFailed"), { + description: extractErrorMessage(error) || undefined, + }); + }, + }); +} + /** * Returns a handler that probes the local Hermes Web UI, opens it in the * system browser, and surfaces a localized toast on failure. Callers only diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a2a21aa5c..dd18e1c8b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1675,6 +1675,19 @@ "parseFailed": "config.yaml could not be parsed as valid YAML. Fix the file before editing it here.", "configNotFound": "Hermes config.yaml not found. Create it at ~/.hermes/config.yaml or configure the path in settings.", "envParseFailed": "The .env file could not be parsed." + }, + "memory": { + "title": "Memory", + "agentTab": "Agent Memory (MEMORY.md)", + "userTab": "User Profile (USER.md)", + "usage": "{{current}} / {{limit}} characters", + "overLimit": "Over budget — Hermes will truncate on next load", + "disabled": "Memory is disabled in config.yaml. Enable it in Hermes config for edits to take effect.", + "saveSuccess": "Memory saved", + "saveFailed": "Failed to save memory", + "loadFailed": "Failed to read memory file", + "openConfig": "Adjust limits in Hermes Web UI", + "runtimeNote": "Changes apply on Hermes restart or new session." } }, "env": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 50925fcea..2f9abdae4 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1675,6 +1675,19 @@ "parseFailed": "config.yaml を有効な YAML として解析できません。ここで編集する前にファイルを修正してください。", "configNotFound": "Hermes config.yaml が見つかりません。~/.hermes/config.yaml に作成するか、設定でパスを構成してください。", "envParseFailed": ".env ファイルを解析できません。" + }, + "memory": { + "title": "メモリ", + "agentTab": "エージェント記憶 (MEMORY.md)", + "userTab": "ユーザープロファイル (USER.md)", + "usage": "{{current}} / {{limit}} 文字", + "overLimit": "上限を超えました。Hermes は次回読み込み時に切り詰めます", + "disabled": "Memory が config.yaml で無効化されています。Hermes 設定で有効化すると編集内容が反映されます。", + "saveSuccess": "記憶を保存しました", + "saveFailed": "記憶の保存に失敗しました", + "loadFailed": "記憶ファイルの読み込みに失敗しました", + "openConfig": "Hermes Web UI で上限を調整", + "runtimeNote": "変更は Hermes の再起動または新規セッション時に反映されます。" } }, "env": { diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index b0edf8d9c..acefa6c9e 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1675,6 +1675,19 @@ "parseFailed": "config.yaml 无法解析为有效 YAML。请先修复文件再在此编辑。", "configNotFound": "未找到 Hermes config.yaml。请在 ~/.hermes/config.yaml 创建或在设置中配置路径。", "envParseFailed": ".env 文件无法解析。" + }, + "memory": { + "title": "记忆管理", + "agentTab": "Agent 记忆 (MEMORY.md)", + "userTab": "用户画像 (USER.md)", + "usage": "已用 {{current}} / {{limit}} 字符", + "overLimit": "已超过上限,Hermes 下次加载时会截断", + "disabled": "Memory 在 config.yaml 中已禁用,编辑后需在 Hermes 配置启用才会生效。", + "saveSuccess": "记忆已保存", + "saveFailed": "保存记忆失败", + "loadFailed": "读取记忆文件失败", + "openConfig": "在 Hermes Web UI 调整上限", + "runtimeNote": "更改将在 Hermes 下次启动或新建会话时生效。" } }, "env": { diff --git a/src/lib/api/hermes.ts b/src/lib/api/hermes.ts index f5cbb504a..a11e37923 100644 --- a/src/lib/api/hermes.ts +++ b/src/lib/api/hermes.ts @@ -1,5 +1,10 @@ import { invoke } from "@tauri-apps/api/core"; -import type { HermesModelConfig, HermesHealthWarning } from "@/types"; +import type { + HermesHealthWarning, + HermesMemoryKind, + HermesMemoryLimits, + HermesModelConfig, +} from "@/types"; /** * Hermes Agent configuration API (CC Switch side). @@ -27,4 +32,25 @@ export const hermesApi = { async openWebUI(path?: string): Promise { await invoke("open_hermes_web_ui", { path: path ?? null }); }, + + /** + * Read one of Hermes' memory blobs (`MEMORY.md` or `USER.md`). Returns an + * empty string when the file hasn't been created yet. + */ + async getMemory(kind: HermesMemoryKind): Promise { + return await invoke("get_hermes_memory", { kind }); + }, + + /** Atomically overwrite a Hermes memory file. */ + async setMemory(kind: HermesMemoryKind, content: string): Promise { + await invoke("set_hermes_memory", { kind, content }); + }, + + /** + * Character budgets + enable flags for both memory blobs, read from + * config.yaml with Hermes defaults as fallback. + */ + async getMemoryLimits(): Promise { + return await invoke("get_hermes_memory_limits"); + }, }; diff --git a/src/types.ts b/src/types.ts index b868faa74..9a183d140 100644 --- a/src/types.ts +++ b/src/types.ts @@ -597,3 +597,12 @@ export interface HermesWriteOutcome { backupPath?: string; warnings: HermesHealthWarning[]; } + +export type HermesMemoryKind = "memory" | "user"; + +export interface HermesMemoryLimits { + memory: number; + user: number; + memoryEnabled: boolean; + userEnabled: boolean; +} From 31fb998575e580ea2f449b69a5aa47a6d3a4db64 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 20 Apr 2026 09:48:59 +0800 Subject: [PATCH 037/156] refactor(hermes): simplify schema handling + preserve unknown provider fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the v11→v12 providers-dict compat layer: CC Switch now only reads/writes `custom_providers:`, leaving migrated `providers:` dict entries to Hermes Web UI for reconciliation (Hermes' runtime already merges both shapes via `get_compatible_custom_providers`). The `schema_migrated_v12` health warning now points users there when a dict-migrated config is detected. Adds forward-compat merge to `set_provider`: when updating an existing entry, on-disk fields the UI payload didn't submit (e.g. Hermes-only `request_timeout_seconds`, `key_env`) are carried over. Without this, editing one field via CC Switch would silently strip the rest. Adds `set_memory_enabled` + `set_hermes_memory_enabled` Tauri command for the upcoming memory-switch UI. Writes go through a merge-aware section replacement so character budgets and external-provider fields survive toggle operations. Removes four dict-only helpers (`normalize_providers_dict_entry_for_read`, `rename_alias_key`, `json_obj_non_empty_str`, `resolve_provider_name_from_yaml_entry`) and the multi-section write helper. Simplifies `get_providers` / `remove_provider` / health scan back to list-only. Replaces nine obsolete dict-related tests with `set_provider_preserves_unknown_fields_on_update` and `set_memory_enabled_preserves_other_fields`. --- src-tauri/src/commands/hermes.rs | 8 + src-tauri/src/hermes_config.rs | 451 ++++++++++++++++++++++++++----- src-tauri/src/lib.rs | 1 + 3 files changed, 399 insertions(+), 61 deletions(-) diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index c6af00a4a..95df3e96c 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -76,6 +76,14 @@ pub fn get_hermes_memory_limits() -> Result Result { + hermes_config::set_memory_enabled(kind, enabled).map_err(|e| e.to_string()) +} + // ============================================================================ // Hermes Web UI launcher // ============================================================================ diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs index d2e438ef7..594e15261 100644 --- a/src-tauri/src/hermes_config.rs +++ b/src-tauri/src/hermes_config.rs @@ -346,7 +346,8 @@ fn write_yaml_section_to_config_locked( let warnings = scan_hermes_health_internal(&new_raw); log::debug!( - "Hermes config section '{section_key}' written to {:?}", + "Hermes config section '{}' written to {:?}", + section_key, config_path ); Ok(HermesWriteOutcome { @@ -447,6 +448,28 @@ fn denormalize_provider_models_for_read(config: &mut serde_json::Value) { } } +/// Borrow a YAML scalar as a non-empty trimmed `&str`, or `None` when the +/// value is absent, non-string, or blank after trimming. +fn yaml_as_non_empty_str(v: &serde_yaml::Value) -> Option<&str> { + v.as_str().map(str::trim).filter(|s| !s.is_empty()) +} + +/// Extract string keys from a YAML mapping as owned strings, dropping non-string keys. +fn collect_mapping_string_keys(m: &serde_yaml::Mapping) -> Vec { + m.iter() + .filter_map(|(k, _)| k.as_str().map(str::to_string)) + .collect() +} + +/// Boilerplate-free constructor for [`HermesHealthWarning`]. +fn hermes_warning(code: &str, message: String, path: &str) -> HermesHealthWarning { + HermesHealthWarning { + code: code.to_string(), + message, + path: Some(path.to_string()), + } +} + /// Get all custom providers as a JSON map keyed by provider name. /// /// Reads the `custom_providers:` YAML sequence where each item has a `name` @@ -454,38 +477,32 @@ fn denormalize_provider_models_for_read(config: &mut serde_json::Value) { /// `models` field is converted from the YAML dict shape back to the /// UI-friendly ordered array shape. /// -/// We intentionally use the legacy `custom_providers:` list (not the v12+ -/// `providers:` dict) because Hermes's runtime_provider.py -/// `_get_named_custom_provider` has a bug in its `providers:` branch: the -/// returned entry dict drops `api_mode` / `transport` / `models` / -/// singular `model:`. That leaves `_resolve_named_custom_runtime` to fall -/// back to `chat_completions`, breaking every anthropic_messages provider. -/// The legacy `custom_providers:` branch goes through -/// `_normalize_custom_provider_entry` and preserves all fields correctly. +/// Entries that live under Hermes' v12+ `providers:` dict are deliberately +/// not surfaced here — Hermes' own runtime merges both shapes via +/// `get_compatible_custom_providers`, but CC Switch only manages the legacy +/// `custom_providers:` list. The `schema_migrated_v12` health warning flags +/// any non-empty `providers:` dict so the user can reconcile via Hermes Web UI. pub fn get_providers() -> Result, AppError> { let config = read_hermes_config()?; - let providers = config - .get("custom_providers") - .and_then(|v| v.as_sequence()) - .map(|seq| { - let mut map = serde_json::Map::new(); - for item in seq { - if let Some(name) = item.get("name").and_then(|n| n.as_str()) { - match yaml_to_json(item) { - Ok(mut json_val) => { - denormalize_provider_models_for_read(&mut json_val); - map.insert(name.to_string(), json_val); - } - Err(e) => { - log::warn!("Failed to convert Hermes provider '{name}' to JSON: {e}"); - } + let mut map = serde_json::Map::new(); + + if let Some(seq) = config.get("custom_providers").and_then(|v| v.as_sequence()) { + for item in seq { + if let Some(name) = item.get("name").and_then(|n| n.as_str()) { + match yaml_to_json(item) { + Ok(mut json_val) => { + denormalize_provider_models_for_read(&mut json_val); + map.insert(name.to_string(), json_val); + } + Err(e) => { + log::warn!("Failed to convert Hermes provider '{name}' to JSON: {e}"); } } } - map - }) - .unwrap_or_default(); - Ok(providers) + } + } + + Ok(map) } /// Get a single custom provider by name. @@ -551,6 +568,18 @@ pub fn set_provider( .iter_mut() .find(|p| p.get("name").and_then(|n| n.as_str()) == Some(name)) { + // Forward-compat: carry over any on-disk fields the UI payload didn't + // include. Hermes keeps evolving (e.g. `request_timeout_seconds`, + // `key_env`), and users may set those via Hermes Web UI — without + // this merge, a CC Switch edit to an unrelated field would silently + // strip them on write-back. + if let (Some(existing_map), serde_yaml::Value::Mapping(new_map)) = + (existing.as_mapping(), &mut yaml_val) + { + for (k, v) in existing_map { + new_map.entry(k.clone()).or_insert_with(|| v.clone()); + } + } *existing = yaml_val; } else { providers.push(yaml_val); @@ -567,8 +596,8 @@ pub fn set_provider( /// read-modify-write is done under the write lock to prevent TOCTOU races. pub fn remove_provider(name: &str) -> Result { let _guard = hermes_write_lock().lock()?; - let config = read_hermes_config()?; + let mut providers: Vec = config .get("custom_providers") .and_then(|v| v.as_sequence()) @@ -577,7 +606,6 @@ pub fn remove_provider(name: &str) -> Result { let original_len = providers.len(); providers.retain(|p| p.get("name").and_then(|n| n.as_str()) != Some(name)); - if providers.len() == original_len { return Ok(HermesWriteOutcome::default()); } @@ -669,39 +697,131 @@ fn scan_hermes_health_internal(content: &str) -> Vec { return warnings; } - match serde_yaml::from_str::(content) { - Ok(config) => { - // Check for model section issues - if let Some(model) = config.get("model") { - if model.get("default").is_none() && model.get("provider").is_none() { - warnings.push(HermesHealthWarning { - code: "model_no_default".to_string(), - message: "No default model or provider configured in 'model' section" - .to_string(), - path: Some("model".to_string()), - }); - } - } - - // Check custom_providers is a sequence - if let Some(providers) = config.get("custom_providers") { - if !providers.is_sequence() { - warnings.push(HermesHealthWarning { - code: "custom_providers_not_list".to_string(), - message: "custom_providers should be a YAML list (sequence), not a mapping" - .to_string(), - path: Some("custom_providers".to_string()), - }); - } - } - } + let config = match serde_yaml::from_str::(content) { + Ok(cfg) => cfg, Err(err) => { - warnings.push(HermesHealthWarning { - code: "config_parse_failed".to_string(), - message: format!("Hermes config could not be parsed as YAML: {err}"), - path: Some(get_hermes_config_path().display().to_string()), - }); + warnings.push(hermes_warning( + "config_parse_failed", + format!("Hermes config could not be parsed as YAML: {err}"), + &get_hermes_config_path().display().to_string(), + )); + return warnings; } + }; + + if let Some(model) = config.get("model") { + if model.get("default").is_none() && model.get("provider").is_none() { + warnings.push(hermes_warning( + "model_no_default", + "No default model or provider configured in 'model' section".to_string(), + "model", + )); + } + } + + let cp_is_mapping = config + .get("custom_providers") + .map(|v| v.is_mapping()) + .unwrap_or(false); + if cp_is_mapping { + warnings.push(hermes_warning( + "custom_providers_not_list", + "custom_providers should be a YAML list (sequence), not a mapping".to_string(), + "custom_providers", + )); + } + + let mut provider_models: HashMap> = HashMap::new(); + let mut name_counts: HashMap = HashMap::new(); + let mut base_url_counts: HashMap = HashMap::new(); + + if let Some(seq) = config.get("custom_providers").and_then(|v| v.as_sequence()) { + for item in seq { + if let Some(name) = item.get("name").and_then(yaml_as_non_empty_str) { + *name_counts.entry(name.to_string()).or_insert(0) += 1; + if let Some(models) = item.get("models").and_then(|m| m.as_mapping()) { + provider_models + .entry(name.to_string()) + .or_insert_with(|| collect_mapping_string_keys(models)); + } + } + if let Some(url) = item + .get("base_url") + .and_then(|n| n.as_str()) + .map(|s| s.trim().trim_end_matches('/').to_lowercase()) + .filter(|s| !s.is_empty()) + { + *base_url_counts.entry(url).or_insert(0) += 1; + } + } + } + + // name_counts keys are unique by construction, so iterating it already + // reports each duplicate name exactly once — no extra dedupe set needed. + for (name, count) in &name_counts { + if *count > 1 { + warnings.push(hermes_warning( + "duplicate_provider_name", + format!( + "Duplicate provider name '{name}' in custom_providers — only one entry will be used" + ), + "custom_providers", + )); + } + } + for (url, count) in &base_url_counts { + if *count > 1 { + warnings.push(hermes_warning( + "duplicate_provider_base_url", + format!( + "Duplicate base_url '{url}' in custom_providers — possible accidental copy" + ), + "custom_providers", + )); + } + } + + if let Some(model) = config.get("model") { + if let Some(provider_ref) = model.get("provider").and_then(yaml_as_non_empty_str) { + if !name_counts.contains_key(provider_ref) { + warnings.push(hermes_warning( + "model_provider_unknown", + format!( + "model.provider '{provider_ref}' does not match any configured provider" + ), + "model.provider", + )); + } else if let Some(default) = model.get("default").and_then(yaml_as_non_empty_str) { + if let Some(ids) = provider_models.get(provider_ref) { + if !ids.is_empty() && !ids.iter().any(|id| id == default) { + warnings.push(hermes_warning( + "model_default_not_in_provider", + format!( + "model.default '{default}' is not in provider '{provider_ref}' models list" + ), + "model.default", + )); + } + } + } + } + } + + let version = config + .get("_config_version") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let providers_dict_populated = config + .get("providers") + .and_then(|v| v.as_mapping()) + .map(|m| !m.is_empty()) + .unwrap_or(false); + if version >= 12 && providers_dict_populated { + warnings.push(hermes_warning( + "schema_migrated_v12", + "Hermes v12 moved some entries into the 'providers:' dict. CC Switch only manages 'custom_providers:' — edit or remove those entries via Hermes Web UI.".to_string(), + "providers", + )); } warnings @@ -842,6 +962,32 @@ impl Default for HermesMemoryLimits { } } +/// Toggle the on/off flag for one of Hermes' two memory blobs, preserving all +/// other fields in the `memory:` section (character budgets, external provider +/// settings, etc.). Hermes stores the user-profile toggle under +/// `user_profile_enabled` (not `user_enabled`), so the mapping to on-disk keys +/// lives here rather than leaking to callers. +pub fn set_memory_enabled(kind: MemoryKind, enabled: bool) -> Result { + let _guard = hermes_write_lock().lock()?; + let config = read_hermes_config()?; + + let mut memory = match config.get("memory") { + Some(serde_yaml::Value::Mapping(m)) => m.clone(), + _ => serde_yaml::Mapping::new(), + }; + + let key = match kind { + MemoryKind::Memory => "memory_enabled", + MemoryKind::User => "user_profile_enabled", + }; + memory.insert( + serde_yaml::Value::String(key.to_string()), + serde_yaml::Value::Bool(enabled), + ); + + write_yaml_section_to_config_locked("memory", &serde_yaml::Value::Mapping(memory)) +} + /// Read memory budgets + toggles from `config.yaml`. Missing/unparsable /// fields fall back to `HermesMemoryLimits::default()` rather than erroring, /// so an empty or partially-populated config still yields a usable UI. @@ -1114,6 +1260,41 @@ model: }); } + #[test] + #[serial] + fn set_provider_preserves_unknown_fields_on_update() { + // Hermes keeps adding provider-level fields (e.g. + // `request_timeout_seconds`, `key_env`). Users may set those via + // Hermes Web UI; a later CC Switch edit must not strip them — set_provider + // carries over any existing on-disk fields that the UI payload didn't + // submit. + with_test_home(|| { + let yaml = "\ +custom_providers: + - name: acme + base_url: https://old.example.com + api_key: sk-old + request_timeout_seconds: 300 + key_env: ACME_API_KEY +"; + let config_path = get_hermes_config_path(); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, yaml).unwrap(); + + let update = serde_json::json!({ + "base_url": "https://new.example.com", + "api_key": "sk-new" + }); + set_provider("acme", update).unwrap(); + + let provider = get_provider("acme").unwrap().unwrap(); + assert_eq!(provider["base_url"], "https://new.example.com"); + assert_eq!(provider["api_key"], "sk-new"); + assert_eq!(provider["request_timeout_seconds"], 300); + assert_eq!(provider["key_env"], "ACME_API_KEY"); + }); + } + // ---- Model config tests ---- #[test] @@ -1496,6 +1677,43 @@ custom_providers: }); } + #[test] + #[serial] + fn set_memory_enabled_preserves_other_fields() { + // Flipping one toggle must preserve character budgets and external + // provider settings the user configured via Hermes Web UI — otherwise + // a CC Switch toggle would silently wipe those fields. + with_test_home(|| { + let yaml = "\ +memory: + memory_char_limit: 4096 + user_char_limit: 2048 + memory_enabled: true + user_profile_enabled: true + provider: mem0 +"; + let config_path = get_hermes_config_path(); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, yaml).unwrap(); + + set_memory_enabled(MemoryKind::Memory, false).unwrap(); + + let limits = read_memory_limits().unwrap(); + assert!(!limits.memory_enabled, "toggle applied"); + assert!(limits.user_enabled, "unrelated toggle untouched"); + assert_eq!(limits.memory, 4096, "budgets preserved"); + assert_eq!(limits.user, 2048); + + // Verify the external provider field survived the section replacement. + let config = read_hermes_config().unwrap(); + let provider = config + .get("memory") + .and_then(|v| v.get("provider")) + .and_then(|v| v.as_str()); + assert_eq!(provider, Some("mem0")); + }); + } + #[test] #[serial] fn memory_limits_read_from_config_yaml() { @@ -1553,4 +1771,115 @@ user_profile_enabled: false assert_eq!(user, MemoryKind::User); assert!(serde_json::from_str::("\"bogus\"").is_err()); } + + // ---- Extended health scan rules ---- + + #[test] + fn health_check_model_provider_unknown() { + let yaml = "\ +model: + default: gpt-4 + provider: ghost +custom_providers: + - name: real + base_url: https://real.example.com +"; + let warnings = scan_hermes_health_internal(yaml); + assert!(warnings.iter().any(|w| w.code == "model_provider_unknown")); + } + + #[test] + fn health_check_model_default_not_in_provider() { + let yaml = "\ +model: + default: missing-model + provider: real +custom_providers: + - name: real + base_url: https://real.example.com + models: + present-model: {} +"; + let warnings = scan_hermes_health_internal(yaml); + assert!(warnings + .iter() + .any(|w| w.code == "model_default_not_in_provider")); + } + + #[test] + fn health_check_duplicate_provider_name() { + let yaml = "\ +custom_providers: + - name: twin + base_url: https://a.example.com + - name: twin + base_url: https://b.example.com +"; + let warnings = scan_hermes_health_internal(yaml); + let dup_count = warnings + .iter() + .filter(|w| w.code == "duplicate_provider_name") + .count(); + assert_eq!(dup_count, 1, "each duplicate name should be reported once"); + } + + #[test] + fn health_check_duplicate_provider_base_url() { + let yaml = "\ +custom_providers: + - name: a + base_url: https://same.example.com/ + - name: b + base_url: https://SAME.example.com +"; + let warnings = scan_hermes_health_internal(yaml); + assert!(warnings + .iter() + .any(|w| w.code == "duplicate_provider_base_url")); + } + + #[test] + fn health_check_schema_migrated_v12_warning() { + let yaml = "\ +_config_version: 19 +providers: + ds-1: + base_url: https://api.deepseek.com +"; + let warnings = scan_hermes_health_internal(yaml); + assert!(warnings.iter().any(|w| w.code == "schema_migrated_v12")); + } + + #[test] + fn health_check_schema_migrated_not_reported_when_dict_empty() { + // v19 with empty providers: {} should NOT surface the migration warning — + // otherwise fresh installs would see it spuriously. + let yaml = "\ +_config_version: 19 +providers: {} +"; + let warnings = scan_hermes_health_internal(yaml); + assert!(!warnings.iter().any(|w| w.code == "schema_migrated_v12")); + } + + #[test] + fn health_check_valid_config_with_matching_model_and_provider() { + // Regression guard: none of the new rules should fire on a well-formed config. + let yaml = "\ +model: + default: gpt-4 + provider: openrouter +custom_providers: + - name: openrouter + base_url: https://openrouter.ai/api/v1 + models: + gpt-4: {} +"; + let warnings = scan_hermes_health_internal(yaml); + assert!( + warnings.is_empty(), + "expected no warnings, got: {:?}", + warnings + ); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2040c3a20..b14afee60 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1261,6 +1261,7 @@ pub fn run() { commands::get_hermes_memory, commands::set_hermes_memory, commands::get_hermes_memory_limits, + commands::set_hermes_memory_enabled, // Global upstream proxy commands::get_global_proxy_url, commands::set_global_proxy_url, From b8a3534cb5209c4a5ddea5fe7ba37631b7d74fe4 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 20 Apr 2026 09:49:13 +0800 Subject: [PATCH 038/156] feat(settings): add Hermes config dir override with data-driven dispatch Adds a dedicated Hermes row to the directory-override settings so users can point CC Switch at alternate Hermes config locations (e.g. a second profile directory for work/personal split). `get_config_dir` on the Rust side already supports hermes; this just wires up the frontend row. Wiring it through `useDirectorySettings` revealed a scaling problem: every supported app required five parallel ternary chains across `computeDefaultConfigDir`, `updateDirectory`, `browseDirectory`, `resetDirectory`, and `updateDirectoryState`. Replaces those with two lookup tables (`APP_DIRECTORY_META`, `DIRECTORY_KEY_TO_SETTINGS_FIELD`) so adding the next app is two entries, not fifteen edit sites. Drive-by cleanup from the same touch: * `resetAllDirectories` takes a `ResolvedAppDirectoryOverrides` object instead of five positional optional strings. * `setResolvedDirs` returns the same reference when the sanitized value is unchanged, so no-op edits don't cascade renders. Also lands all i18n updates for this series (`hermesConfigDir` and placeholder, Memory section's enable/disable/toggleFailed copy, and the reworded `schemaMigratedV12` warning) in zh/en/ja together. --- src/components/settings/DirectorySettings.tsx | 13 ++ src/components/settings/SettingsPage.tsx | 1 + src/hooks/useDirectorySettings.ts | 155 ++++++++---------- src/hooks/useSettings.ts | 15 +- src/i18n/locales/en.json | 17 +- src/i18n/locales/ja.json | 17 +- src/i18n/locales/zh.json | 17 +- tests/hooks/useDirectorySettings.test.tsx | 18 +- tests/hooks/useSettings.test.tsx | 15 +- 9 files changed, 151 insertions(+), 117 deletions(-) diff --git a/src/components/settings/DirectorySettings.tsx b/src/components/settings/DirectorySettings.tsx index d66f09b9c..408a56901 100644 --- a/src/components/settings/DirectorySettings.tsx +++ b/src/components/settings/DirectorySettings.tsx @@ -17,6 +17,7 @@ interface DirectorySettingsProps { geminiDir?: string; opencodeDir?: string; openclawDir?: string; + hermesDir?: string; onDirectoryChange: (app: AppId, value?: string) => void; onBrowseDirectory: (app: AppId) => Promise; onResetDirectory: (app: AppId) => Promise; @@ -33,6 +34,7 @@ export function DirectorySettings({ geminiDir, opencodeDir, openclawDir, + hermesDir, onDirectoryChange, onBrowseDirectory, onResetDirectory, @@ -143,6 +145,17 @@ export function DirectorySettings({ onBrowse={() => onBrowseDirectory("openclaw")} onReset={() => onResetDirectory("openclaw")} /> + + onDirectoryChange("hermes", val)} + onBrowse={() => onBrowseDirectory("hermes")} + onReset={() => onResetDirectory("hermes")} + />
); diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 688e81683..ccd3e68bc 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -317,6 +317,7 @@ export function SettingsPage({ geminiDir={settings.geminiConfigDir} opencodeDir={settings.opencodeConfigDir} openclawDir={settings.openclawConfigDir} + hermesDir={settings.hermesConfigDir} onDirectoryChange={updateDirectory} onBrowseDirectory={browseDirectory} onResetDirectory={resetDirectory} diff --git a/src/hooks/useDirectorySettings.ts b/src/hooks/useDirectorySettings.ts index 51a28dc4a..d6fff53f5 100644 --- a/src/hooks/useDirectorySettings.ts +++ b/src/hooks/useDirectorySettings.ts @@ -5,13 +5,14 @@ import { homeDir, join } from "@tauri-apps/api/path"; import { settingsApi, type AppId } from "@/lib/api"; import type { SettingsFormState } from "./useSettingsForm"; -type DirectoryKey = - | "appConfig" +type AppDirectoryKey = | "claude" | "codex" | "gemini" | "opencode" - | "openclaw"; + | "openclaw" + | "hermes"; +type DirectoryKey = "appConfig" | AppDirectoryKey; export interface ResolvedDirectories { appConfig: string; @@ -20,8 +21,34 @@ export interface ResolvedDirectories { gemini: string; opencode: string; openclaw: string; + hermes: string; } +// Single source of truth for per-app directory metadata. +const APP_DIRECTORY_META: Record< + AppId, + { key: AppDirectoryKey; defaultFolder: string } +> = { + claude: { key: "claude", defaultFolder: ".claude" }, + codex: { key: "codex", defaultFolder: ".codex" }, + gemini: { key: "gemini", defaultFolder: ".gemini" }, + opencode: { key: "opencode", defaultFolder: ".config/opencode" }, + openclaw: { key: "openclaw", defaultFolder: ".openclaw" }, + hermes: { key: "hermes", defaultFolder: ".hermes" }, +}; + +const DIRECTORY_KEY_TO_SETTINGS_FIELD: Record< + AppDirectoryKey, + keyof SettingsFormState +> = { + claude: "claudeConfigDir", + codex: "codexConfigDir", + gemini: "geminiConfigDir", + opencode: "opencodeConfigDir", + openclaw: "openclawConfigDir", + hermes: "hermesConfigDir", +}; + const sanitizeDir = (value?: string | null): string | undefined => { if (!value) return undefined; const trimmed = value.trim(); @@ -46,17 +73,7 @@ const computeDefaultConfigDir = async ( ): Promise => { try { const home = await homeDir(); - const folder = - app === "claude" - ? ".claude" - : app === "codex" - ? ".codex" - : app === "gemini" - ? ".gemini" - : app === "opencode" - ? ".config/opencode" - : ".openclaw"; - return await join(home, folder); + return await join(home, APP_DIRECTORY_META[app].defaultFolder); } catch (error) { console.error( "[useDirectorySettings] Failed to resolve default config dir", @@ -82,15 +99,13 @@ export interface UseDirectorySettingsResult { browseAppConfigDir: () => Promise; resetDirectory: (app: AppId) => Promise; resetAppConfigDir: () => Promise; - resetAllDirectories: ( - claudeDir?: string, - codexDir?: string, - geminiDir?: string, - opencodeDir?: string, - openclawDir?: string, - ) => void; + resetAllDirectories: (overrides?: ResolvedAppDirectoryOverrides) => void; } +export type ResolvedAppDirectoryOverrides = Partial< + Record +>; + /** * useDirectorySettings - 目录管理 * 负责: @@ -116,6 +131,7 @@ export function useDirectorySettings({ gemini: "", opencode: "", openclaw: "", + hermes: "", }); const [isLoading, setIsLoading] = useState(true); @@ -126,6 +142,7 @@ export function useDirectorySettings({ gemini: "", opencode: "", openclaw: "", + hermes: "", }); const initialAppConfigDirRef = useRef(undefined); @@ -143,12 +160,14 @@ export function useDirectorySettings({ geminiDir, opencodeDir, openclawDir, + hermesDir, defaultAppConfig, defaultClaudeDir, defaultCodexDir, defaultGeminiDir, defaultOpencodeDir, defaultOpenclawDir, + defaultHermesDir, ] = await Promise.all([ settingsApi.getAppConfigDirOverride(), settingsApi.getConfigDir("claude"), @@ -156,12 +175,14 @@ export function useDirectorySettings({ settingsApi.getConfigDir("gemini"), settingsApi.getConfigDir("opencode"), settingsApi.getConfigDir("openclaw"), + settingsApi.getConfigDir("hermes"), computeDefaultAppConfigDir(), computeDefaultConfigDir("claude"), computeDefaultConfigDir("codex"), computeDefaultConfigDir("gemini"), computeDefaultConfigDir("opencode"), computeDefaultConfigDir("openclaw"), + computeDefaultConfigDir("hermes"), ]); if (!active) return; @@ -175,6 +196,7 @@ export function useDirectorySettings({ gemini: defaultGeminiDir ?? "", opencode: defaultOpencodeDir ?? "", openclaw: defaultOpenclawDir ?? "", + hermes: defaultHermesDir ?? "", }; setAppConfigDir(normalizedOverride); @@ -187,6 +209,7 @@ export function useDirectorySettings({ gemini: geminiDir || defaultsRef.current.gemini, opencode: opencodeDir || defaultsRef.current.opencode, openclaw: openclawDir || defaultsRef.current.openclaw, + hermes: hermesDir || defaultsRef.current.hermes, }); } catch (error) { console.error( @@ -212,23 +235,18 @@ export function useDirectorySettings({ if (key === "appConfig") { setAppConfigDir(sanitized); } else { - onUpdateSettings( - key === "claude" - ? { claudeConfigDir: sanitized } - : key === "codex" - ? { codexConfigDir: sanitized } - : key === "gemini" - ? { geminiConfigDir: sanitized } - : key === "opencode" - ? { opencodeConfigDir: sanitized } - : { openclawConfigDir: sanitized }, - ); + onUpdateSettings({ + [DIRECTORY_KEY_TO_SETTINGS_FIELD[key]]: sanitized, + }); } - setResolvedDirs((prev) => ({ - ...prev, - [key]: sanitized ?? defaultsRef.current[key], - })); + setResolvedDirs((prev) => { + const next = sanitized ?? defaultsRef.current[key]; + // Same-ref early-return: unchanged value shouldn't cascade renders + // through the settings tree. + if (prev[key] === next) return prev; + return { ...prev, [key]: next }; + }); }, [onUpdateSettings], ); @@ -242,44 +260,17 @@ export function useDirectorySettings({ const updateDirectory = useCallback( (app: AppId, value?: string) => { - updateDirectoryState( - app === "claude" - ? "claude" - : app === "codex" - ? "codex" - : app === "gemini" - ? "gemini" - : app === "opencode" - ? "opencode" - : "openclaw", - value, - ); + updateDirectoryState(APP_DIRECTORY_META[app].key, value); }, [updateDirectoryState], ); const browseDirectory = useCallback( async (app: AppId) => { - const key: DirectoryKey = - app === "claude" - ? "claude" - : app === "codex" - ? "codex" - : app === "gemini" - ? "gemini" - : app === "opencode" - ? "opencode" - : "openclaw"; + const key = APP_DIRECTORY_META[app].key; + const settingsField = DIRECTORY_KEY_TO_SETTINGS_FIELD[key]; const currentValue = - key === "claude" - ? (settings?.claudeConfigDir ?? resolvedDirs.claude) - : key === "codex" - ? (settings?.codexConfigDir ?? resolvedDirs.codex) - : key === "gemini" - ? (settings?.geminiConfigDir ?? resolvedDirs.gemini) - : key === "opencode" - ? (settings?.opencodeConfigDir ?? resolvedDirs.opencode) - : (settings?.openclawConfigDir ?? resolvedDirs.openclaw); + (settings?.[settingsField] as string | undefined) ?? resolvedDirs[key]; try { const picked = await settingsApi.selectConfigDirectory(currentValue); @@ -320,16 +311,7 @@ export function useDirectorySettings({ const resetDirectory = useCallback( async (app: AppId) => { - const key: DirectoryKey = - app === "claude" - ? "claude" - : app === "codex" - ? "codex" - : app === "gemini" - ? "gemini" - : app === "opencode" - ? "opencode" - : "openclaw"; + const key = APP_DIRECTORY_META[app].key; if (!defaultsRef.current[key]) { const fallback = await computeDefaultConfigDir(app); if (fallback) { @@ -358,22 +340,17 @@ export function useDirectorySettings({ }, [updateDirectoryState]); const resetAllDirectories = useCallback( - ( - claudeDir?: string, - codexDir?: string, - geminiDir?: string, - opencodeDir?: string, - openclawDir?: string, - ) => { + (overrides?: ResolvedAppDirectoryOverrides) => { setAppConfigDir(initialAppConfigDirRef.current); setResolvedDirs({ appConfig: initialAppConfigDirRef.current ?? defaultsRef.current.appConfig, - claude: claudeDir ?? defaultsRef.current.claude, - codex: codexDir ?? defaultsRef.current.codex, - gemini: geminiDir ?? defaultsRef.current.gemini, - opencode: opencodeDir ?? defaultsRef.current.opencode, - openclaw: openclawDir ?? defaultsRef.current.openclaw, + claude: overrides?.claude ?? defaultsRef.current.claude, + codex: overrides?.codex ?? defaultsRef.current.codex, + gemini: overrides?.gemini ?? defaultsRef.current.gemini, + opencode: overrides?.opencode ?? defaultsRef.current.opencode, + openclaw: overrides?.openclaw ?? defaultsRef.current.openclaw, + hermes: overrides?.hermes ?? defaultsRef.current.hermes, }); }, [], diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index c7c77af96..2982eb1f9 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -107,13 +107,14 @@ export function useSettings(): UseSettingsResult { const resetSettings = useCallback(() => { resetForm(data ?? null); syncLanguage(initialLanguage); - resetAllDirectories( - sanitizeDir(data?.claudeConfigDir), - sanitizeDir(data?.codexConfigDir), - sanitizeDir(data?.geminiConfigDir), - sanitizeDir(data?.opencodeConfigDir), - sanitizeDir(data?.openclawConfigDir), - ); + resetAllDirectories({ + claude: sanitizeDir(data?.claudeConfigDir), + codex: sanitizeDir(data?.codexConfigDir), + gemini: sanitizeDir(data?.geminiConfigDir), + opencode: sanitizeDir(data?.opencodeConfigDir), + openclaw: sanitizeDir(data?.openclawConfigDir), + hermes: sanitizeDir(data?.hermesConfigDir), + }); setRequiresRestart(false); }, [ data, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index dd18e1c8b..cfa420472 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -579,11 +579,14 @@ "opencodeConfigDirDescription": "Override OpenCode configuration directory (opencode.json).", "openclawConfigDir": "OpenClaw Configuration Directory", "openclawConfigDirDescription": "Override OpenClaw configuration directory (openclaw.json).", + "hermesConfigDir": "Hermes Configuration Directory", + "hermesConfigDirDescription": "Override Hermes configuration directory (config.yaml).", "browsePlaceholderClaude": "e.g., /home//.claude", "browsePlaceholderCodex": "e.g., /home//.codex", "browsePlaceholderGemini": "e.g., /home//.gemini", "browsePlaceholderOpencode": "e.g., /home//.config/opencode", "browsePlaceholderOpenclaw": "e.g., /home//.openclaw", + "browsePlaceholderHermes": "e.g., /home//.hermes", "browseDirectory": "Browse Directory", "resetDefault": "Reset to default directory (takes effect after saving)", "checkForUpdates": "Check for Updates", @@ -1674,7 +1677,14 @@ "title": "Hermes config warnings detected", "parseFailed": "config.yaml could not be parsed as valid YAML. Fix the file before editing it here.", "configNotFound": "Hermes config.yaml not found. Create it at ~/.hermes/config.yaml or configure the path in settings.", - "envParseFailed": "The .env file could not be parsed." + "envParseFailed": "The .env file could not be parsed.", + "modelNoDefault": "No default model or provider is configured in the 'model' section.", + "customProvidersNotList": "custom_providers should be a YAML list (items prefixed with '-'), not a mapping.", + "modelProviderUnknown": "model.provider references a provider that is not configured.", + "modelDefaultNotInProvider": "model.default is not in the selected provider's models list.", + "duplicateProviderName": "custom_providers contains duplicate provider names — only one entry will be used.", + "duplicateProviderBaseUrl": "custom_providers contains duplicate base_urls — possible accidental copy.", + "schemaMigratedV12": "Hermes moved some providers into the 'providers:' dict. CC Switch only manages 'custom_providers:' — edit or remove those entries via Hermes Web UI." }, "memory": { "title": "Memory", @@ -1682,7 +1692,10 @@ "userTab": "User Profile (USER.md)", "usage": "{{current}} / {{limit}} characters", "overLimit": "Over budget — Hermes will truncate on next load", - "disabled": "Memory is disabled in config.yaml. Enable it in Hermes config for edits to take effect.", + "enableOn": "Enabled", + "enableOff": "Disabled", + "disabledHint": "Hermes will skip this memory until re-enabled", + "toggleFailed": "Failed to toggle memory", "saveSuccess": "Memory saved", "saveFailed": "Failed to save memory", "loadFailed": "Failed to read memory file", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 2f9abdae4..2c00458f5 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -579,11 +579,14 @@ "opencodeConfigDirDescription": "OpenCode の設定ディレクトリ(opencode.json)を上書きします。", "openclawConfigDir": "OpenClaw 設定ディレクトリ", "openclawConfigDirDescription": "OpenClaw の設定ディレクトリ(openclaw.json)を上書きします。", + "hermesConfigDir": "Hermes 設定ディレクトリ", + "hermesConfigDirDescription": "Hermes の設定ディレクトリ(config.yaml)を上書きします。", "browsePlaceholderClaude": "例: /home//.claude", "browsePlaceholderCodex": "例: /home//.codex", "browsePlaceholderGemini": "例: /home//.gemini", "browsePlaceholderOpencode": "例: /home//.config/opencode", "browsePlaceholderOpenclaw": "例: /home//.openclaw", + "browsePlaceholderHermes": "例: /home//.hermes", "browseDirectory": "ディレクトリを選択", "resetDefault": "デフォルトに戻す(保存後に反映)", "checkForUpdates": "アップデートを確認", @@ -1674,7 +1677,14 @@ "title": "Hermes 設定の警告を検出", "parseFailed": "config.yaml を有効な YAML として解析できません。ここで編集する前にファイルを修正してください。", "configNotFound": "Hermes config.yaml が見つかりません。~/.hermes/config.yaml に作成するか、設定でパスを構成してください。", - "envParseFailed": ".env ファイルを解析できません。" + "envParseFailed": ".env ファイルを解析できません。", + "modelNoDefault": "'model' セクションに既定モデルまたはプロバイダーが設定されていません。", + "customProvidersNotList": "custom_providers はマッピングではなく、YAML リスト('-' で始まる項目)である必要があります。", + "modelProviderUnknown": "model.provider が指すプロバイダーは設定に存在しません。", + "modelDefaultNotInProvider": "model.default は選択中のプロバイダーのモデル一覧に含まれていません。", + "duplicateProviderName": "custom_providers にプロバイダー名の重複があります。1 件のみ有効になります。", + "duplicateProviderBaseUrl": "custom_providers に重複した base_url があります。誤ってコピーされた可能性があります。", + "schemaMigratedV12": "Hermes は一部のプロバイダーを 'providers:' dict に移しました。CC Switch は 'custom_providers:' のみを管理します。該当する項目は Hermes Web UI で編集・削除してください。" }, "memory": { "title": "メモリ", @@ -1682,7 +1692,10 @@ "userTab": "ユーザープロファイル (USER.md)", "usage": "{{current}} / {{limit}} 文字", "overLimit": "上限を超えました。Hermes は次回読み込み時に切り詰めます", - "disabled": "Memory が config.yaml で無効化されています。Hermes 設定で有効化すると編集内容が反映されます。", + "enableOn": "有効", + "enableOff": "無効", + "disabledHint": "再度有効化するまで Hermes はこの記憶をスキップします", + "toggleFailed": "メモリの切り替えに失敗しました", "saveSuccess": "記憶を保存しました", "saveFailed": "記憶の保存に失敗しました", "loadFailed": "記憶ファイルの読み込みに失敗しました", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index acefa6c9e..0ef934332 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -579,11 +579,14 @@ "opencodeConfigDirDescription": "覆盖 OpenCode 配置目录 (opencode.json)。", "openclawConfigDir": "OpenClaw 配置目录", "openclawConfigDirDescription": "覆盖 OpenClaw 配置目录 (openclaw.json)。", + "hermesConfigDir": "Hermes 配置目录", + "hermesConfigDirDescription": "覆盖 Hermes 配置目录 (config.yaml)。", "browsePlaceholderClaude": "例如:/home/<你的用户名>/.claude", "browsePlaceholderCodex": "例如:/home/<你的用户名>/.codex", "browsePlaceholderGemini": "例如:/home/<你的用户名>/.gemini", "browsePlaceholderOpencode": "例如:/home/<你的用户名>/.config/opencode", "browsePlaceholderOpenclaw": "例如:/home/<你的用户名>/.openclaw", + "browsePlaceholderHermes": "例如:/home/<你的用户名>/.hermes", "browseDirectory": "浏览目录", "resetDefault": "恢复默认目录(需保存后生效)", "checkForUpdates": "检查更新", @@ -1674,7 +1677,14 @@ "title": "检测到 Hermes 配置警告", "parseFailed": "config.yaml 无法解析为有效 YAML。请先修复文件再在此编辑。", "configNotFound": "未找到 Hermes config.yaml。请在 ~/.hermes/config.yaml 创建或在设置中配置路径。", - "envParseFailed": ".env 文件无法解析。" + "envParseFailed": ".env 文件无法解析。", + "modelNoDefault": "model 段未设置默认模型或供应商。", + "customProvidersNotList": "custom_providers 应为 YAML 列表(以 `-` 开头),而不是映射。", + "modelProviderUnknown": "model.provider 指向的供应商未在配置中找到。", + "modelDefaultNotInProvider": "model.default 指向的模型不在所选供应商的模型列表中。", + "duplicateProviderName": "custom_providers 中存在重复的供应商名,只会有一条生效。", + "duplicateProviderBaseUrl": "custom_providers 中存在重复的 base_url,可能是意外复制。", + "schemaMigratedV12": "Hermes 把部分供应商移到了 'providers:' dict。CC Switch 只管理 'custom_providers:',请在 Hermes Web UI 编辑或删除这些条目。" }, "memory": { "title": "记忆管理", @@ -1682,7 +1692,10 @@ "userTab": "用户画像 (USER.md)", "usage": "已用 {{current}} / {{limit}} 字符", "overLimit": "已超过上限,Hermes 下次加载时会截断", - "disabled": "Memory 在 config.yaml 中已禁用,编辑后需在 Hermes 配置启用才会生效。", + "enableOn": "已启用", + "enableOff": "已禁用", + "disabledHint": "在重新启用前 Hermes 会跳过此记忆", + "toggleFailed": "切换记忆状态失败", "saveSuccess": "记忆已保存", "saveFailed": "保存记忆失败", "loadFailed": "读取记忆文件失败", diff --git a/tests/hooks/useDirectorySettings.test.tsx b/tests/hooks/useDirectorySettings.test.tsx index 8fa029537..d5ea101ee 100644 --- a/tests/hooks/useDirectorySettings.test.tsx +++ b/tests/hooks/useDirectorySettings.test.tsx @@ -69,7 +69,8 @@ describe("useDirectorySettings", () => { if (app === "codex") return "/remote/codex"; if (app === "gemini") return "/remote/gemini"; if (app === "opencode") return "/remote/opencode"; - return "/remote/openclaw"; + if (app === "openclaw") return "/remote/openclaw"; + return "/remote/hermes"; }); selectConfigDirectoryMock.mockReset(); }); @@ -91,6 +92,7 @@ describe("useDirectorySettings", () => { gemini: "/remote/gemini", opencode: "/remote/opencode", openclaw: "/remote/openclaw", + hermes: "/remote/hermes", }); }); @@ -243,13 +245,13 @@ describe("useDirectorySettings", () => { await waitFor(() => expect(result.current.isLoading).toBe(false)); act(() => { - result.current.resetAllDirectories( - "/server/claude", - "/server/codex", - "/server/gemini", - "/server/opencode", - "/server/openclaw", - ); + result.current.resetAllDirectories({ + claude: "/server/claude", + codex: "/server/codex", + gemini: "/server/gemini", + opencode: "/server/opencode", + openclaw: "/server/openclaw", + }); }); expect(result.current.resolvedDirs.claude).toBe("/server/claude"); diff --git a/tests/hooks/useSettings.test.tsx b/tests/hooks/useSettings.test.tsx index a4c011e9d..f16c4b90a 100644 --- a/tests/hooks/useSettings.test.tsx +++ b/tests/hooks/useSettings.test.tsx @@ -455,13 +455,14 @@ describe("useSettings hook", () => { expect(settingsFormMock.syncLanguage).toHaveBeenCalledWith( settingsFormMock.initialLanguage, ); - expect(directorySettingsMock.resetAllDirectories).toHaveBeenCalledWith( - "/server/claude", - undefined, - "/server/gemini", - "/server/opencode", - "/server/openclaw", - ); + expect(directorySettingsMock.resetAllDirectories).toHaveBeenCalledWith({ + claude: "/server/claude", + codex: undefined, + gemini: "/server/gemini", + opencode: "/server/opencode", + openclaw: "/server/openclaw", + hermes: undefined, + }); expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false); }); From 185ac2be9b44ccc0d6db5df78c3c01d5bd188160 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 20 Apr 2026 09:49:23 +0800 Subject: [PATCH 039/156] feat(hermes): memory enable switch + clearer migration warning copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the greyed-out "Memory is disabled" banner with a real Switch at the top of each memory tab. Users can now toggle Hermes' memory/user blobs without leaving CC Switch; the underlying write goes through the merge-aware `set_memory_enabled`, so budgets and external-provider settings survive toggle operations. The new `useToggleHermesMemoryEnabled` mutation invalidates the limits query so the Switch state and the amber disabled-hint update in lockstep. Reworks the `schema_migrated_v12` health banner copy to match the simplified "CC Switch only manages custom_providers" posture — it now tells users to reconcile migrated dict entries via Hermes Web UI, instead of the earlier (and now inaccurate) "CC Switch reads both". --- src/components/hermes/HermesHealthBanner.tsx | 35 ++++++++++++++++++++ src/components/hermes/HermesMemoryPanel.tsx | 34 ++++++++++++++++--- src/hooks/useHermes.ts | 28 ++++++++++++++++ src/lib/api/hermes.ts | 11 ++++++ 4 files changed, 104 insertions(+), 4 deletions(-) diff --git a/src/components/hermes/HermesHealthBanner.tsx b/src/components/hermes/HermesHealthBanner.tsx index bf6875c6f..81723bd2c 100644 --- a/src/components/hermes/HermesHealthBanner.tsx +++ b/src/components/hermes/HermesHealthBanner.tsx @@ -30,6 +30,41 @@ function getWarningText( return t("hermes.health.envParseFailed", { defaultValue: "The .env file could not be parsed.", }); + case "model_no_default": + return t("hermes.health.modelNoDefault", { + defaultValue: + "No default model or provider is configured in the 'model' section.", + }); + case "custom_providers_not_list": + return t("hermes.health.customProvidersNotList", { + defaultValue: + "custom_providers should be a YAML list (items prefixed with '-'), not a mapping.", + }); + case "model_provider_unknown": + return t("hermes.health.modelProviderUnknown", { + defaultValue: + "model.provider references a provider that is not configured.", + }); + case "model_default_not_in_provider": + return t("hermes.health.modelDefaultNotInProvider", { + defaultValue: + "model.default is not in the selected provider's models list.", + }); + case "duplicate_provider_name": + return t("hermes.health.duplicateProviderName", { + defaultValue: + "custom_providers contains duplicate provider names — only one entry will be used.", + }); + case "duplicate_provider_base_url": + return t("hermes.health.duplicateProviderBaseUrl", { + defaultValue: + "custom_providers contains duplicate base_urls — possible accidental copy.", + }); + case "schema_migrated_v12": + return t("hermes.health.schemaMigratedV12", { + defaultValue: + "Hermes moved some providers into the 'providers:' dict. CC Switch only manages 'custom_providers:' — edit or remove those entries via Hermes Web UI.", + }); default: return fallback; } diff --git a/src/components/hermes/HermesMemoryPanel.tsx b/src/components/hermes/HermesMemoryPanel.tsx index 3e8417bc7..ad84c1c33 100644 --- a/src/components/hermes/HermesMemoryPanel.tsx +++ b/src/components/hermes/HermesMemoryPanel.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { ExternalLink } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import MarkdownEditor from "@/components/MarkdownEditor"; import { @@ -10,6 +11,7 @@ import { useHermesMemoryLimits, useOpenHermesWebUI, useSaveHermesMemory, + useToggleHermesMemoryEnabled, } from "@/hooks/useHermes"; import { useDarkMode } from "@/hooks/useDarkMode"; import type { HermesMemoryKind } from "@/types"; @@ -30,6 +32,7 @@ const MemoryTabPane: React.FC = ({ const darkMode = useDarkMode(); const { data, isLoading } = useHermesMemory(kind, true); const saveMutation = useSaveHermesMemory(); + const toggleMutation = useToggleHermesMemoryEnabled(); const [content, setContent] = useState(""); const [loaded, setLoaded] = useState(false); @@ -57,11 +60,34 @@ const MemoryTabPane: React.FC = ({ return (
- {!enabled && ( -
- {t("hermes.memory.disabled")} +
+
+ + toggleMutation.mutate({ kind, enabled: next }) + } + /> + + {enabled + ? t("hermes.memory.enableOn") + : t("hermes.memory.enableOff")} +
- )} + {!enabled && ( + + {t("hermes.memory.disabledHint")} + + )} +
{isLoading && !loaded ? (
diff --git a/src/hooks/useHermes.ts b/src/hooks/useHermes.ts index 1576224c0..1b1c9174c 100644 --- a/src/hooks/useHermes.ts +++ b/src/hooks/useHermes.ts @@ -125,6 +125,34 @@ export function useSaveHermesMemory() { }); } +/** + * Toggle one memory blob's on/off flag in Hermes' `config.yaml`. Invalidates + * the limits query so the switch UI and disabled banner update immediately. + */ +export function useToggleHermesMemoryEnabled() { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + return useMutation({ + mutationFn: ({ + kind, + enabled, + }: { + kind: HermesMemoryKind; + enabled: boolean; + }) => hermesApi.setMemoryEnabled(kind, enabled), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: hermesKeys.memoryLimits, + }); + }, + onError: (error) => { + toast.error(t("hermes.memory.toggleFailed"), { + description: extractErrorMessage(error) || undefined, + }); + }, + }); +} + /** * Returns a handler that probes the local Hermes Web UI, opens it in the * system browser, and surfaces a localized toast on failure. Callers only diff --git a/src/lib/api/hermes.ts b/src/lib/api/hermes.ts index a11e37923..3fba5c953 100644 --- a/src/lib/api/hermes.ts +++ b/src/lib/api/hermes.ts @@ -53,4 +53,15 @@ export const hermesApi = { async getMemoryLimits(): Promise { return await invoke("get_hermes_memory_limits"); }, + + /** + * Toggle the on/off flag for one memory blob. Other fields in the `memory:` + * section (budgets, external provider config) are preserved. + */ + async setMemoryEnabled( + kind: HermesMemoryKind, + enabled: boolean, + ): Promise { + await invoke("set_hermes_memory_enabled", { kind, enabled }); + }, }; From e10a300c808762c8fd7c15553934bcc06992bd97 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 20 Apr 2026 10:28:51 +0800 Subject: [PATCH 040/156] fix(hermes): prevent YAML pollution and drop of OAuth mcp auth DeepLink Hermes import was emitting camelCase (baseUrl / apiKey / apiMode) that the Hermes runtime does not recognise, poisoning `custom_providers:` entries on activation. The MCP sync path was also stripping `auth: oauth` on round-trip, silently downgrading OAuth-type servers to unauthenticated calls. The Hermes deeplink branch now emits snake_case via a dedicated builder; `sanitize_hermes_provider_keys` runs on both `set_provider` and `get_providers` so legacy DB records heal on next access. `HERMES_EXTRA_FIELDS` preserves `auth`. The `api_mode` dropdown gains `codex_responses` (Copilot / OpenCode), and the schema-migrated warning copy no longer hard-codes "v12" (upstream `_config_version` is now 19). --- src-tauri/src/deeplink/mod.rs | 2 +- src-tauri/src/deeplink/provider.rs | 125 +++++++++++++++- src-tauri/src/hermes_config.rs | 147 ++++++++++++++++++- src-tauri/src/mcp/hermes.rs | 46 ++++++ src/components/hermes/HermesHealthBanner.tsx | 2 +- src/config/hermesProviderPresets.ts | 6 +- src/i18n/locales/en.json | 3 +- src/i18n/locales/ja.json | 3 +- src/i18n/locales/zh.json | 3 +- 9 files changed, 327 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/deeplink/mod.rs b/src-tauri/src/deeplink/mod.rs index 4bd3c8036..f883d3b8f 100644 --- a/src-tauri/src/deeplink/mod.rs +++ b/src-tauri/src/deeplink/mod.rs @@ -31,7 +31,7 @@ pub use skill::import_skill_from_deeplink; /// /// Represents a parsed ccswitch:// URL ready for processing. /// This struct contains all possible fields for all resource types. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DeepLinkImportRequest { /// Protocol version (e.g., "v1") diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 216e1ff04..32fa77b58 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -146,7 +146,8 @@ pub(crate) fn build_provider_from_request( AppType::Codex => build_codex_settings(request), AppType::Gemini => build_gemini_settings(request), AppType::OpenCode => build_opencode_settings(request), - AppType::OpenClaw | AppType::Hermes => build_additive_app_settings(request), + AppType::OpenClaw => build_additive_app_settings(request), + AppType::Hermes => build_hermes_settings(request), }; // Build usage script configuration if provided @@ -393,7 +394,7 @@ fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value }) } -/// Build settings for additive-mode apps (OpenClaw, Hermes) +/// Build settings for OpenClaw (camelCase live config). /// Format: { baseUrl, apiKey, api, models } fn build_additive_app_settings(request: &DeepLinkImportRequest) -> serde_json::Value { let endpoint = get_primary_endpoint(request); @@ -420,6 +421,42 @@ fn build_additive_app_settings(request: &DeepLinkImportRequest) -> serde_json::V json!(config) } +/// Build Hermes provider settings (snake_case YAML-native fields). +/// +/// Hermes' `custom_providers:` entries use `base_url` / `api_key` / `api_mode` +/// (see `_VALID_CUSTOM_PROVIDER_FIELDS` in upstream `hermes_cli/config.py`). +/// Emitting camelCase here — as the OpenClaw path does — would poison the +/// YAML with unknown root fields the Hermes runtime ignores. +/// +/// `api_mode` is deliberately omitted so Hermes auto-detects the protocol +/// from the endpoint; callers can still override via the UI later. +fn build_hermes_settings(request: &DeepLinkImportRequest) -> serde_json::Value { + let endpoint = get_primary_endpoint(request); + + let mut config = serde_json::Map::new(); + + if let Some(name) = request.name.as_deref().filter(|s| !s.is_empty()) { + config.insert("name".to_string(), json!(name)); + } + + if !endpoint.is_empty() { + config.insert("base_url".to_string(), json!(endpoint)); + } + + if let Some(api_key) = &request.api_key { + config.insert("api_key".to_string(), json!(api_key)); + } + + if let Some(model) = &request.model { + config.insert( + "models".to_string(), + json!([{ "id": model, "name": model }]), + ); + } + + json!(config) +} + // ============================================================================= // Config Merge Logic // ============================================================================= @@ -709,3 +746,87 @@ fn extract_codex_base_url(toml_value: &toml::Value) -> Option { } None } + +#[cfg(test)] +mod tests { + use super::*; + + fn hermes_request() -> DeepLinkImportRequest { + DeepLinkImportRequest { + resource: "provider".to_string(), + app: Some("hermes".to_string()), + name: Some("MyHermes".to_string()), + endpoint: Some("https://api.example.com/v1".to_string()), + api_key: Some("sk-test".to_string()), + model: Some("anthropic/claude-opus-4-7".to_string()), + ..Default::default() + } + } + + #[test] + fn build_hermes_settings_emits_snake_case() { + let settings = build_hermes_settings(&hermes_request()); + let obj = settings.as_object().expect("settings must be object"); + + assert_eq!(obj.get("name").unwrap(), "MyHermes"); + assert_eq!(obj.get("base_url").unwrap(), "https://api.example.com/v1"); + assert_eq!(obj.get("api_key").unwrap(), "sk-test"); + + // camelCase and legacy fields must NOT be present + assert!(obj.get("baseUrl").is_none(), "no camelCase baseUrl"); + assert!(obj.get("apiKey").is_none(), "no camelCase apiKey"); + assert!(obj.get("api").is_none(), "no legacy 'api' field"); + + // models array with the deeplink model id + let models = obj.get("models").unwrap().as_array().unwrap(); + assert_eq!(models.len(), 1); + assert_eq!(models[0]["id"], "anthropic/claude-opus-4-7"); + } + + #[test] + fn build_hermes_settings_omits_api_mode_for_auto_detect() { + let settings = build_hermes_settings(&hermes_request()); + assert!( + settings.as_object().unwrap().get("api_mode").is_none(), + "api_mode must be omitted so Hermes auto-detects" + ); + } + + #[test] + fn build_hermes_settings_skips_missing_optional_fields() { + let request = DeepLinkImportRequest { + resource: "provider".to_string(), + app: Some("hermes".to_string()), + name: Some("Minimal".to_string()), + endpoint: None, + api_key: None, + model: None, + ..Default::default() + }; + let settings = build_hermes_settings(&request); + let obj = settings.as_object().unwrap(); + + assert_eq!(obj.get("name").unwrap(), "Minimal"); + assert!(obj.get("base_url").is_none()); + assert!(obj.get("api_key").is_none()); + assert!(obj.get("models").is_none()); + } + + #[test] + fn openclaw_still_uses_camel_case() { + // OpenClaw's live config natively uses camelCase; guard against a + // refactor accidentally flipping it to snake_case. + let request = DeepLinkImportRequest { + resource: "provider".to_string(), + app: Some("openclaw".to_string()), + name: Some("c".to_string()), + endpoint: Some("https://api.example.com".to_string()), + api_key: Some("k".to_string()), + ..Default::default() + }; + let settings = build_additive_app_settings(&request); + let obj = settings.as_object().unwrap(); + assert!(obj.contains_key("baseUrl")); + assert!(obj.contains_key("apiKey")); + } +} diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs index 594e15261..a7ab7cd8a 100644 --- a/src-tauri/src/hermes_config.rs +++ b/src-tauri/src/hermes_config.rs @@ -414,6 +414,43 @@ fn models_dict_to_array(dict: serde_json::Map) -> ser serde_json::Value::Array(out) } +/// Rewrite historical camelCase keys to Hermes' snake_case schema. +/// +/// Older DeepLink import paths emitted `baseUrl` / `apiKey` / `apiMode` / +/// `maxTokens` / `contextLength`, which do not belong to Hermes' +/// `_VALID_CUSTOM_PROVIDER_FIELDS` set. Writing those raw to YAML silently +/// poisons `custom_providers:` entries. This sanitiser runs defensively on +/// every `set_provider` call so stored data heals on the next activation; +/// unknown keys pass through untouched to keep forward-compat with new +/// Hermes fields (e.g. `request_timeout_seconds`). +fn sanitize_hermes_provider_keys(config: &mut serde_json::Value) { + const KEY_ALIASES: &[(&str, &str)] = &[ + ("baseUrl", "base_url"), + ("apiKey", "api_key"), + ("apiMode", "api_mode"), + ("maxTokens", "max_tokens"), + ("contextLength", "context_length"), + ]; + // Legacy DeepLink emitted `api: "openai-completions"` which is neither a + // Hermes field nor mappable to `api_mode`. + const LEGACY_FIELDS_TO_DROP: &[&str] = &["api"]; + + let Some(obj) = config.as_object_mut() else { + return; + }; + + for (from, to) in KEY_ALIASES { + if let Some(val) = obj.remove(*from) { + // snake_case wins when both are present; stale camelCase is dropped. + obj.entry((*to).to_string()).or_insert(val); + } + } + + for field in LEGACY_FIELDS_TO_DROP { + obj.remove(*field); + } +} + /// If `config.models` is a JSON array, convert it in-place to the dict shape. /// No-op when `models` is absent or already a dict. fn normalize_provider_models_for_write(config: &mut serde_json::Value) { @@ -491,6 +528,10 @@ pub fn get_providers() -> Result, App if let Some(name) = item.get("name").and_then(|n| n.as_str()) { match yaml_to_json(item) { Ok(mut json_val) => { + // Heal legacy camelCase records (from older DeepLink + // imports) before the UI sees them, so editing doesn't + // reveal stale `baseUrl` / `apiKey` fields. + sanitize_hermes_provider_keys(&mut json_val); denormalize_provider_models_for_read(&mut json_val); map.insert(name.to_string(), json_val); } @@ -536,8 +577,12 @@ pub fn set_provider( .cloned() .unwrap_or_default(); - // Normalize `models` from UI array to Hermes YAML dict before serializing. + // Rewrite any historical camelCase keys (e.g. from older DeepLink imports) + // before touching models / YAML — avoids writing non-Hermes fields back. let mut normalized = provider_config; + sanitize_hermes_provider_keys(&mut normalized); + + // Normalize `models` from UI array to Hermes YAML dict before serializing. normalize_provider_models_for_write(&mut normalized); // Extract the first model id (now a key in the normalized dict) so we can @@ -819,7 +864,7 @@ fn scan_hermes_health_internal(content: &str) -> Vec { if version >= 12 && providers_dict_populated { warnings.push(hermes_warning( "schema_migrated_v12", - "Hermes v12 moved some entries into the 'providers:' dict. CC Switch only manages 'custom_providers:' — edit or remove those entries via Hermes Web UI.".to_string(), + "Hermes' newer schema moved some entries into the 'providers:' dict. CC Switch only manages 'custom_providers:' — edit or remove those entries via Hermes Web UI.".to_string(), "providers", )); } @@ -1048,6 +1093,75 @@ mod tests { result } + // ---- sanitize_hermes_provider_keys tests ---- + + #[test] + fn sanitize_rewrites_camel_case_aliases() { + let mut v = serde_json::json!({ + "name": "test", + "baseUrl": "https://api.example.com", + "apiKey": "sk-123", + "apiMode": "chat_completions", + "maxTokens": 8192, + "contextLength": 200000, + }); + sanitize_hermes_provider_keys(&mut v); + let obj = v.as_object().unwrap(); + assert_eq!(obj.get("base_url").unwrap(), "https://api.example.com"); + assert_eq!(obj.get("api_key").unwrap(), "sk-123"); + assert_eq!(obj.get("api_mode").unwrap(), "chat_completions"); + assert_eq!(obj.get("max_tokens").unwrap(), 8192); + assert_eq!(obj.get("context_length").unwrap(), 200000); + assert!(obj.get("baseUrl").is_none()); + assert!(obj.get("apiKey").is_none()); + } + + #[test] + fn sanitize_drops_stale_duplicate_when_snake_case_exists() { + let mut v = serde_json::json!({ + "baseUrl": "https://old.example.com", + "base_url": "https://new.example.com", + }); + sanitize_hermes_provider_keys(&mut v); + let obj = v.as_object().unwrap(); + // snake_case wins; stale camelCase is dropped + assert_eq!(obj.get("base_url").unwrap(), "https://new.example.com"); + assert!(obj.get("baseUrl").is_none()); + } + + #[test] + fn sanitize_drops_legacy_api_field() { + let mut v = serde_json::json!({ + "base_url": "https://api.example.com", + "api": "openai-completions", + }); + sanitize_hermes_provider_keys(&mut v); + let obj = v.as_object().unwrap(); + assert!(obj.get("api").is_none(), "legacy 'api' key must be removed"); + assert!(obj.get("base_url").is_some()); + } + + #[test] + fn sanitize_preserves_unknown_fields() { + let mut v = serde_json::json!({ + "base_url": "https://api.example.com", + "request_timeout_seconds": 300, + "rate_limit_delay": 1.5, + }); + sanitize_hermes_provider_keys(&mut v); + let obj = v.as_object().unwrap(); + // Forward-compat: Hermes' own new fields pass through untouched + assert_eq!(obj.get("request_timeout_seconds").unwrap(), 300); + assert_eq!(obj.get("rate_limit_delay").unwrap(), 1.5); + } + + #[test] + fn sanitize_noop_on_non_object() { + let mut v = serde_json::json!(["not", "an", "object"]); + sanitize_hermes_provider_keys(&mut v); + assert!(v.is_array()); + } + // ---- find_yaml_section_range tests ---- #[test] @@ -1295,6 +1409,35 @@ custom_providers: }); } + #[test] + #[serial] + fn get_providers_heals_legacy_camel_case_on_read() { + // A DB may still hold records from older DeepLink imports that wrote + // camelCase fields into `settings_config`. The read path must surface + // them in Hermes' native snake_case so UI editors aren't lying to users. + with_test_home(|| { + let yaml = "\ +custom_providers: + - name: legacy + baseUrl: https://legacy.example.com + apiKey: sk-legacy + apiMode: chat_completions + api: openai-completions +"; + let config_path = get_hermes_config_path(); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, yaml).unwrap(); + + let provider = get_provider("legacy").unwrap().unwrap(); + assert_eq!(provider["base_url"], "https://legacy.example.com"); + assert_eq!(provider["api_key"], "sk-legacy"); + assert_eq!(provider["api_mode"], "chat_completions"); + assert!(provider.get("baseUrl").is_none()); + assert!(provider.get("apiKey").is_none()); + assert!(provider.get("api").is_none()); + }); + } + // ---- Model config tests ---- #[test] diff --git a/src-tauri/src/mcp/hermes.rs b/src-tauri/src/mcp/hermes.rs index c2788212f..612a10973 100644 --- a/src-tauri/src/mcp/hermes.rs +++ b/src-tauri/src/mcp/hermes.rs @@ -25,6 +25,10 @@ use super::validation::validate_server_spec; /// Hermes-specific fields preserved on merge-on-write, stripped on import. /// Update this list when Hermes adds new per-server config fields. +/// +/// `auth` ("oauth" / absent) is an OAuth-type declaration read by Hermes — +/// CC Switch has no OAuth UI, but losing the field on round-trip downgrades +/// the server to unauthenticated calls. const HERMES_EXTRA_FIELDS: &[&str] = &[ "enabled", "timeout", @@ -32,6 +36,7 @@ const HERMES_EXTRA_FIELDS: &[&str] = &[ "tools", "sampling", "roots", + "auth", ]; // ============================================================================ @@ -506,6 +511,47 @@ mod tests { assert_eq!(merged["enabled"], true); } + #[test] + fn test_merge_preserves_auth_field() { + let existing = json!({ + "url": "https://mcp.example.com", + "auth": "oauth", + "enabled": true + }); + + let new_spec = json!({ + "url": "https://mcp.example.com/updated", + "headers": { "X-Trace": "abc" }, + "enabled": true + }); + + let merged = merge_hermes_spec(&existing, &new_spec); + + assert_eq!(merged["url"], "https://mcp.example.com/updated"); + assert_eq!(merged["headers"]["X-Trace"], "abc"); + assert_eq!( + merged["auth"], "oauth", + "auth declaration must survive CC Switch round-trip" + ); + } + + #[test] + fn test_convert_hermes_strips_auth_on_import() { + let spec = json!({ + "url": "https://mcp.example.com", + "auth": "oauth", + "enabled": true + }); + + let result = convert_from_hermes_format("remote", &spec).unwrap(); + assert_eq!(result["type"], "sse"); + assert_eq!(result["url"], "https://mcp.example.com"); + assert!( + result.get("auth").is_none(), + "auth stays Hermes-specific; stripped from unified format" + ); + } + #[test] fn test_merge_new_server_no_existing_extra_fields() { let existing = json!({ diff --git a/src/components/hermes/HermesHealthBanner.tsx b/src/components/hermes/HermesHealthBanner.tsx index 81723bd2c..244036803 100644 --- a/src/components/hermes/HermesHealthBanner.tsx +++ b/src/components/hermes/HermesHealthBanner.tsx @@ -63,7 +63,7 @@ function getWarningText( case "schema_migrated_v12": return t("hermes.health.schemaMigratedV12", { defaultValue: - "Hermes moved some providers into the 'providers:' dict. CC Switch only manages 'custom_providers:' — edit or remove those entries via Hermes Web UI.", + "Hermes' newer schema moved some providers into the 'providers:' dict. CC Switch only manages 'custom_providers:' — edit or remove those entries via Hermes Web UI.", }); default: return fallback; diff --git a/src/config/hermesProviderPresets.ts b/src/config/hermesProviderPresets.ts index 2acd542f2..59d048d9d 100644 --- a/src/config/hermesProviderPresets.ts +++ b/src/config/hermesProviderPresets.ts @@ -45,7 +45,10 @@ export interface HermesSuggestedDefaults { } /** Hermes custom_provider protocol mode (optional; auto-detected when omitted). */ -export type HermesApiMode = "chat_completions" | "anthropic_messages"; +export type HermesApiMode = + | "chat_completions" + | "anthropic_messages" + | "codex_responses"; /** * Form-facing value used by the API Mode dropdown. @@ -70,6 +73,7 @@ export const hermesApiModes: Array<{ value: "anthropic_messages", labelKey: "hermes.form.apiModeAnthropicMessages", }, + { value: "codex_responses", labelKey: "hermes.form.apiModeCodexResponses" }, ]; export interface HermesProviderPreset { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index cfa420472..491806485 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1653,6 +1653,7 @@ "apiModeAuto": "Auto-detect", "apiModeChatCompletions": "OpenAI Chat Completions", "apiModeAnthropicMessages": "Anthropic Messages", + "apiModeCodexResponses": "Codex Responses (Copilot / OpenCode)", "models": "Models", "addModel": "Add model", "noModels": "No models configured. Switching to this provider won't change the default model.", @@ -1684,7 +1685,7 @@ "modelDefaultNotInProvider": "model.default is not in the selected provider's models list.", "duplicateProviderName": "custom_providers contains duplicate provider names — only one entry will be used.", "duplicateProviderBaseUrl": "custom_providers contains duplicate base_urls — possible accidental copy.", - "schemaMigratedV12": "Hermes moved some providers into the 'providers:' dict. CC Switch only manages 'custom_providers:' — edit or remove those entries via Hermes Web UI." + "schemaMigratedV12": "Hermes' newer schema moved some providers into the 'providers:' dict. CC Switch only manages 'custom_providers:' — edit or remove those entries via Hermes Web UI." }, "memory": { "title": "Memory", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 2c00458f5..1041f3256 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1653,6 +1653,7 @@ "apiModeAuto": "自動検出", "apiModeChatCompletions": "OpenAI Chat Completions", "apiModeAnthropicMessages": "Anthropic Messages", + "apiModeCodexResponses": "Codex Responses(Copilot / OpenCode)", "models": "モデル一覧", "addModel": "モデルを追加", "noModels": "モデル未設定。このプロバイダーに切り替えてもデフォルトモデルは更新されません。", @@ -1684,7 +1685,7 @@ "modelDefaultNotInProvider": "model.default は選択中のプロバイダーのモデル一覧に含まれていません。", "duplicateProviderName": "custom_providers にプロバイダー名の重複があります。1 件のみ有効になります。", "duplicateProviderBaseUrl": "custom_providers に重複した base_url があります。誤ってコピーされた可能性があります。", - "schemaMigratedV12": "Hermes は一部のプロバイダーを 'providers:' dict に移しました。CC Switch は 'custom_providers:' のみを管理します。該当する項目は Hermes Web UI で編集・削除してください。" + "schemaMigratedV12": "Hermes の新しいスキーマでは一部のプロバイダーが 'providers:' dict に移動しました。CC Switch は 'custom_providers:' のみを管理します。該当する項目は Hermes Web UI で編集・削除してください。" }, "memory": { "title": "メモリ", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 0ef934332..ec1965d93 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1653,6 +1653,7 @@ "apiModeAuto": "自动检测", "apiModeChatCompletions": "OpenAI Chat Completions", "apiModeAnthropicMessages": "Anthropic Messages", + "apiModeCodexResponses": "Codex Responses(Copilot / OpenCode)", "models": "模型列表", "addModel": "添加模型", "noModels": "暂无模型配置。切换到此供应商时将不会更新默认模型。", @@ -1684,7 +1685,7 @@ "modelDefaultNotInProvider": "model.default 指向的模型不在所选供应商的模型列表中。", "duplicateProviderName": "custom_providers 中存在重复的供应商名,只会有一条生效。", "duplicateProviderBaseUrl": "custom_providers 中存在重复的 base_url,可能是意外复制。", - "schemaMigratedV12": "Hermes 把部分供应商移到了 'providers:' dict。CC Switch 只管理 'custom_providers:',请在 Hermes Web UI 编辑或删除这些条目。" + "schemaMigratedV12": "Hermes 新版 schema 把部分供应商移到了 'providers:' dict。CC Switch 只管理 'custom_providers:',请在 Hermes Web UI 编辑或删除这些条目。" }, "memory": { "title": "记忆管理", From 3f4739365e47ffba98fb3c04e95864cfbd3c5c39 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 20 Apr 2026 10:34:02 +0800 Subject: [PATCH 041/156] feat(hermes): surface providers: dict entries read-only Hermes v12+ migrated some provider entries from the `custom_providers:` list into a `providers:` dict (keyed by id). CC Switch previously ignored that source entirely, leaving users blind to providers they had configured via Hermes' own Web UI; the only feedback was a generic migration warning in the health banner. `get_providers()` now unions both sources, matching upstream `get_compatible_custom_providers` dedup order (list wins on name collision). Entries coming from the dict carry a `_cc_source = "providers_dict"` marker plus the original `provider_key`, which the UI layer will use to render them read-only. `set_provider` and `remove_provider` now refuse to touch dict-only entries, steering the user to Hermes Web UI. `sanitize_hermes_provider_keys` strips the UI markers on write so they never reach YAML. The `schema_migrated_v12` health warning copy reframes the situation: entries are shown read-only in CC Switch rather than invisible. --- src-tauri/src/hermes_config.rs | 279 ++++++++++++++++++- src/components/hermes/HermesHealthBanner.tsx | 2 +- src/i18n/locales/en.json | 2 +- src/i18n/locales/ja.json | 2 +- src/i18n/locales/zh.json | 2 +- 5 files changed, 270 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs index a7ab7cd8a..a851167d5 100644 --- a/src-tauri/src/hermes_config.rs +++ b/src-tauri/src/hermes_config.rs @@ -432,8 +432,9 @@ fn sanitize_hermes_provider_keys(config: &mut serde_json::Value) { ("contextLength", "context_length"), ]; // Legacy DeepLink emitted `api: "openai-completions"` which is neither a - // Hermes field nor mappable to `api_mode`. - const LEGACY_FIELDS_TO_DROP: &[&str] = &["api"]; + // Hermes field nor mappable to `api_mode`. `_cc_source` / `provider_key` + // are UI-only markers injected on read — they must never reach YAML. + const LEGACY_FIELDS_TO_DROP: &[&str] = &["api", PROVIDER_SOURCE_FIELD, "provider_key"]; let Some(obj) = config.as_object_mut() else { return; @@ -507,18 +508,92 @@ fn hermes_warning(code: &str, message: String, path: &str) -> HermesHealthWarnin } } -/// Get all custom providers as a JSON map keyed by provider name. +/// Marker field injected on provider payloads sourced from Hermes v12+ +/// `providers:` dict. CC Switch treats those as read-only — writes have to +/// go through Hermes' own Web UI to keep its overlay semantics intact. +pub const PROVIDER_SOURCE_FIELD: &str = "_cc_source"; +pub const PROVIDER_SOURCE_CUSTOM_LIST: &str = "custom_providers"; +pub const PROVIDER_SOURCE_DICT: &str = "providers_dict"; + +/// Normalize a single entry from the v12+ `providers:` dict into the same +/// JSON shape that `custom_providers:` list entries take, mirroring upstream +/// `_normalize_custom_provider_entry` (hermes_cli/config.py). /// -/// Reads the `custom_providers:` YAML sequence where each item has a `name` -/// field, and converts it to a map for CC Switch consumption. Each entry's -/// `models` field is converted from the YAML dict shape back to the -/// UI-friendly ordered array shape. +/// Returns `None` when the entry is not a mapping or lacks any usable name. +fn normalize_providers_dict_entry( + key: &str, + entry: &serde_yaml::Value, +) -> Result, AppError> { + if !entry.is_mapping() { + return Ok(None); + } + let mut json_val = yaml_to_json(entry)?; + let Some(obj) = json_val.as_object_mut() else { + return Ok(None); + }; + // Upstream prefers an explicit `name` when present, falling back to the + // dict key. Always round-trip it to a trimmed non-empty string. + let resolved_name = obj + .get("name") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| key.trim().to_string()); + if resolved_name.is_empty() { + return Ok(None); + } + obj.insert("name".to_string(), serde_json::json!(resolved_name)); + obj.insert("provider_key".to_string(), serde_json::json!(key)); + obj.insert( + PROVIDER_SOURCE_FIELD.to_string(), + serde_json::json!(PROVIDER_SOURCE_DICT), + ); + Ok(Some(json_val)) +} + +/// Collect provider entries living under the v12+ `providers:` dict. +fn read_providers_dict_entries( + config: &serde_yaml::Value, +) -> Vec<(String, serde_json::Value)> { + let Some(mapping) = config.get("providers").and_then(|v| v.as_mapping()) else { + return Vec::new(); + }; + let mut out = Vec::with_capacity(mapping.len()); + for (k, v) in mapping { + let Some(key_str) = k.as_str().map(str::trim).filter(|s| !s.is_empty()) else { + continue; + }; + match normalize_providers_dict_entry(key_str, v) { + Ok(Some(entry)) => { + let name = entry + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or(key_str) + .to_string(); + out.push((name, entry)); + } + Ok(None) => { + log::debug!("Skipping Hermes providers['{key_str}']: not a mapping"); + } + Err(e) => { + log::warn!("Failed to normalize Hermes providers['{key_str}']: {e}"); + } + } + } + out +} + +/// Get all providers as a JSON map keyed by provider name. /// -/// Entries that live under Hermes' v12+ `providers:` dict are deliberately -/// not surfaced here — Hermes' own runtime merges both shapes via -/// `get_compatible_custom_providers`, but CC Switch only manages the legacy -/// `custom_providers:` list. The `schema_migrated_v12` health warning flags -/// any non-empty `providers:` dict so the user can reconcile via Hermes Web UI. +/// Unions two on-disk sources, matching upstream `get_compatible_custom_providers`: +/// - `custom_providers:` list entries (writable by CC Switch) +/// - `providers:` dict entries (v12+ schema, surfaced read-only with +/// `_cc_source = "providers_dict"` so the UI can disable edit/delete) +/// +/// When a name appears in both, the list entry wins (upstream dedup order), +/// keeping CC Switch free to edit it. Models are denormalized from the YAML +/// dict shape to the UI-friendly ordered array. pub fn get_providers() -> Result, AppError> { let config = read_hermes_config()?; let mut map = serde_json::Map::new(); @@ -533,6 +608,12 @@ pub fn get_providers() -> Result, App // reveal stale `baseUrl` / `apiKey` fields. sanitize_hermes_provider_keys(&mut json_val); denormalize_provider_models_for_read(&mut json_val); + if let Some(obj) = json_val.as_object_mut() { + obj.insert( + PROVIDER_SOURCE_FIELD.to_string(), + serde_json::json!(PROVIDER_SOURCE_CUSTOM_LIST), + ); + } map.insert(name.to_string(), json_val); } Err(e) => { @@ -543,9 +624,48 @@ pub fn get_providers() -> Result, App } } + for (name, mut entry) in read_providers_dict_entries(&config) { + if map.contains_key(&name) { + continue; // list wins over dict on duplicate names + } + denormalize_provider_models_for_read(&mut entry); + map.insert(name, entry); + } + Ok(map) } +/// True when `name` appears in `providers:` dict but not in `custom_providers:` +/// list — i.e. it is a read-only overlay CC Switch must not touch. +fn is_dict_only_provider(config: &serde_yaml::Value, name: &str) -> bool { + let list_has = config + .get("custom_providers") + .and_then(|v| v.as_sequence()) + .map(|seq| { + seq.iter() + .any(|item| item.get("name").and_then(|n| n.as_str()) == Some(name)) + }) + .unwrap_or(false); + if list_has { + return false; + } + config + .get("providers") + .and_then(|v| v.as_mapping()) + .map(|m| { + m.iter().any(|(k, v)| { + let key_matches = k.as_str() == Some(name); + let name_matches = v + .get("name") + .and_then(|n| n.as_str()) + .map(|s| s == name) + .unwrap_or(false); + (key_matches || name_matches) && v.is_mapping() + }) + }) + .unwrap_or(false) +} + /// Get a single custom provider by name. pub fn get_provider(name: &str) -> Result, AppError> { Ok(get_providers()?.get(name).cloned()) @@ -571,6 +691,11 @@ pub fn set_provider( let _guard = hermes_write_lock().lock()?; let config = read_hermes_config()?; + if is_dict_only_provider(&config, name) { + return Err(AppError::Config(format!( + "Provider '{name}' is managed by Hermes' 'providers:' dict — edit via Hermes Web UI" + ))); + } let mut providers: Vec = config .get("custom_providers") .and_then(|v| v.as_sequence()) @@ -643,6 +768,12 @@ pub fn remove_provider(name: &str) -> Result { let _guard = hermes_write_lock().lock()?; let config = read_hermes_config()?; + if is_dict_only_provider(&config, name) { + return Err(AppError::Config(format!( + "Provider '{name}' is managed by Hermes' 'providers:' dict — remove via Hermes Web UI" + ))); + } + let mut providers: Vec = config .get("custom_providers") .and_then(|v| v.as_sequence()) @@ -864,7 +995,7 @@ fn scan_hermes_health_internal(content: &str) -> Vec { if version >= 12 && providers_dict_populated { warnings.push(hermes_warning( "schema_migrated_v12", - "Hermes' newer schema moved some entries into the 'providers:' dict. CC Switch only manages 'custom_providers:' — edit or remove those entries via Hermes Web UI.".to_string(), + "Hermes' newer schema moved some entries into the 'providers:' dict. They are shown read-only in CC Switch — edit or remove those entries via Hermes Web UI.".to_string(), "providers", )); } @@ -1409,6 +1540,128 @@ custom_providers: }); } + #[test] + #[serial] + fn get_providers_surfaces_providers_dict_as_read_only() { + with_test_home(|| { + let yaml = "\ +_config_version: 19 +custom_providers: + - name: mine + base_url: https://mine.example.com + api_key: sk-mine +providers: + anthropic: + base_url: https://api.anthropic.com + api_key: sk-ant + model: claude-opus-4.6 + ollama-local: + base_url: http://localhost:11434/v1 + request_timeout_seconds: 300 +"; + let config_path = get_hermes_config_path(); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, yaml).unwrap(); + + let providers = get_providers().unwrap(); + assert_eq!(providers.len(), 3); + + let mine = providers.get("mine").unwrap(); + assert_eq!(mine[PROVIDER_SOURCE_FIELD], PROVIDER_SOURCE_CUSTOM_LIST); + + let anthropic = providers.get("anthropic").unwrap(); + assert_eq!(anthropic[PROVIDER_SOURCE_FIELD], PROVIDER_SOURCE_DICT); + assert_eq!(anthropic["provider_key"], "anthropic"); + assert_eq!(anthropic["base_url"], "https://api.anthropic.com"); + + let ollama = providers.get("ollama-local").unwrap(); + assert_eq!(ollama[PROVIDER_SOURCE_FIELD], PROVIDER_SOURCE_DICT); + // Forward-compat fields from the dict pass through untouched + assert_eq!(ollama["request_timeout_seconds"], 300); + }); + } + + #[test] + #[serial] + fn get_providers_list_wins_on_name_collision() { + with_test_home(|| { + let yaml = "\ +_config_version: 19 +custom_providers: + - name: shared + base_url: https://writable.example.com +providers: + shared: + base_url: https://overlay.example.com +"; + let config_path = get_hermes_config_path(); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, yaml).unwrap(); + + let providers = get_providers().unwrap(); + assert_eq!(providers.len(), 1); + let shared = providers.get("shared").unwrap(); + assert_eq!(shared["base_url"], "https://writable.example.com"); + assert_eq!(shared[PROVIDER_SOURCE_FIELD], PROVIDER_SOURCE_CUSTOM_LIST); + }); + } + + #[test] + #[serial] + fn set_provider_rejects_dict_only_entries() { + with_test_home(|| { + let yaml = "\ +_config_version: 19 +providers: + anthropic: + base_url: https://api.anthropic.com + model: claude-opus-4.6 +"; + let config_path = get_hermes_config_path(); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, yaml).unwrap(); + + let update = serde_json::json!({ "base_url": "https://hacked.example.com" }); + let err = set_provider("anthropic", update).unwrap_err(); + assert!( + format!("{err}").contains("providers:"), + "error message should point user at providers dict: {err}" + ); + }); + } + + #[test] + #[serial] + fn remove_provider_rejects_dict_only_entries() { + with_test_home(|| { + let yaml = "\ +_config_version: 19 +providers: + anthropic: + base_url: https://api.anthropic.com +"; + let config_path = get_hermes_config_path(); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, yaml).unwrap(); + + assert!(remove_provider("anthropic").is_err()); + }); + } + + #[test] + fn sanitize_strips_ui_only_markers() { + let mut v = serde_json::json!({ + "base_url": "https://api.example.com", + "_cc_source": "providers_dict", + "provider_key": "anthropic", + }); + sanitize_hermes_provider_keys(&mut v); + let obj = v.as_object().unwrap(); + assert!(obj.get("_cc_source").is_none()); + assert!(obj.get("provider_key").is_none()); + assert!(obj.get("base_url").is_some()); + } + #[test] #[serial] fn get_providers_heals_legacy_camel_case_on_read() { diff --git a/src/components/hermes/HermesHealthBanner.tsx b/src/components/hermes/HermesHealthBanner.tsx index 244036803..ad3750f78 100644 --- a/src/components/hermes/HermesHealthBanner.tsx +++ b/src/components/hermes/HermesHealthBanner.tsx @@ -63,7 +63,7 @@ function getWarningText( case "schema_migrated_v12": return t("hermes.health.schemaMigratedV12", { defaultValue: - "Hermes' newer schema moved some providers into the 'providers:' dict. CC Switch only manages 'custom_providers:' — edit or remove those entries via Hermes Web UI.", + "Hermes' newer schema moved some providers into the 'providers:' dict. They are shown read-only in CC Switch — edit or remove those entries via Hermes Web UI.", }); default: return fallback; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 491806485..ecc8e8b5e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1685,7 +1685,7 @@ "modelDefaultNotInProvider": "model.default is not in the selected provider's models list.", "duplicateProviderName": "custom_providers contains duplicate provider names — only one entry will be used.", "duplicateProviderBaseUrl": "custom_providers contains duplicate base_urls — possible accidental copy.", - "schemaMigratedV12": "Hermes' newer schema moved some providers into the 'providers:' dict. CC Switch only manages 'custom_providers:' — edit or remove those entries via Hermes Web UI." + "schemaMigratedV12": "Hermes' newer schema moved some providers into the 'providers:' dict. They are shown read-only in CC Switch — edit or remove those entries via Hermes Web UI." }, "memory": { "title": "Memory", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 1041f3256..657a12fe6 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1685,7 +1685,7 @@ "modelDefaultNotInProvider": "model.default は選択中のプロバイダーのモデル一覧に含まれていません。", "duplicateProviderName": "custom_providers にプロバイダー名の重複があります。1 件のみ有効になります。", "duplicateProviderBaseUrl": "custom_providers に重複した base_url があります。誤ってコピーされた可能性があります。", - "schemaMigratedV12": "Hermes の新しいスキーマでは一部のプロバイダーが 'providers:' dict に移動しました。CC Switch は 'custom_providers:' のみを管理します。該当する項目は Hermes Web UI で編集・削除してください。" + "schemaMigratedV12": "Hermes の新しいスキーマでは一部のプロバイダーが 'providers:' dict に移動しました。CC Switch では読み取り専用で表示されます。編集・削除は Hermes Web UI から行ってください。" }, "memory": { "title": "メモリ", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index ec1965d93..7017fab4e 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1685,7 +1685,7 @@ "modelDefaultNotInProvider": "model.default 指向的模型不在所选供应商的模型列表中。", "duplicateProviderName": "custom_providers 中存在重复的供应商名,只会有一条生效。", "duplicateProviderBaseUrl": "custom_providers 中存在重复的 base_url,可能是意外复制。", - "schemaMigratedV12": "Hermes 新版 schema 把部分供应商移到了 'providers:' dict。CC Switch 只管理 'custom_providers:',请在 Hermes Web UI 编辑或删除这些条目。" + "schemaMigratedV12": "Hermes 新版 schema 把部分供应商移到了 'providers:' dict。CC Switch 中以只读方式显示,编辑或删除请通过 Hermes Web UI 操作。" }, "memory": { "title": "记忆管理", From abb305a82fd1545d9d19bba7f5fce8cbe05f2123 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 20 Apr 2026 10:36:09 +0800 Subject: [PATCH 042/156] feat(hermes): render providers: dict overlays as read-only cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProviderCard now detects Hermes provider entries sourced from the v12+ `providers:` dict via the `_cc_source` marker that the backend injects, and renders a "Hermes Managed" badge beside the title. ProviderActions receives an `isReadOnly` prop that disables the Edit and Delete buttons (with a tooltip pointing the user at Hermes Web UI) while keeping Switch and Duplicate enabled — switching only touches `model.*`, and duplicate lets users fork the overlay into their own `custom_providers:` list. Three-locale i18n keys `provider.managedByHermes` / `provider.managedByHermesHint` added. --- src/components/providers/ProviderActions.tsx | 21 +++++++++++++++----- src/components/providers/ProviderCard.tsx | 20 +++++++++++++++++++ src/i18n/locales/en.json | 2 ++ src/i18n/locales/ja.json | 2 ++ src/i18n/locales/zh.json | 2 ++ 5 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/components/providers/ProviderActions.tsx b/src/components/providers/ProviderActions.tsx index 50872decf..30dfe3d90 100644 --- a/src/components/providers/ProviderActions.tsx +++ b/src/components/providers/ProviderActions.tsx @@ -38,6 +38,8 @@ interface ProviderActionsProps { isInFailoverQueue?: boolean; onToggleFailover?: (enabled: boolean) => void; isOfficialBlockedByProxy?: boolean; + // Hermes v12+ providers: dict overlay — edit/delete must go through Web UI + isReadOnly?: boolean; // OpenClaw: default model isDefaultModel?: boolean; onSetAsDefault?: () => void; @@ -63,6 +65,7 @@ export function ProviderActions({ isInFailoverQueue = false, onToggleFailover, isOfficialBlockedByProxy = false, + isReadOnly = false, // OpenClaw: default model isDefaultModel = false, onSetAsDefault, @@ -205,7 +208,11 @@ export function ProviderActions({ const buttonState = getMainButtonState(); - const canDelete = isOmo || isAdditiveMode ? true : !isCurrent; + const canDelete = + !isReadOnly && (isOmo || isAdditiveMode ? true : !isCurrent); + const readOnlyHint = t("provider.managedByHermesHint", { + defaultValue: "由 Hermes 管理,请在 Hermes Web UI 中编辑", + }); return (
@@ -255,9 +262,13 @@ export function ProviderActions({ @@ -323,7 +334,7 @@ export function ProviderActions({ size="icon" variant="ghost" onClick={canDelete ? onDelete : undefined} - title={t("common.delete")} + title={isReadOnly ? readOnlyHint : t("common.delete")} className={cn( iconButtonClass, canDelete && "hover:text-red-500 dark:hover:text-red-400", diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index 37d33303a..a54d3da15 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -180,6 +180,12 @@ export function ProviderCard({ const isCopilot = provider.meta?.providerType === PROVIDER_TYPES.GITHUB_COPILOT || provider.meta?.usage_script?.templateType === "github_copilot"; + // Hermes v12+ overlay entries live under the `providers:` dict and are + // read-only here — writes have to go through Hermes Web UI. + const isHermesReadOnly = + appId === "hermes" && + (provider.settingsConfig as Record)?._cc_source === + "providers_dict"; const isCodexOauth = provider.meta?.providerType === PROVIDER_TYPES.CODEX_OAUTH; @@ -336,6 +342,19 @@ export function ProviderCard({ ⭐ )} + + {isHermesReadOnly && ( + + {t("provider.managedByHermes", { + defaultValue: "Hermes Managed", + })} + + )}
{displayUrl && ( @@ -429,6 +448,7 @@ export function ProviderCard({ isTesting={isTesting} isProxyTakeover={isProxyTakeover} isOfficialBlockedByProxy={isOfficialBlockedByProxy} + isReadOnly={isHermesReadOnly} isOmo={isAnyOmo} onSwitch={() => onSwitch(provider)} onEdit={() => onEdit(provider)} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ecc8e8b5e..f3616a1d3 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -141,6 +141,8 @@ "sortUpdateFailed": "Failed to update sort order", "configureUsage": "Configure usage query", "officialPartner": "Official Partner", + "managedByHermes": "Hermes Managed", + "managedByHermesHint": "Defined in Hermes' providers: dict. Edit or remove it via Hermes Web UI.", "openTerminal": "Open Terminal", "terminalOpened": "Terminal opened", "terminalOpenFailed": "Failed to open terminal", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 657a12fe6..1389b7ce6 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -141,6 +141,8 @@ "sortUpdateFailed": "並び順の更新に失敗しました", "configureUsage": "利用状況を設定", "officialPartner": "公式パートナー", + "managedByHermes": "Hermes 管理", + "managedByHermesHint": "Hermes の providers: dict で定義されています。Hermes Web UI で編集または削除してください。", "openTerminal": "ターミナルを開く", "terminalOpened": "ターミナルを開きました", "terminalOpenFailed": "ターミナルを開けませんでした", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 7017fab4e..74ec9e379 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -141,6 +141,8 @@ "sortUpdateFailed": "排序更新失败", "configureUsage": "配置用量查询", "officialPartner": "官方合作伙伴", + "managedByHermes": "Hermes 托管", + "managedByHermesHint": "该条目定义在 Hermes 的 providers: dict,请在 Hermes Web UI 中编辑或删除。", "openTerminal": "打开终端", "terminalOpened": "终端已打开", "terminalOpenFailed": "打开终端失败", From f57edfd69770144cde0fb5647f1a779099156353 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 20 Apr 2026 10:43:23 +0800 Subject: [PATCH 043/156] refactor(hermes): share provider-source marker constants and write guard After /simplify review of the P1-3 second wave, two small cleanups: - Lift the `_cc_source` / `providers_dict` magic strings out of ProviderCard into a shared helper (`isHermesReadOnlyProvider`) and named constants in hermesProviderPresets.ts. Front-end and back-end now document the same marker contract in two mirrored places instead of drifting strings. - Replace the duplicate `is_dict_only_provider` + `format!` branches at the top of `set_provider` / `remove_provider` with a single `ensure_provider_writable(config, name, verb)` guard. Future error copy tweaks only have to happen once. No behaviour change; all 52 hermes_config tests stay green. --- src-tauri/src/hermes_config.rs | 29 +++++++++++++++-------- src/components/providers/ProviderCard.tsx | 5 ++-- src/config/hermesProviderPresets.ts | 23 ++++++++++++++++++ 3 files changed, 44 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs index a851167d5..9565c1988 100644 --- a/src-tauri/src/hermes_config.rs +++ b/src-tauri/src/hermes_config.rs @@ -635,6 +635,23 @@ pub fn get_providers() -> Result, App Ok(map) } +/// Reject writes that would target a dict-only overlay entry. +/// +/// `verb` is inlined into the user-facing error so both "edit" and "remove" +/// callers can share one implementation. +fn ensure_provider_writable( + config: &serde_yaml::Value, + name: &str, + verb: &str, +) -> Result<(), AppError> { + if is_dict_only_provider(config, name) { + return Err(AppError::Config(format!( + "Provider '{name}' is managed by Hermes' 'providers:' dict — {verb} via Hermes Web UI" + ))); + } + Ok(()) +} + /// True when `name` appears in `providers:` dict but not in `custom_providers:` /// list — i.e. it is a read-only overlay CC Switch must not touch. fn is_dict_only_provider(config: &serde_yaml::Value, name: &str) -> bool { @@ -691,11 +708,7 @@ pub fn set_provider( let _guard = hermes_write_lock().lock()?; let config = read_hermes_config()?; - if is_dict_only_provider(&config, name) { - return Err(AppError::Config(format!( - "Provider '{name}' is managed by Hermes' 'providers:' dict — edit via Hermes Web UI" - ))); - } + ensure_provider_writable(&config, name, "edit")?; let mut providers: Vec = config .get("custom_providers") .and_then(|v| v.as_sequence()) @@ -768,11 +781,7 @@ pub fn remove_provider(name: &str) -> Result { let _guard = hermes_write_lock().lock()?; let config = read_hermes_config()?; - if is_dict_only_provider(&config, name) { - return Err(AppError::Config(format!( - "Provider '{name}' is managed by Hermes' 'providers:' dict — remove via Hermes Web UI" - ))); - } + ensure_provider_writable(&config, name, "remove")?; let mut providers: Vec = config .get("custom_providers") diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index a54d3da15..049672715 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -15,6 +15,7 @@ import SubscriptionQuotaFooter from "@/components/SubscriptionQuotaFooter"; import CopilotQuotaFooter from "@/components/CopilotQuotaFooter"; import CodexOauthQuotaFooter from "@/components/CodexOauthQuotaFooter"; import { PROVIDER_TYPES } from "@/config/constants"; +import { isHermesReadOnlyProvider } from "@/config/hermesProviderPresets"; import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge"; import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge"; import { extractCodexBaseUrl } from "@/utils/providerConfigUtils"; @@ -183,9 +184,7 @@ export function ProviderCard({ // Hermes v12+ overlay entries live under the `providers:` dict and are // read-only here — writes have to go through Hermes Web UI. const isHermesReadOnly = - appId === "hermes" && - (provider.settingsConfig as Record)?._cc_source === - "providers_dict"; + appId === "hermes" && isHermesReadOnlyProvider(provider.settingsConfig); const isCodexOauth = provider.meta?.providerType === PROVIDER_TYPES.CODEX_OAUTH; diff --git a/src/config/hermesProviderPresets.ts b/src/config/hermesProviderPresets.ts index 59d048d9d..bcc41e523 100644 --- a/src/config/hermesProviderPresets.ts +++ b/src/config/hermesProviderPresets.ts @@ -5,6 +5,29 @@ import type { ProviderCategory } from "../types"; import type { PresetTheme, TemplateValueConfig } from "./claudeProviderPresets"; +/** + * Marker field and source values that `hermes_config.rs::get_providers` + * injects onto each settings payload. Kept in sync with the Rust constants + * `PROVIDER_SOURCE_FIELD` / `PROVIDER_SOURCE_CUSTOM_LIST` / `PROVIDER_SOURCE_DICT`. + */ +export const HERMES_PROVIDER_SOURCE_FIELD = "_cc_source"; +export const HERMES_PROVIDER_SOURCE_CUSTOM_LIST = "custom_providers"; +export const HERMES_PROVIDER_SOURCE_DICT = "providers_dict"; + +/** + * True when the provider was sourced from Hermes' v12+ `providers:` dict — + * CC Switch renders those read-only and routes edits to Hermes Web UI. + */ +export function isHermesReadOnlyProvider(settingsConfig: unknown): boolean { + if (!settingsConfig || typeof settingsConfig !== "object") { + return false; + } + const marker = (settingsConfig as Record)[ + HERMES_PROVIDER_SOURCE_FIELD + ]; + return marker === HERMES_PROVIDER_SOURCE_DICT; +} + /** * A model entry under a Hermes custom_provider. * From a9461ad52f990dea598b5f28421c31f57f94b387 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 20 Apr 2026 11:12:38 +0800 Subject: [PATCH 044/156] =?UTF-8?q?docs(readme):=20update=20SiliconFlow=20?= =?UTF-8?q?signup=20bonus=20to=20=C2=A516?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync the bonus credit amount across en/zh/ja README files to reflect the current SiliconFlow sponsor offer. --- README.md | 2 +- README_JA.md | 2 +- README_ZH.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 87e44d47f..6f6b71471 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ MiniMax-M2.7 is a next-generation large language model designed for autonomous e SiliconFlow -Thanks to SiliconFlow for sponsoring this project! SiliconFlow is a high-performance AI infrastructure and model API platform, providing fast and reliable access to language, speech, image, and video models in one place. With pay-as-you-go billing, broad multimodal model support, high-speed inference, and enterprise-grade stability, SiliconFlow helps developers and teams build and scale AI applications more efficiently. Register via this link and complete real-name verification to receive ¥20 in bonus credit, usable across models on the platform. SiliconFlow is also now compatible with OpenClaw, allowing users to connect a SiliconFlow API key and call major AI models for free. +Thanks to SiliconFlow for sponsoring this project! SiliconFlow is a high-performance AI infrastructure and model API platform, providing fast and reliable access to language, speech, image, and video models in one place. With pay-as-you-go billing, broad multimodal model support, high-speed inference, and enterprise-grade stability, SiliconFlow helps developers and teams build and scale AI applications more efficiently. Register via this link and complete real-name verification to receive ¥16 in bonus credit, usable across models on the platform. SiliconFlow is also now compatible with OpenClaw, allowing users to connect a SiliconFlow API key and call major AI models for free. diff --git a/README_JA.md b/README_JA.md index 0befabdba..2f7bab38c 100644 --- a/README_JA.md +++ b/README_JA.md @@ -48,7 +48,7 @@ MiniMax-M2.7 は、自律的進化と実世界の生産性向上のために設 SiliconFlow -SiliconFlow のご支援に感謝します!SiliconFlow は高性能 AI インフラストラクチャおよびモデル API プラットフォームで、言語・音声・画像・動画モデルへの高速かつ信頼性の高いアクセスをワンストップで提供します。従量課金制、豊富なマルチモーダルモデル対応、高速推論、エンタープライズグレードの安定性を備え、開発者やチームがより効率的に AI アプリケーションを構築・拡張できるようサポートします。このリンクから登録し、本人確認を完了すると、プラットフォーム内の全モデルで利用可能な ¥20 のボーナスクレジットが付与されます。SiliconFlow は OpenClaw にも対応しており、SiliconFlow の API キーを接続することで主要な AI モデルを無料で呼び出すことができます。 +SiliconFlow のご支援に感謝します!SiliconFlow は高性能 AI インフラストラクチャおよびモデル API プラットフォームで、言語・音声・画像・動画モデルへの高速かつ信頼性の高いアクセスをワンストップで提供します。従量課金制、豊富なマルチモーダルモデル対応、高速推論、エンタープライズグレードの安定性を備え、開発者やチームがより効率的に AI アプリケーションを構築・拡張できるようサポートします。このリンクから登録し、本人確認を完了すると、プラットフォーム内の全モデルで利用可能な ¥16 のボーナスクレジットが付与されます。SiliconFlow は OpenClaw にも対応しており、SiliconFlow の API キーを接続することで主要な AI モデルを無料で呼び出すことができます。 diff --git a/README_ZH.md b/README_ZH.md index 8b2206a10..1c3411258 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -48,7 +48,7 @@ MiniMax M2.7 是 MiniMax 首个深度参与自我迭代的模型,可自主构 SiliconFlow -感谢硅基流动赞助了本项目!硅基流动是一个高性能 AI 基础设施与模型 API 平台,一站式提供语言、语音、图像、视频等多模态模型的快速、可靠访问。平台支持按量计费、丰富的多模态模型选择、高速推理和企业级稳定性,帮助开发者和团队更高效地构建和扩展 AI 应用。通过此链接注册并完成实名认证,即可获得 ¥20 奖励金,可在平台内跨模型使用。硅基流动现已兼容 OpenClaw,用户可接入硅基流动 API Key 免费调用主流 AI 模型。 +感谢硅基流动赞助了本项目!硅基流动是一个高性能 AI 基础设施与模型 API 平台,一站式提供语言、语音、图像、视频等多模态模型的快速、可靠访问。平台支持按量计费、丰富的多模态模型选择、高速推理和企业级稳定性,帮助开发者和团队更高效地构建和扩展 AI 应用。通过此链接注册并完成实名认证,即可获得 ¥16 奖励金,可在平台内跨模型使用。硅基流动现已兼容 OpenClaw,用户可接入硅基流动 API Key 免费调用主流 AI 模型。 From 21a1518f9f7dbf001b734739f2083e429464b9b0 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 20 Apr 2026 11:40:59 +0800 Subject: [PATCH 045/156] refactor(hermes): drop "Auto" api_mode and require explicit protocol Hermes' built-in api_mode detection only matches a handful of official endpoints (api.openai.com, api.anthropic.com, api.x.ai, AWS Bedrock); third-party / proxy endpoints silently fall back to chat_completions, which causes opaque 401/404s on Anthropic-protocol or Codex-Responses providers. The "Auto" option was misleading for the common third-party case. - Drop the "Auto" option from the API Mode dropdown; remove the HermesApiModeChoice sentinel type so writes always emit api_mode. - Default new providers and legacy entries lacking api_mode to chat_completions (only persisted on user save). - Deeplink imports now write api_mode: chat_completions explicitly instead of relying on URL heuristics; test renamed accordingly. - Rename the "Codex Responses (Copilot / OpenCode)" label to "OpenAI Responses" to match OpenAI's /v1/responses naming. --- src-tauri/src/deeplink/provider.rs | 19 +++++--- .../providers/forms/HermesFormFields.tsx | 11 +++-- .../forms/hooks/useHermesFormState.ts | 43 ++++++++----------- src/config/hermesProviderPresets.ts | 20 +++------ src/i18n/locales/en.json | 5 +-- src/i18n/locales/ja.json | 5 +-- src/i18n/locales/zh.json | 5 +-- 7 files changed, 48 insertions(+), 60 deletions(-) diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 32fa77b58..2ae90a013 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -428,8 +428,11 @@ fn build_additive_app_settings(request: &DeepLinkImportRequest) -> serde_json::V /// Emitting camelCase here — as the OpenClaw path does — would poison the /// YAML with unknown root fields the Hermes runtime ignores. /// -/// `api_mode` is deliberately omitted so Hermes auto-detects the protocol -/// from the endpoint; callers can still override via the UI later. +/// `api_mode` is always written explicitly. Deeplinks have no field to carry +/// it, so we default to `chat_completions` (the most widely compatible +/// protocol) and let the user adjust via the UI after import. We never rely +/// on Hermes' built-in URL heuristics, which only recognize a handful of +/// official endpoints. fn build_hermes_settings(request: &DeepLinkImportRequest) -> serde_json::Value { let endpoint = get_primary_endpoint(request); @@ -447,6 +450,8 @@ fn build_hermes_settings(request: &DeepLinkImportRequest) -> serde_json::Value { config.insert("api_key".to_string(), json!(api_key)); } + config.insert("api_mode".to_string(), json!("chat_completions")); + if let Some(model) = &request.model { config.insert( "models".to_string(), @@ -784,11 +789,12 @@ mod tests { } #[test] - fn build_hermes_settings_omits_api_mode_for_auto_detect() { + fn build_hermes_settings_writes_default_api_mode() { let settings = build_hermes_settings(&hermes_request()); - assert!( - settings.as_object().unwrap().get("api_mode").is_none(), - "api_mode must be omitted so Hermes auto-detects" + assert_eq!( + settings.as_object().unwrap().get("api_mode").unwrap(), + "chat_completions", + "api_mode must be written explicitly so Hermes never falls back to URL auto-detection" ); } @@ -810,6 +816,7 @@ mod tests { assert!(obj.get("base_url").is_none()); assert!(obj.get("api_key").is_none()); assert!(obj.get("models").is_none()); + assert_eq!(obj.get("api_mode").unwrap(), "chat_completions"); } #[test] diff --git a/src/components/providers/forms/HermesFormFields.tsx b/src/components/providers/forms/HermesFormFields.tsx index e74b5365b..598a2e0d7 100644 --- a/src/components/providers/forms/HermesFormFields.tsx +++ b/src/components/providers/forms/HermesFormFields.tsx @@ -40,7 +40,7 @@ import { } from "@/lib/api/model-fetch"; import { hermesApiModes, - type HermesApiModeChoice, + type HermesApiMode, type HermesModel, } from "@/config/hermesProviderPresets"; import type { ProviderCategory } from "@/types"; @@ -55,8 +55,8 @@ interface HermesFormFieldsProps { websiteUrl: string; isPartner?: boolean; partnerPromotionKey?: string; - apiMode: HermesApiModeChoice; - onApiModeChange: (mode: HermesApiModeChoice) => void; + apiMode: HermesApiMode; + onApiModeChange: (mode: HermesApiMode) => void; models: HermesModel[]; onModelsChange: (models: HermesModel[]) => void; } @@ -181,7 +181,7 @@ export function HermesFormFields({

{t("hermes.form.apiModeHint", { - defaultValue: - "供应商 API 协议。Auto 表示让 Hermes 按端点自动判断。", + defaultValue: "供应商 API 协议。请根据端点选择正确的协议。", })}

diff --git a/src/components/providers/forms/hooks/useHermesFormState.ts b/src/components/providers/forms/hooks/useHermesFormState.ts index d788e367b..44c5450eb 100644 --- a/src/components/providers/forms/hooks/useHermesFormState.ts +++ b/src/components/providers/forms/hooks/useHermesFormState.ts @@ -1,10 +1,11 @@ import { useState, useCallback, useMemo } from "react"; import type { AppId } from "@/lib/api"; import { useProvidersQuery } from "@/lib/query/queries"; -import type { - HermesApiModeChoice, - HermesModel, - HermesProviderSettingsConfig, +import { + HERMES_DEFAULT_API_MODE, + type HermesApiMode, + type HermesModel, + type HermesProviderSettingsConfig, } from "@/config/hermesProviderPresets"; interface UseHermesFormStateParams { @@ -34,12 +35,12 @@ export interface HermesFormState { setHermesProviderKey: (key: string) => void; hermesBaseUrl: string; hermesApiKey: string; - hermesApiMode: HermesApiModeChoice; + hermesApiMode: HermesApiMode; hermesModels: HermesModel[]; existingHermesKeys: string[]; handleHermesBaseUrlChange: (baseUrl: string) => void; handleHermesApiKeyChange: (apiKey: string) => void; - handleHermesApiModeChange: (mode: HermesApiModeChoice) => void; + handleHermesApiModeChange: (mode: HermesApiMode) => void; handleHermesModelsChange: (models: HermesModel[]) => void; resetHermesState: (config?: Partial) => void; } @@ -92,17 +93,15 @@ export function useHermesFormState({ return parseHermesField(initialData, "api_key", ""); }); - const [hermesApiMode, setHermesApiMode] = useState( - () => { - if (appId !== "hermes") return "auto"; - const stored = parseHermesField( - initialData, - "api_mode", - "", - ); - return stored || "auto"; - }, - ); + const [hermesApiMode, setHermesApiMode] = useState(() => { + if (appId !== "hermes") return HERMES_DEFAULT_API_MODE; + const stored = parseHermesField( + initialData, + "api_mode", + "", + ); + return stored || HERMES_DEFAULT_API_MODE; + }); const [hermesModels, setHermesModels] = useState(() => { if (appId !== "hermes") return []; @@ -143,14 +142,10 @@ export function useHermesFormState({ ); const handleHermesApiModeChange = useCallback( - (mode: HermesApiModeChoice) => { + (mode: HermesApiMode) => { setHermesApiMode(mode); updateHermesConfig((config) => { - if (mode === "auto") { - delete config.api_mode; - } else { - config.api_mode = mode; - } + config.api_mode = mode; }); }, [updateHermesConfig], @@ -175,7 +170,7 @@ export function useHermesFormState({ setHermesProviderKey(""); setHermesBaseUrl(config?.base_url || ""); setHermesApiKey(config?.api_key || ""); - setHermesApiMode(config?.api_mode ?? "auto"); + setHermesApiMode(config?.api_mode ?? HERMES_DEFAULT_API_MODE); setHermesModels(config?.models ?? []); }, [], diff --git a/src/config/hermesProviderPresets.ts b/src/config/hermesProviderPresets.ts index bcc41e523..de5dbabbd 100644 --- a/src/config/hermesProviderPresets.ts +++ b/src/config/hermesProviderPresets.ts @@ -67,30 +67,20 @@ export interface HermesSuggestedDefaults { }; } -/** Hermes custom_provider protocol mode (optional; auto-detected when omitted). */ +/** Hermes custom_provider protocol mode. Always written explicitly. */ export type HermesApiMode = | "chat_completions" | "anthropic_messages" | "codex_responses"; -/** - * Form-facing value used by the API Mode dropdown. - * - * `auto` is the UI-only sentinel for "omit `api_mode` and let Hermes detect the - * protocol from the endpoint". When serialized to `settings_config`, `auto` - * becomes `undefined` so the YAML doesn't include `api_mode` at all. - */ -export type HermesApiModeChoice = "auto" | HermesApiMode; +/** Default mode used when a provider has no stored value yet. */ +export const HERMES_DEFAULT_API_MODE: HermesApiMode = "chat_completions"; -/** - * Dropdown options for the API Mode selector. `labelKey` is looked up in i18n; - * `value` of "auto" means "don't write `api_mode` to the config". - */ +/** Dropdown options for the API Mode selector. `labelKey` is looked up in i18n. */ export const hermesApiModes: Array<{ - value: HermesApiModeChoice; + value: HermesApiMode; labelKey: string; }> = [ - { value: "auto", labelKey: "hermes.form.apiModeAuto" }, { value: "chat_completions", labelKey: "hermes.form.apiModeChatCompletions" }, { value: "anthropic_messages", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f3616a1d3..144b7eb16 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1651,11 +1651,10 @@ "providerKeyInvalid": "Provider key can only contain lowercase letters, numbers, and hyphens", "providerKeyDuplicate": "This provider key already exists", "apiMode": "API Mode", - "apiModeHint": "Provider API protocol. Auto lets Hermes detect it from the endpoint.", - "apiModeAuto": "Auto-detect", + "apiModeHint": "Provider API protocol. Choose the format that matches your endpoint.", "apiModeChatCompletions": "OpenAI Chat Completions", "apiModeAnthropicMessages": "Anthropic Messages", - "apiModeCodexResponses": "Codex Responses (Copilot / OpenCode)", + "apiModeCodexResponses": "OpenAI Responses", "models": "Models", "addModel": "Add model", "noModels": "No models configured. Switching to this provider won't change the default model.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 1389b7ce6..3dc46b8b6 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1651,11 +1651,10 @@ "providerKeyInvalid": "プロバイダーキーは小文字、数字、ハイフンのみ使用できます", "providerKeyDuplicate": "このプロバイダーキーは既に存在します", "apiMode": "API モード", - "apiModeHint": "プロバイダーの API プロトコル。Auto はエンドポイントから自動判定します。", - "apiModeAuto": "自動検出", + "apiModeHint": "プロバイダーの API プロトコル。エンドポイントに合わせて正しい形式を選択してください。", "apiModeChatCompletions": "OpenAI Chat Completions", "apiModeAnthropicMessages": "Anthropic Messages", - "apiModeCodexResponses": "Codex Responses(Copilot / OpenCode)", + "apiModeCodexResponses": "OpenAI Responses", "models": "モデル一覧", "addModel": "モデルを追加", "noModels": "モデル未設定。このプロバイダーに切り替えてもデフォルトモデルは更新されません。", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 74ec9e379..0df36ad9e 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1651,11 +1651,10 @@ "providerKeyInvalid": "供应商标识只能包含小写字母、数字和连字符", "providerKeyDuplicate": "该供应商标识已存在", "apiMode": "API 模式", - "apiModeHint": "供应商 API 协议。Auto 表示让 Hermes 按端点自动判断。", - "apiModeAuto": "自动检测", + "apiModeHint": "供应商 API 协议。请根据端点选择正确的协议。", "apiModeChatCompletions": "OpenAI Chat Completions", "apiModeAnthropicMessages": "Anthropic Messages", - "apiModeCodexResponses": "Codex Responses(Copilot / OpenCode)", + "apiModeCodexResponses": "OpenAI Responses", "models": "模型列表", "addModel": "添加模型", "noModels": "暂无模型配置。切换到此供应商时将不会更新默认模型。", From c8d91298c73e1d4f4107fab7ed130d95e0da230b Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 20 Apr 2026 12:18:57 +0800 Subject: [PATCH 046/156] fix(providers): drop legacy ANTHROPIC_REASONING_MODEL from Claude quick-set The model-mapping quick-set button referenced an undefined `reasoningModel` prop and wrote `ANTHROPIC_REASONING_MODEL`, which the backend explicitly marks as deprecated legacy (see services/provider/mod.rs and services/proxy.rs). Remove all three references so typecheck passes and the button matches the provider model schema. --- src/components/providers/forms/ClaudeFormFields.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index 3df63410a..aafb2e272 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -582,13 +582,11 @@ export function ClaudeFormFields({ onClick={() => { const value = claudeModel || - reasoningModel || defaultHaikuModel || defaultSonnetModel || defaultOpusModel; if (value) { onModelChange("ANTHROPIC_MODEL", value); - onModelChange("ANTHROPIC_REASONING_MODEL", value); onModelChange("ANTHROPIC_DEFAULT_HAIKU_MODEL", value); onModelChange("ANTHROPIC_DEFAULT_SONNET_MODEL", value); onModelChange("ANTHROPIC_DEFAULT_OPUS_MODEL", value); @@ -601,7 +599,6 @@ export function ClaudeFormFields({ }} disabled={ !claudeModel && - !reasoningModel && !defaultHaikuModel && !defaultSonnetModel && !defaultOpusModel From 08727528aa25b97755fd7701fb56b535404ff236 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 20 Apr 2026 12:19:08 +0800 Subject: [PATCH 047/156] test: sync stale fixtures and isolate openclaw env tests Three unrelated test failures surfaced after rebase: - McpFormModal expected the apps boolean set without `hermes`; Hermes MCP support is now wired, so the fixture must include `hermes: false`. - therouter Gemini preset was bumped to `gemini-3.1-pro` in a later commit; update the assertions to match current config. - openclaw_config tests mutate process-level `CC_SWITCH_TEST_HOME` and `HOME` inside a module-local Mutex, but hermes_config does the same under its own separate Mutex. Running both modules in parallel let the env races corrupt hermes_config's `with_test_home`. Tag the four env-mutating openclaw tests with `#[serial]` so they serialize across modules via serial_test's process-wide default key. --- src-tauri/src/openclaw_config.rs | 5 +++++ tests/components/McpFormModal.test.tsx | 1 + tests/config/therouterProviderPresets.test.ts | 4 ++-- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/openclaw_config.rs b/src-tauri/src/openclaw_config.rs index 54ef078ce..976fcd1e5 100644 --- a/src-tauri/src/openclaw_config.rs +++ b/src-tauri/src/openclaw_config.rs @@ -914,6 +914,7 @@ pub fn set_tools_config(tools: &OpenClawToolsConfig) -> Result std::sync::MutexGuard<'static, ()> { @@ -966,6 +967,7 @@ mod tests { } #[test] + #[serial] fn default_model_write_preserves_top_level_comments() { let source = r#"{ // top-level comment @@ -994,6 +996,7 @@ mod tests { } #[test] + #[serial] fn default_model_noop_write_skips_backup() { let source = r#"{ models: { @@ -1028,6 +1031,7 @@ mod tests { } #[test] + #[serial] fn save_detects_external_conflict() { let source = r#"{ models: { @@ -1050,6 +1054,7 @@ mod tests { } #[test] + #[serial] fn remove_last_provider_writes_empty_providers_without_panic() { let source = r#"{ models: { diff --git a/tests/components/McpFormModal.test.tsx b/tests/components/McpFormModal.test.tsx index 8816ceaf9..535b99557 100644 --- a/tests/components/McpFormModal.test.tsx +++ b/tests/components/McpFormModal.test.tsx @@ -434,6 +434,7 @@ type = "stdio" gemini: false, opencode: false, openclaw: false, + hermes: false, }); expect(onSave).toHaveBeenCalledTimes(1); expect(toastErrorMock).not.toHaveBeenCalled(); diff --git a/tests/config/therouterProviderPresets.test.ts b/tests/config/therouterProviderPresets.test.ts index 4c00e1798..77a180e13 100644 --- a/tests/config/therouterProviderPresets.test.ts +++ b/tests/config/therouterProviderPresets.test.ts @@ -57,10 +57,10 @@ describe("TheRouter provider presets", () => { expect(preset?.category).toBe("aggregator"); expect(preset?.endpointCandidates).toEqual(["https://api.therouter.ai"]); expect(preset?.baseURL).toBe("https://api.therouter.ai"); - expect(preset?.model).toBe("gemini-2.5-pro"); + expect(preset?.model).toBe("gemini-3.1-pro"); const env = (preset?.settingsConfig as { env: Record }).env; expect(env.GOOGLE_GEMINI_BASE_URL).toBe("https://api.therouter.ai"); - expect(env.GEMINI_MODEL).toBe("gemini-2.5-pro"); + expect(env.GEMINI_MODEL).toBe("gemini-3.1-pro"); }); }); From 111ddf8d73e967761d93e0dc4d3b201bda579ac2 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 20 Apr 2026 12:19:13 +0800 Subject: [PATCH 048/156] style: apply prettier and rustfmt No behavior changes. Brings three files back in line with the project formatters (CI runs `pnpm format:check` and `cargo fmt --check`). --- src-tauri/src/hermes_config.rs | 4 +--- src/components/hermes/HermesMemoryPanel.tsx | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs index 9565c1988..4e8b5bd7c 100644 --- a/src-tauri/src/hermes_config.rs +++ b/src-tauri/src/hermes_config.rs @@ -553,9 +553,7 @@ fn normalize_providers_dict_entry( } /// Collect provider entries living under the v12+ `providers:` dict. -fn read_providers_dict_entries( - config: &serde_yaml::Value, -) -> Vec<(String, serde_json::Value)> { +fn read_providers_dict_entries(config: &serde_yaml::Value) -> Vec<(String, serde_json::Value)> { let Some(mapping) = config.get("providers").and_then(|v| v.as_mapping()) else { return Vec::new(); }; diff --git a/src/components/hermes/HermesMemoryPanel.tsx b/src/components/hermes/HermesMemoryPanel.tsx index ad84c1c33..ad707b085 100644 --- a/src/components/hermes/HermesMemoryPanel.tsx +++ b/src/components/hermes/HermesMemoryPanel.tsx @@ -63,9 +63,7 @@ const MemoryTabPane: React.FC = ({
From 0be75668ccc2da2dbac1815fb906d755589c3f44 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 20 Apr 2026 22:13:54 +0800 Subject: [PATCH 049/156] feat(hermes): align provider schema with Hermes Agent 0.10.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes 0.10.0 tightened custom_providers validation (commit 2cdae233): invalid base_urls are rejected, unknown fields produce warnings, and new fields (rate_limit_delay, bedrock_converse, key_env) landed. - Add bedrock_converse to the api_mode selector (and i18n labels) - Expose rate_limit_delay in a provider-level advanced panel - Validate base_url client-side (URL shape, template-token friendly) - Drop per-model max_tokens — not in _VALID_CUSTOM_PROVIDER_FIELDS - Round-trip test asserts set_provider preserves rate_limit_delay / key_env / any unknown forward-compat field --- src-tauri/src/lib.rs | 2 +- src-tauri/tests/hermes_roundtrip.rs | 124 +++++++++ .../providers/forms/HermesFormFields.tsx | 246 +++++++++++++----- .../providers/forms/ProviderForm.tsx | 4 + .../forms/hooks/useHermesFormState.ts | 32 +++ src/config/hermesProviderPresets.ts | 26 +- src/i18n/locales/en.json | 10 +- src/i18n/locales/ja.json | 10 +- src/i18n/locales/zh.json | 10 +- 9 files changed, 375 insertions(+), 89 deletions(-) create mode 100644 src-tauri/tests/hermes_roundtrip.rs diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b14afee60..ebaa2f8c1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,7 +11,7 @@ mod deeplink; mod error; mod gemini_config; mod gemini_mcp; -mod hermes_config; +pub mod hermes_config; mod init_status; mod lightweight; #[cfg(target_os = "linux")] diff --git a/src-tauri/tests/hermes_roundtrip.rs b/src-tauri/tests/hermes_roundtrip.rs new file mode 100644 index 000000000..9d4fe1d05 --- /dev/null +++ b/src-tauri/tests/hermes_roundtrip.rs @@ -0,0 +1,124 @@ +mod support; + +use cc_switch_lib::{hermes_config, update_settings, AppSettings}; + +/// 读取并回写 Hermes provider 时,Hermes v12+ 新增或未来才会出现的字段 +/// (例如 `rate_limit_delay`、`key_env`)必须透传,不能因为 UI 不感知就静默丢弃。 +/// 否则用户在 Hermes Web UI 配置的高级字段会在 CC Switch 编辑后消失。 +fn with_temp_hermes_dir(f: F) { + let guard = support::test_mutex().lock().expect("test mutex poisoned"); + let home = support::ensure_test_home(); + support::reset_test_fs(); + + let hermes_dir = home.join(".hermes-roundtrip"); + let _ = std::fs::remove_dir_all(&hermes_dir); + std::fs::create_dir_all(&hermes_dir).expect("create temp hermes dir"); + + update_settings(AppSettings { + hermes_config_dir: Some(hermes_dir.to_string_lossy().into_owned()), + ..AppSettings::default() + }) + .expect("set hermes_config_dir override"); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&hermes_dir))); + + // Always restore settings and drop fixture dir, even on test failure. + let _ = update_settings(AppSettings::default()); + let _ = std::fs::remove_dir_all(&hermes_dir); + drop(guard); + + if let Err(err) = result { + std::panic::resume_unwind(err); + } +} + +#[test] +fn set_provider_preserves_unknown_and_future_fields() { + with_temp_hermes_dir(|dir| { + let yaml = r#"custom_providers: + - name: myhost + base_url: https://api.example.com/v1 + api_key: sk-old + api_mode: chat_completions + rate_limit_delay: 0.5 + key_env: MY_API_KEY + foo_bar: keep-me-around + models: + gpt-4: + context_length: 8192 +"#; + let config_path = dir.join("config.yaml"); + std::fs::write(&config_path, yaml).expect("seed config.yaml"); + + // Simulate the UI sending back only the fields it knows about. + let patch = serde_json::json!({ + "name": "myhost", + "base_url": "https://api.example.com/v1", + "api_key": "sk-new", + "api_mode": "chat_completions", + "models": [ + { "id": "gpt-4", "context_length": 8192 } + ] + }); + + hermes_config::set_provider("myhost", patch).expect("set_provider"); + + let written = std::fs::read_to_string(&config_path).expect("read written config"); + + assert!( + written.contains("rate_limit_delay"), + "rate_limit_delay stripped:\n{written}" + ); + assert!( + written.contains("key_env"), + "key_env key stripped:\n{written}" + ); + assert!( + written.contains("MY_API_KEY"), + "key_env value stripped:\n{written}" + ); + assert!( + written.contains("foo_bar"), + "unknown forward-compat field stripped:\n{written}" + ); + assert!( + written.contains("sk-new"), + "api_key was not updated to sk-new:\n{written}" + ); + assert!( + !written.contains("sk-old"), + "old api_key still present:\n{written}" + ); + }); +} + +#[test] +fn get_providers_surfaces_rate_limit_delay_and_key_env() { + with_temp_hermes_dir(|dir| { + let yaml = r#"custom_providers: + - name: myhost + base_url: https://api.example.com/v1 + api_key: sk-xxx + api_mode: chat_completions + rate_limit_delay: 2.5 + key_env: FOO_KEY + models: + m1: {} +"#; + std::fs::write(dir.join("config.yaml"), yaml).expect("seed config.yaml"); + + let providers = hermes_config::get_providers().expect("get_providers"); + let entry = providers.get("myhost").expect("myhost missing"); + + assert_eq!( + entry.get("rate_limit_delay").and_then(|v| v.as_f64()), + Some(2.5), + "rate_limit_delay not surfaced to DAO payload" + ); + assert_eq!( + entry.get("key_env").and_then(|v| v.as_str()), + Some("FOO_KEY"), + "key_env not surfaced to DAO payload" + ); + }); +} diff --git a/src/components/providers/forms/HermesFormFields.tsx b/src/components/providers/forms/HermesFormFields.tsx index 598a2e0d7..a33178f67 100644 --- a/src/components/providers/forms/HermesFormFields.tsx +++ b/src/components/providers/forms/HermesFormFields.tsx @@ -1,5 +1,12 @@ import { useTranslation } from "react-i18next"; -import { useState, useRef, useCallback, useMemo } from "react"; +import { + useState, + useRef, + useCallback, + useMemo, + useEffect, + type ReactNode, +} from "react"; import { FormLabel } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -59,6 +66,77 @@ interface HermesFormFieldsProps { onApiModeChange: (mode: HermesApiMode) => void; models: HermesModel[]; onModelsChange: (models: HermesModel[]) => void; + rateLimitDelay: number | undefined; + onRateLimitDelayChange: (delay: number | undefined) => void; +} + +type BaseUrlErrorCode = "empty" | "invalid" | "scheme"; + +const BASE_URL_ERROR_I18N_KEY: Record = { + empty: "hermes.form.baseUrlRequired", + scheme: "hermes.form.baseUrlScheme", + invalid: "hermes.form.baseUrlInvalid", +}; + +const TEMPLATE_TOKEN_RE = /\$\{[^}]+\}/g; + +/** + * Hermes 0.10.0+ rejects `base_url` entries that don't parse as proper URLs + * (commit 2cdae233). Validate client-side so the error surfaces before the + * request ever reaches Hermes' startup. + */ +function validateBaseUrl(raw: string): BaseUrlErrorCode | null { + const trimmed = raw.trim(); + if (!trimmed) return "empty"; + // Presets like KAT-Coder embed `${VAR}` tokens — swap them before URL parse. + const candidate = trimmed.replace(TEMPLATE_TOKEN_RE, "placeholder"); + let u: URL; + try { + u = new URL(candidate); + } catch { + return "invalid"; + } + if (!u.protocol.startsWith("http")) return "scheme"; + if (!u.hostname) return "invalid"; + return null; +} + +interface AdvancedSectionProps { + open: boolean; + onOpenChange: (next: boolean) => void; + labelKey: string; + children: ReactNode; +} + +function AdvancedSection({ + open, + onOpenChange, + labelKey, + children, +}: AdvancedSectionProps) { + const { t } = useTranslation(); + return ( + + + + + + {children} + + + ); } export function HermesFormFields({ @@ -75,6 +153,8 @@ export function HermesFormFields({ onApiModeChange, models, onModelsChange, + rateLimitDelay, + onRateLimitDelayChange, }: HermesFormFieldsProps) { const { t } = useTranslation(); const [expandedModels, setExpandedModels] = useState>( @@ -82,6 +162,24 @@ export function HermesFormFields({ ); const [fetchedModels, setFetchedModels] = useState([]); const [isFetchingModels, setIsFetchingModels] = useState(false); + const [baseUrlTouched, setBaseUrlTouched] = useState(false); + const [providerAdvancedOpen, setProviderAdvancedOpen] = useState( + rateLimitDelay !== undefined, + ); + + // Auto-expand when a preset switch brings in a value so the user sees it; + // don't force-collapse on clear, to avoid yanking the panel shut mid-edit. + useEffect(() => { + if (rateLimitDelay !== undefined) { + setProviderAdvancedOpen(true); + } + }, [rateLimitDelay]); + + const baseUrlErrorCode = useMemo(() => validateBaseUrl(baseUrl), [baseUrl]); + const showBaseUrlError = baseUrlTouched && baseUrlErrorCode !== null; + const baseUrlErrorMessage = baseUrlErrorCode + ? t(BASE_URL_ERROR_I18N_KEY[baseUrlErrorCode]) + : ""; // Stable list keys: a manual ref rather than UUID-in-state so adding/removing // rows doesn't re-mount unrelated inputs (would drop focus mid-typing). @@ -120,7 +218,7 @@ export function HermesFormFields({ modelKeysRef.current.push(crypto.randomUUID()); onModelsChange([ ...models, - { id: "", name: "", context_length: undefined, max_tokens: undefined }, + { id: "", name: "", context_length: undefined }, ]); }; @@ -209,13 +307,24 @@ export function HermesFormFields({ id="hermes-baseurl" value={baseUrl} onChange={(e) => onBaseUrlChange(e.target.value)} + onBlur={() => setBaseUrlTouched(true)} placeholder="https://api.example.com/v1" + aria-invalid={showBaseUrlError} + className={ + showBaseUrlError + ? "border-destructive focus-visible:ring-destructive" + : undefined + } /> -

- {t("hermes.form.baseUrlHint", { - defaultValue: "供应商的 API 端点地址。", - })} -

+ {showBaseUrlError ? ( +

{baseUrlErrorMessage}

+ ) : ( +

+ {t("hermes.form.baseUrlHint", { + defaultValue: "供应商的 API 端点地址。", + })} +

+ )}
- toggleModelAdvanced(index)} + labelKey="hermes.form.advancedOptions" > - - - - -
-
- - - handleModelChange( - index, - "context_length", - e.target.value - ? parseInt(e.target.value) - : undefined, - ) - } - placeholder="200000" - /> -
-
- - - handleModelChange( - index, - "max_tokens", - e.target.value - ? parseInt(e.target.value) - : undefined, - ) - } - placeholder="32000" - /> -
-
-
-
+ + + handleModelChange( + index, + "context_length", + e.target.value ? parseInt(e.target.value) : undefined, + ) + } + placeholder="200000" + /> +
+
))} @@ -457,6 +523,44 @@ export function HermesFormFields({ })}

+ + +
+ + { + const v = e.target.value; + if (v === "") { + onRateLimitDelayChange(undefined); + return; + } + const n = parseFloat(v); + onRateLimitDelayChange( + Number.isFinite(n) && n >= 0 ? n : undefined, + ); + }} + placeholder="0.5" + /> +

+ {t("hermes.form.rateLimitDelayHint", { + defaultValue: + "连续请求间的最小间隔秒数(可选)。留空表示无限制。", + })} +

+
+
); } diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 398f76ae2..2cb445c82 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -1902,6 +1902,10 @@ export function ProviderForm({ onApiModeChange={hermesForm.handleHermesApiModeChange} models={hermesForm.hermesModels} onModelsChange={hermesForm.handleHermesModelsChange} + rateLimitDelay={hermesForm.hermesRateLimitDelay} + onRateLimitDelayChange={ + hermesForm.handleHermesRateLimitDelayChange + } /> )} diff --git a/src/components/providers/forms/hooks/useHermesFormState.ts b/src/components/providers/forms/hooks/useHermesFormState.ts index 44c5450eb..f306efa93 100644 --- a/src/components/providers/forms/hooks/useHermesFormState.ts +++ b/src/components/providers/forms/hooks/useHermesFormState.ts @@ -37,11 +37,13 @@ export interface HermesFormState { hermesApiKey: string; hermesApiMode: HermesApiMode; hermesModels: HermesModel[]; + hermesRateLimitDelay: number | undefined; existingHermesKeys: string[]; handleHermesBaseUrlChange: (baseUrl: string) => void; handleHermesApiKeyChange: (apiKey: string) => void; handleHermesApiModeChange: (mode: HermesApiMode) => void; handleHermesModelsChange: (models: HermesModel[]) => void; + handleHermesRateLimitDelayChange: (delay: number | undefined) => void; resetHermesState: (config?: Partial) => void; } @@ -63,6 +65,12 @@ function parseHermesField( } } +function parseRateLimitDelay(raw: unknown): number | undefined { + return typeof raw === "number" && Number.isFinite(raw) && raw >= 0 + ? raw + : undefined; +} + export function useHermesFormState({ initialData, appId, @@ -108,6 +116,13 @@ export function useHermesFormState({ return parseHermesField(initialData, "models", []); }); + const [hermesRateLimitDelay, setHermesRateLimitDelay] = useState< + number | undefined + >(() => { + if (appId !== "hermes") return undefined; + return parseRateLimitDelay(initialData?.settingsConfig?.rate_limit_delay); + }); + const updateHermesConfig = useCallback( (updater: (config: Record) => void) => { try { @@ -165,6 +180,20 @@ export function useHermesFormState({ [updateHermesConfig], ); + const handleHermesRateLimitDelayChange = useCallback( + (delay: number | undefined) => { + setHermesRateLimitDelay(delay); + updateHermesConfig((config) => { + if (delay === undefined) { + delete config.rate_limit_delay; + } else { + config.rate_limit_delay = delay; + } + }); + }, + [updateHermesConfig], + ); + const resetHermesState = useCallback( (config?: Partial) => { setHermesProviderKey(""); @@ -172,6 +201,7 @@ export function useHermesFormState({ setHermesApiKey(config?.api_key || ""); setHermesApiMode(config?.api_mode ?? HERMES_DEFAULT_API_MODE); setHermesModels(config?.models ?? []); + setHermesRateLimitDelay(parseRateLimitDelay(config?.rate_limit_delay)); }, [], ); @@ -183,11 +213,13 @@ export function useHermesFormState({ hermesApiKey, hermesApiMode, hermesModels, + hermesRateLimitDelay, existingHermesKeys, handleHermesBaseUrlChange, handleHermesApiKeyChange, handleHermesApiModeChange, handleHermesModelsChange, + handleHermesRateLimitDelayChange, resetHermesState, }; } diff --git a/src/config/hermesProviderPresets.ts b/src/config/hermesProviderPresets.ts index de5dbabbd..2b061a40d 100644 --- a/src/config/hermesProviderPresets.ts +++ b/src/config/hermesProviderPresets.ts @@ -37,8 +37,12 @@ export function isHermesReadOnlyProvider(settingsConfig: unknown): boolean { * models: * anthropic/claude-opus-4-7: * context_length: 200000 - * max_tokens: 32000 * ``` + * + * Hermes' `_VALID_CUSTOM_PROVIDER_FIELDS` (hermes_cli/config.py) does not include + * `max_tokens` at the per-model level — writing it produces an "unknown field" + * warning on Hermes startup. Max tokens is a per-request parameter, not a + * provider-level config. */ export interface HermesModel { /** Model ID — becomes the YAML key and the value written to top-level model.default. */ @@ -47,16 +51,14 @@ export interface HermesModel { name?: string; /** Override the auto-detected context window. */ context_length?: number; - /** Response-length cap. */ - max_tokens?: number; } /** * Top-level `model:` defaults suggested by a preset. * * Written to the YAML `model:` section when the user switches to this provider. - * Per-model `context_length` / `max_tokens` live on the individual `HermesModel` - * entries and flow through `custom_providers[].models`, not this object. + * Per-model `context_length` lives on the individual `HermesModel` entries and + * flows through `custom_providers[].models`, not this object. */ export interface HermesSuggestedDefaults { model: { @@ -71,7 +73,8 @@ export interface HermesSuggestedDefaults { export type HermesApiMode = | "chat_completions" | "anthropic_messages" - | "codex_responses"; + | "codex_responses" + | "bedrock_converse"; /** Default mode used when a provider has no stored value yet. */ export const HERMES_DEFAULT_API_MODE: HermesApiMode = "chat_completions"; @@ -87,6 +90,10 @@ export const hermesApiModes: Array<{ labelKey: "hermes.form.apiModeAnthropicMessages", }, { value: "codex_responses", labelKey: "hermes.form.apiModeCodexResponses" }, + { + value: "bedrock_converse", + labelKey: "hermes.form.apiModeBedrockConverse", + }, ]; export interface HermesProviderPreset { @@ -115,6 +122,8 @@ export interface HermesProviderSettingsConfig { api_mode?: HermesApiMode; /** UI-side ordered list; serialized to YAML as a dict keyed by id. */ models?: HermesModel[]; + /** Delay in seconds between consecutive requests to this provider. */ + rate_limit_delay?: number; [key: string]: unknown; } @@ -154,19 +163,16 @@ export const hermesProviderPresets: HermesProviderPreset[] = [ id: "anthropic/claude-opus-4-7", name: "Claude Opus 4.7", context_length: 1000000, - max_tokens: 32000, }, { id: "anthropic/claude-sonnet-4-6", name: "Claude Sonnet 4.6", context_length: 1000000, - max_tokens: 32000, }, { id: "anthropic/claude-haiku-4-5", name: "Claude Haiku 4.5", context_length: 200000, - max_tokens: 32000, }, { id: "openai/gpt-5.4", @@ -202,13 +208,11 @@ export const hermesProviderPresets: HermesProviderPreset[] = [ id: "deepseek-chat", name: "DeepSeek V3.2", context_length: 128000, - max_tokens: 8000, }, { id: "deepseek-reasoner", name: "DeepSeek R1", context_length: 128000, - max_tokens: 64000, }, ], }, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 144b7eb16..614b01f98 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1655,6 +1655,10 @@ "apiModeChatCompletions": "OpenAI Chat Completions", "apiModeAnthropicMessages": "Anthropic Messages", "apiModeCodexResponses": "OpenAI Responses", + "apiModeBedrockConverse": "AWS Bedrock Converse", + "baseUrlRequired": "API endpoint is required", + "baseUrlScheme": "Use an http:// or https:// address", + "baseUrlInvalid": "API endpoint is not a valid URL", "models": "Models", "addModel": "Add model", "noModels": "No models configured. Switching to this provider won't change the default model.", @@ -1663,11 +1667,13 @@ "modelName": "Display name", "modelNamePlaceholder": "Claude Opus 4.7", "contextLength": "Context length", - "maxTokens": "Max output tokens", "advancedOptions": "Advanced options", "modelsHint": "On switch, the first model is written to top-level model.default.", "primaryModel": "Default", - "fallbackModel": "Alternate" + "fallbackModel": "Alternate", + "providerAdvanced": "Provider advanced options", + "rateLimitDelay": "Rate limit delay (seconds)", + "rateLimitDelayHint": "Minimum delay in seconds between consecutive requests (optional). Leave empty for no limit." }, "webui": { "open": "Open Hermes Web UI", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 3dc46b8b6..dda71b043 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1655,6 +1655,10 @@ "apiModeChatCompletions": "OpenAI Chat Completions", "apiModeAnthropicMessages": "Anthropic Messages", "apiModeCodexResponses": "OpenAI Responses", + "apiModeBedrockConverse": "AWS Bedrock Converse", + "baseUrlRequired": "API エンドポイントは必須です", + "baseUrlScheme": "http:// または https:// で始まるアドレスを指定してください", + "baseUrlInvalid": "API エンドポイントは有効な URL ではありません", "models": "モデル一覧", "addModel": "モデルを追加", "noModels": "モデル未設定。このプロバイダーに切り替えてもデフォルトモデルは更新されません。", @@ -1663,11 +1667,13 @@ "modelName": "表示名", "modelNamePlaceholder": "Claude Opus 4.7", "contextLength": "コンテキスト長", - "maxTokens": "最大出力トークン", "advancedOptions": "詳細オプション", "modelsHint": "切り替え時、最初のモデルが model.default に書き込まれます。", "primaryModel": "デフォルト", - "fallbackModel": "予備" + "fallbackModel": "予備", + "providerAdvanced": "プロバイダー詳細オプション", + "rateLimitDelay": "リクエスト間隔(秒)", + "rateLimitDelayHint": "連続したリクエスト間の最小待機秒数(任意)。空欄の場合は制限なし。" }, "webui": { "open": "Hermes Web UI を開く", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 0df36ad9e..f4170930d 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1655,6 +1655,10 @@ "apiModeChatCompletions": "OpenAI Chat Completions", "apiModeAnthropicMessages": "Anthropic Messages", "apiModeCodexResponses": "OpenAI Responses", + "apiModeBedrockConverse": "AWS Bedrock Converse", + "baseUrlRequired": "API 端点不能为空", + "baseUrlScheme": "请使用 http:// 或 https:// 开头的地址", + "baseUrlInvalid": "API 端点不是有效的 URL", "models": "模型列表", "addModel": "添加模型", "noModels": "暂无模型配置。切换到此供应商时将不会更新默认模型。", @@ -1663,11 +1667,13 @@ "modelName": "显示名称", "modelNamePlaceholder": "Claude Opus 4.7", "contextLength": "上下文长度", - "maxTokens": "最大输出 Tokens", "advancedOptions": "高级选项", "modelsHint": "切换到此供应商时,第一个模型会写入顶层 model.default。", "primaryModel": "默认模型", - "fallbackModel": "备选模型" + "fallbackModel": "备选模型", + "providerAdvanced": "供应商高级选项", + "rateLimitDelay": "请求间隔(秒)", + "rateLimitDelayHint": "连续请求间的最小间隔秒数(可选)。留空表示无限制。" }, "webui": { "open": "打开 Hermes Web UI", From 1227e29d8b6e3b500f4853544ddfe6a45123c135 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 20 Apr 2026 22:40:25 +0800 Subject: [PATCH 050/156] feat(skills): enable Hermes in unified Skills management Wire hermes through SkillApps struct, DAO SQL, command parser, and SKILLS_APP_IDS. Add a Skills entry to the Hermes toolbar. Simplify skill_sync test fixtures to use SkillApps::default(). --- src-tauri/src/app_config.rs | 11 ++++++++--- src-tauri/src/commands/skill.rs | 1 + src-tauri/src/database/dao/skills.rs | 25 ++++++++++++++----------- src-tauri/tests/skill_sync.rs | 23 +++++------------------ src/App.tsx | 9 +++++++++ src/config/appConfig.tsx | 5 +++-- 6 files changed, 40 insertions(+), 34 deletions(-) diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 321d2c484..0262bf2c4 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -82,6 +82,8 @@ pub struct SkillApps { pub gemini: bool, #[serde(default)] pub opencode: bool, + #[serde(default)] + pub hermes: bool, } impl SkillApps { @@ -92,8 +94,8 @@ impl SkillApps { AppType::Codex => self.codex, AppType::Gemini => self.gemini, AppType::OpenCode => self.opencode, + AppType::Hermes => self.hermes, AppType::OpenClaw => false, // OpenClaw doesn't support Skills - AppType::Hermes => false, // Hermes doesn't support Skills yet } } @@ -104,8 +106,8 @@ impl SkillApps { AppType::Codex => self.codex = enabled, AppType::Gemini => self.gemini = enabled, AppType::OpenCode => self.opencode = enabled, + AppType::Hermes => self.hermes = enabled, AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore - AppType::Hermes => {} // Hermes doesn't support Skills yet, ignore } } @@ -124,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 } /// 仅启用指定应用(其他应用设为禁用) diff --git a/src-tauri/src/commands/skill.rs b/src-tauri/src/commands/skill.rs index 5bec2b17b..695e8d08a 100644 --- a/src-tauri/src/commands/skill.rs +++ b/src-tauri/src/commands/skill.rs @@ -25,6 +25,7 @@ fn parse_app_type(app: &str) -> Result { "codex" => Ok(AppType::Codex), "gemini" => Ok(AppType::Gemini), "opencode" => Ok(AppType::OpenCode), + "hermes" => Ok(AppType::Hermes), _ => Err(format!("不支持的 app 类型: {app}")), } } diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs index 530541a1d..9c94eb5ec 100644 --- a/src-tauri/src/database/dao/skills.rs +++ b/src-tauri/src/database/dao/skills.rs @@ -23,7 +23,7 @@ impl Database { .prepare( "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, - installed_at, content_hash, updated_at + enabled_hermes, installed_at, content_hash, updated_at FROM skills ORDER BY name ASC", ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -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()))?; @@ -67,7 +68,7 @@ impl Database { .prepare( "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, - installed_at, content_hash, updated_at + enabled_hermes, installed_at, content_hash, updated_at FROM skills WHERE id = ?1", ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -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), }) }); @@ -109,7 +111,7 @@ impl Database { (id, name, description, directory, repo_owner, repo_name, repo_branch, 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, 0, ?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/tests/skill_sync.rs b/src-tauri/tests/skill_sync.rs index b1e0c3a61..bf960bbe7 100644 --- a/src-tauri/tests/skill_sync.rs +++ b/src-tauri/tests/skill_sync.rs @@ -53,10 +53,8 @@ fn import_from_apps_respects_explicit_app_selection() { vec![ImportSkillSelection { directory: "shared-skill".to_string(), apps: SkillApps { - claude: false, - codex: false, - gemini: false, opencode: true, + ..Default::default() }, }], ) @@ -103,12 +101,7 @@ fn sync_to_app_removes_disabled_and_orphaned_ssot_symlinks() { repo_name: None, repo_branch: None, readme_url: None, - apps: SkillApps { - claude: false, - codex: false, - gemini: false, - opencode: false, - }, + apps: SkillApps::default(), installed_at: 0, content_hash: None, updated_at: 0, @@ -151,9 +144,7 @@ fn uninstall_skill_creates_backup_before_removing_ssot() { readme_url: None, apps: SkillApps { claude: true, - codex: false, - gemini: false, - opencode: false, + ..Default::default() }, installed_at: 123, content_hash: None, @@ -221,9 +212,7 @@ fn restore_skill_backup_restores_files_to_ssot_and_current_app() { readme_url: None, apps: SkillApps { claude: true, - codex: false, - gemini: false, - opencode: false, + ..Default::default() }, installed_at: 456, content_hash: None, @@ -304,9 +293,7 @@ fn delete_skill_backup_removes_backup_directory() { readme_url: None, apps: SkillApps { claude: true, - codex: false, - gemini: false, - opencode: false, + ..Default::default() }, installed_at: 789, content_hash: None, diff --git a/src/App.tsx b/src/App.tsx index 01a1e105d..f9f9ca69c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1388,6 +1388,15 @@ function App() { > {activeApp === "hermes" ? ( <> + + - ) : activeApp === "openclaw" ? ( <> From f4a9021461446fdf015766a65be84a320a9a83d1 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 22 Apr 2026 10:39:17 +0800 Subject: [PATCH 059/156] feat(presets): add LemonData provider across all six apps Register LemonData (third-party partner) preset for Claude, Codex, Gemini, OpenCode, OpenClaw, and Hermes, plus icon assets and i18n partner promotion copy (zh/en/ja). Claude preset uses ANTHROPIC_API_KEY auth; OpenAI-compatible apps target gpt-5.4. --- src/config/claudeProviderPresets.ts | 16 ++++++++++++ src/config/codexProviderPresets.ts | 16 ++++++++++++ src/config/geminiProviderPresets.ts | 19 ++++++++++++++ src/config/hermesProviderPresets.ts | 19 ++++++++++++++ src/config/openclawProviderPresets.ts | 36 ++++++++++++++++++++++++++ src/config/opencodeProviderPresets.ts | 28 ++++++++++++++++++++ src/i18n/locales/en.json | 3 ++- src/i18n/locales/ja.json | 3 ++- src/i18n/locales/zh.json | 3 ++- src/icons/extracted/index.ts | 2 ++ src/icons/extracted/lemondata.png | Bin 0 -> 14359 bytes src/icons/extracted/metadata.ts | 7 +++++ 12 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 src/icons/extracted/lemondata.png diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index 5951a0d0b..4f1abc3d0 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -843,6 +843,22 @@ export const providerPresets: ProviderPreset[] = [ icon: "openai", iconColor: "#000000", }, + { + name: "LemonData", + websiteUrl: "https://lemondata.cc", + apiKeyUrl: "https://lemondata.cc/r/FFX1ZDUP", + apiKeyField: "ANTHROPIC_API_KEY", + settingsConfig: { + env: { + ANTHROPIC_BASE_URL: "https://api.lemondata.cc", + ANTHROPIC_API_KEY: "", + }, + }, + category: "third_party", + isPartner: true, + partnerPromotionKey: "lemondata", + icon: "lemondata", + }, { name: "Nvidia", websiteUrl: "https://build.nvidia.com", diff --git a/src/config/codexProviderPresets.ts b/src/config/codexProviderPresets.ts index 0e8caa046..7ed268af4 100644 --- a/src/config/codexProviderPresets.ts +++ b/src/config/codexProviderPresets.ts @@ -389,6 +389,22 @@ model_auto_compact_token_limit = 9000000`, icon: "eflowcode", iconColor: "#000000", }, + { + name: "LemonData", + websiteUrl: "https://lemondata.cc", + apiKeyUrl: "https://lemondata.cc/r/FFX1ZDUP", + category: "third_party", + auth: generateThirdPartyAuth(""), + config: generateThirdPartyConfig( + "lemondata", + "https://api.lemondata.cc/v1", + "gpt-5.4", + ), + endpointCandidates: ["https://api.lemondata.cc/v1"], + isPartner: true, + partnerPromotionKey: "lemondata", + icon: "lemondata", + }, { name: "PIPELLM", websiteUrl: "https://code.pipellm.ai", diff --git a/src/config/geminiProviderPresets.ts b/src/config/geminiProviderPresets.ts index 89a2621d4..a6d911871 100644 --- a/src/config/geminiProviderPresets.ts +++ b/src/config/geminiProviderPresets.ts @@ -295,6 +295,25 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [ icon: "eflowcode", iconColor: "#000000", }, + { + name: "LemonData", + websiteUrl: "https://lemondata.cc", + apiKeyUrl: "https://lemondata.cc/r/FFX1ZDUP", + settingsConfig: { + env: { + GOOGLE_GEMINI_BASE_URL: "https://api.lemondata.cc", + GEMINI_MODEL: "gemini-3.1-pro", + }, + }, + baseURL: "https://api.lemondata.cc", + model: "gemini-3.1-pro", + description: "LemonData", + category: "third_party", + isPartner: true, + partnerPromotionKey: "lemondata", + endpointCandidates: ["https://api.lemondata.cc"], + icon: "lemondata", + }, { name: "OpenRouter", websiteUrl: "https://openrouter.ai", diff --git a/src/config/hermesProviderPresets.ts b/src/config/hermesProviderPresets.ts index 2b061a40d..5a7dfd979 100644 --- a/src/config/hermesProviderPresets.ts +++ b/src/config/hermesProviderPresets.ts @@ -988,6 +988,25 @@ export const hermesProviderPresets: HermesProviderPreset[] = [ model: { default: "claude-opus-4-7", provider: "lionccapi" }, }, }, + { + name: "LemonData", + websiteUrl: "https://lemondata.cc", + apiKeyUrl: "https://lemondata.cc/r/FFX1ZDUP", + settingsConfig: { + name: "lemondata", + base_url: "https://api.lemondata.cc/v1", + api_key: "", + api_mode: "chat_completions", + models: [{ id: "gpt-5.4", name: "GPT-5.4" }], + }, + category: "third_party", + isPartner: true, + partnerPromotionKey: "lemondata", + icon: "lemondata", + suggestedDefaults: { + model: { default: "gpt-5.4", provider: "lemondata" }, + }, + }, { name: "TheRouter", websiteUrl: "https://therouter.ai", diff --git a/src/config/openclawProviderPresets.ts b/src/config/openclawProviderPresets.ts index 102446be5..dc4397a66 100644 --- a/src/config/openclawProviderPresets.ts +++ b/src/config/openclawProviderPresets.ts @@ -1701,6 +1701,42 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, }, }, + { + name: "LemonData", + websiteUrl: "https://lemondata.cc", + apiKeyUrl: "https://lemondata.cc/r/FFX1ZDUP", + settingsConfig: { + baseUrl: "https://api.lemondata.cc/v1", + apiKey: "", + api: "openai-completions", + models: [ + { + id: "gpt-5.4", + name: "GPT-5.4", + contextWindow: 400000, + }, + ], + }, + category: "third_party", + isPartner: true, + partnerPromotionKey: "lemondata", + icon: "lemondata", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { + primary: "lemondata/gpt-5.4", + }, + modelCatalog: { + "lemondata/gpt-5.4": { alias: "GPT-5.4" }, + }, + }, + }, // ========== Cloud Providers ========== { name: "AWS Bedrock", diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts index 1e70a2a1a..a6c39086b 100644 --- a/src/config/opencodeProviderPresets.ts +++ b/src/config/opencodeProviderPresets.ts @@ -1418,6 +1418,34 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, }, }, + { + name: "LemonData", + websiteUrl: "https://lemondata.cc", + apiKeyUrl: "https://lemondata.cc/r/FFX1ZDUP", + settingsConfig: { + npm: "@ai-sdk/openai-compatible", + name: "LemonData", + options: { + baseURL: "https://api.lemondata.cc/v1", + apiKey: "", + setCacheKey: true, + }, + models: { + "gpt-5.4": { name: "GPT-5.4" }, + }, + }, + category: "third_party", + isPartner: true, + partnerPromotionKey: "lemondata", + icon: "lemondata", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + }, { name: "AWS Bedrock", websiteUrl: "https://aws.amazon.com/bedrock/", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 959aa7ba1..eae16cdc5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -797,7 +797,8 @@ "ctok": "Join the CTok community on the official website and subscribe to a plan.", "ddshub": "DDSHub offers a special bonus for CC Switch users — register via this link and get 10% extra credit on your first top-up (contact group admin to claim)!", "lionccapi": "LionCCAPI offers exclusive benefits for CC Switch users. After registration, add WeChat HSQBJ088888888 and mention 'cc-switch' to receive $10 free credits!", - "shengsuanyun": "Shengsuanyun offers exclusive benefits for CC Switch users. New users who register via this link get ¥10 free credits and 10% bonus on first top-up!" + "shengsuanyun": "Shengsuanyun offers exclusive benefits for CC Switch users. New users who register via this link get ¥10 free credits and 10% bonus on first top-up!", + "lemondata": "Lemon Code offers a special promotion for CC Switch users. Sign up via this link to receive $1 in free credits." }, "presets": { "ucloud": "Compshare", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 5e4f27024..3336108ce 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -797,7 +797,8 @@ "ctok": "公式サイトで CTok コミュニティに参加し、プランを購読してください。", "ddshub": "DDSHub は CC Switch ユーザー向けに特別ボーナスを提供しています。このリンクから登録すると、初回チャージで 10% の追加クレジットがもらえます(グループ管理者に連絡して受け取り)!", "lionccapi": "LionCCAPIはCC Switchユーザーに特別特典を提供しています。登録後、WeChat HSQBJ088888888を追加し、「cc-switch」と伝えると$10分のクレジットがもらえます!", - "shengsuanyun": "胜算云はCC Switchユーザーに特別特典を提供しています。このリンクから登録すると、10元分の無料クレジットと初回チャージ10%ボーナスがもらえます!" + "shengsuanyun": "胜算云はCC Switchユーザーに特別特典を提供しています。このリンクから登録すると、10元分の無料クレジットと初回チャージ10%ボーナスがもらえます!", + "lemondata": "Lemon Code は CC Switch ユーザー向けの特別オファーを提供しています。このリンクから登録すると 1 ドル分の無料クレジットがもらえます。" }, "presets": { "ucloud": "Compshare", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index b14308b91..59caf241f 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -797,7 +797,8 @@ "ctok": "官网加入CTok社群,订阅套餐。", "ddshub": "呆呆兽为CC Switch 的用户提供了特别福利,通过此链接注册后,首单充值可额外赠送 10% 额度(联系群主领取)!", "lionccapi": "LionCCAPI 为 CC Switch 的用户提供了特别福利,注册后添加客服微信HSQBJ088888888,发暗号cc-switch备注即可送10美金额度!", - "shengsuanyun": "胜算云为 CC Switch 的用户提供了特别福利,使用此链接注册的新用户可获 10 元模力及首充 10% 赠送!" + "shengsuanyun": "胜算云为 CC Switch 的用户提供了特别福利,使用此链接注册的新用户可获 10 元模力及首充 10% 赠送!", + "lemondata": "Lemon Code 为 CC Switch 的用户提供了特别优惠。使用此链接注册可以获得1美元免费额度。" }, "presets": { "ucloud": "优云智算", diff --git a/src/icons/extracted/index.ts b/src/icons/extracted/index.ts index c17d4b1f7..00673e206 100644 --- a/src/icons/extracted/index.ts +++ b/src/icons/extracted/index.ts @@ -4,6 +4,7 @@ import _dds from "./dds.svg?url"; import _eflowcode from "./eflowcode.png"; import _hermes from "./hermes.png"; +import _lemondata from "./lemondata.png"; import _pipellm from "./pipellm.png"; import _shengsuanyun from "./shengsuanyun.svg?url"; @@ -83,6 +84,7 @@ export const iconUrls: Record = { dds: _dds, eflowcode: _eflowcode, hermes: _hermes, + lemondata: _lemondata, pipellm: _pipellm, shengsuanyun: _shengsuanyun, }; diff --git a/src/icons/extracted/lemondata.png b/src/icons/extracted/lemondata.png new file mode 100644 index 0000000000000000000000000000000000000000..8a6d7ace92f275e1873577de2efb6fbc15a93cce GIT binary patch literal 14359 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 = { keywords: ["hermes", "agent", "nous", "nousresearch"], defaultColor: "#000000", }, + lemondata: { + name: "lemondata", + displayName: "LemonData", + category: "ai-provider", + keywords: ["lemondata", "lemon", "lemoncode"], + defaultColor: "#F5C518", + }, packycode: { name: "packycode", displayName: "PackyCode", From ae1fd2b6626d5bfc34aee65838b7101baf2761f5 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 22 Apr 2026 10:42:24 +0800 Subject: [PATCH 060/156] feat(presets): add DDSHub Codex preset DDSHub now exposes a Codex-compatible endpoint at the same host as its Claude service. Base URL omits the /v1 suffix since the gateway auto-routes OpenAI SDK paths. --- src/config/codexProviderPresets.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/config/codexProviderPresets.ts b/src/config/codexProviderPresets.ts index 7ed268af4..5e71656b7 100644 --- a/src/config/codexProviderPresets.ts +++ b/src/config/codexProviderPresets.ts @@ -364,6 +364,23 @@ requires_openai_auth = true`, partnerPromotionKey: "lionccapi", icon: "lioncc", }, + { + name: "DDSHub", + websiteUrl: "https://www.ddshub.cc", + apiKeyUrl: "https://ddshub.short.gy/ccswitch", + category: "third_party", + auth: generateThirdPartyAuth(""), + config: generateThirdPartyConfig( + "ddshub", + "https://www.ddshub.cc", + "gpt-5.4", + ), + endpointCandidates: ["https://www.ddshub.cc"], + isPartner: true, + partnerPromotionKey: "ddshub", + icon: "dds", + iconColor: "#000000", + }, { name: "E-FlowCode", websiteUrl: "https://e-flowcode.cc", From 95b21ff27e71e828bd48bd92af82759411527a29 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 22 Apr 2026 11:51:25 +0800 Subject: [PATCH 061/156] docs(changelog): extend v3.14.0 entries with late additions Fold six late commits into the v3.14.0 section (Added / Changed / Fixed) and refresh the stats header against the actual v3.13.0..HEAD diff: 100 commits | 219 files | +20,548 / -3,569. Late additions covered: Hermes dashboard toolbar launch, LemonData preset across all six apps, DDSHub Codex endpoint, Hermes toolbar icon + MCP reorder, Hermes health-check schema fix, Usage modal support for Hermes/OpenClaw. --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ebb99f68..f6ad3919b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Development since v3.13.0 focuses on onboarding Hermes Agent as a first-class managed app, rolling out Claude Opus 4.7 across the preset matrix, adding a Gemini Native API proxy, and sharpening session, usage, and proxy workflows. -**Stats**: 94 commits | 216 files changed | +19,923 insertions | -3,554 deletions +**Stats**: 100 commits | 219 files changed | +20,548 insertions | -3,569 deletions ### Added @@ -33,6 +33,9 @@ Development since v3.13.0 focuses on onboarding Hermes Agent as a first-class ma - **Hermes Config Directory Override**: Added a settings option to point CC Switch at a custom `~/.hermes/config.yaml` location, backed by data-driven dispatch. - **StepFun Step Plan Preset**: Added StepFun Step Plan (EN/ZH) provider presets. - **New API Usage Script Template**: Added a User-Agent header to the New API usage script template for better upstream compatibility. +- **Launch Hermes Dashboard from Toolbar**: When the Hermes Web UI probe fails, the toolbar entry now offers to run `hermes dashboard` in the user's preferred terminal via a temp bash/batch script. `hermes dashboard` opens the browser itself once ready, so no polling is required. Also corrects the stale `hermes web` hint in the offline toast (the real command is `hermes dashboard`) and reorders Linux terminal detection to try `which` before stat'ing `/usr/bin`, `/bin`, `/usr/local/bin`. +- **LemonData Provider Preset (All Six Apps)**: Registered LemonData as a third-party partner preset across Claude, Codex, Gemini, OpenCode, OpenClaw, and Hermes, with icon assets and zh/en/ja partner-promotion copy. Claude uses `ANTHROPIC_API_KEY` auth; OpenAI-compatible apps target `gpt-5.4`. +- **DDSHub Codex Preset**: Added a Codex-compatible endpoint for DDSHub at the same host as its Claude service; base URL omits the `/v1` suffix because the gateway auto-routes OpenAI SDK paths. ### Changed @@ -48,6 +51,7 @@ Development since v3.13.0 focuses on onboarding Hermes Agent as a first-class ma - **Copilot Request Classification**: Refined request routing inside the Copilot optimizer to further reduce unnecessary premium interaction consumption. - **Usage Script Intranet Support**: Removed private-IP / suspicious-hostname blocking from usage scripts, unblocking enterprise intranet, Docker, and self-hosted API endpoints. Built-in templates still enforce HTTPS (except localhost) and same-origin checks; custom templates remain user-controlled with those request-URL checks skipped. - **Failover Queue Notes**: Provider notes now appear in failover queue selectors and queue rows for easier identification across multi-provider queues. +- **Hermes Toolbar Layout**: Swapped the Hermes Web UI button from `ExternalLink` to `LayoutDashboard` (clicking may spawn `hermes dashboard` rather than just opening a URL), and moved MCP to the final toolbar slot so Hermes matches the Claude / Codex / Gemini / OpenCode layout. ### Fixed @@ -76,6 +80,8 @@ Development since v3.13.0 focuses on onboarding Hermes Agent as a first-class ma - **Stream Check Default Models Refresh**: Updated stream-check default probe models to match each vendor's current lineup. - **Skills Import Sync**: Imported Skills are now immediately synced into enabled app directories instead of only being recorded in the database, so the UI no longer shows "installed" while the target app directory is missing the skill. - **Ghostty Session Restore**: Fixed Ghostty session restore launch by using shell execution with `--working-directory`, avoiding `cwd` escaping issues when the path contains spaces or special characters. +- **Hermes Health Check Borrowing OpenClaw Schema**: Hermes providers were routed through `check_additive_app_stream` (the OpenClaw dispatcher), which reads camelCase `baseUrl` / `apiKey` / `api` and surfaced "OpenClaw provider is missing baseUrl" even when every Hermes field was filled. Introduced `check_hermes_stream` with Hermes-specific extractors that map `api_mode` (`chat_completions` / `anthropic_messages` / `codex_responses`) to the matching `check_claude_stream` `api_format`, and returns `bedrock_converse` as unsupported. `api_mode` is now resolved before URL / API key extraction, so `bedrock_converse` users see the real cause rather than a misleading "missing base_url". +- **Usage Query Modal for Hermes & OpenClaw**: `getProviderCredentials` now reads flat `settingsConfig` fields for Hermes (snake_case `base_url` / `api_key`) and OpenClaw (camelCase `baseUrl` / `apiKey`), so the "official balance" template auto-selects for matching providers like SiliconFlow. Also refactored the BALANCE and TOKEN_PLAN test paths to reuse the precomputed `providerCredentials` instead of re-reading `env.ANTHROPIC_*` directly, fixing the "empty key" error for non-Claude apps even when the key was configured. ### Docs From 6495da03b7ac7a917af7e1661f5876d923a3be44 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 22 Apr 2026 11:51:32 +0800 Subject: [PATCH 062/156] docs(release-notes): add v3.14.0 release notes (en/zh/ja) Cover Hermes Agent onboarding (6th managed app), Claude Opus 4.7 rollout, Gemini Native API proxy, "Local Routing" rename, application-level window controls, Copilot premium consumption deep optimization, session list virtualization, Usage date range picker, Stream Check error classification, pricing v8->v9 reseed, and related breaking changes. 18 external PR contributions credited in all three locales. --- docs/release-notes/v3.14.0-en.md | 469 +++++++++++++++++++++++++++++++ docs/release-notes/v3.14.0-ja.md | 469 +++++++++++++++++++++++++++++++ docs/release-notes/v3.14.0-zh.md | 468 ++++++++++++++++++++++++++++++ 3 files changed, 1406 insertions(+) create mode 100644 docs/release-notes/v3.14.0-en.md create mode 100644 docs/release-notes/v3.14.0-ja.md create mode 100644 docs/release-notes/v3.14.0-zh.md diff --git a/docs/release-notes/v3.14.0-en.md b/docs/release-notes/v3.14.0-en.md new file mode 100644 index 000000000..0760a24fd --- /dev/null +++ b/docs/release-notes/v3.14.0-en.md @@ -0,0 +1,469 @@ +# CC Switch v3.14.0 + +> Hermes Agent becomes the 6th managed app, Claude Opus 4.7 rolls out across the preset matrix, Gemini Native API proxy, "Local Routing" rename, and application-level window controls + +**[中文版 →](v3.14.0-zh.md) | [日本語版 →](v3.14.0-ja.md)** + +--- + +## Overview + +CC Switch v3.14.0 is a major release centered on onboarding **Hermes Agent as the 6th first-class managed app** and rolling out **Claude Opus 4.7** across the full aggregator and Bedrock preset matrix. Hermes support covers a database v9 → v10 migration, a complete Rust command surface, YAML-backed `~/.hermes/config.yaml` read/write with atomic backups, MCP sync, Skills sync, SQLite + JSONL session management, and dedicated frontend panels including a Memory editor. All four API protocols aligned with Hermes Agent 0.10.0 (`chat_completions`, `anthropic_messages`, `codex_responses`, `bedrock_converse`) are selectable. Providers owned by the user-authored `providers:` dict are rendered as read-only cards, and deep YAML configuration is delegated directly to the Hermes Web UI. + +Beyond Hermes, this release adds a **Gemini Native API proxy** (`api_format = "gemini_native"`) so the proxy can forward directly to Google's `generateContent` endpoint with full streaming, schema conversion, and shadow request support; renames the legacy "Local Proxy Takeover" to **Local Routing** across UI copy, README, and docs in all three locales; introduces **application-level window controls**, an opt-in setting that materially improves the experience on Linux Wayland where compositor-drawn buttons can become inert; and bundles late additions for launching `hermes dashboard` from the toolbar, a LemonData preset across all six apps, a DDSHub Codex endpoint, plus several Hermes health-check and Usage modal fixes. + +On the session side, the message list is **virtualized** via `@tanstack/react-virtual` so conversations with thousands of records scroll smoothly and long messages collapse by default; the Usage dashboard adds a **date range picker** (Today / 1d / 7d / 14d / 30d + custom date-time calendar) and a page-jump input; **Stream Check error classification** now surfaces color-coded toasts with refreshed default probe models and an explicit "model not found" branch; and switching to official providers is **blocked while Local Routing is active** to avoid account-suspension risk. The pricing database is reseeded from v8 → v9 with ~50 new model entries (Claude 4.7, Opus 4.7 Adaptive Thinking, Grok 4, Qwen 3.5/3.6, MiniMax M2.5/M2.7, Doubao Seed 2.0 series, GLM-5/5.1 and others) and corrected stale prices. + +**Release Date**: 2026-04-21 + +**Update Scale**: 100 commits | 219 files changed | +20,548 / -3,569 lines + +--- + +## Highlights + +- **Hermes Agent Support (6th Managed App)**: Database v9 → v10 migration, full Rust command surface, YAML read/write with atomic backups, MCP sync, Skills sync, SQLite + JSONL session management, dedicated frontend panels, and four API protocols (`chat_completions` / `anthropic_messages` / `codex_responses` / `bedrock_converse`) +- **Claude Opus 4.7 Rollout**: Adaptive thinking whitelisting, per-million pricing seed, Bedrock SKU (`anthropic.claude-opus-4-7` / `global.anthropic.claude-opus-4-7`, dropping the legacy `-v1` suffix); all aggregator and Bedrock presets migrated to Opus 4.7 as the default Opus model +- **Claude `max` Effort Tier**: Effort dropdown upgraded from `high` to `max` +- **Gemini Native API Proxy**: New `api_format = "gemini_native"` forwards directly to Google's `generateContent` with full streaming / schema conversion / shadow request support +- **GitHub Copilot Enterprise Server**: GHES authentication and endpoint configuration for Copilot-backed Claude providers +- **Copilot Premium Consumption Deep Optimization**: Proactive thinking-block stripping before forwarding, `tool_result` classification fix, subagent detection, `x-interaction-id` billing merge, orphan `tool_result` sanitization, and default warmup downgrade — a systematic reduction in premium interaction consumption +- **Session List Virtualization**: Long conversations scroll smoothly and long messages collapse by default to reduce text layout cost +- **Codex / OpenClaw Session Title Extraction**: Meaningful title extraction with 2-line display; strips OpenClaw `message_id` suffix noise +- **Usage Date Range Picker**: Today / 1d / 7d / 14d / 30d preset tabs + custom date-time calendar; page-jump input on paginated lists +- **Stream Check Error Classification**: Color-coded error toasts; refreshed default probe models; explicit "model not found" detection +- **Block Official Provider Switching During Local Routing**: Routing official API traffic through the local proxy carries account-suspension risk — switches are blocked with a warning toast +- **Pricing Database Refresh (v8 → v9)**: ~50 new model entries and corrected stale prices +- **Application-Level Window Controls**: Opt-in setting to render CC Switch's own min/max/close buttons, materially improving Linux Wayland experience +- **Hermes in Unified Skills Management**: Skill install, enable, and filter now cover Hermes +- **Hermes / OpenClaw Config Directory Override**: Point CC Switch at a custom `~/.hermes/config.yaml` or `openclaw.json` location +- **Launch Hermes Dashboard from Toolbar**: When the Hermes Web UI probe fails, the toolbar entry offers to run `hermes dashboard` in the user's preferred terminal +- **New Partner Presets**: LemonData across all six apps; DDSHub Codex endpoint; StepFun Step Plan + +--- + +## Added + +### Hermes Agent Support (6th Managed App) + +CC Switch now treats Hermes Agent as a first-class managed app alongside Claude / Codex / Gemini / OpenCode / OpenClaw. + +- **Database Migration v9 → v10**: Adds `enabled_hermes` columns to `mcp_servers` and `skills` tables (`DEFAULT 0`, auto-migrated, no data loss) +- **YAML Configuration Read/Write**: `~/.hermes/config.yaml` read/write with atomic backups; `tests/hermes_roundtrip.rs` guards against dropped OAuth MCP `auth` blocks or pollution of unrelated YAML keys +- **Four API Protocols**: Aligned with Hermes Agent 0.10.0 — `chat_completions` / `anthropic_messages` / `codex_responses` / `bedrock_converse`; new deeplinks default to `chat_completions` +- **User `providers:` Dict Read-Only Rendering**: User-authored providers in the YAML appear as read-only cards in CC Switch; deep configuration delegates to the Hermes Web UI +- **Additive Switching**: Unlike Claude / Codex's "override" style, all Hermes providers coexist in the same YAML + +### Hermes Memory Panel + +- New Memory panel for editing `MEMORY.md` / `USER.md` directly, with an enable switch, character-count limits, and a live save flow +- Replaces the Prompts entry for Hermes + +### Hermes Provider Presets (~50) + +- Covers Nous Research, Shengsuanyun, OpenRouter, DeepSeek, Together AI, StepFun, Zhipu GLM, Bailian, Kimi, MiniMax, DouBao, BaiLing, ModelScope, KAT-Coder, PackyCode, Cubence, AIGoCode, RightCode, AICodeMirror, AICoding, CrazyRouter, SSSAiCode, Micu, CTok.ai, DDSHub, E-FlowCode, LionCCAPI, PIPELLM, Compshare, SiliconFlow, AiHubMix, DMXAPI, TheRouter, Novita, Nvidia, and Xiaomi MiMo + +### Launch Hermes Dashboard from Toolbar + +- When the Hermes Web UI probe fails, the toolbar entry opens a confirm dialog offering to run `hermes dashboard` in the user's preferred terminal +- Spawned via a temp bash / batch script; `hermes dashboard` opens the browser itself once ready, so no polling is required +- The Memory panel and Health banner keep the existing toast behavior +- Also corrects the stale `hermes web` hint in the offline toast (the real command is `hermes dashboard`) +- Linux terminal detection reordered to try `which` before stat'ing `/usr/bin`, `/bin`, `/usr/local/bin` + +### Claude Opus 4.7 Support + +- New Claude Opus 4.7 with adaptive thinking whitelisting, per-million pricing seed, and Bedrock SKU (`anthropic.claude-opus-4-7` / `global.anthropic.claude-opus-4-7`, dropping the legacy `-v1` suffix) +- All aggregator and Bedrock presets migrated to Opus 4.7 as the default Opus model + +### Claude `max` Effort Tier + +- Claude effort dropdown upgraded from `high` to `max` for extended reasoning capacity + +### Gemini Native API Proxy + +- New `api_format = "gemini_native"` so the proxy can forward directly to Google's `generateContent` API (#1918, thanks @yovinchen) +- Full streaming, schema conversion, and shadow request support +- Adds `gemini_url.rs`, `gemini_schema.rs`, `gemini_shadow.rs`, `streaming_gemini.rs`, and `transform_gemini.rs` under the proxy providers module + +### GitHub Copilot Enterprise Server (GHES) + +- GHES authentication and endpoint configuration for Copilot-backed Claude providers (#2175, thanks @hotelbe) + +### Session List Virtualization + +- Virtualized the session list via `@tanstack/react-virtual` so long conversations (thousands of records) scroll smoothly +- Long session messages are collapsed by default to reduce text layout cost + +### Codex / OpenClaw Session Title Extraction + +- Meaningful title auto-extraction for Codex and OpenClaw sessions with 2-line display +- Strips OpenClaw `message_id` suffix noise + +### Usage Date Range Picker + +- New date range selector on the usage dashboard with preset tabs (Today / 1d / 7d / 14d / 30d) + custom date + time calendar (#2002, thanks @yovinchen) +- Page-jump input added on paginated lists + +### Model Mapping Quick-Set + +- New quick-set button next to model mapping fields in provider forms for faster edits (#2179, thanks @lispking) + +### Stream Check Error Classification + +- Stream Check errors are classified and surfaced as color-coded toasts +- Refreshed default probe models to match each vendor's current lineup +- Explicit detection for "model not found" responses + +### Block Official Provider Switching During Local Routing + +- Switching to official providers is blocked while Local Routing is active, with a warning toast +- Reason: routing official API traffic through the local proxy carries account-suspension risk + +### Pricing Database Refresh (v8 → v9) + +- Reseed-on-migration pricing table +- ~50 new model pricing entries including Claude 4.7, Opus 4.7 Adaptive Thinking, Grok 4, Qwen 3.5/3.6, MiniMax M2.5/M2.7, Doubao Seed 2.0 series, GLM-5/5.1 +- Corrected stale prices for DeepSeek, Kimi K2.5, and others + +### Application-Level Window Controls + +- Opt-in setting to render CC Switch's own minimize / toggle-maximize / close buttons instead of system decorations (#1119, thanks @git1677967754) +- Materially improves the experience on Linux Wayland where compositor-drawn buttons can become inert + +### Hermes in Unified Skills Management + +- Hermes is added to the unified Skills surface +- Skill install, enable, and filter now cover the Hermes app alongside Claude / Codex / Gemini / OpenCode / OpenClaw + +### OpenClaw Config Directory Override + +- New settings option to point CC Switch at a custom `openclaw.json` location (#1518, thanks @mrFranklin) + +### Hermes Config Directory Override + +- New settings option to point CC Switch at a custom `~/.hermes/config.yaml` location, backed by data-driven dispatch + +### StepFun Step Plan Preset + +- StepFun Step Plan (EN / ZH) provider presets (#2155, thanks @hengm3467) + +### New API Usage Script Template + +- Added a User-Agent header to the New API usage script template for better upstream compatibility + +### LemonData Provider Preset (All Six Apps) + +- LemonData registered as a third-party partner preset across Claude, Codex, Gemini, OpenCode, OpenClaw, and Hermes +- Icon assets and zh / en / ja partner-promotion copy +- Claude preset uses `ANTHROPIC_API_KEY` auth; OpenAI-compatible apps target `gpt-5.4` + +### DDSHub Codex Preset + +- Added a Codex-compatible endpoint for DDSHub at the same host as its Claude service +- Base URL omits the `/v1` suffix because the gateway auto-routes OpenAI SDK paths + +--- + +## Changed + +### "Local Proxy Takeover" → "Local Routing" + +- Unified the terminology across UI copy, README, and docs in all three locales +- Functional behavior is unchanged + +### Hermes `Auto` api_mode Removed + +- Users must pick an explicit protocol; new deeplinks default to `chat_completions` +- Eliminates URL-based heuristic surprises + +### Hermes Provider Form + +- Added an API mode dropdown and per-provider model editor +- Binds per-provider models to the top-level `model:` when switching active providers + +### Hermes Deep Config Delegation + +- Deep YAML knobs are no longer duplicated in the CC Switch form — they are delegated to the Hermes Web UI via a direct launch action + +### Hermes Toolbar Layout + +- Swapped the Hermes Web UI button from `ExternalLink` to `LayoutDashboard` (clicking may spawn `hermes dashboard` rather than just opening a URL) +- Moved MCP to the final toolbar slot so Hermes matches the Claude / Codex / Gemini / OpenCode layout + +### `ANTHROPIC_REASONING_MODEL` Removed from Claude Quick-Set + +- Decoupled the reasoning capability from model selection; the legacy field is no longer surfaced in the quick-set form + +### Per-Provider Proxy Config Removed + +- Consolidated into global Local Routing +- Provider-level proxy toggle and associated storage are gone + +### Unified Toolbar Icon Button Width + +- Normalized icon-button widths across Claude / Codex / Gemini / OpenCode / OpenClaw / Hermes panels for a consistent header look + +### Rust Toolchain Pinned to 1.95 + +- Adopted clippy 1.95 suggestions across the workspace and pinned the toolchain to prevent nightly drift + +### Tray Menu ID Constant + +- The tray identifier moved from the hardcoded string `"main"` to a `TRAY_ID` constant (`"cc-switch"`) across all call sites (#1978, thanks @lidaxian121) + +### Copilot Premium Consumption Deep Optimization + +A systematic overhaul to reduce Copilot reverse-proxy premium interaction consumption across multiple dimensions: + +- **Proactive Thinking Block Stripping Before Forwarding**: Anthropic's `thinking` / `redacted_thinking` blocks are rejected by OpenAI-compatible endpoints. Previously, the request failed upstream, burning one premium interaction before the `thinking_rectifier` could retry. A new proactive strip step (Copilot optimization pipeline step 3.5, after `tool_result` merging) eliminates that wasted interaction +- **Request Classification Fix**: Messages containing `tool_result` are now classified as agent continuation instead of user-initiated, preventing every tool call from being falsely counted as a premium interaction +- **Subagent Detection**: Identifies subagents via `__SUBAGENT_MARKER__` with `metadata._agent_` fallback, setting `x-interaction-type=conversation-subagent` +- **Deterministic `x-interaction-id` Billing Merge**: Derives `x-interaction-id` from the session ID so multiple requests within the same session collapse into a single billing interaction +- **Orphan `tool_result` Sanitization**: Cleans up orphan `tool_result` entries to prevent upstream errors that would trigger retries and duplicate billing +- **Warmup Downgrade Enabled by Default**: Uses `gpt-5-mini` as the default downgrade model +- **Optimization Pipeline Reorder**: classify → sanitize → merge → warmup, so classification sees raw `tool_result` semantics +- Fixed a `CopilotOptimizerConfig` default-value inconsistency (unified to `gpt-5-mini`) + +### Usage Script Intranet Support + +- Removed private-IP / suspicious-hostname blocking from usage scripts, unblocking enterprise intranet, Docker, and self-hosted API endpoints +- Built-in templates still enforce HTTPS (except localhost) and same-origin checks; custom templates remain user-controlled with those request-URL checks skipped + +### Failover Queue Notes + +- Provider notes now appear in failover queue selectors and queue rows for easier identification across multi-provider queues (#2138, thanks @Coconut-Fish) + +--- + +## Fixed + +### Header Auto-Compact Latching After Maximize + +- The toolbar no longer stays compacted after maximize/restore; compaction now reevaluates on size changes + +### Hermes YAML Pollution & OAuth MCP `auth` Drop + +- Round-tripping through CC Switch no longer drops OAuth MCP `auth` blocks or pollutes unrelated YAML keys +- Guard tests added via `tests/hermes_roundtrip.rs` + +### Hermes Active Provider Display + +- Hermes UI now correctly surfaces the active provider and wires add / enable / remove actions + +### Hermes Provider Persistence + +- Providers persist under `custom_providers:` so `api_mode` and `model` survive restarts and config reloads + +### Hermes Health Check Borrowing OpenClaw Schema + +- Hermes providers were routed through `check_additive_app_stream` (the OpenClaw dispatcher), which reads camelCase `baseUrl` / `apiKey` / `api` and surfaced "OpenClaw provider is missing baseUrl" even when every Hermes field was filled +- Introduced `check_hermes_stream` with Hermes-specific extractors that map `api_mode` (`chat_completions` / `anthropic_messages` / `codex_responses`) to the matching `check_claude_stream` `api_format`; `bedrock_converse` returns as unsupported +- `api_mode` is now resolved before URL / API key extraction, so `bedrock_converse` users see the real cause rather than a misleading "missing base_url" + +### Usage Query Modal for Hermes & OpenClaw + +- `getProviderCredentials` now reads flat `settingsConfig` fields for Hermes (snake_case `base_url` / `api_key`) and OpenClaw (camelCase `baseUrl` / `apiKey`), so the "official balance" template auto-selects for matching providers like SiliconFlow +- Refactored the BALANCE and TOKEN_PLAN test paths to reuse the precomputed `providerCredentials` instead of re-reading `env.ANTHROPIC_*` directly, fixing the "empty key" error for non-Claude apps even when the key was configured + +### Codex `cache_control` Preservation + +- Preserve `cache_control` when merging system prompts during Codex format conversion (#1946, thanks @yovinchen) + +### Claude Prompt Cache Key Leak + +- Stopped sending prompt cache keys during Claude chat conversions (#2003, thanks @yovinchen) + +### Proxy Hop-by-Hop Header Stripping + +- Strip hop-by-hop response headers (Connection, Keep-Alive, Transfer-Encoding, etc.) per RFC 7230 (#2060, thanks @yovinchen) + +### Permissive Proxy CORS Removed + +- Removed the permissive CORS layer from the proxy (#1915, thanks @zerone0x) + +### Backend Error Details in Proxy Toast + +- Surface backend error payload details in proxy-related toast messages instead of a generic failure string + +### Usage Log Deduplication + +- Deduplicated proxy and session-log usage records so the same request is no longer double-counted +- Synced the request log time range with the dashboard's 1d / 7d / 30d selector + +### Common Config Checkbox Persistence + +- Checkbox state for Claude / Codex / Gemini common-config toggles now persists correctly across reopens (#2191, thanks @zxZeng) + +### Claude Plugin `settings.json` Sync + +- Editing the current provider now syncs back to `settings.json` for the Claude plugin path (#1905, thanks @chengww5217) + +### Google Official Gemini Env Preservation + +- Saving the Google Official Gemini provider no longer clobbers the `env` block + +### OpenCode JSON5 Parser for Trailing Commas + +- OpenCode config reads now tolerate trailing commas via a JSON5 parser (#2023, thanks @wwminger) + +### Preset Refreshes + +- Refreshed stale context windows for DeepSeek and Claude 1M +- Refreshed stale model IDs; backfilled Hermes model lists +- Fixed the Nous endpoint and replaced the Hermes placeholder icon with Nous brand artwork +- Pruned unused official Hermes presets + +### Auto-Expand Collapsed Messages on Search Hit + +- Collapsed messages now auto-expand when a search match lands inside hidden content + +### Unknown Subscription Quota Tiers Hidden + +- Provider cards no longer render unknown subscription quota tiers + +### Weekly Limit Label Unified + +- Aligned the `weekly_limit` tier label with the official 7-day naming across locales + +### Root-Level Skill Repo Install + +- Fixed skill installation when the repository root itself is a skill + +### Session ID Parsing Clippy + +- Removed a redundant closure in session ID parsing (clippy warning) + +### Stream Check Default Models Refresh + +- Updated stream-check default probe models to match each vendor's current lineup + +### Skills Import Sync + +- Imported Skills are now immediately synced into enabled app directories instead of only being recorded in the database (#2101, thanks @yaoguohh) +- The UI no longer shows "installed" while the target app directory is missing the skill + +### Ghostty Session Restore + +- Fixed Ghostty session restore launch by using shell execution with `--working-directory` (#1976, thanks @Suda202) +- Avoids `cwd` escaping issues when the path contains spaces or special characters + +--- + +## Docs + +### README Sponsor Updates + +- Updated SiliconFlow signup bonus to ¥16 +- Trimmed the SSSAiCode sponsor blurb +- Updated partner logos +- Added LemonData as a new sponsor + +### Global Proxy Hint Clarified + +- Clarified the global proxy hint about local routing across all three locales + +### Takeover → Routing Rename + +- Renamed takeover docs to routing and updated anchors across all languages + +### PIPELLM Website URL + +- Updated the PIPELLM sponsor website URL to `code.pipellm.ai` + +--- + +## ⚠️ Breaking Changes + +### Hermes requires explicit `api_mode` + +- The `Auto` mode is gone; imported or deeplinked providers default to `chat_completions` +- Users with prior `Auto` configs will be prompted to pick a protocol + +### `ANTHROPIC_REASONING_MODEL` removed from Claude quick-set + +- The legacy field is no longer exposed; existing settings are cleaned up automatically + +### Per-provider proxy configuration removed + +- Migrate to the global Local Routing setting +- Existing per-provider proxy values are ignored + +### Database schema v9 → v10 + +- Adds `enabled_hermes` columns to `mcp_servers` and `skills` +- Auto-migrated with `DEFAULT 0`; no data loss + +### Pricing table reseeded (v8 → v9) + +- The `model_pricing` table is cleared and reseeded on first launch to pick up new models and corrected prices + +### XCodeAPI preset removed + +- Users of the XCodeAPI preset should switch to another provider + +--- + +## ⚠️ Risk Notice + +This release inherits the risk notices originally introduced in v3.12.3 / v3.13.0 for reverse-proxy-style features. + +**GitHub Copilot Reverse Proxy**: Using Copilot's reverse-proxy path may violate GitHub / Microsoft's terms of service. See [v3.12.3 release notes](v3.12.3-en.md#️-risk-notice). + +**Codex OAuth Reverse Proxy**: Using the Codex OAuth reverse proxy with a ChatGPT subscription may violate OpenAI's terms of service. See [v3.13.0 release notes](v3.13.0-en.md#️-risk-notice). + +By enabling these features, users **accept all associated risks**. CC Switch is not responsible for any account restrictions, warnings, or service suspensions that result from using these features. + +--- + +## Download & Installation + +Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version. + +### System Requirements + +| OS | Minimum Version | Architecture | +| ------- | ----------------------- | ----------------------------------- | +| Windows | Windows 10 or later | x64 | +| macOS | macOS 12 (Monterey) or later | Intel (x64) / Apple Silicon (arm64) | +| Linux | See table below | x64 | + +### Windows + +| File | Description | +| ---------------------------------------- | ----------------------------------------------- | +| `CC-Switch-v3.14.0-Windows.msi` | **Recommended** - MSI installer, supports auto-update | +| `CC-Switch-v3.14.0-Windows-Portable.zip` | Portable, extract and run, no registry writes | + +### macOS + +| File | Description | +| -------------------------------- | -------------------------------------------------------- | +| `CC-Switch-v3.14.0-macOS.dmg` | **Recommended** - DMG installer, drag into Applications | +| `CC-Switch-v3.14.0-macOS.zip` | Extract and drag into Applications, Universal Binary | +| `CC-Switch-v3.14.0-macOS.tar.gz` | For Homebrew installation and auto-update | + +> macOS builds are Apple code-signed and notarized — install directly. + +### Homebrew (macOS) + +```bash +brew tap farion1231/ccswitch +brew install --cask cc-switch +``` + +Update: + +```bash +brew upgrade --cask cc-switch +``` + +### Linux + +| Distribution | Recommended | Installation | +| --------------------------------------- | ----------- | ---------------------------------------------------------------------- | +| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` | +| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` | +| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` | +| Arch Linux / Manjaro | `.AppImage` | Add execute permission and run, or use AUR | +| Other distros / not sure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` | diff --git a/docs/release-notes/v3.14.0-ja.md b/docs/release-notes/v3.14.0-ja.md new file mode 100644 index 000000000..2f0abf721 --- /dev/null +++ b/docs/release-notes/v3.14.0-ja.md @@ -0,0 +1,469 @@ +# CC Switch v3.14.0 + +> Hermes Agent が 6 番目の管理対象アプリに、Claude Opus 4.7 をプリセットマトリクス全体へ展開、Gemini Native API プロキシ、「Local Routing」への名称統一、アプリケーションレベルのウィンドウコントロール + +**[中文版 →](v3.14.0-zh.md) | [English →](v3.14.0-en.md)** + +--- + +## 概要 + +CC Switch v3.14.0 は、**Hermes Agent を 6 番目の一等管理対象アプリケーション**として CC Switch に取り込み、**Claude Opus 4.7** をアグリゲーターおよび Bedrock プリセットのマトリクス全体に展開することを中心に据えた大型リリースです。Hermes サポートは、データベース v9 → v10 マイグレーション、完全な Rust コマンド面、アトミックバックアップ付きの YAML ベースな `~/.hermes/config.yaml` 読み書き、MCP 同期、Skills 同期、SQLite + JSONL セッション管理、および Memory エディターを含む専用のフロントエンドパネルをカバーします。Hermes Agent 0.10.0 スキーマに整合する 4 つの API プロトコル(`chat_completions`、`anthropic_messages`、`codex_responses`、`bedrock_converse`)すべてを選択可能です。ユーザーが直接記述した `providers:` dict のエントリは読み取り専用カードとして表示され、深い YAML 設定は Hermes Web UI に委譲されます。 + +Hermes に加えて、本リリースでは **Gemini Native API プロキシ**(`api_format = "gemini_native"`)を追加し、プロキシがリクエストを Google の `generateContent` エンドポイントに直接転送できるようにしました(完全なストリーミング、スキーマ変換、シャドウリクエストをサポート)。また、旧「Local Proxy Takeover」を三言語の UI / README / ドキュメント全体で **Local Routing** に統一リネームし、コンポジターが描画するボタンが無反応になり得る Linux Wayland などのシーンで、CC Switch が自前で最小化 / 最大化 / 閉じるボタンを描画できるオプション「**アプリケーションレベルのウィンドウコントロール**」を導入しました。さらにリリース直前に、ツールバーからの `hermes dashboard` 直接起動、LemonData の全アプリプリセット、DDSHub の Codex エンドポイント、および複数の Hermes ヘルスチェックと Usage モーダルの修正が追加されました。 + +セッション側では、`@tanstack/react-virtual` によるセッションリストの**仮想化**で数千件のレコードを持つ長い会話も滑らかにスクロールでき、長いメッセージはデフォルトで折り畳まれます。Usage ダッシュボードには**日付範囲ピッカー**(今日 / 1d / 7d / 14d / 30d + カスタム日時カレンダー)とページジャンプ入力が追加され、**Stream Check エラー分類**は色分けされたトーストで提示され、デフォルトの探索モデルが更新され、「モデルが見つからない」レスポンスを個別に識別するようになりました。また、Local Routing が有効な間に公式プロバイダーへの切り替えを**強制的にブロック**する保護を追加し、公式 API トラフィックがローカルプロキシを経由することによるアカウント停止リスクを防ぎます。Pricing データベースは v8 → v9 で再シードされ、約 50 件の新しいモデルエントリ(Claude 4.7、Opus 4.7 Adaptive Thinking、Grok 4、Qwen 3.5/3.6、MiniMax M2.5/M2.7、Doubao Seed 2.0 系列、GLM-5/5.1 など)を追加し、いくつかの古い価格を修正しました。 + +**リリース日**: 2026-04-21 + +**更新規模**: 100 commits | 219 files changed | +20,548 / -3,569 lines + +--- + +## ハイライト + +- **Hermes Agent サポート(6 番目の管理対象アプリ)**: データベース v9 → v10 マイグレーション、完全な Rust コマンド面、アトミックバックアップ付き YAML 読み書き、MCP 同期、Skills 同期、SQLite + JSONL セッション管理、専用フロントエンドパネル、4 つの API プロトコル(`chat_completions` / `anthropic_messages` / `codex_responses` / `bedrock_converse`) +- **Claude Opus 4.7 の全面展開**: 適応的思考のホワイトリスト、百万トークン単位の価格シード、Bedrock SKU(`anthropic.claude-opus-4-7` / `global.anthropic.claude-opus-4-7`、旧 `-v1` サフィックスを廃止)、全アグリゲーター / Bedrock プリセットを Opus 4.7 をデフォルト Opus モデルに移行 +- **Claude `max` エフォートティア**: エフォートのドロップダウンを `high` から `max` に引き上げ +- **Gemini Native API プロキシ**: 新しい `api_format = "gemini_native"` により、プロキシが Google の `generateContent` に直接転送可能に(完全なストリーミング / スキーマ変換 / シャドウリクエスト対応) +- **GitHub Copilot Enterprise Server**: Copilot ベースの Claude プロバイダーに GHES 認証とエンドポイント設定を追加 +- **Copilot 交互消費の大幅最適化**: 転送前の thinking ブロック主動削除、`tool_result` メッセージ分類修正、subagent 検出、`x-interaction-id` 課金マージ、孤立 `tool_result` のサニタイズ、Warmup ダウングレードのデフォルト有効化など、premium 交互消費を系統的に削減 +- **セッションリスト仮想化**: 長い会話が滑らかにスクロール。長いメッセージはデフォルトで折り畳まれ、テキストレイアウトコストを削減 +- **Codex / OpenClaw セッションタイトル抽出**: 意味のあるタイトルを自動抽出(2 行表示)、OpenClaw の `message_id` 末尾ノイズを除去 +- **Usage 日付範囲ピッカー**: Today / 1d / 7d / 14d / 30d プリセットタブ + カスタム日時カレンダー。ページネーションリストにページジャンプ入力 +- **Stream Check エラー分類**: エラーを分類し色分けトーストで提示。デフォルト探索モデル更新。「モデルが見つからない」レスポンスを明示的に検出 +- **Local Routing 有効時の公式プロバイダー切り替えブロック**: 公式 API トラフィックをローカルプロキシ経由で流すとアカウント停止のリスクがあるため、切り替えを強制ブロックして警告トーストを表示 +- **Pricing データベース刷新(v8 → v9)**: 約 50 件の新しいモデルエントリを追加し、古い価格を修正 +- **アプリケーションレベルのウィンドウコントロール**: CC Switch が自前で最小化 / 最大化トグル / 閉じるボタンを描画するオプション設定。Linux Wayland での体験を大きく改善 +- **統一 Skills 管理への Hermes 追加**: Skill のインストール / 有効化 / フィルターが Hermes をカバー +- **Hermes / OpenClaw 設定ディレクトリのカスタマイズ**: 設定で `~/.hermes/config.yaml` や `openclaw.json` のカスタム位置を指定可能 +- **ツールバーからの Hermes Dashboard 起動**: Hermes Web UI のプローブに失敗した際、ツールバーエントリからユーザーの優先ターミナルで `hermes dashboard` を実行可能 +- **新パートナープリセット**: LemonData を全 6 アプリにわたって追加、DDSHub の Codex エンドポイント、StepFun Step Plan + +--- + +## 新機能 + +### Hermes Agent サポート(6 番目の管理対象アプリ) + +CC Switch は Hermes Agent を Claude / Codex / Gemini / OpenCode / OpenClaw と並ぶ一等の管理対象アプリとして初めてサポートします。 + +- **データベースマイグレーション v9 → v10**: `mcp_servers` と `skills` テーブルに `enabled_hermes` カラムを追加(`DEFAULT 0`、自動マイグレーション、データ損失なし) +- **YAML 設定の読み書き**: `~/.hermes/config.yaml` をアトミックバックアップ付きで読み書き。`tests/hermes_roundtrip.rs` が OAuth MCP `auth` ブロックの消失や無関係なキーの汚染を防止 +- **4 つの API プロトコル**: Hermes Agent 0.10.0 と整合する `chat_completions` / `anthropic_messages` / `codex_responses` / `bedrock_converse`。新しいディープリンクはデフォルトで `chat_completions` +- **ユーザー `providers:` dict の読み取り専用表示**: YAML に手書きされたプロバイダーエントリは CC Switch で読み取り専用カードとして表示され、深い設定は Hermes Web UI に委譲 +- **加算的な切り替え**: Claude / Codex の「上書き」型切り替えと異なり、Hermes ではすべてのプロバイダーが同じ YAML に共存 + +### Hermes Memory パネル + +- `MEMORY.md` / `USER.md` を直接編集できる Memory パネルを追加(有効化スイッチ、文字数制限、ライブ保存フロー付き) +- Hermes の Prompts エントリを置き換え + +### Hermes プロバイダープリセット(約 50 個) + +- Nous Research、Shengsuanyun(胜算云)、OpenRouter、DeepSeek、Together AI、StepFun、Zhipu GLM、Bailian(百炼)、Kimi、MiniMax、DouBao(豆包)、BaiLing(百灵)、ModelScope(魔搭)、KAT-Coder、PackyCode、Cubence、AIGoCode、RightCode、AICodeMirror、AICoding、CrazyRouter、SSSAiCode、Micu、CTok.ai、DDSHub、E-FlowCode、LionCCAPI、PIPELLM、Compshare、SiliconFlow、AiHubMix、DMXAPI、TheRouter、Novita、Nvidia、Xiaomi MiMo をカバー + +### ツールバーからの Hermes Dashboard 起動 + +- Hermes Web UI のプローブに失敗した際、ツールバーエントリがユーザーの優先ターミナルで `hermes dashboard` を実行する確認ダイアログを表示 +- 一時 bash / batch スクリプト経由で起動。`hermes dashboard` 自身が準備完了後にブラウザを開くため、ポーリングは不要 +- Memory パネルと Health バナーは既存のトースト動作を維持 +- オフラインのトーストにあった古い `hermes web` のヒントも修正(正しいコマンドは `hermes dashboard`) +- Linux ターミナル検出の順序を変更し、`/usr/bin`、`/bin`、`/usr/local/bin` を stat する前に `which` を試すように + +### Claude Opus 4.7 サポート + +- Claude Opus 4.7 を追加。適応的思考のホワイトリスト、百万トークン単位の価格シード、Bedrock SKU(`anthropic.claude-opus-4-7` / `global.anthropic.claude-opus-4-7`、旧 `-v1` サフィックスを廃止) +- 全アグリゲーター / Bedrock プリセットをデフォルト Opus モデルとして Opus 4.7 に移行 + +### Claude `max` エフォートティア + +- Claude エフォートドロップダウンを `high` から `max` に引き上げ、より強力な推論容量を解放 + +### Gemini Native API プロキシ + +- 新しい `api_format = "gemini_native"` により、プロキシが Google の `generateContent` API に直接転送可能 (#1918, 感謝 @yovinchen) +- 完全なストリーミング、スキーマ変換、シャドウリクエストに対応 +- proxy providers モジュール下に `gemini_url.rs`、`gemini_schema.rs`、`gemini_shadow.rs`、`streaming_gemini.rs`、`transform_gemini.rs` を追加 + +### GitHub Copilot Enterprise Server(GHES) + +- Copilot ベースの Claude プロバイダーに GHES 認証とエンドポイント設定を追加 (#2175, 感謝 @hotelbe) + +### セッションリスト仮想化 + +- `@tanstack/react-virtual` によりセッションリストを仮想化。数千件のレコードを持つ長い会話も滑らかにスクロール +- 長いセッションメッセージはデフォルトで折り畳まれ、テキストレイアウトコストを削減 + +### Codex / OpenClaw セッションタイトル抽出 + +- Codex と OpenClaw セッションから意味のあるタイトルを自動抽出し、2 行表示 +- OpenClaw の `message_id` 末尾ノイズを除去 + +### Usage 日付範囲ピッカー + +- Usage ダッシュボードに日付範囲セレクターを追加。プリセットタブ(Today / 1d / 7d / 14d / 30d)+ カスタム日時カレンダー (#2002, 感謝 @yovinchen) +- ページネーションリストにページジャンプ入力を追加 + +### モデルマッピングのクイック入力 + +- プロバイダーフォームのモデルマッピングフィールドの横にクイック入力ボタンを追加し、編集を高速化 (#2179, 感謝 @lispking) + +### Stream Check エラー分類 + +- Stream Check エラーを分類し、色分けトーストとして提示 +- デフォルトの探索モデルを各ベンダーの現行ラインナップに合わせて更新 +- 「モデルが見つからない」レスポンスを明示的に検出 + +### Local Routing 有効時の公式プロバイダー切り替えブロック + +- Local Routing が有効な状態で公式プロバイダーに切り替えようとすると、強制的にブロックされ警告トーストが表示される +- 理由: 公式 API トラフィックをローカルプロキシ経由で流すとアカウント停止のリスクがあるため + +### Pricing データベース刷新(v8 → v9) + +- マイグレーション時に定価テーブルを再シード +- Claude 4.7、Opus 4.7 Adaptive Thinking、Grok 4、Qwen 3.5/3.6、MiniMax M2.5/M2.7、Doubao Seed 2.0 系列、GLM-5/5.1 などを含む約 50 件の新しいモデルエントリを追加 +- DeepSeek、Kimi K2.5 などの古い価格を修正 + +### アプリケーションレベルのウィンドウコントロール + +- CC Switch が自前で最小化 / 最大化トグル / 閉じるボタンを描画するオプション設定を追加。システム装飾の代わりに使用 (#1119, 感謝 @git1677967754) +- コンポジター描画ボタンが無反応になり得る Linux Wayland での体験を大きく改善 + +### 統一 Skills 管理への Hermes 追加 + +- 統一 Skills サーフェスに Hermes を追加 +- Skill のインストール / 有効化 / フィルターが、Claude / Codex / Gemini / OpenCode / OpenClaw と並んで Hermes アプリをカバー + +### OpenClaw 設定ディレクトリのカスタマイズ + +- CC Switch が参照する `openclaw.json` のカスタム位置を設定できるオプションを追加 (#1518, 感謝 @mrFranklin) + +### Hermes 設定ディレクトリのカスタマイズ + +- CC Switch が参照する `~/.hermes/config.yaml` のカスタム位置を設定できるオプションを追加。データ駆動 dispatch でサポート + +### StepFun Step Plan プリセット + +- StepFun Step Plan(EN / ZH)プロバイダープリセットを追加 (#2155, 感謝 @hengm3467) + +### New API 用量スクリプトテンプレート + +- New API の用量スクリプトテンプレートに User-Agent ヘッダーを追加し、上流互換性を向上 + +### LemonData プロバイダープリセット(全 6 アプリ) + +- LemonData をサードパーティパートナープリセットとして Claude、Codex、Gemini、OpenCode、OpenClaw、Hermes の全 6 アプリに登録 +- アイコンアセットと zh / en / ja 三言語のパートナー推奨文面を追加 +- Claude プリセットは `ANTHROPIC_API_KEY` 認証を使用。OpenAI 互換アプリは `gpt-5.4` をターゲット + +### DDSHub Codex プリセット + +- DDSHub の Codex 互換エンドポイントを追加(Claude サービスと同じホスト) +- ベース URL は `/v1` サフィックスを省略(ゲートウェイが OpenAI SDK パスを自動ルーティング) + +--- + +## 変更 + +### 「Local Proxy Takeover」→「Local Routing」 + +- 三言語の UI 文言、README、ドキュメント全体で用語を統一リネーム +- 機能的な動作は変更なし + +### Hermes `Auto` api_mode の削除 + +- ユーザーは明示的にプロトコルを選択する必要あり。新しいディープリンクはデフォルトで `chat_completions` +- URL ベースのヒューリスティックによる意外な挙動を排除 + +### Hermes プロバイダーフォーム + +- API モードドロップダウンとプロバイダー単位のモデルエディターを追加 +- アクティブなプロバイダーを切り替える際、プロバイダー単位のモデルをトップレベルの `model:` にバインド + +### Hermes 深い設定の委譲 + +- 深い YAML 設定は CC Switch フォームで重複させず、「Hermes Web UI を起動」ボタン経由で Web UI に直接委譲 + +### Hermes ツールバーレイアウト + +- Hermes Web UI ボタンのアイコンを `ExternalLink` から `LayoutDashboard` に変更(クリック時に単に URL を開くのではなく `hermes dashboard` を起動する場合があるため、パネル型アイコンのほうが意味的に正確) +- MCP をツールバーの末尾に移動し、Hermes のレイアウトを Claude / Codex / Gemini / OpenCode と揃える + +### Claude Quick-Set から `ANTHROPIC_REASONING_MODEL` を削除 + +- 推論能力とモデル選択を分離。レガシーフィールドは Quick-Set フォームから除外 + +### プロバイダー単位のプロキシ設定を削除 + +- グローバルな Local Routing に統合 +- プロバイダー単位のプロキシトグルと関連ストレージは削除済み + +### ツールバーアイコンボタン幅の統一 + +- Claude / Codex / Gemini / OpenCode / OpenClaw / Hermes パネルの間でアイコンボタン幅を正規化し、ヘッダーの見た目を統一 + +### Rust Toolchain を 1.95 にピン留め + +- ワークスペース全体で clippy 1.95 の提案を採用し、nightly ドリフトを防ぐためツールチェーンをピン留め + +### トレイメニュー ID 定数 + +- トレイ識別子をハードコーディング文字列 `"main"` から `TRAY_ID` 定数(`"cc-switch"`)に移行。すべての呼び出し箇所で同期 (#1978, 感謝 @lidaxian121) + +### Copilot 交互消費の大幅最適化 + +Copilot リバースプロキシの premium 交互消費を削減するための系統的な最適化。以下の複数の改善をカバー: + +- **転送前に thinking ブロックを主動削除**: Anthropic の `thinking` / `redacted_thinking` ブロックは OpenAI 互換エンドポイントに拒否される。従来は上流でリクエストが失敗して premium 交互を 1 回消費した後、`thinking_rectifier` によってリトライされていた。新しい主動削除ステップ(Copilot 最適化パイプラインの 3.5 ステップ目、`tool_result` マージ後)により、この無駄な premium 消費を直接解消 +- **リクエスト分類の修正**: `tool_result` を含むメッセージをユーザー発起の新規リクエストではなく、エージェント継続として分類。ツール呼び出しが毎回 premium 交互としてカウントされる問題を防止 +- **subagent 検出**: `__SUBAGENT_MARKER__` と `metadata._agent_` フォールバックで subagent を識別し、`x-interaction-type=conversation-subagent` を設定 +- **決定論的 `x-interaction-id` による課金マージ**: セッション ID から `x-interaction-id` を導出し、同一セッション内の複数リクエストを 1 回の課金交互に統合 +- **孤立 `tool_result` のサニタイズ**: 孤立した `tool_result` を整理し、上流エラーによるリトライおよび重複課金を防止 +- **Warmup ダウングレードをデフォルトで有効化**: `gpt-5-mini` をデフォルトのダウングレードモデルとして使用 +- **最適化パイプラインの並び替え**: classify → sanitize → merge → warmup の順序で、分類が生の `tool_result` セマンティクスを参照可能に +- `CopilotOptimizerConfig` のデフォルト値の不一致を修正(`gpt-5-mini` に統一) + +### 用量スクリプトのイントラネットサポート + +- 用量スクリプトからプライベート IP / 不審なホスト名のブロッキングを削除し、エンタープライズイントラネット、Docker、自己ホスト API エンドポイントを解放 +- ビルトインテンプレートは引き続き HTTPS(localhost を除く)と同一オリジンチェックを強制。カスタムテンプレートはユーザー制御のまま、リクエスト URL のチェックをスキップ + +### Failover キューの備考表示 + +- プロバイダーの備考が failover キューセレクターとキュー行に表示され、マルチプロバイダーキューでの識別が容易に (#2138, 感謝 @Coconut-Fish) + +--- + +## バグ修正 + +### 最大化後のツールバー自動折り畳みラッチ + +- ウィンドウの最大化 / 復元後、ツールバーが折り畳まれたままになる問題を修正。折り畳み判定はサイズ変更時に再評価される + +### Hermes YAML 汚染と OAuth MCP `auth` 消失 + +- CC Switch 経由でラウンドトリップしても OAuth MCP `auth` ブロックが消失したり、無関係な YAML キーが汚染されたりしなくなった +- `tests/hermes_roundtrip.rs` をガードテストとして追加 + +### Hermes アクティブプロバイダー表示 + +- Hermes UI がアクティブプロバイダーを正しく表示するようになり、追加 / 有効化 / 削除アクションが正しく動作 + +### Hermes プロバイダーの永続化 + +- プロバイダーは `custom_providers:` の下に永続化され、`api_mode` と `model` が再起動 / 設定再読み込みを生き延びる + +### Hermes ヘルスチェックが OpenClaw のスキーマを流用していた問題 + +- 以前 Hermes プロバイダーは `check_additive_app_stream`(OpenClaw のディスパッチャー)にルーティングされており、これは camelCase の `baseUrl` / `apiKey` / `api` を読むため、Hermes フィールドをすべて記入しても "OpenClaw provider is missing baseUrl" と表示されていた +- `check_hermes_stream` を導入し、Hermes 専用のエクストラクターで `api_mode`(`chat_completions` / `anthropic_messages` / `codex_responses`)を対応する `check_claude_stream` の `api_format` にマッピング。`bedrock_converse` は非対応として返す +- URL / API キーの抽出前に `api_mode` を解決することで、`bedrock_converse` を選んだユーザーには「missing base_url」という誤解を招くメッセージではなく実際の原因が表示される + +### Hermes / OpenClaw 向け Usage クエリモーダル + +- `getProviderCredentials` が Hermes(snake_case の `base_url` / `api_key`)と OpenClaw(camelCase の `baseUrl` / `apiKey`)のフラットな `settingsConfig` フィールドを読むようになり、SiliconFlow などマッチするプロバイダーで「official balance」テンプレートが自動選択される +- BALANCE と TOKEN_PLAN テストパスをリファクタリングし、`env.ANTHROPIC_*` を直接再読するのではなく、事前計算された `providerCredentials` を再利用するように変更。これにより非 Claude アプリでキーが設定されていても「empty key」エラーが出ていた問題を修正 + +### Codex `cache_control` 保持 + +- Codex フォーマット変換中に system prompt をマージする際の `cache_control` を保持 (#1946, 感謝 @yovinchen) + +### Claude プロンプトキャッシュキーのリーク + +- Claude chat 変換時にプロンプトキャッシュキーを送信しないように修正 (#2003, 感謝 @yovinchen) + +### プロキシ Hop-by-Hop レスポンスヘッダーの削除 + +- RFC 7230 に従ってプロキシレスポンスの hop-by-hop ヘッダー(Connection、Keep-Alive、Transfer-Encoding など)を削除 (#2060, 感謝 @yovinchen) + +### プロキシの寛容な CORS レイヤー削除 + +- プロキシの寛容な CORS レイヤーを削除 (#1915, 感謝 @zerone0x) + +### プロキシトーストでのバックエンドエラー詳細表示 + +- プロキシ関連のトーストメッセージで、汎用的な失敗文字列ではなくバックエンドのエラーペイロードの詳細を表示 + +### Usage ログの重複排除 + +- プロキシとセッションログの用量レコードを重複排除し、同じリクエストが二重にカウントされないように修正 +- リクエストログの時間範囲をダッシュボードの 1d / 7d / 30d セレクターと同期 + +### Common Config チェックボックスの永続化 + +- Claude / Codex / Gemini の common-config トグルのチェック状態が再オープンをまたいで正しく保持されるように修正 (#2191, 感謝 @zxZeng) + +### Claude プラグイン `settings.json` 同期 + +- 現在のプロバイダーを編集すると、Claude プラグインパスの `settings.json` に同期されるように修正 (#1905, 感謝 @chengww5217) + +### Google Official Gemini の env 保持 + +- Google Official Gemini プロバイダーを保存しても `env` ブロックが消えないように修正 + +### OpenCode の JSON5 による末尾カンマ解析 + +- OpenCode 設定読み取りが JSON5 パーサーにより末尾カンマを許容するように修正 (#2023, 感謝 @wwminger) + +### プリセットの刷新 + +- DeepSeek と Claude 1M の古いコンテキストウィンドウを刷新 +- 古いモデル ID を刷新。Hermes のモデルリストをバックフィル +- Nous エンドポイントを修正し、Hermes のプレースホルダーアイコンを Nous ブランドのアートワークに置き換え +- 未使用の公式 Hermes プリセットを整理 + +### 検索ヒット時の折り畳みメッセージの自動展開 + +- 隠されたコンテンツ内部で検索マッチが発生した場合、折り畳みメッセージを自動展開してマッチを示す + +### 不明なサブスクリプション配額ティアの非表示 + +- プロバイダーカードは不明なサブスクリプション配額ティアを表示しないように変更 + +### weekly_limit ラベルの統一 + +- `weekly_limit` ティアラベルを公式の「7 日」命名にロケール間で揃えた + +### ルートレベルの Skill リポジトリインストール + +- リポジトリのルート自体が skill の場合のインストール失敗を修正 + +### Session ID 解析の clippy 警告 + +- session ID 解析内の冗長なクロージャを削除(clippy 警告) + +### Stream Check デフォルトモデルの刷新 + +- Stream Check のデフォルト探索モデルを各ベンダーの現行ラインナップに合わせて更新 + +### Skills インポートの同期 + +- インポートされた Skills はデータベースに記録されるだけでなく、有効化されたアプリディレクトリにも即座に同期されるように変更 (#2101, 感謝 @yaoguohh) +- UI が「インストール済み」と表示しているのに対象アプリディレクトリに skill が存在しない状態を解消 + +### Ghostty セッション復元 + +- Ghostty セッション復元の起動を `--working-directory` 付きのシェル実行に変更 (#1976, 感謝 @Suda202) +- パスにスペースや特殊文字が含まれる場合の `cwd` エスケープ問題を回避 + +--- + +## ドキュメント + +### README スポンサー更新 + +- SiliconFlow のサインアップボーナスを ¥16 に更新 +- SSSAiCode のスポンサー文面を簡潔化 +- パートナーロゴを更新 +- 新しいスポンサーとして LemonData を追加 + +### グローバルプロキシヒントの明確化 + +- 三言語でグローバルプロキシと Local Routing の関係を明確化 + +### Takeover → Routing ドキュメントのリネーム + +- テイクオーバー関連ドキュメントを三言語で routing にリネームし、アンカーを同期更新 + +### PIPELLM ウェブサイト URL + +- PIPELLM スポンサーのウェブサイト URL を `code.pipellm.ai` に更新 + +--- + +## ⚠️ 重要な変更(Breaking) + +### Hermes は明示的な `api_mode` が必須 + +- `Auto` モードは廃止。インポートまたはディープリンクで取得したプロバイダーはデフォルトで `chat_completions` +- 既存の `Auto` 設定のユーザーはプロトコルを選択するよう促される + +### Claude Quick-Set から `ANTHROPIC_REASONING_MODEL` を削除 + +- レガシーフィールドは公開されなくなった。既存の設定は自動的にクリーンアップされる + +### プロバイダー単位のプロキシ設定を削除 + +- グローバル Local Routing 設定に移行 +- 既存のプロバイダー単位のプロキシ値は無視される + +### データベーススキーマ v9 → v10 + +- `mcp_servers` と `skills` に `enabled_hermes` カラムを追加 +- `DEFAULT 0` で自動マイグレーション、データ損失なし + +### Pricing テーブルの再シード(v8 → v9) + +- 新しいモデルと修正済み価格を取り込むため、初回起動時に `model_pricing` テーブルがクリアされ再シードされる + +### XCodeAPI プリセットの削除 + +- XCodeAPI プリセットを使用していたユーザーは別のプロバイダーに切り替える必要がある + +--- + +## ⚠️ リスクに関する注意事項 + +本リリースは、リバースプロキシ型機能について v3.12.3 / v3.13.0 で提起された既存のリスク注意事項を継承します。 + +**GitHub Copilot リバースプロキシ**: Copilot のリバースプロキシパスを使用すると、GitHub / Microsoft の利用規約に違反する可能性があります。詳細は [v3.12.3 リリースノート](v3.12.3-ja.md#️-リスクに関する注意事項) を参照してください。 + +**Codex OAuth リバースプロキシ**: ChatGPT サブスクリプションで Codex OAuth リバースプロキシを使用すると、OpenAI の利用規約に違反する可能性があります。詳細は [v3.13.0 リリースノート](v3.13.0-ja.md#️-リスクに関する注意事項) を参照してください。 + +これらの機能を有効にすることで、ユーザーは**すべての関連リスクを自己責任で受諾**したものとみなされます。CC Switch はこれらの機能の使用に起因するアカウントの制限、警告、サービス停止について一切の責任を負いません。 + +--- + +## ダウンロード・インストール + +[Releases](https://github.com/farion1231/cc-switch/releases/latest) から対応バージョンをダウンロードしてください。 + +### システム要件 + +| OS | 最小バージョン | アーキテクチャ | +| ------- | ---------------------------- | ----------------------------------- | +| Windows | Windows 10 以降 | x64 | +| macOS | macOS 12 (Monterey) 以降 | Intel (x64) / Apple Silicon (arm64) | +| Linux | 下表参照 | x64 | + +### Windows + +| ファイル | 説明 | +| ---------------------------------------- | ------------------------------------------- | +| `CC-Switch-v3.14.0-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 | +| `CC-Switch-v3.14.0-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ不要 | + +### macOS + +| ファイル | 説明 | +| -------------------------------- | -------------------------------------------------------- | +| `CC-Switch-v3.14.0-macOS.dmg` | **推奨** - DMG インストーラー、Applications にドラッグ | +| `CC-Switch-v3.14.0-macOS.zip` | 解凍して Applications にドラッグ、Universal Binary | +| `CC-Switch-v3.14.0-macOS.tar.gz` | Homebrew インストールと自動更新用 | + +> macOS 版は Apple のコード署名および公証済みで、直接インストールして使用できます。 + +### Homebrew(macOS) + +```bash +brew tap farion1231/ccswitch +brew install --cask cc-switch +``` + +更新: + +```bash +brew upgrade --cask cc-switch +``` + +### Linux + +| ディストリビューション | 推奨形式 | インストール方法 | +| --------------------------------------- | ----------- | ---------------------------------------------------------------------- | +| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` | +| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` | +| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` | +| Arch Linux / Manjaro | `.AppImage` | 実行権限を付与して実行、または AUR を使用 | +| その他のディストリビューション / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` | diff --git a/docs/release-notes/v3.14.0-zh.md b/docs/release-notes/v3.14.0-zh.md new file mode 100644 index 000000000..7f2a0d585 --- /dev/null +++ b/docs/release-notes/v3.14.0-zh.md @@ -0,0 +1,468 @@ +# CC Switch v3.14.0 + +> Hermes Agent 成为第 6 个受管应用、Claude Opus 4.7 全面接入、Gemini Native API 代理、Local Routing 统一重命名、应用级窗口控件 + +**[English →](v3.14.0-en.md) | [日本語版 →](v3.14.0-ja.md)** + +--- + +## 概览 + +CC Switch v3.14.0 是一次大版本更新,核心焦点是把 **Hermes Agent 作为第 6 个一等受管应用**接入 CC Switch,并把 **Claude Opus 4.7** 铺设到全部聚合器与 Bedrock 预设矩阵。Hermes 支持覆盖数据库 v9 → v10 迁移、完整的 Rust 命令面、基于 YAML 的 `~/.hermes/config.yaml` 读写(含原子备份)、MCP 同步、Skills 同步、SQLite + JSONL 会话管理,以及专属的前端面板和 Memory 编辑面板;与 Hermes Agent 0.10.0 schema 对齐的四种协议(`chat_completions`、`anthropic_messages`、`codex_responses`、`bedrock_converse`)全部可选。用户自行维护的 `providers:` dict 条目以只读卡片形式呈现,深度 YAML 配置则直接委托给 Hermes Web UI。 + +除了 Hermes,本次还新增了 **Gemini Native API 代理**(`api_format = "gemini_native"`),让代理可以把请求直接转发到 Google 的 `generateContent` 端点,完整支持流式、schema 转换和 shadow 请求;把老的 "Local Proxy Takeover" 在三语 UI / README / 文档中统一重命名为 **Local Routing**;新增 **应用级窗口控件**,在 Linux Wayland 等合成器绘制按钮失灵的场景下可选让 CC Switch 自绘最小化 / 最大化 / 关闭按钮;并在本版本发布前额外合入了从工具栏直接启动 `hermes dashboard`、LemonData 全应用预设、DDSHub Codex 端点以及若干 Hermes 健康检查与 Usage 模态框的修复。 + +会话侧通过 `@tanstack/react-virtual` **虚拟化会话列表**,让上千条记录的长会话也能流畅滚动,长消息默认折叠;Usage 面板新增**日期范围选择器**(今日 / 1d / 7d / 14d / 30d + 自定义日期时间)和翻页输入;**Stream Check 错误分类**以彩色 toast 呈现,默认探测模型重新梳理,"模型不存在"响应被单独识别;并新增在 Local Routing 激活时**阻止切换到官方供应商**的保护,以免官方流量被引入本地代理造成账号风险。Pricing 数据库 v8 → v9 重新种入约 50 个新模型条目(包括 Claude 4.7、Opus 4.7 Adaptive Thinking、Grok 4、Qwen 3.5/3.6、MiniMax M2.5/M2.7、Doubao Seed 2.0 系列、GLM-5/5.1 等),并修正了多项陈旧价格。 + +**发布日期**:2026-04-21 + +**更新规模**:100 commits | 219 files changed | +20,548 / -3,569 lines + +--- + +## 重点内容 + +- **Hermes Agent 支持(第 6 个受管应用)**:数据库 v9 → v10 迁移、完整 Rust 命令面、YAML 读写带原子备份、MCP 同步、Skills 同步、SQLite + JSONL 会话管理、专属前端面板、四种 API 协议(`chat_completions` / `anthropic_messages` / `codex_responses` / `bedrock_converse`) +- **Claude Opus 4.7 全面接入**:自适应思维白名单、按百万 token 定价种子、Bedrock SKU(`anthropic.claude-opus-4-7` / `global.anthropic.claude-opus-4-7`,丢弃老 `-v1` 后缀),全部聚合器 / Bedrock 预设升级为默认 Opus 模型 +- **Claude `max` 推理力度**:推理下拉从 `high` 升级到 `max` +- **Gemini Native API 代理**:新增 `api_format = "gemini_native"`,代理可直达 Google `generateContent`,完整流式 / schema 转换 / shadow 请求 +- **GitHub Copilot 企业版**:为 Copilot 型 Claude 供应商新增 GHES 认证与端点配置 +- **Copilot 次数消耗深度优化**:转发前主动剥离 thinking 块、`tool_result` 消息归类修正、subagent 检测、`x-interaction-id` 合并计费、orphan `tool_result` 清理、默认启用 warmup 降级 —— 系统性降低 premium 交互消耗 +- **会话列表虚拟化**:长会话流畅滚动,长消息默认折叠降低文字布局成本 +- **Codex / OpenClaw 会话标题提取**:自动抽取有意义标题,两行显示,剥离 OpenClaw `message_id` 尾噪声 +- **Usage 日期范围选择器**:Today / 1d / 7d / 14d / 30d 预设 + 自定义日期时间日历;分页列表支持页码跳转输入 +- **Stream Check 错误分类**:错误按类别分色 toast;默认探测模型刷新;单独识别 "model not found" +- **Local Routing 激活时阻止官方供应商切换**:官方流量走本地代理有账号暂停风险,强制拦截并 toast 警告 +- **Pricing 数据库刷新(v8 → v9)**:新增 ~50 条模型条目并修正陈旧价格 +- **应用级窗口控件**:可选让 CC Switch 自绘 min/max/close,显著改善 Linux Wayland 体验 +- **Hermes 接入统一 Skills 管理**:Skills 安装 / 启用 / 过滤现覆盖 Hermes +- **Hermes / OpenClaw 配置目录自定义**:在设置里指定 `~/.hermes/config.yaml` 或 `openclaw.json` 的自定义位置 +- **从工具栏启动 Hermes Dashboard**:Web UI 探测失败时,点击可在用户首选终端中启动 `hermes dashboard` +- **新合作伙伴预设**:LemonData 覆盖全部 6 个应用;DDSHub 新增 Codex 端点;StepFun Step Plan + +--- + +## 新功能 + +### Hermes Agent 支持(第 6 个受管应用) + +CC Switch 首次支持 Hermes Agent 作为一等受管应用,与 Claude / Codex / Gemini / OpenCode / OpenClaw 并列。 + +- **数据库迁移 v9 → v10**:为 `mcp_servers` 和 `skills` 表新增 `enabled_hermes` 列(`DEFAULT 0` 自动迁移,无数据丢失) +- **YAML 配置读写**:`~/.hermes/config.yaml` 读写带原子备份;`tests/hermes_roundtrip.rs` 守护不损坏不相关键和 OAuth MCP `auth` 块 +- **四种 API 协议**:与 Hermes Agent 0.10.0 对齐的 `chat_completions` / `anthropic_messages` / `codex_responses` / `bedrock_converse`;新 deeplink 默认为 `chat_completions` +- **用户 `providers:` dict 只读呈现**:用户在 YAML 里手写的 providers 条目在 CC Switch 中以只读卡片展示,深度配置跳转到 Hermes Web UI +- **累加式切换**:与 Claude / Codex 的"覆盖式"切换不同,Hermes 所有供应商共存于同一 YAML + +### Hermes Memory 面板 + +- 新增 Memory 面板直接编辑 `MEMORY.md` / `USER.md`,带启用开关、字符数限制和保存流 +- 替换 Hermes 的 Prompts 入口 + +### Hermes 供应商预设(约 50 个) + +- 覆盖 Nous Research、胜算云、OpenRouter、DeepSeek、Together AI、StepFun、智谱 GLM、百炼、Kimi、MiniMax、豆包、百灵、魔搭、KAT-Coder、PackyCode、Cubence、AIGoCode、RightCode、AICodeMirror、AICoding、CrazyRouter、SSSAiCode、Micu、CTok.ai、DDSHub、E-FlowCode、LionCCAPI、PIPELLM、Compshare、SiliconFlow、AiHubMix、DMXAPI、TheRouter、Novita、Nvidia、小米 MiMo + +### 从工具栏启动 Hermes Dashboard + +- Hermes Web UI 探测失败时,工具栏按钮改为弹出确认框,提供在用户首选终端里运行 `hermes dashboard` +- 通过临时 bash / batch 脚本启动,`hermes dashboard` 就绪后自动打开浏览器,无需轮询 +- Memory 面板和 Health banner 保留原有 toast 行为 +- 顺便修正了离线 toast 里过时的 `hermes web` 提示(正确命令是 `hermes dashboard`) +- Linux 终端探测改为先 `which` 后 stat,提升兼容性 + +### Claude Opus 4.7 支持 + +- 新增 Claude Opus 4.7 及其自适应思维白名单、按百万 token 定价种子、Bedrock SKU(`anthropic.claude-opus-4-7` / `global.anthropic.claude-opus-4-7`,丢弃老 `-v1` 后缀) +- 全部聚合器 / Bedrock 预设升级为默认 Opus 模型 + +### Claude `max` 推理力度 + +- Claude 推理下拉从 `high` 升级到 `max`,解锁更强的思考容量 + +### Gemini Native API 代理 + +- 新增 `api_format = "gemini_native"`,代理可直接转发到 Google `generateContent` API (#1918, 感谢 @yovinchen) +- 完整支持流式、schema 转换、shadow 请求 +- 在 proxy providers 模块下新增 `gemini_url.rs`、`gemini_schema.rs`、`gemini_shadow.rs`、`streaming_gemini.rs`、`transform_gemini.rs` + +### GitHub Copilot 企业版(GHES) + +- 为 Copilot 型 Claude 供应商新增 GHES 认证与端点配置 (#2175, 感谢 @hotelbe) + +### 会话列表虚拟化 + +- 通过 `@tanstack/react-virtual` 虚拟化会话列表,上千条记录流畅滚动 +- 长会话消息默认折叠,减少文字布局开销 + +### Codex / OpenClaw 会话标题提取 + +- Codex 和 OpenClaw 会话自动抽取有意义的标题,两行显示 +- 剥离 OpenClaw `message_id` 后缀噪声 + +### Usage 日期范围选择器 + +- Usage 面板新增日期范围选择器,预设 Tab(Today / 1d / 7d / 14d / 30d)+ 自定义日期 + 时间日历 (#2002, 感谢 @yovinchen) +- 分页列表新增页码跳转输入 + +### 模型映射快速填入 + +- 供应商表单的模型映射字段旁新增快速填入按钮,加快编辑 (#2179, 感谢 @lispking) + +### Stream Check 错误分类 + +- 按类别为 Stream Check 错误上色并以 toast 呈现 +- 刷新所有厂商默认探测模型到当前主力机型 +- 对 "model not found" 响应做单独识别 + +### Local Routing 激活时阻止官方供应商切换 + +- 在 Local Routing 激活状态下,切换到官方供应商会被强制拦截并弹出警告 toast +- 原因:官方 API 流量经由本地代理存在账号暂停风险 + +### Pricing 数据库刷新(v8 → v9) + +- 迁移时重新种入定价表 +- 新增约 50 条模型条目,覆盖 Claude 4.7、Opus 4.7 Adaptive Thinking、Grok 4、Qwen 3.5/3.6、MiniMax M2.5/M2.7、Doubao Seed 2.0 系列、GLM-5/5.1 +- 修正 DeepSeek、Kimi K2.5 等陈旧价格 + +### 应用级窗口控件 + +- 新增可选设置,让 CC Switch 自绘最小化 / 切换最大化 / 关闭按钮,代替系统装饰 (#1119, 感谢 @git1677967754) +- 在合成器按钮可能失灵的 Linux Wayland 上显著改善体验 + +### Hermes 接入统一 Skills 管理 + +- 统一的 Skills 界面新增 Hermes +- Skills 安装 / 启用 / 过滤现覆盖 Hermes,与 Claude / Codex / Gemini / OpenCode / OpenClaw 并列 + +### OpenClaw 配置目录自定义 + +- 新增设置项,允许把 CC Switch 指向自定义的 `openclaw.json` 位置 (#1518, 感谢 @mrFranklin) + +### Hermes 配置目录自定义 + +- 新增设置项,允许把 CC Switch 指向自定义的 `~/.hermes/config.yaml` 位置,底层通过数据驱动 dispatch + +### StepFun Step Plan 预设 + +- 新增 StepFun Step Plan(EN / ZH)供应商预设 (#2155, 感谢 @hengm3467) + +### New API 用量脚本模板 + +- 为 New API 用量脚本模板新增 User-Agent 头,提升上游兼容性 + +### LemonData 全应用预设 + +- LemonData 作为第三方合作伙伴预设覆盖 Claude / Codex / Gemini / OpenCode / OpenClaw / Hermes 全部 6 个应用 +- 含图标资源和 zh / en / ja 三语合作伙伴推广文案 +- Claude 预设使用 `ANTHROPIC_API_KEY` 认证,OpenAI 兼容应用目标为 `gpt-5.4` + +### DDSHub Codex 预设 + +- 新增 DDSHub 的 Codex 兼容端点(与 Claude 服务同 host) +- base URL 省略 `/v1` 后缀,由网关自动路由 OpenAI SDK 路径 + +--- + +## 变更 + +### "Local Proxy Takeover" → "Local Routing" + +- 三语 UI 文案、README、文档中全部统一重命名 +- 功能行为保持不变 + +### Hermes `Auto` api_mode 移除 + +- 用户必须显式选择协议;新 deeplink 默认为 `chat_completions` +- 消除了基于 URL 的启发式识别带来的意外 + +### Hermes 供应商表单 + +- 新增 API mode 下拉和按供应商的模型编辑器 +- 切换激活供应商时,把按供应商的模型绑定到顶层 `model:` + +### Hermes 深度配置委托 + +- 深度 YAML 配置不再在 CC Switch 表单里重复,直接通过"启动 Hermes Web UI"按钮交给 Web UI + +### Hermes 工具栏布局 + +- Web UI 按钮图标从 `ExternalLink` 换成 `LayoutDashboard` —— 点击可能启动 `hermes dashboard` 而非仅仅打开 URL,面板式图标语义更准 +- MCP 移到工具栏末尾,与 Claude / Codex / Gemini / OpenCode 的布局对齐 + +### Claude Quick-Set 移除 `ANTHROPIC_REASONING_MODEL` + +- 把推理能力和模型选择解耦,quick-set 表单不再暴露该遗留字段 + +### 按供应商代理配置移除 + +- 统一到全局的 Local Routing +- 按供应商的代理开关和存储都已移除 + +### 统一工具栏图标按钮宽度 + +- 在 Claude / Codex / Gemini / OpenCode / OpenClaw / Hermes 面板之间规格化图标按钮宽度,表头视觉一致 + +### Rust Toolchain 锁定 1.95 + +- 全仓库采纳 clippy 1.95 建议并锁定 toolchain,防止 nightly 漂移 + +### 托盘菜单 ID 常量 + +- 托盘标识符从硬编码字符串 `"main"` 改为 `TRAY_ID` 常量(`"cc-switch"`),所有调用点同步 (#1978, 感谢 @lidaxian121) + +### Copilot 次数消耗深度优化 + +一次系统性优化专门降低 Copilot 反向代理的 premium 交互消耗,涵盖以下多项改进: + +- **转发前主动剥离 thinking 块**:Anthropic 的 `thinking` / `redacted_thinking` 块会被 OpenAI 兼容端点拒绝,过去一次请求先失败消耗一次 premium 交互、再由 `thinking_rectifier` 触发重试。新增主动剥离步骤(Copilot 优化管线第 3.5 步,位于 `tool_result` 合并之后),直接省掉那一次无谓的 premium 消耗 +- **请求分类修正**:含 `tool_result` 的消息归类为代理继续,而不是用户发起的新请求 —— 避免每次工具调用都被错误计入 premium 次数 +- **subagent 检测**:通过 `__SUBAGENT_MARKER__` 和 `metadata._agent_` 回退识别 subagent,设置 `x-interaction-type=conversation-subagent` +- **确定性 `x-interaction-id` 合并计费**:从 session ID 推导 `x-interaction-id`,把同一会话内的多次请求合并为一次计费交互 +- **Orphan `tool_result` 清理**:清理孤立的 `tool_result`,避免触发上游错误导致重试和重复计费 +- **Warmup 降级默认开启**:使用 `gpt-5-mini` 作为默认降级模型 +- **优化管线重排**:classify → sanitize → merge → warmup,让分类看到原始 `tool_result` 语义 +- 修复 `CopilotOptimizerConfig` 默认值不一致(统一到 `gpt-5-mini`) + +### 用量脚本内网支持 + +- 移除 usage script 的私网 IP / 可疑主机名屏蔽,解锁企业内网、Docker、自建 API 端点 +- 内置模板仍强制 HTTPS(localhost 除外)和同源检查;自定义模板仍由用户控制,这类请求 URL 检查跳过 + +### Failover 队列备注 + +- 供应商备注现在在 failover 队列选择器和队列行中显示,方便在多供应商队列里识别 (#2138, 感谢 @Coconut-Fish) + +--- + +## Bug 修复 + +### 工具栏最大化后持续折叠 + +- 窗口最大化 / 还原后,工具栏不再卡在折叠状态;折叠判定会随尺寸变化重新计算 + +### Hermes YAML 污染与 OAuth MCP `auth` 丢失 + +- 经 CC Switch 往返写入不再丢失 OAuth MCP `auth` 块、也不污染不相关的 YAML 键 +- 新增 `tests/hermes_roundtrip.rs` 作为守护测试 + +### Hermes 激活供应商展示 + +- Hermes UI 现在正确展示激活供应商,并连通添加 / 启用 / 移除动作 + +### Hermes 供应商持久化 + +- 供应商持久化到 `custom_providers:` 下,`api_mode` 和 `model` 可跨重启 / 配置重载存活 + +### Hermes 健康检查错借 OpenClaw schema + +- 以前 Hermes 供应商被路由到 `check_additive_app_stream`(OpenClaw 的调度器),后者读 camelCase 的 `baseUrl` / `apiKey` / `api`,导致即便 Hermes 字段全填还是报 "OpenClaw provider is missing baseUrl" +- 新增 `check_hermes_stream`,用 Hermes 专用提取器把 `api_mode`(`chat_completions` / `anthropic_messages` / `codex_responses`)映射到对应的 `check_claude_stream` `api_format`,`bedrock_converse` 明确标记为不支持 +- 先解析 `api_mode` 再抽 URL / API key,让 `bedrock_converse` 用户看到真实原因,而不是误导性的 "missing base_url" + +### Usage 查询模态框支持 Hermes / OpenClaw + +- `getProviderCredentials` 新增对 Hermes(snake_case `base_url` / `api_key`)和 OpenClaw(camelCase `baseUrl` / `apiKey`)的扁平 `settingsConfig` 字段读取,让 SiliconFlow 等匹配供应商自动选中 "official balance" 模板 +- 重构 BALANCE 和 TOKEN_PLAN 测试路径复用 `providerCredentials`,不再直接读 `env.ANTHROPIC_*`,修正了非 Claude 应用即使配置了 key 也报 "empty key" 的问题 + +### Codex `cache_control` 保留 + +- 在 Codex 格式转换合并 system prompt 时保留 `cache_control` (#1946, 感谢 @yovinchen) + +### Claude prompt cache key 泄漏 + +- Claude chat 转换时不再发送 prompt cache key (#2003, 感谢 @yovinchen) + +### 代理逐跳响应头剥离 + +- 按 RFC 7230 剥离代理响应的 hop-by-hop 头(Connection、Keep-Alive、Transfer-Encoding 等) (#2060, 感谢 @yovinchen) + +### 代理 CORS 层移除 + +- 移除代理中过于宽松的 CORS 层 (#1915, 感谢 @zerone0x) + +### 代理 toast 显示后端错误详情 + +- 代理相关 toast 现在展示后端错误 payload 的详情,而不是一句笼统的失败 + +### Usage 日志去重 + +- 代理和会话日志的用量记录去重,相同请求不再被重复计数 +- 请求日志时间范围与面板的 1d / 7d / 30d 选择器同步 + +### Common Config 勾选持久化 + +- Claude / Codex / Gemini common-config 勾选状态重开后正确保留 (#2191, 感谢 @zxZeng) + +### Claude 插件 `settings.json` 同步 + +- 编辑当前供应商时,会同步回 Claude 插件路径下的 `settings.json` (#1905, 感谢 @chengww5217) + +### Google Official Gemini env 保留 + +- 保存 Google Official Gemini 供应商时不再清空 `env` 块 + +### OpenCode JSON5 尾逗号解析 + +- OpenCode 配置读取容忍尾逗号(JSON5) (#2023, 感谢 @wwminger) + +### 预设刷新 + +- 刷新 DeepSeek 和 Claude 1M 的陈旧 context 窗口 +- 刷新陈旧模型 ID,回填 Hermes 模型列表 +- 修正 Nous 端点,Hermes 占位图替换为 Nous 品牌图 +- 移除未使用的官方 Hermes 预设 + +### 搜索命中时折叠消息自动展开 + +- 搜索匹配落在折叠内容内部时,消息自动展开以定位匹配 + +### 未知订阅配额等级隐藏 + +- 供应商卡片不再渲染未知订阅配额等级 + +### weekly_limit 标签统一 + +- 跨语言把 `weekly_limit` 等级标签对齐到官方的"7 天"命名 + +### 根级 Skill 仓库安装 + +- 修复当仓库根本身就是一个 skill 时的安装失败 + +### Session ID 解析 clippy + +- 移除 session ID 解析里的冗余闭包(clippy 警告) + +### Stream Check 默认探测模型刷新 + +- 默认探测模型更新到每家厂商当前主力 + +### Skills 导入同步 + +- 导入的 Skills 即时同步到启用应用目录,不再仅记录在数据库里导致 UI 显示"已安装"但目标目录空缺 (#2101, 感谢 @yaoguohh) + +### Ghostty 会话恢复 + +- 改为通过 shell 执行 + `--working-directory` 启动 Ghostty 会话恢复 (#1976, 感谢 @Suda202) +- 避免路径含空格 / 特殊字符时 `cwd` 转义问题 + +--- + +## 文档 + +### README 赞助商更新 + +- SiliconFlow 注册赠送更新为 ¥16 +- 精简 SSSAiCode 赞助文案 +- 更新合作伙伴 logo +- 新增 LemonData 赞助商 + +### 全局代理提示澄清 + +- 三语澄清全局代理与 Local Routing 的关系 + +### Takeover → Routing 文档重命名 + +- 接管相关文档在三语下重命名为 routing,同步更新锚点 + +### PIPELLM 网站 URL + +- PIPELLM 赞助商网站 URL 更新为 `code.pipellm.ai` + +--- + +## ⚠️ 重要变更(Breaking) + +### Hermes 必须显式 `api_mode` + +- `Auto` 模式移除;导入或 deeplink 得到的供应商默认落到 `chat_completions` +- 既有 `Auto` 配置的用户会被提示选择协议 + +### Claude Quick-Set 移除 `ANTHROPIC_REASONING_MODEL` + +- 该遗留字段不再暴露;既有设置自动清理 + +### 按供应商代理配置移除 + +- 迁移到全局 Local Routing 设置 +- 既有按供应商代理值被忽略 + +### 数据库 schema v9 → v10 + +- 为 `mcp_servers` 和 `skills` 表新增 `enabled_hermes` 列 +- 自动迁移,`DEFAULT 0`,无数据丢失 + +### Pricing 表 v8 → v9 重置 + +- 首次启动时 `model_pricing` 表被清空并重新种入,以应用新模型和修正后的价格 + +### XCodeAPI 预设移除 + +- 使用 XCodeAPI 预设的用户请迁移到其它供应商 + +--- + +## ⚠️ 风险提示 + +本版本在涉及反向代理类功能上沿用 v3.12.3 / v3.13.0 提出的风险提示。 + +**GitHub Copilot 反向代理**:使用 Copilot 的反代路径可能违反 GitHub / Microsoft 服务条款。详情见 [v3.12.3 release notes](v3.12.3-zh.md#️-风险提示)。 + +**Codex OAuth 反向代理**:使用 ChatGPT 订阅的 Codex OAuth 反代可能违反 OpenAI 服务条款,详情见 [v3.13.0 release notes](v3.13.0-zh.md#️-风险提示)。 + +用户启用上述功能即表示**自行承担所有风险**。CC Switch 不对因使用这些功能而导致的任何账号限制、警告或服务暂停承担责任。 + +--- + +## 下载与安装 + +访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。 + +### 系统要求 + +| 系统 | 最低版本 | 架构 | +| ------- | -------------------------- | ----------------------------------- | +| Windows | Windows 10 及以上 | x64 | +| macOS | macOS 12 (Monterey) 及以上 | Intel (x64) / Apple Silicon (arm64) | +| Linux | 见下表 | x64 | + +### Windows + +| 文件 | 说明 | +| ---------------------------------------- | ----------------------------------- | +| `CC-Switch-v3.14.0-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 | +| `CC-Switch-v3.14.0-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 | + +### macOS + +| 文件 | 说明 | +| -------------------------------- | --------------------------------------------- | +| `CC-Switch-v3.14.0-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 | +| `CC-Switch-v3.14.0-macOS.zip` | 解压后拖入 Applications,Universal Binary | +| `CC-Switch-v3.14.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 | + +> macOS 版本已通过 Apple 代码签名和公证,可直接安装使用。 + +### Homebrew(macOS) + +```bash +brew tap farion1231/ccswitch +brew install --cask cc-switch +``` + +更新: + +```bash +brew upgrade --cask cc-switch +``` + +### Linux + +| 发行版 | 推荐格式 | 安装方式 | +| --------------------------------------- | ----------- | ---------------------------------------------------------------------- | +| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` 或 `sudo apt install ./CC-Switch-*.deb` | +| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` 或 `sudo dnf install ./CC-Switch-*.rpm` | +| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` | +| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR | +| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` | From 1f254115246c1f5265a7870c96e54a861dd6d356 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 22 Apr 2026 12:17:19 +0800 Subject: [PATCH 063/156] feat(presets): upgrade Kimi K2.5 to K2.6 in direct Moonshot configs Bump model id and display name from K2.5 to K2.6 in Hermes, OpenClaw, OpenCode, and Claude (direct api.moonshot.cn) presets. Pricing, context window, and base URL are unchanged. Add kimi-k2.6 row to model_pricing seed; no migration needed since seed_model_pricing uses INSERT OR IGNORE and runs on every startup via ensure_model_pricing_seeded. Old kimi-k2.5 row is kept to preserve historical usage stats. Nvidia aggregator forwards (moonshotai/kimi-k2.5) intentionally keep the K2.5 SKU until Nvidia's catalog confirms K2.6. --- src-tauri/src/database/schema.rs | 1 + src/config/claudeProviderPresets.ts | 8 ++++---- src/config/hermesProviderPresets.ts | 4 ++-- src/config/openclawProviderPresets.ts | 10 +++++----- src/config/opencodeProviderPresets.ts | 10 +++++----- 5 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 34019df17..baada0cce 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -1639,6 +1639,7 @@ impl Database { "0", ), ("kimi-k2.5", "Kimi K2.5", "0.60", "2.50", "0.10", "0"), + ("kimi-k2.6", "Kimi K2.6", "0.60", "2.50", "0.10", "0"), // MiniMax 系列 ("minimax-m2.1", "MiniMax M2.1", "0.27", "0.95", "0.03", "0"), ( diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index 4f1abc3d0..ec0143659 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -209,10 +209,10 @@ export const providerPresets: ProviderPreset[] = [ env: { ANTHROPIC_BASE_URL: "https://api.moonshot.cn/anthropic", ANTHROPIC_AUTH_TOKEN: "", - ANTHROPIC_MODEL: "kimi-k2.5", - ANTHROPIC_DEFAULT_HAIKU_MODEL: "kimi-k2.5", - ANTHROPIC_DEFAULT_SONNET_MODEL: "kimi-k2.5", - ANTHROPIC_DEFAULT_OPUS_MODEL: "kimi-k2.5", + ANTHROPIC_MODEL: "kimi-k2.6", + ANTHROPIC_DEFAULT_HAIKU_MODEL: "kimi-k2.6", + ANTHROPIC_DEFAULT_SONNET_MODEL: "kimi-k2.6", + ANTHROPIC_DEFAULT_OPUS_MODEL: "kimi-k2.6", }, }, category: "cn_official", diff --git a/src/config/hermesProviderPresets.ts b/src/config/hermesProviderPresets.ts index 5a7dfd979..85160335e 100644 --- a/src/config/hermesProviderPresets.ts +++ b/src/config/hermesProviderPresets.ts @@ -381,13 +381,13 @@ export const hermesProviderPresets: HermesProviderPreset[] = [ base_url: "https://api.moonshot.cn/v1", api_key: "", api_mode: "chat_completions", - models: [{ id: "kimi-k2.5-preview", name: "Kimi K2.5" }], + models: [{ id: "kimi-k2.6", name: "Kimi K2.6" }], }, category: "cn_official", icon: "kimi", iconColor: "#6366F1", suggestedDefaults: { - model: { default: "kimi-k2.5-preview", provider: "kimi" }, + model: { default: "kimi-k2.6", provider: "kimi" }, }, }, { diff --git a/src/config/openclawProviderPresets.ts b/src/config/openclawProviderPresets.ts index dc4397a66..eab8ad61f 100644 --- a/src/config/openclawProviderPresets.ts +++ b/src/config/openclawProviderPresets.ts @@ -264,7 +264,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, }, { - name: "Kimi k2.5", + name: "Kimi k2.6", websiteUrl: "https://platform.moonshot.cn/console", apiKeyUrl: "https://platform.moonshot.cn/console/api-keys", settingsConfig: { @@ -273,8 +273,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ api: "openai-completions", models: [ { - id: "kimi-k2.5", - name: "Kimi K2.5", + id: "kimi-k2.6", + name: "Kimi K2.6", contextWindow: 131072, cost: { input: 0.002, output: 0.006 }, }, @@ -297,8 +297,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, }, suggestedDefaults: { - model: { primary: "kimi/kimi-k2.5" }, - modelCatalog: { "kimi/kimi-k2.5": { alias: "Kimi" } }, + model: { primary: "kimi/kimi-k2.6" }, + modelCatalog: { "kimi/kimi-k2.6": { alias: "Kimi" } }, }, }, { diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts index a6c39086b..35f508451 100644 --- a/src/config/opencodeProviderPresets.ts +++ b/src/config/opencodeProviderPresets.ts @@ -56,8 +56,8 @@ export const OPENCODE_PRESET_MODEL_VARIANTS: Record< modalities: { input: ["text"], output: ["text"] }, }, { - id: "kimi-k2.5", - name: "Kimi K2.5", + id: "kimi-k2.6", + name: "Kimi K2.6", contextLimit: 262144, outputLimit: 262144, modalities: { input: ["text", "image", "video"], output: ["text"] }, @@ -451,19 +451,19 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, }, { - name: "Kimi k2.5", + name: "Kimi k2.6", websiteUrl: "https://platform.moonshot.cn/console", apiKeyUrl: "https://platform.moonshot.cn/console/api-keys", settingsConfig: { npm: "@ai-sdk/openai-compatible", - name: "Kimi k2.5", + name: "Kimi k2.6", options: { baseURL: "https://api.moonshot.cn/v1", apiKey: "", setCacheKey: true, }, models: { - "kimi-k2.5": { name: "Kimi K2.5" }, + "kimi-k2.6": { name: "Kimi K2.6" }, }, }, category: "cn_official", From 85295cf23f9890e9f1a71d55997026eb4e375968 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 22 Apr 2026 12:29:27 +0800 Subject: [PATCH 064/156] chore(release): bump version to 3.14.0 --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index d07ecbf96..0ecc8d018 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cc-switch", - "version": "3.13.0", + "version": "3.14.0", "description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1443b2388..df17243c7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -735,7 +735,7 @@ dependencies = [ [[package]] name = "cc-switch" -version = "3.13.0" +version = "3.14.0" dependencies = [ "anyhow", "arboard", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e62bc1e36..51f44e8c5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cc-switch" -version = "3.13.0" +version = "3.14.0" description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI" authors = ["Jason Young"] license = "MIT" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f3354cb75..e360b6fdc 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CC Switch", - "version": "3.13.0", + "version": "3.14.0", "identifier": "com.ccswitch.desktop", "build": { "frontendDist": "../dist", From c7ba3cf51d77b3cbe2923e1e6254b587c620f57c Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 22 Apr 2026 14:32:03 +0800 Subject: [PATCH 065/156] fix(pricing): correct Kimi K2.6 seed prices to match official rates Moonshot's official USD pricing for kimi-k2.6 is $0.95 input / $4.00 output / $0.16 cache-hit per 1M tokens (~58-60% higher than K2.5). The previous commit copied K2.5's $0.60/$2.50/$0.10, which would have under-billed K2.6 traffic in the usage dashboard. No migration needed since this version is unreleased; INSERT OR IGNORE will write the correct values on first launch. --- src-tauri/src/database/schema.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index baada0cce..20c6b626c 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -1639,7 +1639,7 @@ impl Database { "0", ), ("kimi-k2.5", "Kimi K2.5", "0.60", "2.50", "0.10", "0"), - ("kimi-k2.6", "Kimi K2.6", "0.60", "2.50", "0.10", "0"), + ("kimi-k2.6", "Kimi K2.6", "0.95", "4.00", "0.16", "0"), // MiniMax 系列 ("minimax-m2.1", "MiniMax M2.1", "0.27", "0.95", "0.03", "0"), ( From 1b3c53d235a88f770f8c26647895e8e6a536dce3 Mon Sep 17 00:00:00 2001 From: tison Date: Wed, 22 Apr 2026 23:19:52 +0800 Subject: [PATCH 066/156] fix: always show name on hover (#2237) Signed-off-by: tison --- src/components/ProviderIcon.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/ProviderIcon.tsx b/src/components/ProviderIcon.tsx index ddc9ed64d..0639d0619 100644 --- a/src/components/ProviderIcon.tsx +++ b/src/components/ProviderIcon.tsx @@ -74,6 +74,7 @@ export const ProviderIcon: React.FC = ({ "inline-flex items-center justify-center flex-shrink-0", className, )} + title={name} style={{ ...sizeStyle, color: effectiveColor }} dangerouslySetInnerHTML={{ __html: iconSvg }} /> @@ -86,6 +87,7 @@ export const ProviderIcon: React.FC = ({ {name} = ({ "bg-muted text-muted-foreground font-semibold", className, )} + title={name} style={sizeStyle} > Date: Thu, 23 Apr 2026 08:54:31 +0800 Subject: [PATCH 067/156] fix(proxy): handle Codex OAuth responses as streaming (#2235) --- src-tauri/src/proxy/handlers.rs | 58 ++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 086f944e6..39c7243bc 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -151,10 +151,20 @@ async fn handle_claude_transform( api_format: &str, ) -> Result { let status = response.status(); + let use_streaming = should_use_claude_transform_streaming( + is_stream, + response.is_sse(), + api_format, + ctx.provider + .meta + .as_ref() + .and_then(|meta| meta.provider_type.as_deref()) + == Some("codex_oauth"), + ); let tool_schema_hints = transform_gemini::extract_anthropic_tool_schema_hints(original_body); let tool_schema_hints = (!tool_schema_hints.is_empty()).then_some(tool_schema_hints); - if is_stream { + if use_streaming { // 根据 api_format 选择流式转换器 let stream = response.bytes_stream(); let sse_stream: Box< @@ -561,6 +571,17 @@ pub async fn handle_gemini( process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await } +fn should_use_claude_transform_streaming( + requested_streaming: bool, + upstream_is_sse: bool, + api_format: &str, + is_codex_oauth: bool, +) -> bool { + requested_streaming + || upstream_is_sse + || (is_codex_oauth && api_format == "openai_responses") +} + // ============================================================================ // 使用量记录(保留用于 Claude 转换逻辑) // ============================================================================ @@ -641,3 +662,38 @@ async fn log_usage( log::warn!("[USG-001] 记录使用量失败: {e}"); } } + +#[cfg(test)] +mod tests { + use super::should_use_claude_transform_streaming; + + #[test] + fn codex_oauth_responses_force_streaming_even_if_client_sent_false() { + assert!(should_use_claude_transform_streaming( + false, + false, + "openai_responses", + true, + )); + } + + #[test] + fn upstream_sse_response_always_uses_streaming_path() { + assert!(should_use_claude_transform_streaming( + false, + true, + "openai_chat", + false, + )); + } + + #[test] + fn non_streaming_response_stays_non_streaming_for_regular_openai_responses() { + assert!(!should_use_claude_transform_streaming( + false, + false, + "openai_responses", + false, + )); + } +} From e7ff2ee47d6e82b41906a90257279b07b2c4b282 Mon Sep 17 00:00:00 2001 From: tison Date: Thu, 23 Apr 2026 08:55:11 +0800 Subject: [PATCH 068/156] fix: gemini cli resume session can have project_dir (#2240) Signed-off-by: tison --- src-tauri/src/session_manager/providers/gemini.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/session_manager/providers/gemini.rs b/src-tauri/src/session_manager/providers/gemini.rs index c600f7bd7..73ae17868 100644 --- a/src-tauri/src/session_manager/providers/gemini.rs +++ b/src-tauri/src/session_manager/providers/gemini.rs @@ -17,7 +17,7 @@ pub fn scan_sessions() -> Vec { let mut sessions = Vec::new(); - // Iterate over project hash directories: tmp//chats/session-*.json + // Iterate over project directories: tmp//chats/session-*.json let project_dirs = match std::fs::read_dir(&tmp_dir) { Ok(entries) => entries, Err(_) => return Vec::new(), @@ -34,13 +34,22 @@ pub fn scan_sessions() -> Vec { Err(_) => continue, }; + let project_root_file = entry.path().join(".project_root"); + let project_dir = match std::fs::read_to_string(project_root_file) { + Ok(name) => Some(name), + Err(_) => None, + }; + for file_entry in chat_files.flatten() { let path = file_entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("json") { continue; } if let Some(meta) = parse_session(&path) { - sessions.push(meta); + sessions.push(SessionMeta { + project_dir: project_dir.clone(), + ..meta + }); } } } @@ -159,7 +168,7 @@ fn parse_session(path: &Path) -> Option { session_id: session_id.clone(), title: title.clone(), summary: title, - project_dir: None, // project hash is not reversible + project_dir: None, // (optionally) populated later created_at, last_active_at: last_active_at.or(created_at), source_path: Some(source_path), From 7bfe2609dbd5b54272e848baa5e41cd2ddfaeaae Mon Sep 17 00:00:00 2001 From: Coconut-Fish <78026974+Coconut-Fish@users.noreply.github.com> Date: Thu, 23 Apr 2026 08:56:57 +0800 Subject: [PATCH 069/156] Style/session manager list UI (#2201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * style(FailoverQueueManager): 显示供应商备注信息 * style(FailoverQueueItem): 添加供应商备注字段以支持备注信息显示 * style(FailoverQueueManager): 显示供应商备注信息 * style(FailoverQueueItem): 添加供应商备注字段以支持备注信息显示 * style(FailoverQueueManager): 更新供应商备注信息的显示样式 * style(FailoverQueueItem): 添加条件序列化以优化供应商备注字段 * style(App, SettingsPage, ScrollArea): 调整组件样式以改善布局和视觉效果 * style(App, SettingsPage, ScrollArea): 调整组件样式以改善布局和视觉效果 * style(SettingsPage, useSettings): 统一代码格式,调整样式和变量声明 * style(App): 调整底部内边距以改善布局 --- src/App.tsx | 2 +- src/components/settings/SettingsPage.tsx | 2 +- src/components/ui/scroll-area.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 058917172..f621bc651 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1049,7 +1049,7 @@ function App() { return (
{(dragBarHeight > 0 || useAppWindowControls) && ( diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index ccd3e68bc..5e48fbaed 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -475,7 +475,7 @@ export function SettingsPage({ {activeTab === "advanced" && settings && (
diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx index b1f11275a..20f751297 100644 --- a/src/components/ui/scroll-area.tsx +++ b/src/components/ui/scroll-area.tsx @@ -14,7 +14,7 @@ const ScrollArea = React.forwardRef< className={cn("relative overflow-hidden", className)} {...props} > - + {children} From 29e87487a1d2739fbd56e581840e04fcd84fbe42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B6=82=E7=91=9C?= <861506831@qq.com> Date: Thu, 23 Apr 2026 10:25:01 +0800 Subject: [PATCH 070/156] fix(skills): prevent duplicate imports when import button is double-clicked (#2211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #2139 Two related defects let the installed-skills count balloon when users tap the import confirm button multiple times — either deliberately or because the button is still clickable while a slow import is in flight: - The confirm button only disabled itself while `selected.size === 0`, so it stayed clickable during a pending mutation. Each extra click triggered another `importFromApps` mutation. - `useImportSkillsFromApps` appended the server response to the installed cache without deduping, so re-firing the mutation stacked the same skills into the list again. Disable the confirm (and cancel) buttons while the mutation is pending — matching the `isRestoring` / `isDeleting` pattern already used by `RestoreSkillsDialog` — and merge success payloads by `InstalledSkill.id` so repeated results overwrite rather than accumulate. The merge is extracted as a pure `mergeImportedSkills` reducer to make the behaviour unit-testable and to short-circuit on an empty payload, returning the existing reference so React Query does not notify subscribers about a no-op cache update. --- src/components/skills/UnifiedSkillsPanel.tsx | 10 +++- src/hooks/useSkills.helpers.ts | 19 ++++++ src/hooks/useSkills.ts | 6 +- tests/hooks/useImportSkillsFromApps.test.tsx | 61 ++++++++++++++++++++ 4 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 src/hooks/useSkills.helpers.ts create mode 100644 tests/hooks/useImportSkillsFromApps.test.tsx diff --git a/src/components/skills/UnifiedSkillsPanel.tsx b/src/components/skills/UnifiedSkillsPanel.tsx index 77199ef17..215e3cbca 100644 --- a/src/components/skills/UnifiedSkillsPanel.tsx +++ b/src/components/skills/UnifiedSkillsPanel.tsx @@ -454,6 +454,7 @@ const UnifiedSkillsPanel = React.forwardRef< {importDialogOpen && unmanagedSkills && ( setImportDialogOpen(false)} /> @@ -600,6 +601,7 @@ interface ImportSkillsDialogProps { foundIn: string[]; path: string; }>; + isImporting: boolean; onImport: (imports: ImportSkillSelection[]) => void; onClose: () => void; } @@ -724,6 +726,7 @@ const RestoreSkillsDialog: React.FC = ({ const ImportSkillsDialog: React.FC = ({ skills, + isImporting, onImport, onClose, }) => { @@ -846,10 +849,13 @@ const ImportSkillsDialog: React.FC = ({
- -
diff --git a/src/hooks/useSkills.helpers.ts b/src/hooks/useSkills.helpers.ts new file mode 100644 index 000000000..01c7e011c --- /dev/null +++ b/src/hooks/useSkills.helpers.ts @@ -0,0 +1,19 @@ +import type { InstalledSkill } from "@/lib/api/skills"; + +/** + * 合并本次导入结果到已安装缓存,按 id 去重。 + * + * 同一技能重复出现时以新记录为准,避免 mutation 被重复触发时 + * 在 installed 列表中看到重复条目。imported 为空时返回原引用, + * 让 React Query 跳过无谓的订阅者通知。 + */ +export function mergeImportedSkills( + existing: InstalledSkill[] | undefined, + imported: InstalledSkill[], +): InstalledSkill[] { + if (!existing) return imported; + if (imported.length === 0) return existing; + const importedIds = new Set(imported.map((s) => s.id)); + const preserved = existing.filter((s) => !importedIds.has(s.id)); + return [...preserved, ...imported]; +} diff --git a/src/hooks/useSkills.ts b/src/hooks/useSkills.ts index ea641f099..095f14328 100644 --- a/src/hooks/useSkills.ts +++ b/src/hooks/useSkills.ts @@ -14,6 +14,7 @@ import { type SkillsShSearchResult, } from "@/lib/api/skills"; import type { AppId } from "@/lib/api/types"; +import { mergeImportedSkills } from "@/hooks/useSkills.helpers"; /** * 查询所有已安装的 Skills @@ -208,10 +209,7 @@ export function useImportSkillsFromApps() { // 直接更新 installed 缓存 queryClient.setQueryData( ["skills", "installed"], - (oldData) => { - if (!oldData) return importedSkills; - return [...oldData, ...importedSkills]; - }, + (oldData) => mergeImportedSkills(oldData, importedSkills), ); // 刷新 unmanaged 列表(已被导入的应该移除) queryClient.invalidateQueries({ queryKey: ["skills", "unmanaged"] }); diff --git a/tests/hooks/useImportSkillsFromApps.test.tsx b/tests/hooks/useImportSkillsFromApps.test.tsx new file mode 100644 index 000000000..27eea0875 --- /dev/null +++ b/tests/hooks/useImportSkillsFromApps.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { mergeImportedSkills } from "@/hooks/useSkills.helpers"; +import type { InstalledSkill } from "@/lib/api/skills"; + +function makeSkill(overrides: Partial = {}): InstalledSkill { + return { + id: "skill-a", + name: "Skill A", + directory: "skill-a", + apps: { + claude: true, + codex: false, + gemini: false, + opencode: false, + openclaw: false, + }, + installedAt: 0, + updatedAt: 0, + ...overrides, + }; +} + +// Regression coverage for issue #2139: when a user double-clicks the import +// button (or the mutation otherwise fires twice with the same payload), the +// installed cache must not accumulate duplicate entries for the same skill. +describe("mergeImportedSkills", () => { + it("returns the imported list as-is when no cache exists yet", () => { + const imported = [makeSkill()]; + expect(mergeImportedSkills(undefined, imported)).toEqual(imported); + }); + + it("dedupes by id when the same skill is imported twice in a row", () => { + const existing = [makeSkill()]; + const secondImport = [makeSkill()]; + const merged = mergeImportedSkills(existing, secondImport); + expect(merged).toHaveLength(1); + expect(merged[0]).toBe(secondImport[0]); + }); + + it("replaces stale cache entries with fresh imports for the same id", () => { + const stale = [makeSkill({ name: "Stale Name" })]; + const fresh = [makeSkill({ name: "Fresh Name" })]; + const merged = mergeImportedSkills(stale, fresh); + expect(merged).toHaveLength(1); + expect(merged[0].name).toBe("Fresh Name"); + }); + + it("returns the existing reference unchanged when the imported list is empty", () => { + const existing = [makeSkill()]; + expect(mergeImportedSkills(existing, [])).toBe(existing); + }); + + it("appends newly imported skills without dropping existing unrelated ones", () => { + const existing = [makeSkill({ id: "skill-a", directory: "skill-a" })]; + const imported = [ + makeSkill({ id: "skill-b", directory: "skill-b", name: "Skill B" }), + ]; + const merged = mergeImportedSkills(existing, imported); + expect(merged.map((s) => s.id).sort()).toEqual(["skill-a", "skill-b"]); + }); +}); From 3ad7d0b1cc9ca46163bbcf85d0b0316027a40421 Mon Sep 17 00:00:00 2001 From: nmsn <136696700@qq.com> Date: Thu, 23 Apr 2026 10:38:08 +0800 Subject: [PATCH 071/156] fix: use TOML parser instead of regex for Codex model extraction (#2222) (#2227) * fix(codex): use TOML parser instead of regex for model extraction Regex only matched model=... on first line, TOML parser handles multiline TOML correctly. Fixes #2222 * fix(stream_check): drop unused regex::Regex import The previous commit replaced the only Regex usage in stream_check.rs with toml::Table parsing, leaving `use regex::Regex;` orphaned. Without this removal, `cargo clippy -- -D warnings` (run in CI) fails with `unused import: regex::Regex`. --------- Co-authored-by: Jason --- src-tauri/src/services/stream_check.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 3fd7d97cf..b8ce157e1 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -3,7 +3,6 @@ //! 使用流式 API 进行快速健康检查,只需接收首个 chunk 即判定成功。 use futures::StreamExt; -use regex::Regex; use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -1412,10 +1411,11 @@ impl StreamCheckService { return None; } - let re = Regex::new(r#"^model\s*=\s*["']([^"']+)["']"#).ok()?; - re.captures(config_text) - .and_then(|caps| caps.get(1)) - .map(|m| m.as_str().trim().to_string()) + let table = toml::from_str::(config_text).ok()?; + table + .get("model") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) .filter(|value| !value.is_empty()) } From 444c123ad0589b4fe5510422e1d13737b8078954 Mon Sep 17 00:00:00 2001 From: lif <1835304752@qq.com> Date: Thu, 23 Apr 2026 11:15:01 +0800 Subject: [PATCH 072/156] [codex] Stabilize Codex OAuth cache routing (#2218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Stabilize Codex OAuth cache routing Codex OAuth-backed Claude proxy requests now reuse a client-provided session identity for prompt cache routing and send Codex-like session headers when that identity exists. Generated proxy UUIDs are intentionally excluded so they do not fragment cache locality.\n\nThe same path exposed two runtime issues during validation: rustls needed an explicit process crypto provider, and Codex OAuth can return Responses SSE even when the original Claude request is non-streaming. Those are handled so cache-routed requests can complete instead of panicking or being parsed as JSON.\n\nConstraint: Official Codex uses conversation identity and Responses session headers for prompt cache routing.\nRejected: Always use generated proxy session IDs | generated IDs change per request and reduce cache reuse.\nConfidence: medium\nScope-risk: moderate\nDirective: Do not remove the client-provided-session guard unless generated session IDs become stable per conversation.\nTested: cargo test codex_oauth\nTested: Local dev app health check on 127.0.0.1:15721\nTested: Local proxy logs showed cache_read_tokens after restart\nNot-tested: Full cargo test without local cc-switch port conflict\nRelated: #2217 * feat(proxy): aggregate forced Codex OAuth SSE into JSON for non-streaming clients Narrow override on top of #2235's streaming fallback. Codex OAuth always forces upstream openai_responses into SSE, even when the original Claude request is stream:false. #2235 handles this by routing such responses through the streaming transform so the client receives text/event-stream — that avoids the 422 that JSON parsing would produce, and it also protects any other provider that unexpectedly returns SSE (the response.is_sse() guard). But for Claude SDK callers that sent stream:false, returning SSE still violates the Anthropic non-streaming contract. This commit adds an override on exactly one combination — non-streaming client + codex_oauth + openai_responses — to aggregate the upstream Responses SSE into a synthetic Responses JSON and then run the regular responses_to_anthropic non-streaming transform. All other paths, including the generic response.is_sse() fallback, remain on the streaming path from #2235. The aggregator reuses proxy::sse::take_sse_block / strip_sse_field, which support both \n\n and \r\n\r\n delimiters; a hand-rolled split("\n\n") would silently fail on real HTTPS upstreams. Tests cover the happy path, CRLF delimiters, response.failed errors, and the missing response.completed defensive branch. --------- Co-authored-by: Jason --- src-tauri/src/lib.rs | 2 + src-tauri/src/provider.rs | 3 +- src-tauri/src/proxy/forwarder.rs | 66 ++++++++- src-tauri/src/proxy/handler_context.rs | 4 + src-tauri/src/proxy/handlers.rs | 180 +++++++++++++++++++++--- src-tauri/src/proxy/providers/claude.rs | 136 ++++++++++++++++-- 6 files changed, 357 insertions(+), 34 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 72cff929a..ac2b227e9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -273,6 +273,8 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_store::Builder::new().build()) .setup(|app| { + let _ = rustls::crypto::ring::default_provider().install_default(); + // 预先刷新 Store 覆盖配置,确保后续路径读取正确(日志/数据库等) app_store::refresh_app_config_dir_override(app.handle()); panic_hook::init_app_config_dir(crate::config::get_app_config_dir()); diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index af3af3f4b..efcd1c5dc 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -258,7 +258,8 @@ pub struct ProviderMeta { pub is_full_url: Option, /// Prompt cache key for OpenAI Responses-compatible endpoints. /// When set, injected into converted Responses requests to improve cache hit rate. - /// If not set, provider ID is used automatically during Claude -> Responses conversion. + /// If not set, Codex OAuth uses the current session ID; other Claude -> Responses + /// conversions fall back to provider ID. #[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")] pub prompt_cache_key: Option, /// 累加模式应用中,该 provider 是否已写入 live config。 diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index f31bafcb7..48f2af19f 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -55,6 +55,8 @@ pub struct RequestForwarder { current_provider_id_at_start: String, /// 代理会话 ID(用于 Gemini Native shadow replay) session_id: String, + /// Session ID 是否由客户端提供;生成值不能作为上游缓存身份。 + session_client_provided: bool, /// 整流器配置 rectifier_config: RectifierConfig, /// 优化器配置 @@ -77,6 +79,7 @@ impl RequestForwarder { app_handle: Option, current_provider_id_at_start: String, session_id: String, + session_client_provided: bool, _streaming_first_byte_timeout: u64, _streaming_idle_timeout: u64, rectifier_config: RectifierConfig, @@ -92,6 +95,7 @@ impl RequestForwarder { app_handle, current_provider_id_at_start, session_id, + session_client_provided, rectifier_config, optimizer_config, copilot_optimizer_config, @@ -966,7 +970,8 @@ impl RequestForwarder { mapped_body, provider, api_format, - Some(&self.session_id), + self.session_client_provided + .then_some(self.session_id.as_str()), Some(self.gemini_shadow.as_ref()), )? } else { @@ -984,6 +989,7 @@ impl RequestForwarder { // Codex OAuth 需要注入的 ChatGPT-Account-Id(在动态 token 获取期间填充) let mut codex_oauth_account_id: Option = None; + let mut should_send_codex_oauth_session_headers = false; // 获取认证头(提前准备,用于内联替换) let mut auth_headers = if let Some(mut auth) = adapter.extract_auth(provider) { @@ -1065,6 +1071,7 @@ impl RequestForwarder { match token_result { Ok(token) => { auth = AuthInfo::new(token, AuthStrategy::CodexOAuth); + should_send_codex_oauth_session_headers = true; // 解析使用的 account_id(用于注入 ChatGPT-Account-Id header) codex_oauth_account_id = match account_id { Some(id) => Some(id), @@ -1102,6 +1109,13 @@ impl RequestForwarder { } } + let codex_oauth_session_headers = + if should_send_codex_oauth_session_headers && self.session_client_provided { + build_codex_oauth_session_headers(&self.session_id) + } else { + Vec::new() + }; + // --- Copilot 优化器:动态 header 注入 --- if let Some((ref classification, ref det_request_id, ref interaction_id)) = copilot_optimization @@ -1342,6 +1356,12 @@ impl RequestForwarder { ); } + // Codex OAuth 反代尽量对齐官方 Codex CLI 的会话路由信号。 + // 只发送客户端提供的 session_id;生成的 UUID 每次不同,反而会破坏前缀缓存。 + for (name, value) in codex_oauth_session_headers { + ordered_headers.insert(name, value); + } + // 序列化请求体 let body_bytes = serde_json::to_vec(&filtered_body) .map_err(|e| ProxyError::Internal(format!("Failed to serialize request body: {e}")))?; @@ -1773,6 +1793,28 @@ fn append_query_to_full_url(base_url: &str, query: Option<&str>) -> String { } } +fn build_codex_oauth_session_headers( + session_id: &str, +) -> Vec<(http::HeaderName, http::HeaderValue)> { + let session_id = session_id.trim(); + if session_id.is_empty() { + return Vec::new(); + } + + let mut headers = Vec::new(); + if let Ok(value) = http::HeaderValue::from_str(session_id) { + headers.push((http::HeaderName::from_static("session_id"), value.clone())); + headers.push((http::HeaderName::from_static("x-client-request-id"), value)); + } + + let window_id = format!("{session_id}:0"); + if let Ok(value) = http::HeaderValue::from_str(&window_id) { + headers.push((http::HeaderName::from_static("x-codex-window-id"), value)); + } + + headers +} + fn should_force_identity_encoding( endpoint: &str, body: &Value, @@ -1882,6 +1924,28 @@ mod tests { assert_eq!(summary, "line1 line2..."); } + #[test] + fn codex_oauth_session_headers_match_codex_cache_identity() { + let headers = build_codex_oauth_session_headers("session-123"); + let mut map = HeaderMap::new(); + for (name, value) in headers { + map.insert(name, value); + } + + assert_eq!( + map.get("session_id"), + Some(&HeaderValue::from_static("session-123")) + ); + assert_eq!( + map.get("x-client-request-id"), + Some(&HeaderValue::from_static("session-123")) + ); + assert_eq!( + map.get("x-codex-window-id"), + Some(&HeaderValue::from_static("session-123:0")) + ); + } + #[test] fn rewrite_claude_transform_endpoint_strips_beta_for_chat_completions() { let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint( diff --git a/src-tauri/src/proxy/handler_context.rs b/src-tauri/src/proxy/handler_context.rs index da2413ec4..151a8bc10 100644 --- a/src-tauri/src/proxy/handler_context.rs +++ b/src-tauri/src/proxy/handler_context.rs @@ -57,6 +57,8 @@ pub struct RequestContext { pub app_type: AppType, /// Session ID(从客户端请求提取或新生成) pub session_id: String, + /// Session ID 是否由客户端提供。生成的 UUID 不能作为上游缓存 key,否则每个请求都会换 key。 + pub session_client_provided: bool, /// 整流器配置 pub rectifier_config: RectifierConfig, /// 优化器配置 @@ -161,6 +163,7 @@ impl RequestContext { app_type_str, app_type, session_id, + session_client_provided: session_result.client_provided, rectifier_config, optimizer_config, copilot_optimizer_config, @@ -223,6 +226,7 @@ impl RequestContext { state.app_handle.clone(), self.current_provider_id.clone(), self.session_id.clone(), + self.session_client_provided, first_byte_timeout, idle_timeout, self.rectifier_config.clone(), diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 39c7243bc..ca5632c71 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -25,6 +25,7 @@ use super::{ SseUsageCollector, }, server::ProxyState, + sse::{strip_sse_field, take_sse_block}, types::*, usage::parser::TokenUsage, ProxyError, @@ -151,16 +152,29 @@ async fn handle_claude_transform( api_format: &str, ) -> Result { let status = response.status(); - let use_streaming = should_use_claude_transform_streaming( - is_stream, - response.is_sse(), - api_format, - ctx.provider - .meta - .as_ref() - .and_then(|meta| meta.provider_type.as_deref()) - == Some("codex_oauth"), - ); + let is_codex_oauth = ctx + .provider + .meta + .as_ref() + .and_then(|meta| meta.provider_type.as_deref()) + == Some("codex_oauth"); + // Codex OAuth 会把 openai_responses 响应强制升级为 SSE,即使客户端发的是 stream:false。 + // should_use_claude_transform_streaming 默认会把这个组合路由到流式转换器——虽然能避免 + // JSON parse 报 422,但会让非流客户端收到 text/event-stream,违反 Anthropic 非流语义。 + // 这里为这个特定组合打开 override:把上游 SSE 聚合成 Anthropic JSON 回给客户端,其它 + // 场景(任意上游 is_sse、非 Codex OAuth 等)仍沿用原有流式兜底。 + let aggregate_codex_oauth_responses_sse = + !is_stream && is_codex_oauth && api_format == "openai_responses"; + let use_streaming = if aggregate_codex_oauth_responses_sse { + false + } else { + should_use_claude_transform_streaming( + is_stream, + response.is_sse(), + api_format, + is_codex_oauth, + ) + }; let tool_schema_hints = transform_gemini::extract_anthropic_tool_schema_hints(original_body); let tool_schema_hints = (!tool_schema_hints.is_empty()).then_some(tool_schema_hints); @@ -255,10 +269,14 @@ async fn handle_claude_transform( let body_str = String::from_utf8_lossy(&body_bytes); - let upstream_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| { - log::error!("[Claude] 解析上游响应失败: {e}, body: {body_str}"); - ProxyError::TransformError(format!("Failed to parse upstream response: {e}")) - })?; + let upstream_response: Value = if aggregate_codex_oauth_responses_sse { + responses_sse_to_response_value(&body_str)? + } else { + serde_json::from_slice(&body_bytes).map_err(|e| { + log::error!("[Claude] 解析上游响应失败: {e}, body: {body_str}"); + ProxyError::TransformError(format!("Failed to parse upstream response: {e}")) + })? + }; // 根据 api_format 选择非流式转换器 let anthropic_response = if api_format == "openai_responses" { @@ -577,9 +595,79 @@ fn should_use_claude_transform_streaming( api_format: &str, is_codex_oauth: bool, ) -> bool { - requested_streaming - || upstream_is_sse - || (is_codex_oauth && api_format == "openai_responses") + requested_streaming || upstream_is_sse || (is_codex_oauth && api_format == "openai_responses") +} + +/// 把 OpenAI Responses SSE 流聚合成一个完整的 Responses JSON 对象,供下游转成 Anthropic +/// 非流响应。仅在 Codex OAuth 把 `stream:false` 强制升级为 SSE 的场景下调用。 +/// +/// 复用 `proxy::sse` 的 `take_sse_block`/`strip_sse_field`:`take_sse_block` 同时支持 +/// `\n\n` 与 `\r\n\r\n` 两种分隔符,`strip_sse_field` 兼容带/不带空格的字段写法。 +fn responses_sse_to_response_value(body: &str) -> Result { + let mut buffer = body.to_string(); + let mut completed_response: Option = None; + let mut output_items = Vec::new(); + + while let Some(block) = take_sse_block(&mut buffer) { + let mut event_name = ""; + let mut data_lines: Vec<&str> = Vec::new(); + + for line in block.lines() { + if let Some(evt) = strip_sse_field(line, "event") { + event_name = evt.trim(); + } else if let Some(d) = strip_sse_field(line, "data") { + data_lines.push(d); + } + } + + if data_lines.is_empty() { + continue; + } + + let data_str = data_lines.join("\n"); + if data_str.trim() == "[DONE]" { + continue; + } + + let data: Value = serde_json::from_str(&data_str).map_err(|e| { + ProxyError::TransformError(format!("Failed to parse upstream SSE event: {e}")) + })?; + + match event_name { + "response.output_item.done" => { + if let Some(item) = data.get("item") { + output_items.push(item.clone()); + } + } + "response.completed" => { + completed_response = Some(data.get("response").cloned().unwrap_or(data)); + } + "response.failed" => { + let message = data + .pointer("/response/error/message") + .and_then(|v| v.as_str()) + .unwrap_or("response.failed event received"); + return Err(ProxyError::TransformError(message.to_string())); + } + _ => {} + } + } + + let mut response = completed_response.ok_or_else(|| { + ProxyError::TransformError("No response.completed event in upstream SSE".to_string()) + })?; + + if !output_items.is_empty() { + if let Some(obj) = response.as_object_mut() { + obj.insert("output".to_string(), Value::Array(output_items)); + } else { + return Err(ProxyError::TransformError( + "response.completed payload is not an object".to_string(), + )); + } + } + + Ok(response) } // ============================================================================ @@ -665,7 +753,8 @@ async fn log_usage( #[cfg(test)] mod tests { - use super::should_use_claude_transform_streaming; + use super::{responses_sse_to_response_value, should_use_claude_transform_streaming}; + use crate::proxy::ProxyError; #[test] fn codex_oauth_responses_force_streaming_even_if_client_sent_false() { @@ -696,4 +785,59 @@ mod tests { false, )); } + + #[test] + fn responses_sse_to_response_value_collects_output_items() { + let sse = r#"event: response.output_item.done +data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","model":"gpt-5.4","output":[],"usage":{"input_tokens":10,"output_tokens":2}}} + +"#; + + let response = responses_sse_to_response_value(sse).unwrap(); + + assert_eq!(response["id"], "resp_1"); + assert_eq!(response["output"][0]["type"], "message"); + assert_eq!(response["output"][0]["content"][0]["text"], "hello"); + } + + #[test] + fn responses_sse_to_response_value_handles_crlf_delimiters() { + // 真实 HTTP SSE 按规范使用 \r\n\r\n 分隔事件;take_sse_block 必须同时处理两种分隔符, + // 否则此路径在任何标准上游(含 Codex OAuth HTTPS 后端)下都会 TransformError。 + let sse = "event: response.output_item.done\r\n\ +data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"hi\"}]}}\r\n\ +\r\n\ +event: response.completed\r\n\ +data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_crlf\",\"status\":\"completed\",\"model\":\"gpt-5.4\",\"output\":[],\"usage\":{\"input_tokens\":5,\"output_tokens\":1}}}\r\n\ +\r\n"; + + let response = responses_sse_to_response_value(sse).unwrap(); + + assert_eq!(response["id"], "resp_crlf"); + assert_eq!(response["output"][0]["type"], "message"); + assert_eq!(response["output"][0]["content"][0]["text"], "hi"); + } + + #[test] + fn responses_sse_to_response_value_returns_err_on_response_failed() { + let sse = "event: response.failed\n\ +data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"message\":\"upstream blew up\"}}}\n\n"; + + let err = responses_sse_to_response_value(sse).unwrap_err(); + match err { + ProxyError::TransformError(msg) => assert!(msg.contains("upstream blew up")), + other => panic!("expected TransformError, got {other:?}"), + } + } + + #[test] + fn responses_sse_to_response_value_errors_when_no_completed_event() { + let sse = "event: response.output_item.done\n\ +data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\"}}\n\n"; + + assert!(responses_sse_to_response_value(sse).is_err()); + } } diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index 604c9ebfe..be454c65f 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -89,6 +89,12 @@ pub fn transform_claude_request_for_api_format( session_id: Option<&str>, shadow_store: Option<&super::gemini_shadow::GeminiShadowStore>, ) -> Result { + let is_codex_oauth = provider + .meta + .as_ref() + .and_then(|m| m.provider_type.as_deref()) + == Some("codex_oauth"); + // Copilot 场景:优先从 metadata.user_id 提取 session ID 作为 cache key // 格式: "uuid_sessionId" → 提取 "_" 后面的部分作为 session 标识 // 同一会话的请求共享 cache key,提升 Copilot 缓存命中率 @@ -118,28 +124,33 @@ pub fn transform_claude_request_for_api_format( .filter(|s| !s.is_empty()) .map(|s| s.to_string()) }) + } else if is_codex_oauth { + session_id + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToString::to_string) } else { None }; - let cache_key = session_cache_key - .as_deref() - .or_else(|| { - provider - .meta - .as_ref() - .and_then(|m| m.prompt_cache_key.as_deref()) - }) - .unwrap_or(&provider.id); + let explicit_cache_key = provider + .meta + .as_ref() + .and_then(|m| m.prompt_cache_key.as_deref()); + let cache_key = if is_codex_oauth { + explicit_cache_key + .or(session_cache_key.as_deref()) + .unwrap_or(&provider.id) + } else { + session_cache_key + .as_deref() + .or(explicit_cache_key) + .unwrap_or(&provider.id) + }; match api_format { "openai_responses" => { // Codex OAuth (ChatGPT Plus/Pro 反代) 需要在请求体里强制 store: false // + include: ["reasoning.encrypted_content"],由 transform 层统一处理。 - let is_codex_oauth = provider - .meta - .as_ref() - .and_then(|m| m.provider_type.as_deref()) - == Some("codex_oauth"); super::transform_responses::anthropic_to_responses( body, Some(cache_key), @@ -1222,6 +1233,103 @@ mod tests { assert!(transformed.get("max_output_tokens").is_some()); } + #[test] + fn test_transform_claude_request_for_codex_oauth_uses_session_cache_key() { + let provider = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex" + } + }), + ProviderMeta { + api_format: Some("openai_responses".to_string()), + provider_type: Some("codex_oauth".to_string()), + ..ProviderMeta::default() + }, + ); + let body = json!({ + "model": "gpt-5.4", + "messages": [{ "role": "user", "content": "hello" }], + "max_tokens": 128 + }); + + let transformed = transform_claude_request_for_api_format( + body, + &provider, + "openai_responses", + Some("session-123"), + None, + ) + .unwrap(); + + assert_eq!(transformed["prompt_cache_key"], "session-123"); + } + + #[test] + fn test_transform_claude_request_for_codex_oauth_without_session_falls_back_to_provider_id() { + let provider = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex" + } + }), + ProviderMeta { + api_format: Some("openai_responses".to_string()), + provider_type: Some("codex_oauth".to_string()), + ..ProviderMeta::default() + }, + ); + let body = json!({ + "model": "gpt-5.4", + "messages": [{ "role": "user", "content": "hello" }], + "max_tokens": 128 + }); + + let transformed = transform_claude_request_for_api_format( + body, + &provider, + "openai_responses", + None, + None, + ) + .unwrap(); + + assert_eq!(transformed["prompt_cache_key"], provider.id); + } + + #[test] + fn test_transform_claude_request_for_codex_oauth_keeps_explicit_cache_key() { + let provider = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex" + } + }), + ProviderMeta { + api_format: Some("openai_responses".to_string()), + provider_type: Some("codex_oauth".to_string()), + prompt_cache_key: Some("explicit-cache-key".to_string()), + ..ProviderMeta::default() + }, + ); + let body = json!({ + "model": "gpt-5.4", + "messages": [{ "role": "user", "content": "hello" }], + "max_tokens": 128 + }); + + let transformed = transform_claude_request_for_api_format( + body, + &provider, + "openai_responses", + Some("session-123"), + None, + ) + .unwrap(); + + assert_eq!(transformed["prompt_cache_key"], "explicit-cache-key"); + } + #[test] fn test_transform_claude_request_for_api_format_gemini_native() { let provider = create_provider_with_meta( From 10e0772d8cf5a12cb3ae697283be80c859de6772 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20D=C3=ADaz=20Rivas?= Date: Thu, 23 Apr 2026 06:05:17 +0200 Subject: [PATCH 073/156] feat: Add Codex OAuth FAST mode toggle (#2210) * Add Codex OAuth FAST mode toggle * fix(codex-oauth): default FAST mode to off to avoid surprise quota burn service_tier="priority" consumes ChatGPT subscription quota at a higher rate. Users must now opt in explicitly rather than inherit FAST mode silently when this feature ships. --------- Co-authored-by: Jason --- src-tauri/src/provider.rs | 9 ++ src-tauri/src/proxy/providers/claude.rs | 43 +++++++++ .../proxy/providers/transform_responses.rs | 93 ++++++++++++------- src-tauri/src/services/stream_check.rs | 14 ++- .../providers/forms/ClaudeFormFields.tsx | 6 ++ .../providers/forms/CodexOAuthSection.tsx | 28 ++++++ .../providers/forms/ProviderForm.tsx | 14 ++- src/i18n/locales/en.json | 4 +- src/i18n/locales/ja.json | 4 +- src/i18n/locales/zh.json | 4 +- src/types.ts | 2 + 11 files changed, 181 insertions(+), 40 deletions(-) diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index efcd1c5dc..6e076a310 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -262,6 +262,9 @@ pub struct ProviderMeta { /// conversions fall back to provider ID. #[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")] pub prompt_cache_key: Option, + /// Codex OAuth FAST mode: inject `service_tier = "priority"` for ChatGPT Codex requests. + #[serde(rename = "codexFastMode", skip_serializing_if = "Option::is_none")] + pub codex_fast_mode: Option, /// 累加模式应用中,该 provider 是否已写入 live config。 /// `None` 表示旧数据/未知状态,`Some(false)` 表示明确仅存在于数据库中。 #[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")] @@ -277,6 +280,12 @@ pub struct ProviderMeta { } impl ProviderMeta { + /// Codex OAuth FAST mode 是否启用。默认关闭,因为 `service_tier="priority"` + /// 会按更高速率消耗 ChatGPT 订阅配额,用户需显式开启以换取更低延迟。 + pub fn codex_fast_mode_enabled(&self) -> bool { + self.codex_fast_mode.unwrap_or(false) + } + /// 解析指定托管认证供应商绑定的账号 ID。 /// /// 新版优先读取 authBinding,旧版继续兼容 githubAccountId。 diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index be454c65f..585a83990 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -151,10 +151,16 @@ pub fn transform_claude_request_for_api_format( "openai_responses" => { // Codex OAuth (ChatGPT Plus/Pro 反代) 需要在请求体里强制 store: false // + include: ["reasoning.encrypted_content"],由 transform 层统一处理。 + let codex_fast_mode = provider + .meta + .as_ref() + .map(|m| m.codex_fast_mode_enabled()) + .unwrap_or(false); super::transform_responses::anthropic_to_responses( body, Some(cache_key), is_codex_oauth, + codex_fast_mode, ) } "openai_chat" => { @@ -1330,6 +1336,43 @@ mod tests { assert_eq!(transformed["prompt_cache_key"], "explicit-cache-key"); } + #[test] + fn test_transform_claude_request_for_api_format_codex_oauth_fast_mode_off() { + let provider = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex" + } + }), + ProviderMeta { + provider_type: Some("codex_oauth".to_string()), + codex_fast_mode: Some(false), + ..ProviderMeta::default() + }, + ); + let body = json!({ + "model": "gpt-5.4", + "messages": [{ "role": "user", "content": "hello" }], + "max_tokens": 128 + }); + + let transformed = transform_claude_request_for_api_format( + body, + &provider, + "openai_responses", + None, + None, + ) + .unwrap(); + + assert_eq!(transformed["store"], json!(false)); + assert!(transformed.get("service_tier").is_none()); + assert_eq!( + transformed["include"], + json!(["reasoning.encrypted_content"]) + ); + } + #[test] fn test_transform_claude_request_for_api_format_gemini_native() { let provider = create_provider_with_meta( diff --git a/src-tauri/src/proxy/providers/transform_responses.rs b/src-tauri/src/proxy/providers/transform_responses.rs index 64d9a649a..9ed4209c9 100644 --- a/src-tauri/src/proxy/providers/transform_responses.rs +++ b/src-tauri/src/proxy/providers/transform_responses.rs @@ -17,10 +17,13 @@ use serde_json::{json, Value}; /// `is_codex_oauth`: 当目标后端是 ChatGPT Plus/Pro 反代 (`chatgpt.com/backend-api/codex`) 时为 true。 /// 该后端强制要求 `store: false`,并要求 `include` 包含 `reasoning.encrypted_content` /// 以便在无服务端状态下保持多轮 reasoning 上下文。 +/// `codex_fast_mode`: 仅在 `is_codex_oauth` 为 true 时生效,控制是否注入 +/// `service_tier = "priority"`。 pub fn anthropic_to_responses( body: Value, cache_key: Option<&str>, is_codex_oauth: bool, + codex_fast_mode: bool, ) -> Result { let mut result = json!({}); @@ -125,10 +128,15 @@ pub fn anthropic_to_responses( // (codex-rs 结构体根本没有这三个字段,OpenAI 自己的客户端不发它们) // - instructions / tools / parallel_tool_calls: 必填字段,缺则兜底默认值 // (cc-switch 的 transform 当前是"条件写入",可能产生缺失) + // - service_tier: 仅在 FAST mode 开启时写入 "priority" + // (与 OpenAI 官方 codex-rs 当前请求结构保持一致) // - stream: 必须永远 true(codex-rs 硬编码 true,且 cc-switch 的 // SSE 解析层只处理流式响应,强制覆盖避免客户端误传 false) if is_codex_oauth { result["store"] = json!(false); + if codex_fast_mode { + result["service_tier"] = json!("priority"); + } const REASONING_MARKER: &str = "reasoning.encrypted_content"; let mut includes: Vec = body @@ -520,7 +528,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false).unwrap(); + let result = anthropic_to_responses(input, None, false, true).unwrap(); assert_eq!(result["model"], "gpt-4o"); assert_eq!(result["max_output_tokens"], 1024); assert_eq!(result["input"][0]["role"], "user"); @@ -539,7 +547,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false).unwrap(); + let result = anthropic_to_responses(input, None, false, true).unwrap(); assert_eq!(result["instructions"], "You are a helpful assistant."); // system should not appear in input assert_eq!(result["input"].as_array().unwrap().len(), 1); @@ -557,7 +565,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false).unwrap(); + let result = anthropic_to_responses(input, None, false, true).unwrap(); assert_eq!(result["instructions"], "Part 1\n\nPart 2"); } @@ -574,7 +582,7 @@ mod tests { }] }); - let result = anthropic_to_responses(input, None, false).unwrap(); + let result = anthropic_to_responses(input, None, false, true).unwrap(); assert_eq!(result["tools"][0]["type"], "function"); assert_eq!(result["tools"][0]["name"], "get_weather"); assert!(result["tools"][0].get("parameters").is_some()); @@ -591,7 +599,7 @@ mod tests { "tool_choice": {"type": "any"} }); - let result = anthropic_to_responses(input, None, false).unwrap(); + let result = anthropic_to_responses(input, None, false, true).unwrap(); assert_eq!(result["tool_choice"], "required"); } @@ -604,7 +612,7 @@ mod tests { "tool_choice": {"type": "tool", "name": "get_weather"} }); - let result = anthropic_to_responses(input, None, false).unwrap(); + let result = anthropic_to_responses(input, None, false, true).unwrap(); assert_eq!(result["tool_choice"]["type"], "function"); assert_eq!(result["tool_choice"]["name"], "get_weather"); } @@ -623,7 +631,7 @@ mod tests { }] }); - let result = anthropic_to_responses(input, None, false).unwrap(); + let result = anthropic_to_responses(input, None, false, true).unwrap(); let input_arr = result["input"].as_array().unwrap(); // Should produce: assistant message (text) + function_call item @@ -653,7 +661,7 @@ mod tests { }] }); - let result = anthropic_to_responses(input, None, false).unwrap(); + let result = anthropic_to_responses(input, None, false, true).unwrap(); let input_arr = result["input"].as_array().unwrap(); // Should produce: function_call_output item (lifted) @@ -677,7 +685,7 @@ mod tests { }] }); - let result = anthropic_to_responses(input, None, false).unwrap(); + let result = anthropic_to_responses(input, None, false, true).unwrap(); let input_arr = result["input"].as_array().unwrap(); // thinking should be discarded, only text remains @@ -700,7 +708,7 @@ mod tests { }] }); - let result = anthropic_to_responses(input, None, false).unwrap(); + let result = anthropic_to_responses(input, None, false, true).unwrap(); let content = result["input"][0]["content"].as_array().unwrap(); assert_eq!(content[0]["type"], "input_text"); @@ -858,7 +866,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false).unwrap(); + let result = anthropic_to_responses(input, None, false, true).unwrap(); assert_eq!(result["model"], "o3-mini"); } @@ -870,7 +878,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, Some("my-provider-id"), false).unwrap(); + let result = anthropic_to_responses(input, Some("my-provider-id"), false, true).unwrap(); assert_eq!(result["prompt_cache_key"], "my-provider-id"); } @@ -888,7 +896,7 @@ mod tests { }] }); - let result = anthropic_to_responses(input, None, false).unwrap(); + let result = anthropic_to_responses(input, None, false, true).unwrap(); assert!(result["tools"][0].get("cache_control").is_none()); } @@ -905,7 +913,7 @@ mod tests { }] }); - let result = anthropic_to_responses(input, None, false).unwrap(); + let result = anthropic_to_responses(input, None, false, true).unwrap(); assert!(result["input"][0]["content"][0] .get("cache_control") .is_none()); @@ -967,7 +975,7 @@ mod tests { "max_tokens": 4096, "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false).unwrap(); + let result = anthropic_to_responses(input, None, false, true).unwrap(); assert_eq!(result["max_output_tokens"], 4096); assert!(result.get("max_completion_tokens").is_none()); } @@ -981,7 +989,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false).unwrap(); + let result = anthropic_to_responses(input, None, false, true).unwrap(); assert_eq!(result["reasoning"]["effort"], "xhigh"); } @@ -995,7 +1003,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false).unwrap(); + let result = anthropic_to_responses(input, None, false, true).unwrap(); assert_eq!(result["reasoning"]["effort"], "low"); } @@ -1008,7 +1016,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false).unwrap(); + let result = anthropic_to_responses(input, None, false, true).unwrap(); assert_eq!(result["reasoning"]["effort"], "low"); } @@ -1021,7 +1029,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false).unwrap(); + let result = anthropic_to_responses(input, None, false, true).unwrap(); assert_eq!(result["reasoning"]["effort"], "medium"); } @@ -1034,7 +1042,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false).unwrap(); + let result = anthropic_to_responses(input, None, false, true).unwrap(); assert_eq!(result["reasoning"]["effort"], "high"); } @@ -1047,7 +1055,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false).unwrap(); + let result = anthropic_to_responses(input, None, false, true).unwrap(); assert_eq!(result["reasoning"]["effort"], "xhigh"); } @@ -1060,7 +1068,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false).unwrap(); + let result = anthropic_to_responses(input, None, false, true).unwrap(); assert!(result.get("reasoning").is_none()); } @@ -1074,10 +1082,11 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, true).unwrap(); + let result = anthropic_to_responses(input, None, true, true).unwrap(); // store 必须显式为 false(ChatGPT 后端拒绝 true) assert_eq!(result["store"], json!(false)); + assert_eq!(result["service_tier"], json!("priority")); // include 必须包含 reasoning.encrypted_content(无服务端状态下保持多轮 reasoning) assert_eq!(result["include"], json!(["reasoning.encrypted_content"])); @@ -1093,9 +1102,10 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false).unwrap(); + let result = anthropic_to_responses(input, None, false, true).unwrap(); assert!(result.get("store").is_none()); + assert!(result.get("service_tier").is_none()); assert!(result.get("include").is_none()); } @@ -1109,7 +1119,7 @@ mod tests { "include": ["something.else", "reasoning.encrypted_content"] }); - let result = anthropic_to_responses(input, None, true).unwrap(); + let result = anthropic_to_responses(input, None, true, true).unwrap(); let includes = result["include"] .as_array() .expect("include should be array"); @@ -1130,6 +1140,21 @@ mod tests { assert_eq!(marker_count, 1, "marker 不应被重复添加(idempotent 失败)"); } + #[test] + fn test_anthropic_to_responses_codex_oauth_fast_mode_can_be_disabled() { + let input = json!({ + "model": "gpt-5-codex", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_responses(input, None, true, false).unwrap(); + + assert_eq!(result["store"], json!(false)); + assert!(result.get("service_tier").is_none()); + assert_eq!(result["include"], json!(["reasoning.encrypted_content"])); + } + #[test] fn test_anthropic_to_responses_codex_oauth_strips_max_output_tokens() { // ChatGPT Plus/Pro 反代不接受 max_output_tokens(OpenAI 官方 codex-rs 的 @@ -1141,7 +1166,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, true).unwrap(); + let result = anthropic_to_responses(input, None, true, true).unwrap(); assert!( result.get("max_output_tokens").is_none(), @@ -1159,7 +1184,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false).unwrap(); + let result = anthropic_to_responses(input, None, false, true).unwrap(); assert_eq!(result["max_output_tokens"], json!(1024)); } @@ -1177,7 +1202,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, true).unwrap(); + let result = anthropic_to_responses(input, None, true, true).unwrap(); assert!( result.get("temperature").is_none(), @@ -1195,7 +1220,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, true).unwrap(); + let result = anthropic_to_responses(input, None, true, true).unwrap(); assert!( result.get("top_p").is_none(), @@ -1212,7 +1237,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, true).unwrap(); + let result = anthropic_to_responses(input, None, true, true).unwrap(); assert_eq!( result["instructions"], @@ -1246,7 +1271,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, true).unwrap(); + let result = anthropic_to_responses(input, None, true, true).unwrap(); assert_eq!( result["instructions"], @@ -1270,7 +1295,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, true).unwrap(); + let result = anthropic_to_responses(input, None, true, true).unwrap(); assert_eq!( result["stream"], @@ -1291,7 +1316,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false).unwrap(); + let result = anthropic_to_responses(input, None, false, true).unwrap(); assert_eq!(result["temperature"], json!(0.7)); assert_eq!(result["top_p"], json!(0.9)); @@ -1307,7 +1332,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false).unwrap(); + let result = anthropic_to_responses(input, None, false, true).unwrap(); assert!( result.get("parallel_tool_calls").is_none(), diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index b8ce157e1..c4f7fea76 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -360,10 +360,20 @@ impl StreamCheckService { .as_ref() .and_then(|m| m.provider_type.as_deref()) == Some("codex_oauth"); + let codex_fast_mode = provider + .meta + .as_ref() + .map(|m| m.codex_fast_mode_enabled()) + .unwrap_or(false); let body = if is_openai_responses { - anthropic_to_responses(anthropic_body, Some(&provider.id), is_codex_oauth) - .map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))? + anthropic_to_responses( + anthropic_body, + Some(&provider.id), + is_codex_oauth, + codex_fast_mode, + ) + .map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))? } else if is_gemini_native { anthropic_to_gemini(anthropic_body) .map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))? diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index aafb2e272..08fcbc876 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -82,6 +82,8 @@ interface ClaudeFormFieldsProps { isCodexOauthAuthenticated?: boolean; selectedCodexAccountId?: string | null; onCodexAccountSelect?: (accountId: string | null) => void; + codexFastMode?: boolean; + onCodexFastModeChange?: (enabled: boolean) => void; // Template Values templateValueEntries: Array<[string, TemplateValueConfig]>; @@ -148,6 +150,8 @@ export function ClaudeFormFields({ isCodexOauthPreset, selectedCodexAccountId, onCodexAccountSelect, + codexFastMode, + onCodexFastModeChange, templateValueEntries, templateValues, templatePresetName, @@ -374,6 +378,8 @@ export function ClaudeFormFields({ )} diff --git a/src/components/providers/forms/CodexOAuthSection.tsx b/src/components/providers/forms/CodexOAuthSection.tsx index 8f66b4dca..603939ad6 100644 --- a/src/components/providers/forms/CodexOAuthSection.tsx +++ b/src/components/providers/forms/CodexOAuthSection.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, @@ -30,6 +31,10 @@ interface CodexOAuthSectionProps { selectedAccountId?: string | null; /** 账号选择回调 */ onAccountSelect?: (accountId: string | null) => void; + /** 是否开启 Codex FAST mode */ + fastModeEnabled?: boolean; + /** FAST mode 切换回调 */ + onFastModeChange?: (enabled: boolean) => void; } /** @@ -42,6 +47,8 @@ export const CodexOAuthSection: React.FC = ({ className, selectedAccountId, onAccountSelect, + fastModeEnabled = false, + onFastModeChange, }) => { const { t } = useTranslation(); const [copied, setCopied] = React.useState(false); @@ -140,6 +147,27 @@ export const CodexOAuthSection: React.FC = ({
)} + {onFastModeChange && ( +
+
+ +

+ {t("codexOauth.fastModeDescription", { + defaultValue: + 'Send service_tier="priority" for lower latency. Turn it off if the ChatGPT Codex backend rejects the parameter.', + })} +

+
+ +
+ )} + {/* 已登录账号列表 */} {hasAnyAccount && (
diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 2cb445c82..87f4b79c7 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -382,6 +382,9 @@ export function ProviderForm({ const [selectedCodexAccountId, setSelectedCodexAccountId] = useState< string | null >(() => resolveManagedAccountId(initialData?.meta, "codex_oauth")); + const [codexFastMode, setCodexFastMode] = useState( + () => initialData?.meta?.codexFastMode ?? false, + ); const { codexAuth, @@ -1115,7 +1118,7 @@ export function ProviderForm({ const providerType = templatePreset?.providerType || initialData?.meta?.providerType; - payload.meta = { + const nextMeta: ProviderMeta = { ...(baseMeta ?? {}), commonConfigEnabled: appId === "claude" @@ -1146,6 +1149,7 @@ export function ProviderForm({ isCopilotProvider && selectedGitHubAccountId ? selectedGitHubAccountId : undefined, + codexFastMode: isCodexOauthProvider ? codexFastMode : undefined, testConfig: testConfig.enabled ? testConfig : undefined, costMultiplier: pricingConfig.enabled ? pricingConfig.costMultiplier @@ -1170,6 +1174,12 @@ export function ProviderForm({ : undefined, }; + if (!isCodexOauthProvider && "codexFastMode" in nextMeta) { + delete nextMeta.codexFastMode; + } + + payload.meta = nextMeta; + await onSubmit(payload); }; @@ -1735,6 +1745,8 @@ export function ProviderForm({ isCodexOauthAuthenticated={isCodexOauthAuthenticated} selectedCodexAccountId={selectedCodexAccountId} onCodexAccountSelect={setSelectedCodexAccountId} + codexFastMode={codexFastMode} + onCodexFastModeChange={setCodexFastMode} templateValueEntries={templateValueEntries} templateValues={templateValues} templatePresetName={templatePreset?.name || ""} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index eae16cdc5..79a167f12 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -929,7 +929,9 @@ "addAnotherAccount": "Add another account", "logoutAll": "Logout all accounts", "retry": "Retry", - "copyCode": "Copy code" + "copyCode": "Copy code", + "fastMode": "FAST mode", + "fastModeDescription": "Send service_tier=\"priority\" for lower latency. Off by default — enabling it consumes your ChatGPT quota at a higher rate." }, "endpointTest": { "title": "API Endpoint Management", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 3336108ce..405d627a7 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -929,7 +929,9 @@ "addAnotherAccount": "別のアカウントを追加", "logoutAll": "すべてのアカウントをログアウト", "retry": "再試行", - "copyCode": "コードをコピー" + "copyCode": "コードをコピー", + "fastMode": "FAST モード", + "fastModeDescription": "低遅延のため service_tier=\"priority\" を送信します。既定ではオフ——オンにすると ChatGPT のクォータがより速く消費されます。" }, "endpointTest": { "title": "API エンドポイント管理", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 59caf241f..6a1abe87a 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -929,7 +929,9 @@ "addAnotherAccount": "添加其他账号", "logoutAll": "注销所有账号", "retry": "重试", - "copyCode": "复制代码" + "copyCode": "复制代码", + "fastMode": "FAST 模式", + "fastModeDescription": "发送 service_tier=\"priority\" 换取更低延迟。默认关闭——开启后会按更高速率消耗 ChatGPT 配额。" }, "endpointTest": { "title": "请求地址管理", diff --git a/src/types.ts b/src/types.ts index 9a183d140..68b938842 100644 --- a/src/types.ts +++ b/src/types.ts @@ -155,6 +155,8 @@ export interface ProviderMeta { isFullUrl?: boolean; // Prompt cache key for OpenAI Responses-compatible endpoints (improves cache hit rate) promptCacheKey?: string; + // Codex OAuth FAST mode: injects service_tier="priority" on ChatGPT Codex requests + codexFastMode?: boolean; // 供应商类型(用于识别 Copilot 等特殊供应商) providerType?: string; // GitHub Copilot 关联账号 ID(旧字段,保留兼容读取) From 59735f976bb165b9dfa1fbd43fe2e9eaf0239f62 Mon Sep 17 00:00:00 2001 From: santugege <70630899+santugege@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:21:47 +0800 Subject: [PATCH 074/156] fix(skill): resolve root-level repo skills consistently (#2231) Co-authored-by: luoyaoqi --- src-tauri/src/services/skill.rs | 150 +++++++++++++++++++++++--------- 1 file changed, 109 insertions(+), 41 deletions(-) diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 9de239d36..cfa2aa38a 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -672,36 +672,15 @@ impl SkillService { repo_branch = used_branch; // 复制到 SSOT - let mut source = temp_dir.join(&source_rel); - if !source.exists() { - // 回退:在 temp_dir 中递归查找名称匹配的目录(含 SKILL.md) - let target_name = source_rel - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(); - if let Some(found) = Self::find_skill_dir_by_name(&temp_dir, &target_name) { - log::info!( - "Skill directory '{}' not found at direct path, using fallback: {}", - target_name, - found.display() - ); - source = found; - } else if temp_dir.join("SKILL.md").exists() { - // 根级 Skill:仓库本身就是 skill,SKILL.md 直接在解压根目录 - log::info!( - "Skill directory '{}' not found, but SKILL.md exists at root, using temp_dir", - target_name, - ); - source = temp_dir.clone(); - } else { - let _ = fs::remove_dir_all(&temp_dir); - return Err(anyhow!(format_skill_error( - "SKILL_DIR_NOT_FOUND", - &[("path", &source.display().to_string())], - Some("checkRepoUrl"), - ))); - } - } + let source = Self::resolve_skill_source_dir(&temp_dir, &skill.directory).ok_or_else(|| { + let missing = temp_dir.join(&source_rel).display().to_string(); + let _ = fs::remove_dir_all(&temp_dir); + anyhow!(format_skill_error( + "SKILL_DIR_NOT_FOUND", + &[("path", &missing)], + Some("checkRepoUrl"), + )) + })?; let canonical_temp = temp_dir.canonicalize().unwrap_or_else(|_| temp_dir.clone()); let canonical_source = source.canonicalize().map_err(|_| { @@ -954,11 +933,14 @@ impl SkillService { }); let remote_skill_dir = match remote_match { - Some(rs) => temp_dir.join(&rs.directory), + Some(rs) => match Self::resolve_skill_source_dir(&temp_dir, &rs.directory) { + Some(path) => path, + None => continue, + }, None => continue, }; - if !remote_skill_dir.exists() { + if !remote_skill_dir.is_dir() { continue; } @@ -1065,15 +1047,16 @@ impl SkillService { )) })?; - let source = temp_dir.join(&remote_match.directory); - if !source.exists() { - let _ = fs::remove_dir_all(&temp_dir); - return Err(anyhow!(format_skill_error( - "SKILL_DIR_NOT_FOUND", - &[("path", &source.display().to_string())], - Some("checkRepoUrl"), - ))); - } + let source = Self::resolve_skill_source_dir(&temp_dir, &remote_match.directory) + .ok_or_else(|| { + let missing = temp_dir.join(&remote_match.directory).display().to_string(); + let _ = fs::remove_dir_all(&temp_dir); + anyhow!(format_skill_error( + "SKILL_DIR_NOT_FOUND", + &[("path", &missing)], + Some("checkRepoUrl"), + )) + })?; // 备份旧文件 let _ = Self::create_uninstall_backup(&skill); @@ -2108,6 +2091,40 @@ impl SkillService { walk(root, target_name, 0) } + /// 将 discoverable skill 的目录信息重新解析为解压目录中的真实源目录。 + /// + /// 兼容三种情况: + /// 1. `skills/foo` 这类直接相对路径; + /// 2. 仅持有安装名 `foo`,需要在仓库中递归查找真实目录; + /// 3. 仓库根目录本身就是 skill,此时回退到解压根目录。 + fn resolve_skill_source_dir(root: &Path, raw_directory: &str) -> Option { + let source_rel = Self::sanitize_skill_source_path(raw_directory)?; + let direct = root.join(&source_rel); + if direct.is_dir() { + return Some(direct); + } + + let target_name = source_rel.file_name()?.to_string_lossy().to_string(); + if let Some(found) = Self::find_skill_dir_by_name(root, &target_name) { + log::info!( + "Skill directory '{}' not found at direct path, using fallback: {}", + target_name, + found.display() + ); + return Some(found); + } + + if root.is_dir() && root.join("SKILL.md").exists() { + log::info!( + "Skill directory '{}' not found, but SKILL.md exists at root, using repo root", + target_name, + ); + return Some(root.to_path_buf()); + } + + None + } + /// 去重技能列表(基于完整 key,不同仓库的同名 skill 分开显示) fn deduplicate_discoverable_skills(skills: &mut Vec) { let mut seen = HashMap::new(); @@ -2975,3 +2992,54 @@ pub fn migrate_skills_to_ssot(db: &Arc) -> Result { Ok(count) } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn write_skill(dir: &Path, name: &str) { + fs::create_dir_all(dir).expect("create skill dir"); + fs::write( + dir.join("SKILL.md"), + format!("---\nname: {name}\ndescription: Test skill\n---\n"), + ) + .expect("write SKILL.md"); + } + + #[test] + fn resolve_skill_source_dir_returns_repo_root_for_root_level_skill() { + let temp = tempdir().expect("tempdir"); + write_skill(temp.path(), "Root Skill"); + + let resolved = SkillService::resolve_skill_source_dir(temp.path(), "last30days-skill-cn") + .expect("root-level skill should resolve to the extracted repo root"); + + assert_eq!(resolved, temp.path()); + } + + #[test] + fn resolve_skill_source_dir_returns_direct_nested_directory_when_present() { + let temp = tempdir().expect("tempdir"); + let nested = temp.path().join("skills").join("nested-skill"); + write_skill(&nested, "Nested Skill"); + + let resolved = + SkillService::resolve_skill_source_dir(temp.path(), "skills/nested-skill") + .expect("nested skill should resolve from its relative source path"); + + assert_eq!(resolved, nested); + } + + #[test] + fn resolve_skill_source_dir_falls_back_to_matching_install_name() { + let temp = tempdir().expect("tempdir"); + let nested = temp.path().join("skills").join("nested-skill"); + write_skill(&nested, "Nested Skill"); + + let resolved = SkillService::resolve_skill_source_dir(temp.path(), "nested-skill") + .expect("install name should fall back to the matching discovered skill directory"); + + assert_eq!(resolved, nested); + } +} From 010b163430735aa5d850c8e3f49313798398597d Mon Sep 17 00:00:00 2001 From: Coconut-Fish <78026974+Coconut-Fish@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:36:03 +0800 Subject: [PATCH 075/156] =?UTF-8?q?Fix/=E4=B8=80=E9=94=AE=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=A4=B1=E6=95=88=20(#2249)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * style(FailoverQueueManager): 显示供应商备注信息 * style(FailoverQueueItem): 添加供应商备注字段以支持备注信息显示 * style(FailoverQueueManager): 显示供应商备注信息 * style(FailoverQueueItem): 添加供应商备注字段以支持备注信息显示 * style(FailoverQueueManager): 更新供应商备注信息的显示样式 * style(FailoverQueueItem): 添加条件序列化以优化供应商备注字段 * fix: 优化模型状态管理,确保配置更新时正确引用最新设置 * fix(skill): improve error handling for skill source directory resolution Co-authored-by: Copilot * fix(gemini): simplify project directory retrieval in scan_sessions function * fix(useModelState): optimize latestConfigRef assignment in useModelState hook * fix(useModelState): remove unnecessary blank line in useModelState hook --------- Co-authored-by: Copilot --- src-tauri/src/services/skill.rs | 24 +++++++++---------- .../src/session_manager/providers/gemini.rs | 5 +--- .../providers/forms/hooks/useModelState.ts | 13 ++++++---- tests/hooks/useImportSkillsFromApps.test.tsx | 1 + 4 files changed, 23 insertions(+), 20 deletions(-) diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index cfa2aa38a..d395fe58f 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -672,15 +672,16 @@ impl SkillService { repo_branch = used_branch; // 复制到 SSOT - let source = Self::resolve_skill_source_dir(&temp_dir, &skill.directory).ok_or_else(|| { - let missing = temp_dir.join(&source_rel).display().to_string(); - let _ = fs::remove_dir_all(&temp_dir); - anyhow!(format_skill_error( - "SKILL_DIR_NOT_FOUND", - &[("path", &missing)], - Some("checkRepoUrl"), - )) - })?; + let source = + Self::resolve_skill_source_dir(&temp_dir, &skill.directory).ok_or_else(|| { + let missing = temp_dir.join(&source_rel).display().to_string(); + let _ = fs::remove_dir_all(&temp_dir); + anyhow!(format_skill_error( + "SKILL_DIR_NOT_FOUND", + &[("path", &missing)], + Some("checkRepoUrl"), + )) + })?; let canonical_temp = temp_dir.canonicalize().unwrap_or_else(|_| temp_dir.clone()); let canonical_source = source.canonicalize().map_err(|_| { @@ -3024,9 +3025,8 @@ mod tests { let nested = temp.path().join("skills").join("nested-skill"); write_skill(&nested, "Nested Skill"); - let resolved = - SkillService::resolve_skill_source_dir(temp.path(), "skills/nested-skill") - .expect("nested skill should resolve from its relative source path"); + let resolved = SkillService::resolve_skill_source_dir(temp.path(), "skills/nested-skill") + .expect("nested skill should resolve from its relative source path"); assert_eq!(resolved, nested); } diff --git a/src-tauri/src/session_manager/providers/gemini.rs b/src-tauri/src/session_manager/providers/gemini.rs index 73ae17868..82cb6fcd8 100644 --- a/src-tauri/src/session_manager/providers/gemini.rs +++ b/src-tauri/src/session_manager/providers/gemini.rs @@ -35,10 +35,7 @@ pub fn scan_sessions() -> Vec { }; let project_root_file = entry.path().join(".project_root"); - let project_dir = match std::fs::read_to_string(project_root_file) { - Ok(name) => Some(name), - Err(_) => None, - }; + let project_dir = std::fs::read_to_string(project_root_file).ok(); for file_entry in chat_files.flatten() { let path = file_entry.path(); diff --git a/src/components/providers/forms/hooks/useModelState.ts b/src/components/providers/forms/hooks/useModelState.ts index d7c333e9d..7d6c7dd98 100644 --- a/src/components/providers/forms/hooks/useModelState.ts +++ b/src/components/providers/forms/hooks/useModelState.ts @@ -61,6 +61,9 @@ export function useModelState({ const isUserEditingRef = useRef(false); const lastConfigRef = useRef(settingsConfig); + const latestConfigRef = useRef(settingsConfig); + + latestConfigRef.current = settingsConfig; // 初始化读取:读新键;若缺失,按兼容优先级回退 // Haiku: DEFAULT_HAIKU || SMALL_FAST || MODEL @@ -130,8 +133,8 @@ export function useModelState({ if (field === "ANTHROPIC_DEFAULT_OPUS_MODEL") setDefaultOpusModel(value); try { - const currentConfig = settingsConfig - ? JSON.parse(settingsConfig) + const currentConfig = latestConfigRef.current + ? JSON.parse(latestConfigRef.current) : { env: {} }; if (!currentConfig.env) currentConfig.env = {}; const env = currentConfig.env as Record; @@ -146,12 +149,14 @@ export function useModelState({ // 删除旧键 delete env["ANTHROPIC_SMALL_FAST_MODEL"]; - onConfigChange(JSON.stringify(currentConfig, null, 2)); + const updatedConfig = JSON.stringify(currentConfig, null, 2); + latestConfigRef.current = updatedConfig; + onConfigChange(updatedConfig); } catch (err) { console.error("Failed to update model config:", err); } }, - [settingsConfig, onConfigChange], + [onConfigChange], ); return { diff --git a/tests/hooks/useImportSkillsFromApps.test.tsx b/tests/hooks/useImportSkillsFromApps.test.tsx index 27eea0875..cd8036d4a 100644 --- a/tests/hooks/useImportSkillsFromApps.test.tsx +++ b/tests/hooks/useImportSkillsFromApps.test.tsx @@ -13,6 +13,7 @@ function makeSkill(overrides: Partial = {}): InstalledSkill { gemini: false, opencode: false, openclaw: false, + hermes: false, }, installedAt: 0, updatedAt: 0, From dc04165f18d35171da2c37c97c37cc71bfb70a59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B6=82=E7=91=9C?= <861506831@qq.com> Date: Thu, 23 Apr 2026 16:17:15 +0800 Subject: [PATCH 076/156] feat(tray): show cached provider usage in the system tray menu (#2184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Rust-side write-through usage cache Introduce an in-memory UsageCache on AppState that the existing usage query commands populate on success. The cache is read-only to the rest of the app today; the next commit consumes it from the tray menu. - New services::usage_cache module with split maps: subscription keyed by AppType, script keyed by (AppType, provider_id). - AppType gains Eq + Hash so it can be used as a HashMap key. - commands::subscription::get_subscription_quota now takes State and writes through on success (signature change is invisible to the frontend — Tauri injects State automatically). - commands::provider::queryProviderUsage body extracted into an inner async fn; the public command wraps it with write-through, covering Copilot, coding-plan, balance, and generic script paths uniformly. Cache is in-memory only; auto-query interval and the upcoming tray refresh action rebuild it after restarts. * feat(tray): surface cached usage in the system tray menu Read UsageCache populated by the previous commit and render it in three places, scoped to whatever TRAY_SECTIONS covers (Claude/Codex/Gemini): 1. Inline suffix on each provider submenu item "AnyProvider · 🟢 5h 18% / 7d 23%" 2. Disabled summary row per visible app under "Show Main" "Claude · Anthropic Official · 🟢 5h 18% / 7d 23%" 3. "Refresh all usage" menu item that triggers get_subscription_quota + queryProviderUsage for every applicable provider, then rebuilds the tray menu via the existing refresh_tray_menu path. Color encoding uses emoji (🟢 <70% / 🟠 70-89% / 🔴 ≥90%) since Tauri 2 tray labels are plain text. Missing cache entry leaves the label unchanged — tray never issues network requests when opened. Three new i18n-ready strings live in TrayTexts (en/zh/ja), following the existing pattern for tray text. Closes #2178. * feat(usage): bridge tray UsageCache writes to frontend React Query Why: tray hover triggers backend-only refresh that wrote to UsageCache but never notified the frontend, leaving main UI stale while tray showed fresh numbers. Emit a payload-carrying event after each cache write so React Query can setQueryData directly, keeping both views in sync without duplicate fetches. * fix(tray): skip hidden apps on hover refresh and drop stale disabled-script cache Address P2 findings from automated review on #2184: 1. refresh_all_usage_in_tray now filters TRAY_SECTIONS by settings.visible_apps before scheduling subscription/script queries, matching create_tray_menu and preventing wasted external API calls (and rate-limit/auth-error log noise) for apps the user has hidden. 2. format_usage_suffix only trusts the script cache when provider.meta.usage_script is still enabled; when a script is disabled/removed the cached suffix is now invalidated so the tray label no longer shows stale data indefinitely. * refactor: consolidate codex provider helpers and fix test semantics - Add Provider::is_codex_oauth() and Provider::codex_fast_mode_enabled() to eliminate duplicated meta extraction in claude.rs and stream_check.rs - Fix non-codex-oauth tests to pass codex_fast_mode=false (was true, harmless but semantically misleading) - Remove redundant is_dir() guard after resolve_skill_source_dir already guarantees the returned path is a directory * style: apply cargo fmt * fix(tray): reflect failed refreshes in cache and support Gemini flash-lite Follow-up to the tray usage-display feature addressing review feedback: - Write snapshots for both Ok(success:false) and Err paths in queryProviderUsage / get_subscription_quota so stale success data no longer persists across failed refreshes; the original Err is still returned to the frontend onError handler. - Include gemini_flash_lite tier in the tray summary with label "l". Matches the frontend SubscriptionQuotaFooter and keeps the worst emoji correct when lite is the highest utilization. - Add TIER_GEMINI_PRO / _FLASH / _FLASH_LITE constants in services/subscription.rs and reuse them in classify_gemini_model and sort_order. - Extract Provider::has_usage_script_enabled() to remove the duplicated meta.usage_script chain at two call sites. - Use db.get_provider_by_id in refresh_all_usage_in_tray instead of materialising the full provider map, and parallelise subscription and script futures via futures::future::join. - Narrow refresh_all_usage_in_tray to each section's effective current provider (script if enabled, else subscription when the provider is official). Hover refreshes now issue at most TRAY_SECTIONS.len() outbound requests. - Add 10 unit tests in tray::tests covering Claude/Codex h/w dispatch, Gemini p/f/l dispatch (including lite-only and lite-worst cases), and success/failure guards. --------- Co-authored-by: Jason --- src-tauri/src/app_config.rs | 2 +- src-tauri/src/commands/provider.rs | 44 +- src-tauri/src/commands/subscription.rs | 39 +- src-tauri/src/lib.rs | 13 +- src-tauri/src/provider.rs | 19 + src-tauri/src/proxy/providers/claude.rs | 12 +- .../proxy/providers/transform_responses.rs | 52 +- src-tauri/src/services/mod.rs | 2 + src-tauri/src/services/skill.rs | 4 - src-tauri/src/services/stream_check.rs | 12 +- src-tauri/src/services/subscription.rs | 32 +- src-tauri/src/services/usage_cache.rs | 143 ++++++ src-tauri/src/store.rs | 9 +- src-tauri/src/tray.rs | 480 +++++++++++++++++- src-tauri/tests/deeplink_import.rs | 16 +- src-tauri/tests/support.rs | 10 +- src/App.tsx | 3 + src/hooks/useUsageCacheBridge.ts | 66 +++ src/lib/query/queries.ts | 3 +- src/lib/query/subscription.ts | 7 +- src/lib/query/usage.ts | 2 + 21 files changed, 855 insertions(+), 115 deletions(-) create mode 100644 src-tauri/src/services/usage_cache.rs create mode 100644 src/hooks/useUsageCacheBridge.ts diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 0262bf2c4..4623d4e39 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -316,7 +316,7 @@ use crate::prompt_files::prompt_file_path; use crate::provider::ProviderManager; /// 应用类型 -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum AppType { Claude, diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index 565cc8bac..30db8f7ff 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -1,5 +1,5 @@ use indexmap::IndexMap; -use tauri::State; +use tauri::{Emitter, State}; use crate::app_config::AppType; use crate::commands::copilot::CopilotAuthState; @@ -153,19 +153,55 @@ pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result< #[allow(non_snake_case)] #[tauri::command] pub async fn queryProviderUsage( + app_handle: tauri::AppHandle, state: State<'_, AppState>, copilot_state: State<'_, CopilotAuthState>, #[allow(non_snake_case)] providerId: String, // 使用 camelCase 匹配前端 app: String, ) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; + // inner 可能以两种形式失败: + // 1) 返回 Ok(UsageResult { success: false, .. }) —— 业务失败(401、脚本报错等) + // 2) 返回 Err(String) —— RPC/DB/Copilot fetch_usage 等 transport 层失败 + // 两种都要把"失败"写进 UsageCache 并刷新托盘,让 format_script_summary 的 + // success 守卫生效、suffix 自然消失,避免旧 success 快照长期滞留。 + // 同时保持原始 Err 返回给前端 React Query 的 onError 回调,不吞错误。 + let inner = + query_provider_usage_inner(&state, &copilot_state, app_type.clone(), &providerId).await; + let snapshot = match &inner { + Ok(r) => r.clone(), + Err(err_msg) => crate::provider::UsageResult { + success: false, + data: None, + error: Some(err_msg.clone()), + }, + }; + let payload = serde_json::json!({ + "kind": "script", + "appType": app_type.as_str(), + "providerId": &providerId, + "data": &snapshot, + }); + if let Err(e) = app_handle.emit("usage-cache-updated", payload) { + log::error!("emit usage-cache-updated (script) 失败: {e}"); + } + state.usage_cache.put_script(app_type, providerId, snapshot); + crate::tray::schedule_tray_refresh(&app_handle); + inner +} +async fn query_provider_usage_inner( + state: &AppState, + copilot_state: &CopilotAuthState, + app_type: AppType, + provider_id: &str, +) -> Result { // 从数据库读取供应商信息,检查特殊模板类型 let providers = state .db .get_all_providers(app_type.as_str()) .map_err(|e| format!("Failed to get providers: {e}"))?; - let provider = providers.get(&providerId); + let provider = providers.get(provider_id); let usage_script = provider .and_then(|p| p.meta.as_ref()) .and_then(|m| m.usage_script.as_ref()); @@ -294,7 +330,7 @@ pub async fn queryProviderUsage( } // ── 通用 JS 脚本路径 ── - ProviderService::query_usage(state.inner(), app_type, &providerId) + ProviderService::query_usage(state, app_type, provider_id) .await .map_err(|e| e.to_string()) } @@ -406,7 +442,7 @@ pub fn update_providers_sort_order( use crate::provider::UniversalProvider; use std::collections::HashMap; -use tauri::{AppHandle, Emitter}; +use tauri::AppHandle; #[derive(Clone, serde::Serialize)] pub struct UniversalProviderSyncedEvent { diff --git a/src-tauri/src/commands/subscription.rs b/src-tauri/src/commands/subscription.rs index 00b456eb7..053045b71 100644 --- a/src-tauri/src/commands/subscription.rs +++ b/src-tauri/src/commands/subscription.rs @@ -1,10 +1,41 @@ -use crate::services::subscription::SubscriptionQuota; +use std::str::FromStr; +use tauri::{Emitter, State}; + +use crate::app_config::AppType; +use crate::services::subscription::{CredentialStatus, SubscriptionQuota}; +use crate::store::AppState; /// 查询官方订阅额度 /// /// 读取 CLI 工具已有的 OAuth 凭据并调用官方 API 获取使用额度。 -/// 不需要 AppState(不访问数据库),直接读文件 + 发 HTTP。 +/// 结果(无论业务失败还是 transport 层 Err)都会写入 `UsageCache`、通知托盘 +/// 刷新,并 emit `usage-cache-updated`,让前端 React Query 与托盘共享同一份 +/// 最新数据。失败快照写入后 `format_subscription_summary` 会通过 `success=false` +/// 守卫返回 `None`,托盘 suffix 自然消失,避免长期滞留旧配额数字。 +/// Err 原样向前端返回,React Query 的 onError 不会被吞掉。 #[tauri::command] -pub async fn get_subscription_quota(tool: String) -> Result { - crate::services::subscription::get_subscription_quota(&tool).await +pub async fn get_subscription_quota( + app: tauri::AppHandle, + state: State<'_, AppState>, + tool: String, +) -> Result { + let inner = crate::services::subscription::get_subscription_quota(&tool).await; + let snapshot = match &inner { + Ok(q) => q.clone(), + // transport 层 Err —— 凭据状态不明,用 Valid 表达"凭据没问题,是通信/parse 出错"。 + Err(err_msg) => SubscriptionQuota::error(&tool, CredentialStatus::Valid, err_msg.clone()), + }; + if let Ok(app_type) = AppType::from_str(&tool) { + let payload = serde_json::json!({ + "kind": "subscription", + "appType": app_type.as_str(), + "data": &snapshot, + }); + if let Err(e) = app.emit("usage-cache-updated", payload) { + log::error!("emit usage-cache-updated (subscription) 失败: {e}"); + } + state.usage_cache.put_subscription(app_type, snapshot); + crate::tray::schedule_tray_refresh(&app); + } + inner } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ac2b227e9..0966cd49d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -748,9 +748,16 @@ pub fn run() { // 构建托盘 let mut tray_builder = TrayIconBuilder::with_id(tray::TRAY_ID) - .on_tray_icon_event(|_tray, event| match event { - // 左键点击已通过 show_menu_on_left_click(true) 打开菜单,这里不再额外处理 - TrayIconEvent::Click { .. } => {} + .on_tray_icon_event(|tray, event| match event { + // 鼠标悬停/点击到托盘图标时,后台异步刷新用量缓存, + // 让用户下一次(或快速打开菜单的那一刻)看到较新的数字。 + // refresh_all_usage_in_tray 内部有 10 秒防抖。 + TrayIconEvent::Enter { .. } | TrayIconEvent::Click { .. } => { + let app = tray.app_handle().clone(); + tauri::async_runtime::spawn(async move { + crate::tray::refresh_all_usage_in_tray(&app).await; + }); + } _ => log::debug!("unhandled event {event:?}"), }) .menu(&menu) diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 6e076a310..60c591fe7 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -65,6 +65,25 @@ impl Provider { in_failover_queue: false, } } + + pub fn is_codex_oauth(&self) -> bool { + self.meta.as_ref().and_then(|m| m.provider_type.as_deref()) == Some("codex_oauth") + } + + pub fn codex_fast_mode_enabled(&self) -> bool { + self.meta + .as_ref() + .map(|m| m.codex_fast_mode_enabled()) + .unwrap_or(false) + } + + pub fn has_usage_script_enabled(&self) -> bool { + self.meta + .as_ref() + .and_then(|m| m.usage_script.as_ref()) + .map(|s| s.enabled) + .unwrap_or(false) + } } /// 供应商管理器 diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index 585a83990..dd2e76f94 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -89,11 +89,7 @@ pub fn transform_claude_request_for_api_format( session_id: Option<&str>, shadow_store: Option<&super::gemini_shadow::GeminiShadowStore>, ) -> Result { - let is_codex_oauth = provider - .meta - .as_ref() - .and_then(|m| m.provider_type.as_deref()) - == Some("codex_oauth"); + let is_codex_oauth = provider.is_codex_oauth(); // Copilot 场景:优先从 metadata.user_id 提取 session ID 作为 cache key // 格式: "uuid_sessionId" → 提取 "_" 后面的部分作为 session 标识 @@ -151,11 +147,7 @@ pub fn transform_claude_request_for_api_format( "openai_responses" => { // Codex OAuth (ChatGPT Plus/Pro 反代) 需要在请求体里强制 store: false // + include: ["reasoning.encrypted_content"],由 transform 层统一处理。 - let codex_fast_mode = provider - .meta - .as_ref() - .map(|m| m.codex_fast_mode_enabled()) - .unwrap_or(false); + let codex_fast_mode = provider.codex_fast_mode_enabled(); super::transform_responses::anthropic_to_responses( body, Some(cache_key), diff --git a/src-tauri/src/proxy/providers/transform_responses.rs b/src-tauri/src/proxy/providers/transform_responses.rs index 9ed4209c9..ddff5136e 100644 --- a/src-tauri/src/proxy/providers/transform_responses.rs +++ b/src-tauri/src/proxy/providers/transform_responses.rs @@ -528,7 +528,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false, true).unwrap(); + let result = anthropic_to_responses(input, None, false, false).unwrap(); assert_eq!(result["model"], "gpt-4o"); assert_eq!(result["max_output_tokens"], 1024); assert_eq!(result["input"][0]["role"], "user"); @@ -547,7 +547,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false, true).unwrap(); + let result = anthropic_to_responses(input, None, false, false).unwrap(); assert_eq!(result["instructions"], "You are a helpful assistant."); // system should not appear in input assert_eq!(result["input"].as_array().unwrap().len(), 1); @@ -565,7 +565,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false, true).unwrap(); + let result = anthropic_to_responses(input, None, false, false).unwrap(); assert_eq!(result["instructions"], "Part 1\n\nPart 2"); } @@ -582,7 +582,7 @@ mod tests { }] }); - let result = anthropic_to_responses(input, None, false, true).unwrap(); + let result = anthropic_to_responses(input, None, false, false).unwrap(); assert_eq!(result["tools"][0]["type"], "function"); assert_eq!(result["tools"][0]["name"], "get_weather"); assert!(result["tools"][0].get("parameters").is_some()); @@ -599,7 +599,7 @@ mod tests { "tool_choice": {"type": "any"} }); - let result = anthropic_to_responses(input, None, false, true).unwrap(); + let result = anthropic_to_responses(input, None, false, false).unwrap(); assert_eq!(result["tool_choice"], "required"); } @@ -612,7 +612,7 @@ mod tests { "tool_choice": {"type": "tool", "name": "get_weather"} }); - let result = anthropic_to_responses(input, None, false, true).unwrap(); + let result = anthropic_to_responses(input, None, false, false).unwrap(); assert_eq!(result["tool_choice"]["type"], "function"); assert_eq!(result["tool_choice"]["name"], "get_weather"); } @@ -631,7 +631,7 @@ mod tests { }] }); - let result = anthropic_to_responses(input, None, false, true).unwrap(); + let result = anthropic_to_responses(input, None, false, false).unwrap(); let input_arr = result["input"].as_array().unwrap(); // Should produce: assistant message (text) + function_call item @@ -661,7 +661,7 @@ mod tests { }] }); - let result = anthropic_to_responses(input, None, false, true).unwrap(); + let result = anthropic_to_responses(input, None, false, false).unwrap(); let input_arr = result["input"].as_array().unwrap(); // Should produce: function_call_output item (lifted) @@ -685,7 +685,7 @@ mod tests { }] }); - let result = anthropic_to_responses(input, None, false, true).unwrap(); + let result = anthropic_to_responses(input, None, false, false).unwrap(); let input_arr = result["input"].as_array().unwrap(); // thinking should be discarded, only text remains @@ -708,7 +708,7 @@ mod tests { }] }); - let result = anthropic_to_responses(input, None, false, true).unwrap(); + let result = anthropic_to_responses(input, None, false, false).unwrap(); let content = result["input"][0]["content"].as_array().unwrap(); assert_eq!(content[0]["type"], "input_text"); @@ -866,7 +866,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false, true).unwrap(); + let result = anthropic_to_responses(input, None, false, false).unwrap(); assert_eq!(result["model"], "o3-mini"); } @@ -878,7 +878,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, Some("my-provider-id"), false, true).unwrap(); + let result = anthropic_to_responses(input, Some("my-provider-id"), false, false).unwrap(); assert_eq!(result["prompt_cache_key"], "my-provider-id"); } @@ -896,7 +896,7 @@ mod tests { }] }); - let result = anthropic_to_responses(input, None, false, true).unwrap(); + let result = anthropic_to_responses(input, None, false, false).unwrap(); assert!(result["tools"][0].get("cache_control").is_none()); } @@ -913,7 +913,7 @@ mod tests { }] }); - let result = anthropic_to_responses(input, None, false, true).unwrap(); + let result = anthropic_to_responses(input, None, false, false).unwrap(); assert!(result["input"][0]["content"][0] .get("cache_control") .is_none()); @@ -975,7 +975,7 @@ mod tests { "max_tokens": 4096, "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false, true).unwrap(); + let result = anthropic_to_responses(input, None, false, false).unwrap(); assert_eq!(result["max_output_tokens"], 4096); assert!(result.get("max_completion_tokens").is_none()); } @@ -989,7 +989,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false, true).unwrap(); + let result = anthropic_to_responses(input, None, false, false).unwrap(); assert_eq!(result["reasoning"]["effort"], "xhigh"); } @@ -1003,7 +1003,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false, true).unwrap(); + let result = anthropic_to_responses(input, None, false, false).unwrap(); assert_eq!(result["reasoning"]["effort"], "low"); } @@ -1016,7 +1016,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false, true).unwrap(); + let result = anthropic_to_responses(input, None, false, false).unwrap(); assert_eq!(result["reasoning"]["effort"], "low"); } @@ -1029,7 +1029,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false, true).unwrap(); + let result = anthropic_to_responses(input, None, false, false).unwrap(); assert_eq!(result["reasoning"]["effort"], "medium"); } @@ -1042,7 +1042,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false, true).unwrap(); + let result = anthropic_to_responses(input, None, false, false).unwrap(); assert_eq!(result["reasoning"]["effort"], "high"); } @@ -1055,7 +1055,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false, true).unwrap(); + let result = anthropic_to_responses(input, None, false, false).unwrap(); assert_eq!(result["reasoning"]["effort"], "xhigh"); } @@ -1068,7 +1068,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false, true).unwrap(); + let result = anthropic_to_responses(input, None, false, false).unwrap(); assert!(result.get("reasoning").is_none()); } @@ -1102,7 +1102,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false, true).unwrap(); + let result = anthropic_to_responses(input, None, false, false).unwrap(); assert!(result.get("store").is_none()); assert!(result.get("service_tier").is_none()); @@ -1184,7 +1184,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false, true).unwrap(); + let result = anthropic_to_responses(input, None, false, false).unwrap(); assert_eq!(result["max_output_tokens"], json!(1024)); } @@ -1316,7 +1316,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false, true).unwrap(); + let result = anthropic_to_responses(input, None, false, false).unwrap(); assert_eq!(result["temperature"], json!(0.7)); assert_eq!(result["top_p"], json!(0.9)); @@ -1332,7 +1332,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_responses(input, None, false, true).unwrap(); + let result = anthropic_to_responses(input, None, false, false).unwrap(); assert!( result.get("parallel_tool_calls").is_none(), diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 5a6bb798f..d9fb5077c 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -16,6 +16,7 @@ pub mod skill; pub mod speedtest; pub mod stream_check; pub mod subscription; +pub mod usage_cache; pub mod usage_stats; pub mod webdav; pub mod webdav_auto_sync; @@ -30,6 +31,7 @@ pub use proxy::ProxyService; #[allow(unused_imports)] pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService}; pub use speedtest::{EndpointLatency, SpeedtestService}; +pub use usage_cache::UsageCache; #[allow(unused_imports)] pub use usage_stats::{ DailyStats, LogFilters, ModelStats, PaginatedLogs, ProviderLimitStatus, ProviderStats, diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index d395fe58f..06d159677 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -941,10 +941,6 @@ impl SkillService { None => continue, }; - if !remote_skill_dir.is_dir() { - continue; - } - let remote_hash = match Self::compute_dir_hash(&remote_skill_dir) { Ok(h) => h, Err(e) => { diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index c4f7fea76..4befbefe1 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -355,16 +355,8 @@ impl StreamCheckService { }); // Codex OAuth (ChatGPT Plus/Pro 反代) 需要 store:false + include 标记, // 否则 Stream Check 会和生产路径一样被服务端 400 拒绝。 - let is_codex_oauth = provider - .meta - .as_ref() - .and_then(|m| m.provider_type.as_deref()) - == Some("codex_oauth"); - let codex_fast_mode = provider - .meta - .as_ref() - .map(|m| m.codex_fast_mode_enabled()) - .unwrap_or(false); + let is_codex_oauth = provider.is_codex_oauth(); + let codex_fast_mode = provider.codex_fast_mode_enabled(); let body = if is_openai_responses { anthropic_to_responses( diff --git a/src-tauri/src/services/subscription.rs b/src-tauri/src/services/subscription.rs index ee4a0b115..e4530a08c 100644 --- a/src-tauri/src/services/subscription.rs +++ b/src-tauri/src/services/subscription.rs @@ -291,12 +291,22 @@ struct ApiExtraUsage { currency: Option, } -/// 已知的 Claude 用量窗口名称 +/// 已知的 Claude 用量窗口名称。`QuotaTier::name` 会是其中之一。 +pub const TIER_FIVE_HOUR: &str = "five_hour"; +pub const TIER_SEVEN_DAY: &str = "seven_day"; +pub const TIER_SEVEN_DAY_OPUS: &str = "seven_day_opus"; +pub const TIER_SEVEN_DAY_SONNET: &str = "seven_day_sonnet"; + +/// Gemini 用量分组名称(按模型而非时间窗口)。`classify_gemini_model` 输出。 +pub const TIER_GEMINI_PRO: &str = "gemini_pro"; +pub const TIER_GEMINI_FLASH: &str = "gemini_flash"; +pub const TIER_GEMINI_FLASH_LITE: &str = "gemini_flash_lite"; + const KNOWN_TIERS: &[&str] = &[ - "five_hour", - "seven_day", - "seven_day_opus", - "seven_day_sonnet", + TIER_FIVE_HOUR, + TIER_SEVEN_DAY, + TIER_SEVEN_DAY_OPUS, + TIER_SEVEN_DAY_SONNET, ]; /// 查询 Claude 官方订阅额度 @@ -993,11 +1003,11 @@ fn extract_project_id(value: &serde_json::Value) -> Option { /// 将 Gemini 模型 ID 分类为 Pro / Flash / Flash Lite fn classify_gemini_model(model_id: &str) -> &str { if model_id.contains("flash-lite") { - "gemini_flash_lite" + TIER_GEMINI_FLASH_LITE } else if model_id.contains("flash") { - "gemini_flash" + TIER_GEMINI_FLASH } else if model_id.contains("pro") { - "gemini_pro" + TIER_GEMINI_PRO } else { model_id } @@ -1152,9 +1162,9 @@ async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota { // 转换为 tiers(remainingFraction → utilization: 已用百分比) let sort_order = |name: &str| -> usize { match name { - "gemini_pro" => 0, - "gemini_flash" => 1, - "gemini_flash_lite" => 2, + TIER_GEMINI_PRO => 0, + TIER_GEMINI_FLASH => 1, + TIER_GEMINI_FLASH_LITE => 2, _ => 3, } }; diff --git a/src-tauri/src/services/usage_cache.rs b/src-tauri/src/services/usage_cache.rs new file mode 100644 index 000000000..638040fca --- /dev/null +++ b/src-tauri/src/services/usage_cache.rs @@ -0,0 +1,143 @@ +//! 托盘展示用的用量缓存(进程内、写穿式)。 +//! +//! 各 usage 查询命令成功时写入;系统托盘构建菜单时读取。不持久化, +//! 进程重启即空,由下一次自动查询或托盘悬停触发的刷新重新填充。 + +use std::collections::HashMap; +use std::sync::RwLock; + +use crate::app_config::AppType; +use crate::provider::UsageResult; +use crate::services::subscription::SubscriptionQuota; + +#[derive(Default)] +pub struct UsageCache { + subscription: RwLock>, + script: RwLock>, +} + +impl UsageCache { + pub fn new() -> Self { + Self::default() + } + + pub fn put_subscription(&self, app_type: AppType, quota: SubscriptionQuota) { + if let Ok(mut w) = self.subscription.write() { + w.insert(app_type, quota); + } + } + + pub fn put_script(&self, app_type: AppType, provider_id: String, result: UsageResult) { + if let Ok(mut w) = self.script.write() { + w.insert((app_type, provider_id), result); + } + } + + /// 以借用形式暴露订阅快照,避免托盘每次重建时深拷贝整个 `SubscriptionQuota`。 + pub fn with_subscription( + &self, + app_type: &AppType, + f: impl FnOnce(&SubscriptionQuota) -> R, + ) -> Option { + self.subscription + .read() + .ok() + .and_then(|r| r.get(app_type).map(f)) + } + + /// 以借用形式暴露脚本型用量结果,同上。 + pub fn with_script( + &self, + app_type: &AppType, + provider_id: &str, + f: impl FnOnce(&UsageResult) -> R, + ) -> Option { + self.script + .read() + .ok() + .and_then(|r| r.get(&(app_type.clone(), provider_id.to_string())).map(f)) + } + + pub fn invalidate_script(&self, app_type: &AppType, provider_id: &str) { + // 热路径会对每个禁用脚本的 provider 在托盘重建时调用一次:先走读锁 + // `contains_key` 快速放行"本来就不在缓存里"的常见情况,避免无谓的写锁升级。 + let key = (app_type.clone(), provider_id.to_string()); + if !self.script.read().is_ok_and(|r| r.contains_key(&key)) { + return; + } + if let Ok(mut w) = self.script.write() { + w.remove(&key); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::subscription::CredentialStatus; + + fn fake_quota() -> SubscriptionQuota { + SubscriptionQuota { + tool: "claude".to_string(), + credential_status: CredentialStatus::Valid, + credential_message: None, + success: true, + tiers: vec![], + extra_usage: None, + error: None, + queried_at: Some(0), + } + } + + fn fake_result() -> UsageResult { + UsageResult { + success: true, + data: None, + error: None, + } + } + + #[test] + fn subscription_round_trip() { + let cache = UsageCache::new(); + assert!(cache + .with_subscription(&AppType::Claude, |q| q.success) + .is_none()); + cache.put_subscription(AppType::Claude, fake_quota()); + let got = cache + .with_subscription(&AppType::Claude, |q| q.success) + .unwrap(); + assert!(got); + assert!(cache + .with_subscription(&AppType::Codex, |q| q.success) + .is_none()); + } + + #[test] + fn script_round_trip_and_invalidate() { + let cache = UsageCache::new(); + assert!(cache + .with_script(&AppType::Codex, "pid", |r| r.success) + .is_none()); + cache.put_script(AppType::Codex, "pid".to_string(), fake_result()); + assert!(cache + .with_script(&AppType::Codex, "pid", |r| r.success) + .is_some()); + cache.invalidate_script(&AppType::Codex, "pid"); + assert!(cache + .with_script(&AppType::Codex, "pid", |r| r.success) + .is_none()); + } + + #[test] + fn script_keys_isolated_by_app_type() { + let cache = UsageCache::new(); + cache.put_script(AppType::Claude, "same".to_string(), fake_result()); + assert!(cache + .with_script(&AppType::Claude, "same", |r| r.success) + .is_some()); + assert!(cache + .with_script(&AppType::Codex, "same", |r| r.success) + .is_none()); + } +} diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index 9c626a5ec..f34e1de9a 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -1,11 +1,12 @@ use crate::database::Database; -use crate::services::ProxyService; +use crate::services::{ProxyService, UsageCache}; use std::sync::Arc; /// 全局应用状态 pub struct AppState { pub db: Arc, pub proxy_service: ProxyService, + pub usage_cache: Arc, } impl AppState { @@ -13,6 +14,10 @@ impl AppState { pub fn new(db: Arc) -> Self { let proxy_service = ProxyService::new(db.clone()); - Self { db, proxy_service } + Self { + db, + proxy_service, + usage_cache: Arc::new(UsageCache::new()), + } } } diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index a1d852bc9..73088f208 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -2,13 +2,20 @@ //! //! 负责系统托盘图标和菜单的创建、更新和事件处理。 -use tauri::menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem, SubmenuBuilder}; +use once_cell::sync::Lazy; +use tauri::menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem, Submenu, SubmenuBuilder}; use tauri::{Emitter, Manager}; use crate::app_config::AppType; use crate::error::AppError; use crate::store::AppState; +/// 每个 app 分区的子菜单句柄,用于 usage 更新时就地改 label 而非整菜单重建。 +/// `create_tray_menu` 每次重建都会整表覆盖写入,保证句柄始终指向当前活跃菜单。 +static TRAY_SECTION_SUBMENUS: Lazy< + std::sync::Mutex>>, +> = Lazy::new(|| std::sync::Mutex::new(std::collections::HashMap::new())); + /// 托盘菜单文本(国际化) #[derive(Clone, Copy)] pub struct TrayTexts { @@ -84,6 +91,140 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [ }, ]; +/// 配色阈值(与前端 `utilizationColor` 语义一致)。 +const UTIL_WARN_PCT: f64 = 70.0; +const UTIL_DANGER_PCT: f64 = 90.0; + +fn emoji_for_utilization(pct: f64) -> &'static str { + if pct >= UTIL_DANGER_PCT { + "\u{1F534}" // 🔴 + } else if pct >= UTIL_WARN_PCT { + "\u{1F7E0}" // 🟠 + } else { + "\u{1F7E2}" // 🟢 + } +} + +fn format_subscription_summary( + quota: &crate::services::subscription::SubscriptionQuota, +) -> Option { + use crate::services::subscription::{ + TIER_FIVE_HOUR, TIER_GEMINI_FLASH, TIER_GEMINI_FLASH_LITE, TIER_GEMINI_PRO, TIER_SEVEN_DAY, + }; + if !quota.success { + return None; + } + + // 按 tool 选取主卡槽 tier 并映射到短 label: + // Claude / Codex 沿用时间窗口(h=5 小时,w=7 天); + // Gemini 用模型维度(p=pro,f=flash,l=flash-lite)——Gemini 后端 tier + // 命名是 gemini_pro / gemini_flash / gemini_flash_lite,与时间窗口不同命名空间。 + // flash_lite 必须纳入:否则 lite 利用率最高时色标偏低,与前端 footer 行为不一致。 + let parts: Vec<(&'static str, f64)> = match quota.tool.as_str() { + "gemini" => { + let mut v = Vec::new(); + if let Some(t) = quota.tiers.iter().find(|t| t.name == TIER_GEMINI_PRO) { + v.push(("p", t.utilization)); + } + if let Some(t) = quota.tiers.iter().find(|t| t.name == TIER_GEMINI_FLASH) { + v.push(("f", t.utilization)); + } + if let Some(t) = quota + .tiers + .iter() + .find(|t| t.name == TIER_GEMINI_FLASH_LITE) + { + v.push(("l", t.utilization)); + } + v + } + _ => { + let mut v = Vec::new(); + if let Some(t) = quota.tiers.iter().find(|t| t.name == TIER_FIVE_HOUR) { + v.push(("h", t.utilization)); + } + if let Some(t) = quota.tiers.iter().find(|t| t.name == TIER_SEVEN_DAY) { + v.push(("w", t.utilization)); + } + v + } + }; + + if parts.is_empty() { + return None; + } + + // 色标取所有已选 tier 里最高的利用率——用户更关心"离上限多近"。 + let worst = parts + .iter() + .map(|(_, u)| *u) + .fold(f64::NEG_INFINITY, f64::max); + if !worst.is_finite() { + return None; + } + + let emoji = emoji_for_utilization(worst); + let body = parts + .iter() + .map(|(label, u)| format!("{label}{}%", u.round() as i64)) + .collect::>() + .join(" "); + Some(format!("{emoji} {body}")) +} + +fn format_script_summary(result: &crate::provider::UsageResult) -> Option { + if !result.success { + return None; + } + let data = result.data.as_ref()?.first()?; + let pct = match (data.used, data.total) { + (Some(used), Some(total)) if total > 0.0 => used / total * 100.0, + _ => return None, + }; + let emoji = emoji_for_utilization(pct); + let plan = data.plan_name.as_deref().unwrap_or(""); + let rounded = pct.round() as i64; + if plan.is_empty() { + Some(format!("{} {}%", emoji, rounded)) + } else { + Some(format!("{} {} {}%", emoji, plan, rounded)) + } +} + +fn format_usage_suffix( + app_state: &AppState, + app_type: &AppType, + provider: &crate::provider::Provider, + provider_id: &str, +) -> Option { + // 当前脚本是否启用:禁用/删除时不再沿用旧 UsageCache 结果, + // 并顺手 invalidate,防止后续重建继续命中过期数据。 + if provider.has_usage_script_enabled() { + // 脚本缓存优先(覆盖 Copilot/coding_plan/balance/自定义脚本),借用访问避免克隆整条 UsageResult。 + if let Some(Some(s)) = + app_state + .usage_cache + .with_script(app_type, provider_id, format_script_summary) + { + return Some(format!(" · {s}")); + } + } else { + app_state + .usage_cache + .invalidate_script(app_type, provider_id); + } + + if provider.category.as_deref() == Some("official") { + if let Some(Some(s)) = app_state + .usage_cache + .with_subscription(app_type, format_subscription_summary) + { + return Some(format!(" · {s}")); + } + } + None +} + /// 对供应商列表排序:sort_index → created_at → name fn sort_providers( providers: &indexmap::IndexMap, @@ -291,6 +432,8 @@ pub fn create_tray_menu( let visible_apps = app_settings.visible_apps.unwrap_or_default(); let mut menu_builder = MenuBuilder::new(app); + let mut section_handles: std::collections::HashMap> = + std::collections::HashMap::new(); // 顶部:打开主界面 let show_main_item = @@ -323,10 +466,13 @@ pub fn create_tray_menu( })?; menu_builder = menu_builder.item(&empty_item); } else { - // 有供应商:构建子菜单 - let current_name = providers.get(¤t_id).map(|p| p.name.as_str()); - let submenu_label = match current_name { - Some(name) => format!("{} · {}", section.header_label, name), + let current_provider = providers.get(¤t_id); + let submenu_label = match current_provider { + Some(p) => { + let suffix = format_usage_suffix(app_state, §ion.app_type, p, ¤t_id) + .unwrap_or_default(); + format!("{} · {}{}", section.header_label, p.name, suffix) + } None => section.header_label.to_string(), }; let submenu_id = format!("submenu_{}", app_type_str); @@ -369,6 +515,7 @@ pub fn create_tray_menu( let submenu = submenu_builder.build().map_err(|e| { AppError::Message(format!("构建{}子菜单失败: {e}", section.log_name)) })?; + section_handles.insert(section.app_type.clone(), submenu.clone()); menu_builder = menu_builder.item(&submenu); } @@ -393,9 +540,51 @@ pub fn create_tray_menu( menu_builder = menu_builder.item(&quit_item); - menu_builder + let menu = menu_builder .build() - .map_err(|e| AppError::Message(format!("构建菜单失败: {e}"))) + .map_err(|e| AppError::Message(format!("构建菜单失败: {e}")))?; + + *TRAY_SECTION_SUBMENUS + .lock() + .unwrap_or_else(|p| p.into_inner()) = section_handles; + + Ok(menu) +} + +/// 就地更新各 app 分区子菜单的标题(usage 后缀变化时走这条), +/// 避免 `set_menu` 导致用户打开中的菜单被关闭。 +/// 句柄由上一次 `create_tray_menu` 填充;为空(从未构建过菜单)时无事发生。 +fn update_tray_usage_labels(app: &tauri::AppHandle) { + let Some(app_state) = app.try_state::() else { + return; + }; + let handles = match TRAY_SECTION_SUBMENUS.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + + for section in TRAY_SECTIONS.iter() { + let Some(submenu) = handles.get(§ion.app_type) else { + continue; + }; + let Ok(providers) = app_state.db.get_all_providers(section.app_type.as_str()) else { + continue; + }; + let Ok(Some(current_id)) = + crate::settings::get_effective_current_provider(&app_state.db, §ion.app_type) + else { + continue; + }; + let Some(provider) = providers.get(¤t_id) else { + continue; + }; + let suffix = format_usage_suffix(&app_state, §ion.app_type, provider, ¤t_id) + .unwrap_or_default(); + let new_label = format!("{} · {}{}", section.header_label, provider.name, suffix); + if let Err(e) = submenu.set_text(&new_label) { + log::debug!("[Tray] 更新{}子菜单标题失败: {e}", section.log_name); + } + } } pub fn refresh_tray_menu(app: &tauri::AppHandle) { @@ -412,17 +601,6 @@ pub fn refresh_tray_menu(app: &tauri::AppHandle) { } } -#[cfg(test)] -mod tests { - use super::TRAY_ID; - - #[test] - fn tray_id_is_unique_to_app() { - assert_eq!(TRAY_ID, "cc-switch"); - assert_ne!(TRAY_ID, "main"); - } -} - #[cfg(target_os = "macos")] pub fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) { use tauri::ActivationPolicy; @@ -491,3 +669,269 @@ pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) { } } } + +static LAST_TRAY_USAGE_REFRESH: std::sync::Mutex> = + std::sync::Mutex::new(None); +const MIN_TRAY_USAGE_REFRESH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10); + +/// 合并多次快速触发的"usage 标题软更新":批量刷新期间多个 usage 命令 +/// 同时成功时,只会产生一次就地 `set_text` 批量调用。走软更新而不是 +/// `refresh_tray_menu` 整建,避免用户打开中的菜单被 macOS 系统关闭。 +static TRAY_REBUILD_SCHEDULED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +pub fn schedule_tray_refresh(app: &tauri::AppHandle) { + use std::sync::atomic::Ordering; + if TRAY_REBUILD_SCHEDULED.swap(true, Ordering::AcqRel) { + return; + } + let app = app.clone(); + tauri::async_runtime::spawn_blocking(move || { + // 50ms 合窗:让同一轮 React Query / 托盘批量刷新触发的多个写入 + // 共享一次标题更新。 + std::thread::sleep(std::time::Duration::from_millis(50)); + TRAY_REBUILD_SCHEDULED.store(false, Ordering::Release); + update_tray_usage_labels(&app); + }); +} + +/// 并行刷新每个可见 app "当前 provider" 的用量;成功 / 失败结果都通过各 +/// command 的 write-through 逻辑写入 `UsageCache`,单次重建菜单由 +/// `schedule_tray_refresh` 做合并。内部 10 秒节流防止鼠标悬停反复进出时 +/// 雪崩请求;互斥锁被毒化时以上次状态为准继续推进,不会永久阻塞。 +/// +/// 刷新面与 `format_usage_suffix` 的展示面严格对齐 —— 每次悬停最多发 +/// `TRAY_SECTIONS.len()` 次外部请求,script 优先(覆盖 coding_plan / balance / +/// Copilot / 自定义脚本),否则当前 provider 必须是 `official` 才查订阅。 +pub(crate) async fn refresh_all_usage_in_tray(app: &tauri::AppHandle) { + use crate::commands::CopilotAuthState; + use futures::future::join_all; + + { + let mut guard = LAST_TRAY_USAGE_REFRESH + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let now = std::time::Instant::now(); + if let Some(last) = *guard { + if now.duration_since(last) < MIN_TRAY_USAGE_REFRESH_INTERVAL { + return; + } + } + *guard = Some(now); + } + + let Some(app_state) = app.try_state::() else { + return; + }; + + // 与 `create_tray_menu` 保持一致:用户隐藏的 app 不参与外部 API 查询, + // 避免在未使用的 app 上浪费请求、撞 rate limit 或反复触发鉴权失败日志。 + let visible_apps = crate::settings::get_settings() + .visible_apps + .unwrap_or_default(); + + let mut subscription_futures = Vec::new(); + let mut script_futures = Vec::new(); + + for section in TRAY_SECTIONS.iter() { + if !visible_apps.is_visible(§ion.app_type) { + continue; + } + + let app_type_str = section.app_type.as_str(); + let log_name = section.log_name; + + // 解析 effective current provider;未设置 / 出错都静默跳过, + // 与 create_tray_menu 的行为保持一致。 + let current_id = + match crate::settings::get_effective_current_provider(&app_state.db, §ion.app_type) + { + Ok(Some(id)) => id, + Ok(None) => continue, + Err(e) => { + log::warn!("[Tray] 读取{log_name}当前供应商失败: {e}"); + continue; + } + }; + // 只需当前 provider —— by-id 查询避免把整个 app 的 provider 列表加载 + // 进内存(每次悬停 × 3 sections 的热路径)。 + let current = match app_state.db.get_provider_by_id(¤t_id, app_type_str) { + Ok(Some(p)) => p, + Ok(None) => continue, + Err(e) => { + log::warn!("[Tray] 读取{log_name}当前供应商失败: {e}"); + continue; + } + }; + + // 与 format_usage_suffix 同一优先级:脚本启用 → 查脚本; + // 否则当前 provider 是 official → 查订阅;其它情况不发请求。 + if current.has_usage_script_enabled() { + let app_clone = app.clone(); + let state = app.state::(); + let copilot_state = app.state::(); + let provider_id = current_id.clone(); + let app_str = app_type_str.to_string(); + script_futures.push(async move { + if let Err(e) = crate::commands::queryProviderUsage( + app_clone, + state, + copilot_state, + provider_id.clone(), + app_str, + ) + .await + { + log::debug!("[Tray] 刷新{log_name}供应商 {provider_id} 用量失败: {e}"); + } + }); + } else if current.category.as_deref() == Some("official") { + let app_clone = app.clone(); + let state = app.state::(); + let tool = app_type_str.to_string(); + subscription_futures.push(async move { + if let Err(e) = + crate::commands::get_subscription_quota(app_clone, state, tool).await + { + log::debug!("[Tray] 刷新{log_name}订阅用量失败(可能未登录): {e}"); + } + }); + } + } + + // 两组并行启动,整体等待 —— 订阅/脚本互不依赖,没必要串行。 + futures::future::join(join_all(subscription_futures), join_all(script_futures)).await; +} + +#[cfg(test)] +mod tests { + use super::{format_subscription_summary, TRAY_ID}; + use crate::services::subscription::{CredentialStatus, QuotaTier, SubscriptionQuota}; + + #[test] + fn tray_id_is_unique_to_app() { + assert_eq!(TRAY_ID, "cc-switch"); + assert_ne!(TRAY_ID, "main"); + } + + fn make_quota(tool: &str, success: bool, tiers: Vec) -> SubscriptionQuota { + SubscriptionQuota { + tool: tool.to_string(), + credential_status: CredentialStatus::Valid, + credential_message: None, + success, + tiers, + extra_usage: None, + error: None, + queried_at: Some(0), + } + } + + fn tier(name: &str, utilization: f64) -> QuotaTier { + QuotaTier { + name: name.to_string(), + utilization, + resets_at: None, + } + } + + #[test] + fn claude_summary_uses_h_and_w_labels() { + let quota = make_quota( + "claude", + true, + vec![tier("five_hour", 9.0), tier("seven_day", 27.0)], + ); + let s = format_subscription_summary("a).expect("should format"); + assert!(s.contains("h9%"), "expected h9% in {s}"); + assert!(s.contains("w27%"), "expected w27% in {s}"); + } + + #[test] + fn gemini_summary_uses_p_and_f_labels() { + let quota = make_quota( + "gemini", + true, + vec![tier("gemini_pro", 15.0), tier("gemini_flash", 42.0)], + ); + let s = format_subscription_summary("a).expect("should format"); + assert!(s.contains("p15%"), "expected p15% in {s}"); + assert!(s.contains("f42%"), "expected f42% in {s}"); + } + + #[test] + fn gemini_summary_includes_all_three_tiers() { + let quota = make_quota( + "gemini", + true, + vec![ + tier("gemini_pro", 5.0), + tier("gemini_flash", 42.0), + tier("gemini_flash_lite", 80.0), + ], + ); + let s = format_subscription_summary("a).expect("should format"); + assert!(s.contains("p5%"), "expected p5% in {s}"); + assert!(s.contains("f42%"), "expected f42% in {s}"); + assert!(s.contains("l80%"), "expected l80% in {s}"); + } + + #[test] + fn gemini_summary_lite_only_still_renders() { + // flash_lite 如果是 API 返回的唯一 tier,仍应显示(避免前端 footer 能看到、 + // 托盘空白的不对称)。 + let quota = make_quota("gemini", true, vec![tier("gemini_flash_lite", 80.0)]); + let s = format_subscription_summary("a).expect("should format"); + assert!(s.contains("l80%"), "expected l80% in {s}"); + } + + #[test] + fn gemini_summary_emoji_reflects_highest_tier_including_lite() { + // lite 是利用率最高的那条 → emoji 必须是红色,不能被 pro/flash 掩盖。 + let quota = make_quota( + "gemini", + true, + vec![ + tier("gemini_pro", 10.0), + tier("gemini_flash", 20.0), + tier("gemini_flash_lite", 95.0), + ], + ); + let s = format_subscription_summary("a).unwrap(); + assert!( + s.starts_with("\u{1F534}"), + "expected red emoji (lite worst) in {s}" + ); + } + + #[test] + fn worst_emoji_reflects_highest_utilization() { + // 🔴 = \u{1F534}; 任一 tier ≥ 90% 时预期显示红色。 + let quota = make_quota( + "claude", + true, + vec![tier("five_hour", 10.0), tier("seven_day", 95.0)], + ); + let s = format_subscription_summary("a).unwrap(); + assert!(s.starts_with("\u{1F534}"), "expected red emoji in {s}"); + } + + #[test] + fn failure_quota_returns_none() { + let quota = make_quota("claude", false, vec![tier("five_hour", 50.0)]); + assert!(format_subscription_summary("a).is_none()); + } + + #[test] + fn unknown_tiers_return_none() { + let quota = make_quota("claude", true, vec![tier("one_hour", 80.0)]); + assert!(format_subscription_summary("a).is_none()); + } + + #[test] + fn gemini_without_any_known_tiers_returns_none() { + // 完全没有 pro/flash/flash_lite 三种 tier 的退化响应 → None。 + let quota = make_quota("gemini", true, vec![tier("some_future_tier", 80.0)]); + assert!(format_subscription_summary("a).is_none()); + } +} diff --git a/src-tauri/tests/deeplink_import.rs b/src-tauri/tests/deeplink_import.rs index 5a0238c61..6cee273e8 100644 --- a/src-tauri/tests/deeplink_import.rs +++ b/src-tauri/tests/deeplink_import.rs @@ -1,8 +1,6 @@ use std::sync::Arc; -use cc_switch_lib::{ - import_provider_from_deeplink, parse_deeplink_url, AppState, Database, ProxyService, -}; +use cc_switch_lib::{import_provider_from_deeplink, parse_deeplink_url, AppState, Database}; #[path = "support.rs"] mod support; @@ -18,11 +16,7 @@ fn deeplink_import_claude_provider_persists_to_db() { let request = parse_deeplink_url(url).expect("parse deeplink url"); let db = Arc::new(Database::memory().expect("create memory db")); - let proxy_service = ProxyService::new(db.clone()); - let state = AppState { - db: db.clone(), - proxy_service, - }; + let state = AppState::new(db.clone()); let provider_id = import_provider_from_deeplink(&state, request.clone()) .expect("import provider from deeplink"); @@ -58,11 +52,7 @@ fn deeplink_import_codex_provider_builds_auth_and_config() { let request = parse_deeplink_url(url).expect("parse deeplink url"); let db = Arc::new(Database::memory().expect("create memory db")); - let proxy_service = ProxyService::new(db.clone()); - let state = AppState { - db: db.clone(), - proxy_service, - }; + let state = AppState::new(db.clone()); let provider_id = import_provider_from_deeplink(&state, request.clone()) .expect("import provider from deeplink"); diff --git a/src-tauri/tests/support.rs b/src-tauri/tests/support.rs index 83aa34d13..86f8f76e0 100644 --- a/src-tauri/tests/support.rs +++ b/src-tauri/tests/support.rs @@ -1,9 +1,7 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; -use cc_switch_lib::{ - update_settings, AppSettings, AppState, Database, MultiAppConfig, ProxyService, -}; +use cc_switch_lib::{update_settings, AppSettings, AppState, Database, MultiAppConfig}; /// 为测试设置隔离的 HOME 目录,避免污染真实用户数据。 pub fn ensure_test_home() -> &'static Path { @@ -62,8 +60,7 @@ pub fn test_mutex() -> &'static Mutex<()> { #[allow(dead_code)] pub fn create_test_state() -> Result> { let db = Arc::new(Database::init()?); - let proxy_service = ProxyService::new(db.clone()); - Ok(AppState { db, proxy_service }) + Ok(AppState::new(db)) } /// 创建测试用的 AppState,并从 MultiAppConfig 迁移数据 @@ -73,6 +70,5 @@ pub fn create_test_state_with_config( ) -> Result> { let db = Arc::new(Database::init()?); db.migrate_from_json(config)?; - let proxy_service = ProxyService::new(db.clone()); - Ok(AppState { db, proxy_service }) + Ok(AppState::new(db)) } diff --git a/src/App.tsx b/src/App.tsx index f621bc651..d3295100e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -49,6 +49,7 @@ import { import { hermesApi } from "@/lib/api/hermes"; import { useProxyStatus } from "@/hooks/useProxyStatus"; import { useAutoCompact } from "@/hooks/useAutoCompact"; +import { useUsageCacheBridge } from "@/hooks/useUsageCacheBridge"; import { useLastValidValue } from "@/hooks/useLastValidValue"; import { extractErrorMessage } from "@/utils/errorUtils"; import { isTextEditableTarget } from "@/utils/domUtils"; @@ -236,6 +237,8 @@ function App() { const toolbarRef = useRef(null); const isToolbarCompact = useAutoCompact(toolbarRef); + useUsageCacheBridge(); + const promptPanelRef = useRef(null); const mcpPanelRef = useRef(null); const skillsPageRef = useRef(null); diff --git a/src/hooks/useUsageCacheBridge.ts b/src/hooks/useUsageCacheBridge.ts new file mode 100644 index 000000000..ed0deb13d --- /dev/null +++ b/src/hooks/useUsageCacheBridge.ts @@ -0,0 +1,66 @@ +import { useEffect } from "react"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { useQueryClient } from "@tanstack/react-query"; +import type { AppId } from "@/lib/api/types"; +import type { UsageResult } from "@/types"; +import type { SubscriptionQuota } from "@/types/subscription"; +import { usageKeys } from "@/lib/query/usage"; +import { subscriptionKeys } from "@/lib/query/subscription"; + +type UsageCacheUpdatedPayload = + | { + kind: "script"; + appType: AppId; + providerId: string; + data: UsageResult; + } + | { + kind: "subscription"; + appType: AppId; + data: SubscriptionQuota; + }; + +/** + * 后端 `UsageCache` 写入后会 emit `usage-cache-updated`,本 hook 把 payload 同步到 + * React Query 缓存,让托盘触发的刷新(不经前端)也能立刻反映到主界面,避免 + * React Query 与 Rust 侧两份缓存各自为战。 + */ +export function useUsageCacheBridge() { + const queryClient = useQueryClient(); + + useEffect(() => { + let unlisten: UnlistenFn | undefined; + let disposed = false; + + (async () => { + const off = await listen( + "usage-cache-updated", + (event) => { + const payload = event.payload; + if (payload.kind === "script") { + queryClient.setQueryData( + usageKeys.script(payload.providerId, payload.appType), + payload.data, + ); + } else if (payload.kind === "subscription") { + queryClient.setQueryData( + subscriptionKeys.quota(payload.appType), + payload.data, + ); + } + }, + ); + + if (disposed) { + off(); + } else { + unlisten = off; + } + })(); + + return () => { + disposed = true; + unlisten?.(); + }; + }, [queryClient]); +} diff --git a/src/lib/query/queries.ts b/src/lib/query/queries.ts index 43034d000..ef0711c36 100644 --- a/src/lib/query/queries.ts +++ b/src/lib/query/queries.ts @@ -17,6 +17,7 @@ import type { SessionMeta, SessionMessage, } from "@/types"; +import { usageKeys } from "@/lib/query/usage"; const sortProviders = ( providers: Record, @@ -113,7 +114,7 @@ export const useUsageQuery = ( : 5 * 60 * 1000; // 默认 5 分钟 const query = useQuery({ - queryKey: ["usage", providerId, appId], + queryKey: usageKeys.script(providerId, appId), queryFn: async () => usageApi.query(providerId, appId), enabled: enabled && !!providerId, refetchInterval: diff --git a/src/lib/query/subscription.ts b/src/lib/query/subscription.ts index f79dd1d6b..4262b55aa 100644 --- a/src/lib/query/subscription.ts +++ b/src/lib/query/subscription.ts @@ -7,13 +7,18 @@ import { PROVIDER_TYPES } from "@/config/constants"; const REFETCH_INTERVAL = 5 * 60 * 1000; // 5 minutes +export const subscriptionKeys = { + all: ["subscription"] as const, + quota: (appId: AppId) => [...subscriptionKeys.all, "quota", appId] as const, +}; + export function useSubscriptionQuota( appId: AppId, enabled: boolean, autoQuery = false, ) { return useQuery({ - queryKey: ["subscription", "quota", appId], + queryKey: subscriptionKeys.quota(appId), queryFn: () => subscriptionApi.getQuota(appId), enabled: enabled && ["claude", "codex", "gemini"].includes(appId), refetchInterval: autoQuery ? REFETCH_INTERVAL : false, diff --git a/src/lib/query/usage.ts b/src/lib/query/usage.ts index ed565508d..22cea395a 100644 --- a/src/lib/query/usage.ts +++ b/src/lib/query/usage.ts @@ -106,6 +106,8 @@ export const usageKeys = { pricing: () => [...usageKeys.all, "pricing"] as const, limits: (providerId: string, appType: string) => [...usageKeys.all, "limits", providerId, appType] as const, + script: (providerId: string, appType: string) => + [...usageKeys.all, providerId, appType] as const, }; // Hooks From cdcc423122380c60cb4238bbb05b0d1e93c42cc4 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 23 Apr 2026 11:59:25 +0800 Subject: [PATCH 077/156] refactor(hermes): drop config health check scanner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Hermes config.yaml schema has stabilized and users have migrated to the current provider fields, so the value of scanning for model.provider dangling references, custom_providers shape errors, v12 migration residue etc. no longer justifies the maintenance surface — and the scan produces false positives when users keep some providers under Hermes' v12+ providers: dict (Hermes' runtime merges both shapes, but CC Switch's scanner only looked at the list form). Removes the whole HermesHealthWarning type, scan_hermes_config_health command, HermesHealthBanner React component, useHermesHealth hook, warnings field on HermesWriteOutcome, and the three helper functions (yaml_as_non_empty_str, collect_mapping_string_keys, hermes_warning) that only served the scanner. Drops the matching i18n keys in zh/en/ja and the fixInWebUI button label that only the banner used. --- src-tauri/src/commands/hermes.rs | 6 - src-tauri/src/hermes_config.rs | 341 ------------------- src-tauri/src/lib.rs | 1 - src/App.tsx | 15 +- src/components/hermes/HermesHealthBanner.tsx | 127 ------- src/hooks/useHermes.ts | 11 - src/i18n/locales/en.json | 14 - src/i18n/locales/ja.json | 14 - src/i18n/locales/zh.json | 14 - src/lib/api/hermes.ts | 11 +- src/types.ts | 11 - 11 files changed, 4 insertions(+), 561 deletions(-) delete mode 100644 src/components/hermes/HermesHealthBanner.tsx diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index ee1aebe34..4c7809cc3 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -40,12 +40,6 @@ pub fn get_hermes_live_provider( hermes_config::get_provider(&providerId).map_err(|e| e.to_string()) } -/// Scan config.yaml for known configuration hazards. -#[tauri::command] -pub fn scan_hermes_config_health() -> Result, String> { - hermes_config::scan_hermes_config_health().map_err(|e| e.to_string()) -} - // ============================================================================ // Model Configuration Commands // ============================================================================ diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs index 4e8b5bd7c..30f24267c 100644 --- a/src-tauri/src/hermes_config.rs +++ b/src-tauri/src/hermes_config.rs @@ -72,24 +72,12 @@ fn hermes_write_lock() -> &'static Mutex<()> { // Type Definitions // ============================================================================ -/// Hermes 健康检查警告 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct HermesHealthWarning { - pub code: String, - pub message: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub path: Option, -} - /// Hermes 写入结果 #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct HermesWriteOutcome { #[serde(skip_serializing_if = "Option::is_none")] pub backup_path: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub warnings: Vec, } /// Hermes model section config @@ -343,8 +331,6 @@ fn write_yaml_section_to_config_locked( atomic_write(&config_path, new_raw.as_bytes())?; - let warnings = scan_hermes_health_internal(&new_raw); - log::debug!( "Hermes config section '{}' written to {:?}", section_key, @@ -352,7 +338,6 @@ fn write_yaml_section_to_config_locked( ); Ok(HermesWriteOutcome { backup_path: backup_path.map(|p| p.display().to_string()), - warnings, }) } @@ -486,28 +471,6 @@ fn denormalize_provider_models_for_read(config: &mut serde_json::Value) { } } -/// Borrow a YAML scalar as a non-empty trimmed `&str`, or `None` when the -/// value is absent, non-string, or blank after trimming. -fn yaml_as_non_empty_str(v: &serde_yaml::Value) -> Option<&str> { - v.as_str().map(str::trim).filter(|s| !s.is_empty()) -} - -/// Extract string keys from a YAML mapping as owned strings, dropping non-string keys. -fn collect_mapping_string_keys(m: &serde_yaml::Mapping) -> Vec { - m.iter() - .filter_map(|(k, _)| k.as_str().map(str::to_string)) - .collect() -} - -/// Boilerplate-free constructor for [`HermesHealthWarning`]. -fn hermes_warning(code: &str, message: String, path: &str) -> HermesHealthWarning { - HermesHealthWarning { - code: code.to_string(), - message, - path: Some(path.to_string()), - } -} - /// Marker field injected on provider payloads sourced from Hermes v12+ /// `providers:` dict. CC Switch treats those as read-only — writes have to /// go through Hermes' own Web UI to keep its overlay semantics intact. @@ -856,160 +819,6 @@ pub fn apply_switch_defaults( set_model_config(&merged) } -// ============================================================================ -// Health Check -// ============================================================================ - -/// Scan Hermes config for known configuration hazards. -/// -/// Parse failures are reported as warnings (not errors) so the UI can -/// display them without blocking. -pub fn scan_hermes_config_health() -> Result, AppError> { - let path = get_hermes_config_path(); - if !path.exists() { - return Ok(Vec::new()); - } - let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; - Ok(scan_hermes_health_internal(&content)) -} - -fn scan_hermes_health_internal(content: &str) -> Vec { - let mut warnings = Vec::new(); - - if content.trim().is_empty() { - return warnings; - } - - let config = match serde_yaml::from_str::(content) { - Ok(cfg) => cfg, - Err(err) => { - warnings.push(hermes_warning( - "config_parse_failed", - format!("Hermes config could not be parsed as YAML: {err}"), - &get_hermes_config_path().display().to_string(), - )); - return warnings; - } - }; - - if let Some(model) = config.get("model") { - if model.get("default").is_none() && model.get("provider").is_none() { - warnings.push(hermes_warning( - "model_no_default", - "No default model or provider configured in 'model' section".to_string(), - "model", - )); - } - } - - let cp_is_mapping = config - .get("custom_providers") - .map(|v| v.is_mapping()) - .unwrap_or(false); - if cp_is_mapping { - warnings.push(hermes_warning( - "custom_providers_not_list", - "custom_providers should be a YAML list (sequence), not a mapping".to_string(), - "custom_providers", - )); - } - - let mut provider_models: HashMap> = HashMap::new(); - let mut name_counts: HashMap = HashMap::new(); - let mut base_url_counts: HashMap = HashMap::new(); - - if let Some(seq) = config.get("custom_providers").and_then(|v| v.as_sequence()) { - for item in seq { - if let Some(name) = item.get("name").and_then(yaml_as_non_empty_str) { - *name_counts.entry(name.to_string()).or_insert(0) += 1; - if let Some(models) = item.get("models").and_then(|m| m.as_mapping()) { - provider_models - .entry(name.to_string()) - .or_insert_with(|| collect_mapping_string_keys(models)); - } - } - if let Some(url) = item - .get("base_url") - .and_then(|n| n.as_str()) - .map(|s| s.trim().trim_end_matches('/').to_lowercase()) - .filter(|s| !s.is_empty()) - { - *base_url_counts.entry(url).or_insert(0) += 1; - } - } - } - - // name_counts keys are unique by construction, so iterating it already - // reports each duplicate name exactly once — no extra dedupe set needed. - for (name, count) in &name_counts { - if *count > 1 { - warnings.push(hermes_warning( - "duplicate_provider_name", - format!( - "Duplicate provider name '{name}' in custom_providers — only one entry will be used" - ), - "custom_providers", - )); - } - } - for (url, count) in &base_url_counts { - if *count > 1 { - warnings.push(hermes_warning( - "duplicate_provider_base_url", - format!( - "Duplicate base_url '{url}' in custom_providers — possible accidental copy" - ), - "custom_providers", - )); - } - } - - if let Some(model) = config.get("model") { - if let Some(provider_ref) = model.get("provider").and_then(yaml_as_non_empty_str) { - if !name_counts.contains_key(provider_ref) { - warnings.push(hermes_warning( - "model_provider_unknown", - format!( - "model.provider '{provider_ref}' does not match any configured provider" - ), - "model.provider", - )); - } else if let Some(default) = model.get("default").and_then(yaml_as_non_empty_str) { - if let Some(ids) = provider_models.get(provider_ref) { - if !ids.is_empty() && !ids.iter().any(|id| id == default) { - warnings.push(hermes_warning( - "model_default_not_in_provider", - format!( - "model.default '{default}' is not in provider '{provider_ref}' models list" - ), - "model.default", - )); - } - } - } - } - } - - let version = config - .get("_config_version") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - let providers_dict_populated = config - .get("providers") - .and_then(|v| v.as_mapping()) - .map(|m| !m.is_empty()) - .unwrap_or(false); - if version >= 12 && providers_dict_populated { - warnings.push(hermes_warning( - "schema_migrated_v12", - "Hermes' newer schema moved some entries into the 'providers:' dict. They are shown read-only in CC Switch — edit or remove those entries via Hermes Web UI.".to_string(), - "providers", - )); - } - - warnings -} - // ============================================================================ // MCP Section Access (for mcp/hermes.rs to use in Phase 4) // ============================================================================ @@ -1727,45 +1536,6 @@ custom_providers: }); } - // ---- Health check tests ---- - - #[test] - fn health_check_on_invalid_yaml() { - let warnings = scan_hermes_health_internal("not: valid: yaml: ["); - assert!(!warnings.is_empty()); - assert_eq!(warnings[0].code, "config_parse_failed"); - } - - #[test] - fn health_check_model_no_default() { - let yaml = "model:\n context_length: 200000\n"; - let warnings = scan_hermes_health_internal(yaml); - assert!(warnings.iter().any(|w| w.code == "model_no_default")); - } - - #[test] - fn health_check_custom_providers_not_list() { - let yaml = "custom_providers:\n foo:\n base_url: http://localhost\n"; - let warnings = scan_hermes_health_internal(yaml); - assert!(warnings - .iter() - .any(|w| w.code == "custom_providers_not_list")); - } - - #[test] - fn health_check_valid_config() { - let yaml = "\ -model: - default: gpt-4 - provider: openrouter -custom_providers: - - name: openrouter - base_url: https://openrouter.ai/api/v1 -"; - let warnings = scan_hermes_health_internal(yaml); - assert!(warnings.is_empty()); - } - // ---- yaml_to_json / json_to_yaml ---- #[test] @@ -2174,115 +1944,4 @@ user_profile_enabled: false assert_eq!(user, MemoryKind::User); assert!(serde_json::from_str::("\"bogus\"").is_err()); } - - // ---- Extended health scan rules ---- - - #[test] - fn health_check_model_provider_unknown() { - let yaml = "\ -model: - default: gpt-4 - provider: ghost -custom_providers: - - name: real - base_url: https://real.example.com -"; - let warnings = scan_hermes_health_internal(yaml); - assert!(warnings.iter().any(|w| w.code == "model_provider_unknown")); - } - - #[test] - fn health_check_model_default_not_in_provider() { - let yaml = "\ -model: - default: missing-model - provider: real -custom_providers: - - name: real - base_url: https://real.example.com - models: - present-model: {} -"; - let warnings = scan_hermes_health_internal(yaml); - assert!(warnings - .iter() - .any(|w| w.code == "model_default_not_in_provider")); - } - - #[test] - fn health_check_duplicate_provider_name() { - let yaml = "\ -custom_providers: - - name: twin - base_url: https://a.example.com - - name: twin - base_url: https://b.example.com -"; - let warnings = scan_hermes_health_internal(yaml); - let dup_count = warnings - .iter() - .filter(|w| w.code == "duplicate_provider_name") - .count(); - assert_eq!(dup_count, 1, "each duplicate name should be reported once"); - } - - #[test] - fn health_check_duplicate_provider_base_url() { - let yaml = "\ -custom_providers: - - name: a - base_url: https://same.example.com/ - - name: b - base_url: https://SAME.example.com -"; - let warnings = scan_hermes_health_internal(yaml); - assert!(warnings - .iter() - .any(|w| w.code == "duplicate_provider_base_url")); - } - - #[test] - fn health_check_schema_migrated_v12_warning() { - let yaml = "\ -_config_version: 19 -providers: - ds-1: - base_url: https://api.deepseek.com -"; - let warnings = scan_hermes_health_internal(yaml); - assert!(warnings.iter().any(|w| w.code == "schema_migrated_v12")); - } - - #[test] - fn health_check_schema_migrated_not_reported_when_dict_empty() { - // v19 with empty providers: {} should NOT surface the migration warning — - // otherwise fresh installs would see it spuriously. - let yaml = "\ -_config_version: 19 -providers: {} -"; - let warnings = scan_hermes_health_internal(yaml); - assert!(!warnings.iter().any(|w| w.code == "schema_migrated_v12")); - } - - #[test] - fn health_check_valid_config_with_matching_model_and_provider() { - // Regression guard: none of the new rules should fire on a well-formed config. - let yaml = "\ -model: - default: gpt-4 - provider: openrouter -custom_providers: - - name: openrouter - base_url: https://openrouter.ai/api/v1 - models: - gpt-4: {} -"; - let warnings = scan_hermes_health_internal(yaml); - assert!( - warnings.is_empty(), - "expected no warnings, got: {:?}", - warnings - ); - } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0966cd49d..9e7b937ce 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1264,7 +1264,6 @@ pub fn run() { commands::import_hermes_providers_from_live, commands::get_hermes_live_provider_ids, commands::get_hermes_live_provider, - commands::scan_hermes_config_health, commands::get_hermes_model_config, commands::open_hermes_web_ui, commands::launch_hermes_dashboard, diff --git a/src/App.tsx b/src/App.tsx index d3295100e..c91b1983a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -41,11 +41,7 @@ import { import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env"; import { useProviderActions } from "@/hooks/useProviderActions"; import { openclawKeys, useOpenClawHealth } from "@/hooks/useOpenClaw"; -import { - hermesKeys, - useHermesHealth, - useOpenHermesWebUI, -} from "@/hooks/useHermes"; +import { hermesKeys, useOpenHermesWebUI } from "@/hooks/useHermes"; import { hermesApi } from "@/lib/api/hermes"; import { useProxyStatus } from "@/hooks/useProxyStatus"; import { useAutoCompact } from "@/hooks/useAutoCompact"; @@ -91,7 +87,6 @@ import EnvPanel from "@/components/openclaw/EnvPanel"; import ToolsPanel from "@/components/openclaw/ToolsPanel"; import AgentsDefaultsPanel from "@/components/openclaw/AgentsDefaultsPanel"; import OpenClawHealthBanner from "@/components/openclaw/OpenClawHealthBanner"; -import HermesHealthBanner from "@/components/hermes/HermesHealthBanner"; import HermesMemoryPanel from "@/components/hermes/HermesMemoryPanel"; type View = @@ -274,8 +269,6 @@ function App() { currentView === "openclawAgents"); const { data: openclawHealthWarnings = [] } = useOpenClawHealth(isOpenClawView); - const isHermesView = activeApp === "hermes" && currentView === "providers"; - const { data: hermesHealthWarnings = [] } = useHermesHealth(isHermesView); const hasSkillsSupport = true; const hasSessionSupport = activeApp === "claude" || @@ -685,9 +678,6 @@ function App() { await queryClient.invalidateQueries({ queryKey: hermesKeys.liveProviderIds, }); - await queryClient.invalidateQueries({ - queryKey: hermesKeys.health, - }); } toast.success( t("notifications.removeFromConfigSuccess", { @@ -1555,9 +1545,6 @@ function App() { {isOpenClawView && openclawHealthWarnings.length > 0 && ( )} - {isHermesView && hermesHealthWarnings.length > 0 && ( - - )} {renderContent()} diff --git a/src/components/hermes/HermesHealthBanner.tsx b/src/components/hermes/HermesHealthBanner.tsx deleted file mode 100644 index ad3750f78..000000000 --- a/src/components/hermes/HermesHealthBanner.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import React, { useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { ExternalLink, TriangleAlert } from "lucide-react"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; -import { useOpenHermesWebUI } from "@/hooks/useHermes"; -import type { HermesHealthWarning } from "@/types"; - -interface HermesHealthBannerProps { - warnings: HermesHealthWarning[]; -} - -function getWarningText( - code: string, - fallback: string, - t: ReturnType["t"], -) { - switch (code) { - case "config_parse_failed": - return t("hermes.health.parseFailed", { - defaultValue: - "config.yaml could not be parsed as valid YAML. Fix the file before editing it here.", - }); - case "config_not_found": - return t("hermes.health.configNotFound", { - defaultValue: - "Hermes config.yaml not found. Create it at ~/.hermes/config.yaml or configure the path in settings.", - }); - case "env_parse_failed": - return t("hermes.health.envParseFailed", { - defaultValue: "The .env file could not be parsed.", - }); - case "model_no_default": - return t("hermes.health.modelNoDefault", { - defaultValue: - "No default model or provider is configured in the 'model' section.", - }); - case "custom_providers_not_list": - return t("hermes.health.customProvidersNotList", { - defaultValue: - "custom_providers should be a YAML list (items prefixed with '-'), not a mapping.", - }); - case "model_provider_unknown": - return t("hermes.health.modelProviderUnknown", { - defaultValue: - "model.provider references a provider that is not configured.", - }); - case "model_default_not_in_provider": - return t("hermes.health.modelDefaultNotInProvider", { - defaultValue: - "model.default is not in the selected provider's models list.", - }); - case "duplicate_provider_name": - return t("hermes.health.duplicateProviderName", { - defaultValue: - "custom_providers contains duplicate provider names — only one entry will be used.", - }); - case "duplicate_provider_base_url": - return t("hermes.health.duplicateProviderBaseUrl", { - defaultValue: - "custom_providers contains duplicate base_urls — possible accidental copy.", - }); - case "schema_migrated_v12": - return t("hermes.health.schemaMigratedV12", { - defaultValue: - "Hermes' newer schema moved some providers into the 'providers:' dict. They are shown read-only in CC Switch — edit or remove those entries via Hermes Web UI.", - }); - default: - return fallback; - } -} - -const HermesHealthBanner: React.FC = ({ - warnings, -}) => { - const { t } = useTranslation(); - const openHermesWebUI = useOpenHermesWebUI(); - - const items = useMemo( - () => - warnings.map((warning) => ({ - ...warning, - text: getWarningText(warning.code, warning.message, t), - })), - [t, warnings], - ); - - if (warnings.length === 0) { - return null; - } - - return ( -
- - - - - {t("hermes.health.title", { - defaultValue: "Hermes config warnings detected", - })} - - - - -
    - {items.map((warning) => ( -
  • - {warning.text} - {warning.path ? ` (${warning.path})` : ""} -
  • - ))} -
-
-
-
- ); -}; - -export default HermesHealthBanner; diff --git a/src/hooks/useHermes.ts b/src/hooks/useHermes.ts index 246aba7c5..74bacf0ca 100644 --- a/src/hooks/useHermes.ts +++ b/src/hooks/useHermes.ts @@ -27,7 +27,6 @@ export const hermesKeys = { all: ["hermes"] as const, liveProviderIds: ["hermes", "liveProviderIds"] as const, modelConfig: ["hermes", "modelConfig"] as const, - health: ["hermes", "health"] as const, memory: (kind: HermesMemoryKind) => ["hermes", "memory", kind] as const, memoryLimits: ["hermes", "memoryLimits"] as const, }; @@ -41,7 +40,6 @@ export function invalidateHermesProviderCaches(queryClient: QueryClient) { return Promise.all([ queryClient.invalidateQueries({ queryKey: hermesKeys.liveProviderIds }), queryClient.invalidateQueries({ queryKey: hermesKeys.modelConfig }), - queryClient.invalidateQueries({ queryKey: hermesKeys.health }), ]); } @@ -65,15 +63,6 @@ export function useHermesModelConfig(enabled: boolean) { }); } -export function useHermesHealth(enabled: boolean) { - return useQuery({ - queryKey: hermesKeys.health, - queryFn: () => hermesApi.scanHealth(), - staleTime: 30_000, - enabled, - }); -} - export function useHermesMemory(kind: HermesMemoryKind, enabled: boolean) { return useQuery({ queryKey: hermesKeys.memory(kind), diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 79a167f12..c473ff59a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1682,26 +1682,12 @@ "open": "Open Hermes Web UI", "offline": "Hermes Web UI is not running. Start it with `hermes dashboard` first.", "openFailed": "Failed to open Hermes Web UI", - "fixInWebUI": "Fix in Hermes Web UI", "launchConfirmTitle": "Hermes Dashboard is not running", "launchConfirmMessage": "Open a terminal and start it now with `hermes dashboard`?\n\nThe browser will open automatically once startup completes.\n\nIf the terminal reports that `hermes` cannot be found or the web extras are missing, run first:\npip install hermes-agent[web]", "launchConfirmAction": "Open terminal & launch", "launching": "Started `hermes dashboard` in a terminal.", "launchFailed": "Failed to open terminal" }, - "health": { - "title": "Hermes config warnings detected", - "parseFailed": "config.yaml could not be parsed as valid YAML. Fix the file before editing it here.", - "configNotFound": "Hermes config.yaml not found. Create it at ~/.hermes/config.yaml or configure the path in settings.", - "envParseFailed": "The .env file could not be parsed.", - "modelNoDefault": "No default model or provider is configured in the 'model' section.", - "customProvidersNotList": "custom_providers should be a YAML list (items prefixed with '-'), not a mapping.", - "modelProviderUnknown": "model.provider references a provider that is not configured.", - "modelDefaultNotInProvider": "model.default is not in the selected provider's models list.", - "duplicateProviderName": "custom_providers contains duplicate provider names — only one entry will be used.", - "duplicateProviderBaseUrl": "custom_providers contains duplicate base_urls — possible accidental copy.", - "schemaMigratedV12": "Hermes' newer schema moved some providers into the 'providers:' dict. They are shown read-only in CC Switch — edit or remove those entries via Hermes Web UI." - }, "memory": { "title": "Memory", "agentTab": "Agent Memory (MEMORY.md)", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 405d627a7..1734b71da 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1682,26 +1682,12 @@ "open": "Hermes Web UI を開く", "offline": "Hermes Web UI が起動していません。まず `hermes dashboard` を実行してください。", "openFailed": "Hermes Web UI を開けませんでした", - "fixInWebUI": "Hermes Web UI で修正", "launchConfirmTitle": "Hermes Dashboard が起動していません", "launchConfirmMessage": "ターミナルを開いて `hermes dashboard` を実行しますか?\n\n起動完了後、ブラウザが自動的に開きます。\n\nターミナルに `hermes` が見つからない、または web 依存が不足するというエラーが表示される場合は、まず次を実行してください:\npip install hermes-agent[web]", "launchConfirmAction": "ターミナルを開いて起動", "launching": "ターミナルで hermes dashboard を起動しました。", "launchFailed": "ターミナルを開けませんでした" }, - "health": { - "title": "Hermes 設定の警告を検出", - "parseFailed": "config.yaml を有効な YAML として解析できません。ここで編集する前にファイルを修正してください。", - "configNotFound": "Hermes config.yaml が見つかりません。~/.hermes/config.yaml に作成するか、設定でパスを構成してください。", - "envParseFailed": ".env ファイルを解析できません。", - "modelNoDefault": "'model' セクションに既定モデルまたはプロバイダーが設定されていません。", - "customProvidersNotList": "custom_providers はマッピングではなく、YAML リスト('-' で始まる項目)である必要があります。", - "modelProviderUnknown": "model.provider が指すプロバイダーは設定に存在しません。", - "modelDefaultNotInProvider": "model.default は選択中のプロバイダーのモデル一覧に含まれていません。", - "duplicateProviderName": "custom_providers にプロバイダー名の重複があります。1 件のみ有効になります。", - "duplicateProviderBaseUrl": "custom_providers に重複した base_url があります。誤ってコピーされた可能性があります。", - "schemaMigratedV12": "Hermes の新しいスキーマでは一部のプロバイダーが 'providers:' dict に移動しました。CC Switch では読み取り専用で表示されます。編集・削除は Hermes Web UI から行ってください。" - }, "memory": { "title": "メモリ", "agentTab": "エージェント記憶 (MEMORY.md)", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 6a1abe87a..a0c81d524 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1682,26 +1682,12 @@ "open": "打开 Hermes Web UI", "offline": "Hermes Web UI 未启动,请先运行 `hermes dashboard` 启动服务。", "openFailed": "打开 Hermes Web UI 失败", - "fixInWebUI": "在 Hermes Web UI 修复", "launchConfirmTitle": "Hermes Dashboard 未启动", "launchConfirmMessage": "是否打开终端并运行 `hermes dashboard` 启动服务?\n\n启动完成后会自动打开浏览器。\n\n若终端提示找不到 hermes 或缺少 web 依赖,请先运行:\npip install hermes-agent[web]", "launchConfirmAction": "打开终端并启动", "launching": "已在终端启动 hermes dashboard", "launchFailed": "打开终端失败" }, - "health": { - "title": "检测到 Hermes 配置警告", - "parseFailed": "config.yaml 无法解析为有效 YAML。请先修复文件再在此编辑。", - "configNotFound": "未找到 Hermes config.yaml。请在 ~/.hermes/config.yaml 创建或在设置中配置路径。", - "envParseFailed": ".env 文件无法解析。", - "modelNoDefault": "model 段未设置默认模型或供应商。", - "customProvidersNotList": "custom_providers 应为 YAML 列表(以 `-` 开头),而不是映射。", - "modelProviderUnknown": "model.provider 指向的供应商未在配置中找到。", - "modelDefaultNotInProvider": "model.default 指向的模型不在所选供应商的模型列表中。", - "duplicateProviderName": "custom_providers 中存在重复的供应商名,只会有一条生效。", - "duplicateProviderBaseUrl": "custom_providers 中存在重复的 base_url,可能是意外复制。", - "schemaMigratedV12": "Hermes 新版 schema 把部分供应商移到了 'providers:' dict。CC Switch 中以只读方式显示,编辑或删除请通过 Hermes Web UI 操作。" - }, "memory": { "title": "记忆管理", "agentTab": "Agent 记忆 (MEMORY.md)", diff --git a/src/lib/api/hermes.ts b/src/lib/api/hermes.ts index e8dea5d54..c212a0429 100644 --- a/src/lib/api/hermes.ts +++ b/src/lib/api/hermes.ts @@ -1,6 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; import type { - HermesHealthWarning, HermesMemoryKind, HermesMemoryLimits, HermesModelConfig, @@ -12,19 +11,15 @@ import type { * CC Switch intentionally keeps its Hermes surface minimal — deep configuration * (model, agent behavior, env vars, skills, cron, logs, analytics) lives in * the Hermes Web UI at http://127.0.0.1:9119. CC Switch only reads the `model` - * section to highlight the active provider, scans config health, and launches - * the Hermes Web UI for everything else. Writes to `model` happen implicitly - * via `apply_switch_defaults` when the user switches providers. + * section to highlight the active provider and launches the Hermes Web UI for + * everything else. Writes to `model` happen implicitly via + * `apply_switch_defaults` when the user switches providers. */ export const hermesApi = { async getModelConfig(): Promise { return await invoke("get_hermes_model_config"); }, - async scanHealth(): Promise { - return await invoke("scan_hermes_config_health"); - }, - /** * Probe the local Hermes Web UI and open it in the system browser. * Optional `path` lets callers deep-link to specific pages like `/config`. diff --git a/src/types.ts b/src/types.ts index 68b938842..611387755 100644 --- a/src/types.ts +++ b/src/types.ts @@ -589,17 +589,6 @@ export interface HermesModelConfig { [key: string]: unknown; } -export interface HermesHealthWarning { - code: string; - message: string; - path?: string; -} - -export interface HermesWriteOutcome { - backupPath?: string; - warnings: HermesHealthWarning[]; -} - export type HermesMemoryKind = "memory" | "user"; export interface HermesMemoryLimits { From 4737158439a7d7bd109f7e9614bb39629456decf Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 23 Apr 2026 17:34:39 +0800 Subject: [PATCH 078/156] feat(tray): show coding-plan usage for Kimi / Zhipu / MiniMax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dc04165f surfaced tray usage badges for Claude/Codex/Gemini official OAuth only. Chinese coding-plan providers already expose 5h + weekly windows through coding_plan::get_coding_plan_quota, but two gaps kept the tray from rendering them. - format_script_summary read only data.first(), truncating the tier- flattened UsageResult to a single window. Detect plan_name matching TIER_FIVE_HOUR / TIER_WEEKLY_LIMIT and emit the "🟢 h12% w80%" layout used by format_subscription_summary; worst utilization drives the emoji. Copilot / balance / custom scripts keep the legacy single- bucket output via fallback. - usage_script previously required manual activation through UsageScriptModal. Auto-inject meta.usage_script on Claude provider creation when ANTHROPIC_BASE_URL matches a known coding plan, so the tray lights up without the user opening the modal. Does not overwrite existing usage_script on update. Extract the URL route table out of UsageScriptModal into a shared codingPlanProviders module so the modal, the creation hook, and the Rust coding_plan::detect_provider mirror all agree on one list. Add TIER_WEEKLY_LIMIT alongside TIER_FIVE_HOUR and a createUsageScript() factory to collapse the duplicated default fields across four call sites and drop the remaining stringly-typed tier names. --- src-tauri/src/services/subscription.rs | 4 + src-tauri/src/tray.rs | 159 +++++++++++++++++++++++-- src/components/UsageScriptModal.tsx | 67 +++-------- src/config/codingPlanProviders.ts | 81 +++++++++++++ src/hooks/useProviderActions.ts | 4 +- src/types.ts | 14 +++ 6 files changed, 267 insertions(+), 62 deletions(-) create mode 100644 src/config/codingPlanProviders.ts diff --git a/src-tauri/src/services/subscription.rs b/src-tauri/src/services/subscription.rs index e4530a08c..0349bfeb9 100644 --- a/src-tauri/src/services/subscription.rs +++ b/src-tauri/src/services/subscription.rs @@ -297,6 +297,10 @@ pub const TIER_SEVEN_DAY: &str = "seven_day"; pub const TIER_SEVEN_DAY_OPUS: &str = "seven_day_opus"; pub const TIER_SEVEN_DAY_SONNET: &str = "seven_day_sonnet"; +/// Coding Plan(Kimi / MiniMax)的周窗口 tier 名。与 `coding_plan::query_*` +/// 写入、tray 渲染、commands::provider 扁平化三处共用同一标识。 +pub const TIER_WEEKLY_LIMIT: &str = "weekly_limit"; + /// Gemini 用量分组名称(按模型而非时间窗口)。`classify_gemini_model` 输出。 pub const TIER_GEMINI_PRO: &str = "gemini_pro"; pub const TIER_GEMINI_FLASH: &str = "gemini_flash"; diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 73088f208..b727e35ca 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -172,17 +172,59 @@ fn format_subscription_summary( Some(format!("{emoji} {body}")) } +fn tier_pct(data: &crate::provider::UsageData) -> Option { + match (data.used, data.total) { + (Some(used), Some(total)) if total > 0.0 => Some(used / total * 100.0), + _ => None, + } +} + fn format_script_summary(result: &crate::provider::UsageResult) -> Option { + use crate::services::subscription::{TIER_FIVE_HOUR, TIER_WEEKLY_LIMIT}; + if !result.success { return None; } - let data = result.data.as_ref()?.first()?; - let pct = match (data.used, data.total) { - (Some(used), Some(total)) if total > 0.0 => used / total * 100.0, - _ => return None, - }; + let data = result.data.as_ref()?; + if data.is_empty() { + return None; + } + + // commands::provider 的 token_plan 分支把 SubscriptionQuota 的每个 tier + // 扁平化为一条 UsageData(plan_name 承载 tier 名),所以这里按 plan_name + // 识别双桶形态,其余 usage 结果(Copilot / balance / 自定义脚本)走 fallback。 + const TOKEN_PLAN_LABELS: &[(&str, &str)] = &[(TIER_FIVE_HOUR, "h"), (TIER_WEEKLY_LIMIT, "w")]; + + let mut parts: Vec<(&'static str, f64)> = Vec::new(); + for &(tier_name, label) in TOKEN_PLAN_LABELS { + let Some(d) = data + .iter() + .find(|d| d.plan_name.as_deref() == Some(tier_name)) + else { + continue; + }; + if let Some(u) = tier_pct(d) { + parts.push((label, u)); + } + } + if !parts.is_empty() { + let worst = parts + .iter() + .map(|(_, u)| *u) + .fold(f64::NEG_INFINITY, f64::max); + let emoji = emoji_for_utilization(worst); + let body = parts + .iter() + .map(|(label, u)| format!("{label}{}%", u.round() as i64)) + .collect::>() + .join(" "); + return Some(format!("{emoji} {body}")); + } + + let first = data.first()?; + let pct = tier_pct(first)?; let emoji = emoji_for_utilization(pct); - let plan = data.plan_name.as_deref().unwrap_or(""); + let plan = first.plan_name.as_deref().unwrap_or(""); let rounded = pct.round() as i64; if plan.is_empty() { Some(format!("{} {}%", emoji, rounded)) @@ -805,8 +847,11 @@ pub(crate) async fn refresh_all_usage_in_tray(app: &tauri::AppHandle) { #[cfg(test)] mod tests { - use super::{format_subscription_summary, TRAY_ID}; - use crate::services::subscription::{CredentialStatus, QuotaTier, SubscriptionQuota}; + use super::{format_script_summary, format_subscription_summary, TRAY_ID}; + use crate::provider::{UsageData, UsageResult}; + use crate::services::subscription::{ + CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_WEEKLY_LIMIT, + }; #[test] fn tray_id_is_unique_to_app() { @@ -934,4 +979,102 @@ mod tests { let quota = make_quota("gemini", true, vec![tier("some_future_tier", 80.0)]); assert!(format_subscription_summary("a).is_none()); } + + fn usage_data(plan_name: Option<&str>, utilization: f64) -> UsageData { + UsageData { + plan_name: plan_name.map(String::from), + extra: None, + is_valid: Some(true), + invalid_message: None, + total: Some(100.0), + used: Some(utilization), + remaining: Some(100.0 - utilization), + unit: Some("%".to_string()), + } + } + + fn usage_result(success: bool, data: Vec) -> UsageResult { + UsageResult { + success, + data: if data.is_empty() { None } else { Some(data) }, + error: None, + } + } + + #[test] + fn script_summary_token_plan_two_tiers() { + let r = usage_result( + true, + vec![ + usage_data(Some(TIER_FIVE_HOUR), 12.0), + usage_data(Some(TIER_WEEKLY_LIMIT), 80.0), + ], + ); + let s = format_script_summary(&r).expect("should format"); + assert!(s.contains("h12%"), "expected h12% in {s}"); + assert!(s.contains("w80%"), "expected w80% in {s}"); + assert!(s.starts_with("\u{1F7E0}"), "expected orange emoji in {s}"); + } + + #[test] + fn script_summary_token_plan_worst_drives_emoji() { + let r = usage_result( + true, + vec![ + usage_data(Some(TIER_FIVE_HOUR), 20.0), + usage_data(Some(TIER_WEEKLY_LIMIT), 95.0), + ], + ); + let s = format_script_summary(&r).unwrap(); + assert!(s.starts_with("\u{1F534}"), "expected red emoji in {s}"); + } + + #[test] + fn script_summary_token_plan_five_hour_only() { + let r = usage_result(true, vec![usage_data(Some(TIER_FIVE_HOUR), 8.0)]); + let s = format_script_summary(&r).expect("should format"); + assert!(s.contains("h8%"), "expected h8% in {s}"); + assert!( + !s.contains("plan_name"), + "plan_name should not leak into label: {s}" + ); + } + + #[test] + fn script_summary_token_plan_weekly_only() { + let r = usage_result(true, vec![usage_data(Some(TIER_WEEKLY_LIMIT), 50.0)]); + let s = format_script_summary(&r).expect("should format"); + assert!(s.contains("w50%"), "expected w50% in {s}"); + } + + #[test] + fn script_summary_single_bucket_fallback_with_plan_name() { + let r = usage_result(true, vec![usage_data(Some("Copilot Pro"), 40.0)]); + let s = format_script_summary(&r).expect("should format"); + assert!(s.contains("Copilot Pro"), "expected plan name in {s}"); + assert!(s.contains("40%"), "expected 40% in {s}"); + assert!( + !s.contains("h40%"), + "must not relabel non-token-plan data as h: {s}" + ); + } + + #[test] + fn script_summary_single_bucket_fallback_without_plan_name() { + let r = usage_result(true, vec![usage_data(None, 15.0)]); + let s = format_script_summary(&r).expect("should format"); + assert_eq!(s, "\u{1F7E2} 15%", "expected emoji + pct only, got {s}"); + } + + #[test] + fn script_summary_failure_returns_none() { + let r = usage_result(false, vec![usage_data(Some(TIER_FIVE_HOUR), 12.0)]); + assert!(format_script_summary(&r).is_none()); + } + + #[test] + fn script_summary_empty_data_returns_none() { + let r = usage_result(true, vec![]); + assert!(format_script_summary(&r).is_none()); + } } diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index 7571933e1..e3083d716 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -3,7 +3,7 @@ import { Play, Wand2, Eye, EyeOff, Save } from "lucide-react"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; -import { Provider, UsageScript, UsageData } from "@/types"; +import { Provider, UsageScript, UsageData, createUsageScript } from "@/types"; import { usageApi, settingsApi, type AppId } from "@/lib/api"; import { copilotGetUsage, copilotGetUsageForAccount } from "@/lib/api/copilot"; import { useSettingsQuery } from "@/lib/query"; @@ -21,6 +21,10 @@ import { FullScreenPanel } from "@/components/common/FullScreenPanel"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { cn } from "@/lib/utils"; import { TEMPLATE_TYPES, PROVIDER_TYPES } from "@/config/constants"; +import { + CODING_PLAN_PROVIDERS, + detectCodingPlanProvider, +} from "@/config/codingPlanProviders"; interface UsageScriptModalProps { provider: Provider; @@ -114,21 +118,6 @@ const TEMPLATE_NAME_KEYS: Record = { [TEMPLATE_TYPES.BALANCE]: "usageScript.templateBalance", }; -/** Coding Plan 供应商选项 */ -const TOKEN_PLAN_PROVIDERS = [ - { id: "kimi", label: "Kimi For Coding", pattern: /api\.kimi\.com\/coding/i }, - { - id: "zhipu", - label: "Zhipu GLM (智谱)", - pattern: /bigmodel\.cn|api\.z\.ai/i, - }, - { - id: "minimax", - label: "MiniMax", - pattern: /api\.minimaxi?\.com|api\.minimax\.io/i, - }, -] as const; - /** 官方余额查询供应商检测 */ const BALANCE_PROVIDERS = [ { id: "deepseek", label: "DeepSeek", pattern: /api\.deepseek\.com/i }, @@ -148,15 +137,6 @@ function detectBalanceProvider(baseUrl: string | undefined): boolean { return BALANCE_PROVIDERS.some((bp) => bp.pattern.test(baseUrl)); } -/** 根据 Base URL 自动检测 Coding Plan 供应商 */ -function detectTokenPlanProvider(baseUrl: string | undefined): string | null { - if (!baseUrl) return null; - for (const cp of TOKEN_PLAN_PROVIDERS) { - if (cp.pattern.test(baseUrl)) return cp.id; - } - return null; -} - const UsageScriptModal: React.FC = ({ provider, appId, @@ -237,43 +217,24 @@ const UsageScriptModal: React.FC = ({ return { ...savedScript, codingPlanProvider: - detectTokenPlanProvider(providerCredentials.baseUrl) || "kimi", + detectCodingPlanProvider(providerCredentials.baseUrl) || "kimi", }; } return savedScript; } - // 新配置:如果 URL 匹配 Coding Plan,自动初始化 - const autoDetected = detectTokenPlanProvider(providerCredentials.baseUrl); + const autoDetected = detectCodingPlanProvider(providerCredentials.baseUrl); if (autoDetected) { - return { - enabled: false, - language: "javascript" as const, - code: "", - timeout: 10, - autoQueryInterval: 5, - codingPlanProvider: autoDetected, - }; + return createUsageScript({ codingPlanProvider: autoDetected }); } - // 新配置:如果 URL 匹配官方余额查询供应商,自动初始化 if (detectBalanceProvider(providerCredentials.baseUrl)) { - return { - enabled: false, - language: "javascript" as const, - code: "", - timeout: 10, - autoQueryInterval: 5, - }; + return createUsageScript(); } - return { - enabled: false, - language: "javascript" as const, + return createUsageScript({ code: PRESET_TEMPLATES[TEMPLATE_TYPES.GENERAL], - timeout: 10, - autoQueryInterval: 5, - }; + }); }); const [testing, setTesting] = useState(false); @@ -346,7 +307,7 @@ const UsageScriptModal: React.FC = ({ return TEMPLATE_TYPES.GENERAL; } // 新配置:如果 URL 匹配 Coding Plan 供应商,自动选择 Coding Plan 模板 - if (detectTokenPlanProvider(providerCredentials.baseUrl)) { + if (detectCodingPlanProvider(providerCredentials.baseUrl)) { return TEMPLATE_TYPES.TOKEN_PLAN; } // 新配置:如果 URL 匹配官方余额查询供应商,自动选择 Balance 模板 @@ -623,7 +584,7 @@ const UsageScriptModal: React.FC = ({ }); } else if (presetName === TEMPLATE_TYPES.TOKEN_PLAN) { // Coding Plan 模板不需要脚本,使用 Rust 原生查询 - const autoDetected = detectTokenPlanProvider( + const autoDetected = detectCodingPlanProvider( providerCredentials.baseUrl, ); setScript({ @@ -862,7 +823,7 @@ const UsageScriptModal: React.FC = ({ {t("usageScript.tokenPlanHint")}

- {TOKEN_PLAN_PROVIDERS.map((cp) => ( + {CODING_PLAN_PROVIDERS.map((cp) => ( -
@@ -2096,6 +2135,50 @@ export function ProviderForm({ onConfirm={() => void handleCommonConfigConfirm()} onCancel={() => void handleCommonConfigConfirm()} /> + + 0} + variant="info" + title={t("providerForm.softValidation.title", { + defaultValue: "配置存在以下问题", + })} + message={ + (softIssues ?? []).map((issue) => `• ${issue}`).join("\n") + + "\n\n" + + t("providerForm.softValidation.hint", { + defaultValue: + "仍要保存吗?保存后切换此供应商时可能失败,可以之后再补全。", + }) + } + confirmText={t("providerForm.softValidation.saveAnyway", { + defaultValue: "仍要保存", + })} + cancelText={t("common.cancel")} + onConfirm={async () => { + if (isConfirmSubmitting) return; + const values = pendingFormValues; + if (!values) { + setSoftIssues(null); + return; + } + setIsConfirmSubmitting(true); + try { + await performSubmit(values); + setSoftIssues(null); + setPendingFormValues(null); + } catch (error) { + console.error("[ProviderForm] soft-confirm submit failed:", error); + // 保留确认框和 pending values,让用户可以重试或取消 + } finally { + setIsConfirmSubmitting(false); + } + }} + onCancel={() => { + if (isConfirmSubmitting) return; + setSoftIssues(null); + setPendingFormValues(null); + }} + /> ); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 0191a1bed..766e018b6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -824,6 +824,11 @@ "fillTemplateValue": "Please fill in {{label}}", "endpointRequired": "API endpoint is required for non-official providers", "apiKeyRequired": "API Key is required for non-official providers", + "softValidation": { + "title": "Configuration has the following issues", + "hint": "Save anyway? Switching to this provider may fail; you can fill these in later.", + "saveAnyway": "Save anyway" + }, "configJsonError": "Config JSON format error, please check syntax", "authJsonRequired": "auth.json must be a JSON object", "authJsonError": "auth.json format error, please check JSON syntax", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 85379bce6..a4e8e412f 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -824,6 +824,11 @@ "fillTemplateValue": "{{label}} を入力してください", "endpointRequired": "公式以外は API エンドポイントが必須です", "apiKeyRequired": "公式以外は API Key が必須です", + "softValidation": { + "title": "設定に以下の問題があります", + "hint": "それでも保存しますか?保存後、このプロバイダーへの切り替えが失敗する可能性がありますが、後から補完できます。", + "saveAnyway": "それでも保存" + }, "configJsonError": "Config JSON の形式が正しくありません。構文を確認してください", "authJsonRequired": "auth.json は JSON オブジェクトで入力してください", "authJsonError": "auth.json の形式が正しくありません。JSON を確認してください", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 1c0fda06e..ae148fe8a 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -824,6 +824,11 @@ "fillTemplateValue": "请填写 {{label}}", "endpointRequired": "非官方供应商请填写 API 端点", "apiKeyRequired": "非官方供应商请填写 API Key", + "softValidation": { + "title": "配置存在以下问题", + "hint": "仍要保存吗?保存后切换此供应商时可能失败,可以之后再补全。", + "saveAnyway": "仍要保存" + }, "configJsonError": "配置JSON格式错误,请检查语法", "authJsonRequired": "auth.json 必须是 JSON 对象", "authJsonError": "auth.json 格式错误,请检查JSON语法", From 4536b95ac955781d4d158b3c350a3d6448489099 Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 25 Apr 2026 17:00:36 +0800 Subject: [PATCH 088/156] refactor: prefer default shell in commands::try_get_version (#2286) Signed-off-by: tison --- src-tauri/src/commands/misc.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 1c7f99912..8818ac26b 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -300,8 +300,13 @@ fn try_get_version(tool: &str) -> (Option, Option) { #[cfg(not(target_os = "windows"))] let output = { - Command::new("sh") - .arg("-c") + let shell = std::env::var("SHELL") + .ok() + .filter(|s| is_valid_shell(s)) + .unwrap_or_else(|| "sh".to_string()); + let flag = default_flag_for_shell(&shell); + Command::new(shell) + .arg(flag) .arg(format!("{tool} --version")) .output() }; @@ -345,7 +350,6 @@ fn is_valid_wsl_distro_name(name: &str) -> bool { } /// Validate that the given shell name is one of the allowed shells. -#[cfg(target_os = "windows")] fn is_valid_shell(shell: &str) -> bool { matches!( shell.rsplit('/').next().unwrap_or(shell), @@ -360,7 +364,6 @@ fn is_valid_shell_flag(flag: &str) -> bool { } /// Return the default invocation flag for the given shell. -#[cfg(target_os = "windows")] fn default_flag_for_shell(shell: &str) -> &'static str { match shell.rsplit('/').next().unwrap_or(shell) { "dash" | "sh" => "-c", From 6441bc5c01a8c829f0a3b2fb8ec8b22030eac60b Mon Sep 17 00:00:00 2001 From: codeasier <48463012+codeasier@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:08:43 +0800 Subject: [PATCH 089/156] fix(proxy): dedupe streaming message_delta (#2366) - Deduplicate repeated upstream `finish_reason` chunks so only one Anthropic `message_delta` is emitted. - Preserve late `choices: []` usage-only chunks before sending the final `message_delta`. - Keep stream error paths from emitting successful terminal events. - Add regressions for duplicate finish reasons, usage-only chunks, missing `[DONE]`, and truncated streams. --- src-tauri/src/proxy/providers/streaming.rs | 339 +++++++++++++++++++-- 1 file changed, 309 insertions(+), 30 deletions(-) diff --git a/src-tauri/src/proxy/providers/streaming.rs b/src-tauri/src/proxy/providers/streaming.rs index d6339e5d2..725255d1c 100644 --- a/src-tauri/src/proxy/providers/streaming.rs +++ b/src-tauri/src/proxy/providers/streaming.rs @@ -6,14 +6,17 @@ use crate::proxy::sse::{strip_sse_field, take_sse_block}; use bytes::Bytes; use futures::stream::{Stream, StreamExt}; use serde::{Deserialize, Serialize}; -use serde_json::json; +use serde_json::{json, Value}; use std::collections::{HashMap, HashSet}; /// OpenAI 流式响应数据结构 #[derive(Debug, Deserialize)] struct OpenAIStreamChunk { + #[serde(default)] id: String, + #[serde(default)] model: String, + #[serde(default)] choices: Vec, #[serde(default)] usage: Option, @@ -95,6 +98,20 @@ struct ToolBlockState { /// 无限空白 bug 的连续空白字符阈值 const INFINITE_WHITESPACE_THRESHOLD: usize = 20; +fn build_anthropic_usage_json(usage: &Usage) -> Value { + let mut usage_json = json!({ + "input_tokens": usage.prompt_tokens, + "output_tokens": usage.completion_tokens + }); + if let Some(cached) = extract_cache_read_tokens(usage) { + usage_json["cache_read_input_tokens"] = json!(cached); + } + if let Some(created) = usage.cache_creation_input_tokens { + usage_json["cache_creation_input_tokens"] = json!(created); + } + usage_json +} + /// 创建 Anthropic SSE 流 pub fn create_anthropic_sse_stream( stream: impl Stream> + Send + 'static, @@ -106,6 +123,16 @@ pub fn create_anthropic_sse_stream( let mut current_model = None; let mut next_content_index: u32 = 0; let mut has_sent_message_start = false; + // 某些上游 provider(如 OpenRouter 的 kimi-k2.6)会在 tool_use 后发送多个 + // 带 finish_reason 的 SSE chunk。Anthropic 协议要求每个消息流只能有一个 + // message_delta,重复会导致 Claude Code abort 连接。因此需要: + // 1) has_emitted_message_delta: 去重,只处理第一个 finish_reason + // 2) pending_message_delta: 缓存延迟到 [DONE] 发送,确保 usage 完整 + let mut has_emitted_message_delta = false; + let mut pending_message_delta: Option<(Option, Option)> = None; + let mut has_sent_message_stop = false; + let mut stream_ended_with_error = false; + let mut latest_usage: Option = None; let mut current_non_tool_block_type: Option<&'static str> = None; let mut current_non_tool_block_index: Option = None; let mut tool_blocks_by_index: HashMap = HashMap::new(); @@ -127,24 +154,53 @@ pub fn create_anthropic_sse_stream( if let Some(data) = strip_sse_field(l, "data") { if data.trim() == "[DONE]" { log::debug!("[Claude/OpenRouter] <<< OpenAI SSE: [DONE]"); + + // 流正常结束,发出缓存的 message_delta(含完整 usage)。 + if let Some((stop_reason, usage_json)) = pending_message_delta.take() { + let mut event = json!({ + "type": "message_delta", + "delta": { + "stop_reason": stop_reason, + "stop_sequence": null + } + }); + if let Some(uj) = usage_json { + event["usage"] = uj; + } + let sse_data = format!("event: message_delta\ndata: {}\n\n", + serde_json::to_string(&event).unwrap_or_default()); + log::debug!("[Claude/OpenRouter] >>> Anthropic SSE: message_delta (from pending)"); + yield Ok(Bytes::from(sse_data)); + } + let event = json!({"type": "message_stop"}); let sse_data = format!("event: message_stop\ndata: {}\n\n", serde_json::to_string(&event).unwrap_or_default()); log::debug!("[Claude/OpenRouter] >>> Anthropic SSE: message_stop"); yield Ok(Bytes::from(sse_data)); + has_sent_message_stop = true; continue; } if let Ok(chunk) = serde_json::from_str::(data) { log::debug!("[Claude/OpenRouter] <<< SSE chunk received"); - if message_id.is_none() { + if message_id.is_none() && !chunk.id.is_empty() { message_id = Some(chunk.id.clone()); } - if current_model.is_none() { + if current_model.is_none() && !chunk.model.is_empty() { current_model = Some(chunk.model.clone()); } + let chunk_usage_json = + chunk.usage.as_ref().map(build_anthropic_usage_json); + if let Some(usage_json) = &chunk_usage_json { + latest_usage = Some(usage_json.clone()); + if let Some((_, pending_usage)) = pending_message_delta.as_mut() { + *pending_usage = Some(usage_json.clone()); + } + } + if let Some(choice) = chunk.choices.first() { if !has_sent_message_start { // Build usage with cache tokens if available from first chunk @@ -420,8 +476,24 @@ pub fn create_anthropic_sse_stream( } } - // 处理 finish_reason + // 处理 finish_reason。 + // 注意:OpenRouter 某些 provider 会发送多个带 finish_reason 的 chunk + // (第一个 usage 为 null,后续才补全)。此处只做缓存,不立即发送, + // 等到 [DONE] 或流末尾再统一发出,确保 usage 完整且只发一次。 if let Some(finish_reason) = &choice.finish_reason { + let stop_reason = map_stop_reason(Some(finish_reason)); + let usage_json = + chunk_usage_json.clone().or_else(|| latest_usage.clone()); + + if has_emitted_message_delta { + // 更新缓存的 message_delta usage(如果有更完整的 usage) + if let (Some((_, ref mut usage)), Some(uj)) = (&mut pending_message_delta, usage_json) { + *usage = Some(uj); + } + continue; + } + has_emitted_message_delta = true; + if let Some(index) = current_non_tool_block_index.take() { let event = json!({ "type": "content_block_stop", @@ -511,32 +583,8 @@ pub fn create_anthropic_sse_stream( open_tool_block_indices.clear(); } - let stop_reason = map_stop_reason(Some(finish_reason)); - // Build usage with cache token fields - let usage_json = chunk.usage.as_ref().map(|u| { - let mut uj = json!({ - "input_tokens": u.prompt_tokens, - "output_tokens": u.completion_tokens - }); - if let Some(cached) = extract_cache_read_tokens(u) { - uj["cache_read_input_tokens"] = json!(cached); - } - if let Some(created) = u.cache_creation_input_tokens { - uj["cache_creation_input_tokens"] = json!(created); - } - uj - }); - let event = json!({ - "type": "message_delta", - "delta": { - "stop_reason": stop_reason, - "stop_sequence": null - }, - "usage": usage_json - }); - let sse_data = format!("event: message_delta\ndata: {}\n\n", - serde_json::to_string(&event).unwrap_or_default()); - yield Ok(Bytes::from(sse_data)); + // 缓存 message_delta,等到 [DONE] 时发送(以便收集完整的 usage) + pending_message_delta = Some((stop_reason, usage_json)); } } } @@ -546,6 +594,7 @@ pub fn create_anthropic_sse_stream( } Err(e) => { log::error!("Stream error: {e}"); + stream_ended_with_error = true; let error_event = json!({ "type": "error", "error": { @@ -560,6 +609,40 @@ pub fn create_anthropic_sse_stream( } } } + + // 流自然结束但未收到 [DONE] 时,确保发送缓存的 message_delta 和 message_stop。 + // 若上游已显式报错,则只保留 error 事件,避免把失败伪装成成功完成。 + if !stream_ended_with_error { + let emitted_pending_message_delta = if let Some((stop_reason, usage_json)) = + pending_message_delta.take() + { + let mut event = json!({ + "type": "message_delta", + "delta": { + "stop_reason": stop_reason, + "stop_sequence": null + } + }); + if let Some(uj) = usage_json { + event["usage"] = uj; + } + let sse_data = format!("event: message_delta\ndata: {}\n\n", + serde_json::to_string(&event).unwrap_or_default()); + log::debug!("[Claude/OpenRouter] >>> Anthropic SSE: message_delta (at stream end)"); + yield Ok(Bytes::from(sse_data)); + true + } else { + false + }; + + if emitted_pending_message_delta && !has_sent_message_stop { + let event = json!({"type": "message_stop"}); + let sse_data = format!("event: message_stop\ndata: {}\n\n", + serde_json::to_string(&event).unwrap_or_default()); + log::debug!("[Claude/OpenRouter] >>> Anthropic SSE: message_stop (at stream end)"); + yield Ok(Bytes::from(sse_data)); + } + } } } @@ -602,6 +685,32 @@ mod tests { use serde_json::Value; use std::collections::HashMap; + async fn collect_anthropic_events(input: &str) -> Vec { + let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from( + input.as_bytes().to_vec(), + ))]); + let converted = create_anthropic_sse_stream(upstream); + let chunks: Vec<_> = converted.collect().await; + let merged = chunks + .into_iter() + .map(|chunk| String::from_utf8_lossy(chunk.unwrap().as_ref()).to_string()) + .collect::(); + + merged + .split("\n\n") + .filter_map(|block| { + let data = block + .lines() + .find_map(|line| strip_sse_field(line, "data"))?; + serde_json::from_str::(data).ok() + }) + .collect() + } + + fn event_type(event: &Value) -> Option<&str> { + event.get("type").and_then(|v| v.as_str()) + } + #[test] fn test_map_stop_reason_legacy_and_filtered_values() { assert_eq!( @@ -818,4 +927,174 @@ mod tests { "output must not contain U+FFFD replacement characters" ); } + + #[tokio::test] + async fn test_duplicate_finish_reason_emits_only_one_message_delta() { + // Simulates OpenRouter behavior where two chunks carry finish_reason: + // first with null usage, second with populated usage. + let input = concat!( + "data: {\"id\":\"chatcmpl_dup\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n", + "data: {\"id\":\"chatcmpl_dup\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5}}\n\n", + "data: [DONE]\n\n" + ); + + let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from( + input.as_bytes().to_vec(), + ))]); + let converted = create_anthropic_sse_stream(upstream); + let chunks: Vec<_> = converted.collect().await; + + let merged = chunks + .into_iter() + .map(|chunk| String::from_utf8_lossy(chunk.unwrap().as_ref()).to_string()) + .collect::(); + + let events: Vec = merged + .split("\n\n") + .filter_map(|block| { + let data = block + .lines() + .find_map(|line| strip_sse_field(line, "data"))?; + serde_json::from_str::(data).ok() + }) + .collect(); + + let message_deltas: Vec<&Value> = events + .iter() + .filter(|e| e.get("type").and_then(|v| v.as_str()) == Some("message_delta")) + .collect(); + + assert_eq!( + message_deltas.len(), + 1, + "duplicate finish_reason chunks must produce exactly one message_delta, got {}: {:?}", + message_deltas.len(), + message_deltas + ); + + assert_eq!(message_deltas[0]["usage"]["input_tokens"], 10); + assert_eq!(message_deltas[0]["usage"]["output_tokens"], 5); + + let message_stops = events + .iter() + .filter(|e| e.get("type").and_then(|v| v.as_str()) == Some("message_stop")) + .count(); + assert_eq!(message_stops, 1, "message_stop must only be emitted once"); + } + + #[tokio::test] + async fn test_usage_only_chunk_after_finish_reason_updates_message_delta_usage() { + let input = concat!( + "data: {\"id\":\"chatcmpl_split\",\"model\":\"glm-5.1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"tool-0924\",\"type\":\"function\",\"function\":{\"name\":\"Bash\",\"arguments\":\"{\\\"command\\\":\\\"pwd\\\"}\"}}]}}]}\n\n", + "data: {\"id\":\"chatcmpl_split\",\"model\":\"glm-5.1\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n", + "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":13312,\"completion_tokens\":79,\"prompt_tokens_details\":{\"cached_tokens\":100}}}\n\n", + "data: [DONE]\n\n" + ); + + let events = collect_anthropic_events(input).await; + let message_deltas: Vec<&Value> = events + .iter() + .filter(|event| event_type(event) == Some("message_delta")) + .collect(); + let message_stops = events + .iter() + .filter(|event| event_type(event) == Some("message_stop")) + .count(); + + assert_eq!(message_deltas.len(), 1); + assert_eq!(message_stops, 1); + + let message_delta = message_deltas[0]; + assert_eq!( + message_delta + .pointer("/delta/stop_reason") + .and_then(|v| v.as_str()), + Some("tool_use") + ); + assert_eq!( + message_delta + .pointer("/usage/input_tokens") + .and_then(|v| v.as_u64()), + Some(13312) + ); + assert_eq!( + message_delta + .pointer("/usage/output_tokens") + .and_then(|v| v.as_u64()), + Some(79) + ); + assert_eq!( + message_delta + .pointer("/usage/cache_read_input_tokens") + .and_then(|v| v.as_u64()), + Some(100) + ); + } + + #[tokio::test] + async fn test_streaming_finalizes_after_finish_when_done_is_missing() { + let input = concat!( + "data: {\"id\":\"chatcmpl_no_done\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n", + "data: {\"id\":\"chatcmpl_no_done\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n" + ); + + let events = collect_anthropic_events(input).await; + + assert!(events.iter().any(|event| { + event_type(event) == Some("message_delta") + && event.pointer("/delta/stop_reason").and_then(|v| v.as_str()) == Some("end_turn") + })); + assert_eq!( + events.last().and_then(|event| event_type(event)), + Some("message_stop") + ); + } + + #[tokio::test] + async fn test_stream_end_without_finish_reason_does_not_emit_success_terminal_events() { + let input = "data: {\"id\":\"chatcmpl_truncated\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n"; + + let events = collect_anthropic_events(input).await; + + assert!(!events + .iter() + .any(|event| event_type(event) == Some("message_delta"))); + assert!(!events + .iter() + .any(|event| event_type(event) == Some("message_stop"))); + } + + #[tokio::test] + async fn test_stream_error_does_not_emit_success_terminal_events() { + let upstream = stream::iter(vec![Err::(std::io::Error::other( + "upstream disconnected", + ))]); + let converted = create_anthropic_sse_stream(upstream); + let chunks: Vec<_> = converted.collect().await; + + let merged = chunks + .into_iter() + .map(|chunk| String::from_utf8_lossy(chunk.unwrap().as_ref()).to_string()) + .collect::(); + + let events: Vec = merged + .split("\n\n") + .filter_map(|block| { + let data = block + .lines() + .find_map(|line| strip_sse_field(line, "data"))?; + serde_json::from_str::(data).ok() + }) + .collect(); + + assert!(events + .iter() + .any(|e| e.get("type").and_then(|v| v.as_str()) == Some("error"))); + assert!(!events + .iter() + .any(|e| e.get("type").and_then(|v| v.as_str()) == Some("message_delta"))); + assert!(!events + .iter() + .any(|e| e.get("type").and_then(|v| v.as_str()) == Some("message_stop"))); + } } From 21e2d68d76e08bee1b626cd1fd4d7419fbeb73e6 Mon Sep 17 00:00:00 2001 From: codeasier <48463012+codeasier@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:08:59 +0800 Subject: [PATCH 090/156] fix(proxy): preserve scoped reasoning_content for tool calls (#2367) - Preserve `reasoning_content` for Kimi/Moonshot OpenAI Chat compatibility paths. - Keep generic OpenAI-compatible requests free of non-standard `reasoning_content` fields. - Continue skipping thinking-only assistant messages. - Add regressions for generic skip and Kimi/Moonshot preservation behavior. --- src-tauri/src/proxy/providers/claude.rs | 111 +++++++++++++++++++- src-tauri/src/proxy/providers/transform.rs | 116 ++++++++++++++++++++- 2 files changed, 224 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index dd2e76f94..30380edc4 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -82,6 +82,40 @@ pub fn claude_api_format_needs_transform(api_format: &str) -> bool { ) } +fn is_moonshot_or_kimi_identifier(value: &str) -> bool { + let value = value.to_ascii_lowercase(); + value.contains("moonshot") || value.contains("kimi") +} + +fn should_preserve_reasoning_content_for_openai_chat( + provider: &Provider, + body: &serde_json::Value, +) -> bool { + if body + .get("model") + .and_then(|m| m.as_str()) + .is_some_and(is_moonshot_or_kimi_identifier) + { + return true; + } + + let settings = &provider.settings_config; + let base_urls = [ + settings + .get("env") + .and_then(|env| env.get("ANTHROPIC_BASE_URL")) + .and_then(|v| v.as_str()), + settings.get("base_url").and_then(|v| v.as_str()), + settings.get("baseURL").and_then(|v| v.as_str()), + settings.get("apiEndpoint").and_then(|v| v.as_str()), + ]; + + base_urls + .into_iter() + .flatten() + .any(is_moonshot_or_kimi_identifier) +} + pub fn transform_claude_request_for_api_format( body: serde_json::Value, provider: &Provider, @@ -156,7 +190,12 @@ pub fn transform_claude_request_for_api_format( ) } "openai_chat" => { - let mut result = super::transform::anthropic_to_openai(body)?; + let preserve_reasoning_content = + should_preserve_reasoning_content_for_openai_chat(provider, &body); + let mut result = super::transform::anthropic_to_openai_with_reasoning_content( + body, + preserve_reasoning_content, + )?; // Inject prompt_cache_key only if explicitly configured in meta if let Some(key) = provider .meta @@ -1453,4 +1492,74 @@ mod tests { assert_eq!(transformed["prompt_cache_key"], "claude-cache-route"); } + + #[test] + fn test_transform_openai_chat_skips_reasoning_content_for_generic_provider() { + let provider = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.example.com", + "ANTHROPIC_API_KEY": "test-key" + } + }), + ProviderMeta { + api_format: Some("openai_chat".to_string()), + ..Default::default() + }, + ); + let body = json!({ + "model": "gpt-5.4", + "max_tokens": 64, + "messages": [{ + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "I should call the tool."}, + {"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}} + ] + }] + }); + + let transformed = + transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None) + .unwrap(); + + let msg = &transformed["messages"][0]; + assert!(msg.get("tool_calls").is_some()); + assert!(msg.get("reasoning_content").is_none()); + } + + #[test] + fn test_transform_openai_chat_preserves_reasoning_content_for_kimi_provider() { + let provider = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.moonshot.cn/v1", + "ANTHROPIC_API_KEY": "test-key" + } + }), + ProviderMeta { + api_format: Some("openai_chat".to_string()), + ..Default::default() + }, + ); + let body = json!({ + "model": "kimi-k2.6", + "max_tokens": 64, + "messages": [{ + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "I should call the tool."}, + {"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}} + ] + }] + }); + + let transformed = + transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None) + .unwrap(); + + let msg = &transformed["messages"][0]; + assert_eq!(msg["reasoning_content"], "I should call the tool."); + assert!(msg.get("tool_calls").is_some()); + } } diff --git a/src-tauri/src/proxy/providers/transform.rs b/src-tauri/src/proxy/providers/transform.rs index 72e6b0f02..5e43a1869 100644 --- a/src-tauri/src/proxy/providers/transform.rs +++ b/src-tauri/src/proxy/providers/transform.rs @@ -73,6 +73,18 @@ pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> { /// Anthropic 请求 → OpenAI Chat Completions 请求 pub fn anthropic_to_openai(body: Value) -> Result { + anthropic_to_openai_with_reasoning_content(body, false) +} + +/// Anthropic 请求 → OpenAI Chat Completions 请求 +/// +/// `preserve_reasoning_content` 仅用于明确需要 Moonshot/Kimi +/// `reasoning_content` 兼容字段的 provider。默认转换保持通用 OpenAI-compatible +/// 请求体,避免向严格后端发送未知字段。 +pub fn anthropic_to_openai_with_reasoning_content( + body: Value, + preserve_reasoning_content: bool, +) -> Result { let mut result = json!({}); // NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。 @@ -106,7 +118,7 @@ pub fn anthropic_to_openai(body: Value) -> Result { for msg in msgs { let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("user"); let content = msg.get("content"); - let converted = convert_message_to_openai(role, content)?; + let converted = convert_message_to_openai(role, content, preserve_reasoning_content)?; messages.extend(converted); } } @@ -252,6 +264,7 @@ fn normalize_openai_system_messages(messages: &mut Vec) { fn convert_message_to_openai( role: &str, content: Option<&Value>, + preserve_reasoning_content: bool, ) -> Result, ProxyError> { let mut result = Vec::new(); @@ -273,6 +286,9 @@ fn convert_message_to_openai( if let Some(blocks) = content.as_array() { let mut content_parts = Vec::new(); let mut tool_calls = Vec::new(); + // reasoning_parts: 仅在兼容 Moonshot/Kimi thinking tool-call 路径时 + // 生成 reasoning_content,通用 OpenAI-compatible 路径不发送该非标准字段。 + let mut reasoning_parts = Vec::new(); for block in blocks { let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or(""); @@ -332,7 +348,12 @@ fn convert_message_to_openai( })); } "thinking" => { - // 跳过 thinking blocks + // 提取 thinking 内容,后续可作为 reasoning_content 传给需要它的上游。 + if let Some(thinking) = block.get("thinking").and_then(|t| t.as_str()) { + if !thinking.is_empty() { + reasoning_parts.push(thinking.to_string()); + } + } } _ => {} } @@ -366,6 +387,15 @@ fn convert_message_to_openai( msg["tool_calls"] = json!(tool_calls); } + if preserve_reasoning_content && role == "assistant" && !tool_calls.is_empty() { + let reasoning_content = if reasoning_parts.is_empty() { + "tool call".to_string() + } else { + reasoning_parts.join("\n") + }; + msg["reasoning_content"] = json!(reasoning_content); + } + result.push(msg); } @@ -710,6 +740,88 @@ mod tests { assert_eq!(msg["role"], "assistant"); assert!(msg.get("tool_calls").is_some()); assert_eq!(msg["tool_calls"][0]["id"], "call_123"); + assert!(msg.get("reasoning_content").is_none()); + } + + #[test] + fn test_anthropic_to_openai_tool_use_preserves_reasoning_content() { + let input = json!({ + "model": "kimi-k2.6", + "max_tokens": 1024, + "messages": [{ + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "I should call the tool."}, + {"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}} + ] + }] + }); + + let result = anthropic_to_openai_with_reasoning_content(input, true).unwrap(); + let msg = &result["messages"][0]; + assert_eq!(msg["role"], "assistant"); + assert_eq!(msg["reasoning_content"], "I should call the tool."); + assert!(msg.get("tool_calls").is_some()); + assert_eq!(msg["tool_calls"][0]["id"], "call_123"); + } + + #[test] + fn test_anthropic_to_openai_tool_use_injects_placeholder_reasoning_content_when_missing() { + let input = json!({ + "model": "kimi-k2.6", + "max_tokens": 1024, + "messages": [{ + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}} + ] + }] + }); + + let result = anthropic_to_openai_with_reasoning_content(input, true).unwrap(); + let msg = &result["messages"][0]; + assert_eq!(msg["role"], "assistant"); + assert_eq!(msg["reasoning_content"], "tool call"); + assert!(msg.get("tool_calls").is_some()); + assert_eq!(msg["tool_calls"][0]["id"], "call_123"); + } + + #[test] + fn test_anthropic_to_openai_does_not_emit_reasoning_content_by_default() { + let input = json!({ + "model": "gpt-5.4", + "max_tokens": 1024, + "messages": [{ + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "I should call the tool."}, + {"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}} + ] + }] + }); + + let result = anthropic_to_openai(input).unwrap(); + let msg = &result["messages"][0]; + assert_eq!(msg["role"], "assistant"); + assert!(msg.get("tool_calls").is_some()); + assert!(msg.get("reasoning_content").is_none()); + } + + #[test] + fn test_anthropic_to_openai_skips_thinking_only_message() { + let input = json!({ + "model": "claude-3-opus", + "max_tokens": 1024, + "messages": [{ + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "No visible content yet."} + ] + }] + }); + + let result = anthropic_to_openai(input).unwrap(); + assert_eq!(result["messages"].as_array().unwrap().len(), 0); } #[test] From 5b6339d78cbf9d531728e7155a0a6dfb16b4165f Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 25 Apr 2026 12:12:38 +0800 Subject: [PATCH 091/156] chore(codex): hide 1M context window toggle in provider edit form Codex models no longer accept model_context_window=1000000, so the toggle and its paired auto-compact-limit input are commented out in the provider edit form. State hooks, helper imports, and i18n keys are preserved so the UI can be restored in one batch if upstream support returns. The TOML editor remains visible, allowing manual edits if needed. --- .../providers/forms/CodexConfigSections.tsx | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/components/providers/forms/CodexConfigSections.tsx b/src/components/providers/forms/CodexConfigSections.tsx index 280528392..8ce78eb30 100644 --- a/src/components/providers/forms/CodexConfigSections.tsx +++ b/src/components/providers/forms/CodexConfigSections.tsx @@ -1,17 +1,17 @@ -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +// NOTE: Codex 1M 上下文 UI 已暂时隐藏(详见下方 CodexConfigSection 内 JSX 注释)。 +// 如需恢复,请同时: +// - 在下方 React import 中加回 `useMemo` +// - 取消下面 `@/utils/providerConfigUtils` import 的注释 +import React, { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import JsonEditor from "@/components/JsonEditor"; +/* import { extractCodexTopLevelInt, setCodexTopLevelInt, removeCodexTopLevelField, } from "@/utils/providerConfigUtils"; +*/ interface CodexAuthSectionProps { value: string; @@ -144,6 +144,8 @@ export const CodexConfigSection: React.FC = ({ [onChange], ); + // Codex 1M 上下文相关状态/回调暂时禁用——见同文件下方 JSX 注释处的恢复说明。 + /* // Parse toggle states from TOML text const toggleStates = useMemo(() => { const contextWindow = extractCodexTopLevelInt( @@ -211,6 +213,7 @@ export const CodexConfigSection: React.FC = ({ useEffect(() => { return () => clearTimeout(compactTimerRef.current); }, []); + */ return (
@@ -249,6 +252,8 @@ export const CodexConfigSection: React.FC = ({

)} + {/* Codex 1M 上下文 UI 已隐藏:模型不再支持该字段。 + 恢复方法:(1) 取消本段 JSX 注释;(2) 取消文件顶部 import 中 useMemo / extractCodexTopLevelInt / setCodexTopLevelInt / removeCodexTopLevelField 的注释;(3) 取消下方 toggleStates / compactTimerRef / handleContextWindowToggle / handleCompactLimitChange / cleanup useEffect 的注释。
+ */} Date: Tue, 28 Apr 2026 09:26:48 +0800 Subject: [PATCH 092/156] fix(balance): show USD on SiliconFlow international site (was CNY) The query_siliconflow function received an is_cn flag that only switched the request domain (.cn vs .com) but the response builder hardcoded unit="CNY" for both sites. International users at api.siliconflow.com saw their USD balance labelled as CNY. Now unit and plan_name follow is_cn, so the EN site shows USD and "SiliconFlow (EN)". --- src-tauri/src/services/balance.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/services/balance.rs b/src-tauri/src/services/balance.rs index fa6b5d5a1..dce56fed8 100644 --- a/src-tauri/src/services/balance.rs +++ b/src-tauri/src/services/balance.rs @@ -233,14 +233,21 @@ async fn query_siliconflow(api_key: &str, is_cn: bool) -> UsageResult { let total_balance = parse_f64_field(data, "totalBalance").unwrap_or(0.0); + let unit = if is_cn { "CNY" } else { "USD" }; + let plan_name = if is_cn { + "SiliconFlow" + } else { + "SiliconFlow (EN)" + }; + UsageResult { success: true, data: Some(vec![UsageData { - plan_name: Some("SiliconFlow".to_string()), + plan_name: Some(plan_name.to_string()), remaining: Some(total_balance), total: None, used: None, - unit: Some("CNY".to_string()), + unit: Some(unit.to_string()), is_valid: Some(true), invalid_message: None, extra: None, From bcf8434c1f6b1f7e4bba64a30462d94a95ea5ab1 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 28 Apr 2026 15:23:58 +0800 Subject: [PATCH 093/156] chore(kimi): update Kimi For Coding website URL to /code/docs/ Sync the preset's websiteUrl from the legacy /coding/docs/ path to the current /code/docs/ path across all four app presets (claude, hermes, openclaw, opencode). --- src/config/claudeProviderPresets.ts | 2 +- src/config/hermesProviderPresets.ts | 2 +- src/config/openclawProviderPresets.ts | 2 +- src/config/opencodeProviderPresets.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index be1808f7f..b9ba96c50 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -227,7 +227,7 @@ export const providerPresets: ProviderPreset[] = [ }, { name: "Kimi For Coding", - websiteUrl: "https://www.kimi.com/coding/docs/", + websiteUrl: "https://www.kimi.com/code/docs/", settingsConfig: { env: { ANTHROPIC_BASE_URL: "https://api.kimi.com/coding/", diff --git a/src/config/hermesProviderPresets.ts b/src/config/hermesProviderPresets.ts index be398aa55..eb48166ec 100644 --- a/src/config/hermesProviderPresets.ts +++ b/src/config/hermesProviderPresets.ts @@ -392,7 +392,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [ }, { name: "Kimi For Coding", - websiteUrl: "https://www.kimi.com/coding/docs/", + websiteUrl: "https://www.kimi.com/code/docs/", settingsConfig: { name: "kimi_coding", base_url: "https://api.kimi.com/coding/", diff --git a/src/config/openclawProviderPresets.ts b/src/config/openclawProviderPresets.ts index 9d7d85c2e..18a181077 100644 --- a/src/config/openclawProviderPresets.ts +++ b/src/config/openclawProviderPresets.ts @@ -303,7 +303,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, { name: "Kimi For Coding", - websiteUrl: "https://www.kimi.com/coding/docs/", + websiteUrl: "https://www.kimi.com/code/docs/", apiKeyUrl: "https://platform.moonshot.cn/console/api-keys", settingsConfig: { baseUrl: "https://api.kimi.com/v1", diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts index fd181a085..74dc18644 100644 --- a/src/config/opencodeProviderPresets.ts +++ b/src/config/opencodeProviderPresets.ts @@ -485,7 +485,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, { name: "Kimi For Coding", - websiteUrl: "https://www.kimi.com/coding/docs/", + websiteUrl: "https://www.kimi.com/code/docs/", apiKeyUrl: "https://platform.moonshot.cn/console/api-keys", settingsConfig: { npm: "@ai-sdk/anthropic", From 2ee7cb41013d717dc5e35a452394425ba1c5c9dd Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 29 Apr 2026 09:35:42 +0800 Subject: [PATCH 094/156] fix(usage): prevent double-counting between proxy and session-log sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proxy writes and session-log sync wrote to proxy_request_logs with mismatched request_ids: only Claude on a native Anthropic backend used the shared `session:{message_id}` key. Codex/Gemini and Claude-through-OpenAI providers always produced distinct ids, so primary-key dedup never fired and every real request was recorded twice. Adds a 7-dim fingerprint dedup (app_type, 4 token counts, 2xx status, model with case-insensitive match, ±10min window) wired into three layers: - Write path: should_skip_session_insert() blocks duplicate session rows before INSERT, unifying the previously-divergent Claude/Codex/Gemini paths through a single DedupKey-based helper. - Read path: effective_usage_log_filter() excludes already-covered session rows from every aggregation query. - Rollup path: same filter applied so usage_daily_rollups never absorbs duplicates. Also adds a covering index (idx_request_logs_dedup_lookup) so the EXISTS subquery stays index-only, and a transform.rs regression test that pins openai_to_anthropic id preservation - the missing piece that lets Claude+OpenAI-compatible providers reuse the session: id scheme. --- src-tauri/src/database/dao/usage_rollup.rs | 100 ++- src-tauri/src/database/schema.rs | 32 + src-tauri/src/proxy/providers/transform.rs | 28 + src-tauri/src/services/session_usage.rs | 101 ++- src-tauri/src/services/session_usage_codex.rs | 82 ++- .../src/services/session_usage_gemini.rs | 70 +- src-tauri/src/services/usage_stats.rs | 596 ++++++++++++++++-- 7 files changed, 917 insertions(+), 92 deletions(-) diff --git a/src-tauri/src/database/dao/usage_rollup.rs b/src-tauri/src/database/dao/usage_rollup.rs index fffb8a3bf..fc5fdb371 100644 --- a/src-tauri/src/database/dao/usage_rollup.rs +++ b/src-tauri/src/database/dao/usage_rollup.rs @@ -4,6 +4,7 @@ use crate::database::{lock_conn, Database}; use crate::error::AppError; +use crate::services::usage_stats::effective_usage_log_filter; use chrono::{Duration, Local, TimeZone}; /// Compute the rollup/prune cutoff aligned to a local-day boundary. @@ -101,7 +102,8 @@ impl Database { fn do_rollup_and_prune(conn: &rusqlite::Connection, cutoff: i64) -> Result { // Aggregate old logs, merging with any pre-existing rollup rows via LEFT JOIN. - conn.execute( + let effective_filter = effective_usage_log_filter("l"); + let aggregation_sql = format!( "INSERT OR REPLACE INTO usage_daily_rollups (date, app_type, provider_id, model, request_count, success_count, @@ -124,27 +126,30 @@ impl Database { ELSE 0 END FROM ( SELECT - date(created_at, 'unixepoch', 'localtime') as d, - app_type as a, provider_id as p, model as m, + date(l.created_at, 'unixepoch', 'localtime') as d, + l.app_type as a, l.provider_id as p, l.model as m, COUNT(*) as new_req, - SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END) as new_succ, - COALESCE(SUM(input_tokens), 0) as new_in, - COALESCE(SUM(output_tokens), 0) as new_out, - COALESCE(SUM(cache_read_tokens), 0) as new_cr, - COALESCE(SUM(cache_creation_tokens), 0) as new_cc, - COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as new_cost, - COALESCE(AVG(latency_ms), 0) as new_lat - FROM proxy_request_logs WHERE created_at < ?1 + SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END) as new_succ, + COALESCE(SUM(l.input_tokens), 0) as new_in, + COALESCE(SUM(l.output_tokens), 0) as new_out, + COALESCE(SUM(l.cache_read_tokens), 0) as new_cr, + COALESCE(SUM(l.cache_creation_tokens), 0) as new_cc, + COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as new_cost, + COALESCE(AVG(l.latency_ms), 0) as new_lat + FROM proxy_request_logs l + WHERE l.created_at < ?1 AND {effective_filter} GROUP BY d, a, p, m ) agg LEFT JOIN usage_daily_rollups old ON old.date = agg.d AND old.app_type = agg.a - AND old.provider_id = agg.p AND old.model = agg.m", - [cutoff], - ) - .map_err(|e| AppError::Database(format!("Rollup aggregation failed: {e}")))?; + AND old.provider_id = agg.p AND old.model = agg.m" + ); - // Delete the aggregated detail rows + conn.execute(&aggregation_sql, [cutoff]) + .map_err(|e| AppError::Database(format!("Rollup aggregation failed: {e}")))?; + + // INSERT uses the effective-log filter to exclude duplicate session rows. + // DELETE intentionally prunes all old details so those duplicates are discarded. let deleted = conn .execute( "DELETE FROM proxy_request_logs WHERE created_at < ?1", @@ -254,6 +259,69 @@ mod tests { Ok(()) } + #[test] + fn test_rollup_uses_effective_usage_logs() -> Result<(), AppError> { + let db = Database::memory()?; + let now = chrono::Utc::now().timestamp(); + let old_ts = now - 40 * 86400; + + { + let conn = crate::database::lock_conn!(db.conn); + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, request_model, + input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, + total_cost_usd, latency_ms, status_code, created_at, data_source + ) VALUES (?1, 'openai', 'codex', 'gpt-5.4', 'gpt-5.4', 100, 20, 10, 0, '0.10', 100, 200, ?2, 'proxy')", + rusqlite::params!["codex-proxy-old", old_ts], + )?; + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, request_model, + input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, + total_cost_usd, latency_ms, status_code, created_at, data_source + ) VALUES (?1, '_codex_session', 'codex', 'gpt-5.4', 'gpt-5.4', 100, 20, 10, 0, '0.10', 0, 200, ?2, 'codex_session')", + rusqlite::params!["codex-session-old-dup", old_ts + 60], + )?; + } + + let deleted = db.rollup_and_prune(30)?; + assert_eq!(deleted, 2); + + let conn = crate::database::lock_conn!(db.conn); + let mut stmt = conn.prepare( + "SELECT provider_id, request_count, input_tokens, output_tokens, cache_read_tokens + FROM usage_daily_rollups WHERE app_type = 'codex'", + )?; + let rows = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)?, + )) + })? + .collect::, _>>()?; + + assert_eq!(rows.len(), 1); + let (provider_id, request_count, input_tokens, output_tokens, cache_read_tokens) = &rows[0]; + assert_eq!(provider_id, "openai"); + assert_eq!(*request_count, 1); + assert_eq!(*input_tokens, 100); + assert_eq!(*output_tokens, 20); + assert_eq!(*cache_read_tokens, 10); + + let remaining: i64 = + conn.query_row("SELECT COUNT(*) FROM proxy_request_logs", [], |row| { + row.get(0) + })?; + assert_eq!(remaining, 0); + + Ok(()) + } + #[test] fn test_rollup_noop_when_no_old_data() -> Result<(), AppError> { let db = Database::memory()?; diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 61c2f28e6..208f79413 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -214,6 +214,7 @@ impl Database { [], ) .map_err(|e| AppError::Database(e.to_string()))?; + Self::create_request_logs_dedup_index_if_supported(conn)?; // 11. Model Pricing 表 conn.execute( @@ -1107,6 +1108,7 @@ impl Database { "data_source", "TEXT NOT NULL DEFAULT 'proxy'", )?; + Self::create_request_logs_dedup_index_if_supported(conn)?; } // 2. 创建会话日志同步状态表 @@ -1908,6 +1910,36 @@ impl Database { Ok(()) } + fn create_request_logs_dedup_index_if_supported(conn: &Connection) -> Result<(), AppError> { + if !Self::table_exists(conn, "proxy_request_logs")? { + return Ok(()); + } + + let required_columns = [ + "app_type", + "data_source", + "input_tokens", + "output_tokens", + "cache_read_tokens", + "created_at", + "cache_creation_tokens", + ]; + for column in required_columns { + if !Self::has_column(conn, "proxy_request_logs", column)? { + return Ok(()); + } + } + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_request_logs_dedup_lookup + ON proxy_request_logs(app_type, data_source, input_tokens, output_tokens, + cache_read_tokens, created_at, cache_creation_tokens)", + [], + ) + .map_err(|e| AppError::Database(format!("创建使用量去重索引失败: {e}")))?; + Ok(()) + } + fn validate_identifier(s: &str, kind: &str) -> Result<(), AppError> { if s.is_empty() { return Err(AppError::Database(format!("{kind} 不能为空"))); diff --git a/src-tauri/src/proxy/providers/transform.rs b/src-tauri/src/proxy/providers/transform.rs index 5e43a1869..d4eafd243 100644 --- a/src-tauri/src/proxy/providers/transform.rs +++ b/src-tauri/src/proxy/providers/transform.rs @@ -869,6 +869,34 @@ mod tests { assert_eq!(result["usage"]["output_tokens"], 5); } + #[test] + fn test_openai_to_anthropic_preserves_id_for_usage_dedup() { + let input = json!({ + "id": "chatcmpl-claude-compatible", + "object": "chat.completion", + "model": "claude-sonnet-4-5", + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": "Hello!"}, + "finish_reason": "stop" + }], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15} + }); + + let result = openai_to_anthropic(input).unwrap(); + let usage = crate::proxy::usage::parser::TokenUsage::from_claude_response(&result) + .expect("converted Anthropic response should parse usage"); + + assert_eq!( + usage.message_id.as_deref(), + Some("chatcmpl-claude-compatible") + ); + assert_eq!( + usage.dedup_request_id(), + "session:chatcmpl-claude-compatible" + ); + } + #[test] fn test_openai_to_anthropic_with_tool_calls() { let input = json!({ diff --git a/src-tauri/src/services/session_usage.rs b/src-tauri/src/services/session_usage.rs index 2b9c5af9f..902b78cbc 100644 --- a/src-tauri/src/services/session_usage.rs +++ b/src-tauri/src/services/session_usage.rs @@ -13,6 +13,9 @@ use crate::database::{lock_conn, Database}; use crate::error::AppError; use crate::proxy::usage::calculator::{CostCalculator, ModelPricing}; use crate::proxy::usage::parser::TokenUsage; +use crate::services::usage_stats::{ + effective_usage_log_filter, should_skip_session_insert, DedupKey, +}; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -346,25 +349,10 @@ fn insert_session_log_entry( ) -> Result { let conn = lock_conn!(db.conn); - // 检查是否已存在 - let exists: bool = conn - .query_row( - "SELECT COUNT(*) FROM proxy_request_logs WHERE request_id = ?1", - rusqlite::params![request_id], - |row| row.get::<_, i64>(0).map(|c| c > 0), - ) - .unwrap_or(false); - - if exists { - return Ok(false); - } - - // 解析时间戳 let created_at = msg .timestamp .as_ref() .and_then(|ts| { - // 尝试解析 ISO 8601 时间戳 chrono::DateTime::parse_from_rfc3339(ts) .ok() .map(|dt| dt.timestamp()) @@ -376,6 +364,19 @@ fn insert_session_log_entry( .unwrap_or(0) }); + let dedup_key = DedupKey { + app_type: "claude", + model: &msg.model, + input_tokens: msg.input_tokens, + output_tokens: msg.output_tokens, + cache_read_tokens: msg.cache_read_tokens, + cache_creation_tokens: msg.cache_creation_tokens, + created_at, + }; + if should_skip_session_insert(&conn, request_id, &dedup_key)? { + return Ok(false); + } + // 计算费用 let usage = TokenUsage { input_tokens: msg.input_tokens, @@ -531,13 +532,17 @@ fn try_find_pricing( pub fn get_data_source_breakdown(db: &Database) -> Result, AppError> { let conn = lock_conn!(db.conn); - let mut stmt = conn.prepare( - "SELECT COALESCE(data_source, 'proxy') as ds, COUNT(*) as cnt, - COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as cost - FROM proxy_request_logs + let effective_filter = effective_usage_log_filter("l"); + let sql = format!( + "SELECT COALESCE(l.data_source, 'proxy') as ds, COUNT(*) as cnt, + COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as cost + FROM proxy_request_logs l + WHERE {effective_filter} GROUP BY ds - ORDER BY cnt DESC", - )?; + ORDER BY cnt DESC" + ); + + let mut stmt = conn.prepare(&sql)?; let rows = stmt.query_map([], |row| { Ok(DataSourceSummary { @@ -636,4 +641,58 @@ mod tests { messages.insert("msg_1".to_string(), final_entry); assert_eq!(messages.get("msg_1").unwrap().output_tokens, 1349); } + + #[test] + fn test_insert_claude_session_skips_matching_proxy_log() -> Result<(), AppError> { + let db = Database::memory()?; + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, request_model, + input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, + total_cost_usd, latency_ms, status_code, created_at, data_source + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + rusqlite::params![ + "proxy-different-id", + "openai-compatible", + "claude", + "claude-sonnet-4-5", + "claude-sonnet-4-5", + 100, + 20, + 10, + 5, + "0.10", + 100, + 200, + 1000, + "proxy" + ], + )?; + } + + let msg = ParsedAssistantUsage { + message_id: "msg_1".to_string(), + model: "claude-sonnet-4-5".to_string(), + input_tokens: 100, + output_tokens: 20, + cache_read_tokens: 10, + cache_creation_tokens: 5, + stop_reason: Some("end_turn".to_string()), + timestamp: Some("1970-01-01T00:16:45Z".to_string()), + session_id: Some("session-1".to_string()), + }; + + let inserted = insert_session_log_entry(&db, "session:msg_1", &msg)?; + assert!(!inserted); + + let conn = lock_conn!(db.conn); + let count: i64 = conn.query_row("SELECT COUNT(*) FROM proxy_request_logs", [], |row| { + row.get(0) + })?; + assert_eq!(count, 1); + + Ok(()) + } } diff --git a/src-tauri/src/services/session_usage_codex.rs b/src-tauri/src/services/session_usage_codex.rs index 8360c7ecf..b666634ff 100644 --- a/src-tauri/src/services/session_usage_codex.rs +++ b/src-tauri/src/services/session_usage_codex.rs @@ -19,6 +19,7 @@ use crate::error::AppError; use crate::proxy::usage::calculator::{CostCalculator, ModelPricing}; use crate::proxy::usage::parser::TokenUsage; use crate::services::session_usage::SessionSyncResult; +use crate::services::usage_stats::{should_skip_session_insert, DedupKey}; use rust_decimal::Decimal; use std::fs; use std::io::{BufRead, BufReader}; @@ -438,20 +439,6 @@ fn insert_codex_session_entry( ) -> Result { let conn = lock_conn!(db.conn); - // 检查是否已存在 - let exists: bool = conn - .query_row( - "SELECT COUNT(*) FROM proxy_request_logs WHERE request_id = ?1", - rusqlite::params![request_id], - |row| row.get::<_, i64>(0).map(|c| c > 0), - ) - .unwrap_or(false); - - if exists { - return Ok(false); - } - - // 解析时间戳 let created_at = timestamp .and_then(|ts| { chrono::DateTime::parse_from_rfc3339(ts) @@ -465,6 +452,19 @@ fn insert_codex_session_entry( .unwrap_or(0) }); + let dedup_key = DedupKey { + app_type: "codex", + model, + input_tokens: delta.input, + output_tokens: delta.output, + cache_read_tokens: delta.cached_input, + cache_creation_tokens: 0, + created_at, + }; + if should_skip_session_insert(&conn, request_id, &dedup_key)? { + return Ok(false); + } + // 计算费用 let usage = TokenUsage { input_tokens: delta.input, @@ -733,6 +733,60 @@ mod tests { assert!(files.is_empty()); } + #[test] + fn test_insert_codex_session_skips_matching_proxy_log() -> Result<(), AppError> { + let db = Database::memory()?; + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, request_model, + input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, + total_cost_usd, latency_ms, status_code, created_at, data_source + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + rusqlite::params![ + "codex-proxy", + "openai", + "codex", + "gpt-5.4", + "gpt-5.4", + 10, + 2, + 1, + 7, + "0.01", + 100, + 200, + 1000, + "proxy" + ], + )?; + } + + let delta = DeltaTokens { + input: 10, + cached_input: 1, + output: 2, + }; + let inserted = insert_codex_session_entry( + &db, + "codex-session-dup", + &delta, + "gpt-5.4", + Some("session-1"), + Some("1970-01-01T00:16:45Z"), + )?; + assert!(!inserted); + + let conn = lock_conn!(db.conn); + let count: i64 = conn.query_row("SELECT COUNT(*) FROM proxy_request_logs", [], |row| { + row.get(0) + })?; + assert_eq!(count, 1); + + Ok(()) + } + // ── 模型名归一化测试 ── #[test] diff --git a/src-tauri/src/services/session_usage_gemini.rs b/src-tauri/src/services/session_usage_gemini.rs index 24ff25c37..e0049f91b 100644 --- a/src-tauri/src/services/session_usage_gemini.rs +++ b/src-tauri/src/services/session_usage_gemini.rs @@ -19,6 +19,7 @@ use crate::gemini_config::get_gemini_dir; use crate::proxy::usage::calculator::{CostCalculator, ModelPricing}; use crate::proxy::usage::parser::TokenUsage; use crate::services::session_usage::SessionSyncResult; +use crate::services::usage_stats::{should_skip_session_insert, DedupKey}; use rust_decimal::Decimal; use std::fs; use std::path::{Path, PathBuf}; @@ -237,7 +238,6 @@ fn insert_gemini_session_entry( ) -> Result { let conn = lock_conn!(db.conn); - // 解析时间戳 let created_at = timestamp .and_then(|ts| { chrono::DateTime::parse_from_rfc3339(ts) @@ -254,6 +254,19 @@ fn insert_gemini_session_entry( // 合并 thoughts 到 output(思考 token 按输出计费) let output_tokens = tokens.output + tokens.thoughts; + let dedup_key = DedupKey { + app_type: "gemini", + model, + input_tokens: tokens.input, + output_tokens, + cache_read_tokens: tokens.cached, + cache_creation_tokens: 0, + created_at, + }; + if should_skip_session_insert(&conn, request_id, &dedup_key)? { + return Ok(false); + } + // 计算费用 let usage = TokenUsage { input_tokens: tokens.input, @@ -433,6 +446,61 @@ mod tests { assert!(files.is_empty()); } + #[test] + fn test_insert_gemini_session_skips_matching_proxy_log() -> Result<(), AppError> { + let db = Database::memory()?; + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, request_model, + input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, + total_cost_usd, latency_ms, status_code, created_at, data_source + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + rusqlite::params![ + "gemini-proxy", + "google", + "gemini", + "gemini-2.5-pro", + "gemini-2.5-pro", + 10, + 7, + 1, + 0, + "0.01", + 100, + 200, + 1000, + "proxy" + ], + )?; + } + + let tokens = GeminiTokens { + input: 10, + output: 2, + cached: 1, + thoughts: 5, + }; + let inserted = insert_gemini_session_entry( + &db, + "gemini-session-dup", + &tokens, + "gemini-2.5-pro", + Some("session-1"), + Some("1970-01-01T00:16:45Z"), + )?; + assert!(!inserted); + + let conn = lock_conn!(db.conn); + let count: i64 = conn.query_row("SELECT COUNT(*) FROM proxy_request_logs", [], |row| { + row.get(0) + })?; + assert_eq!(count, 1); + + Ok(()) + } + #[test] fn test_parse_gemini_tokens() { let json: serde_json::Value = serde_json::json!({ diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index 706fd9f09..9faef593b 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -130,6 +130,123 @@ fn provider_name_coalesce(log_alias: &str, provider_alias: &str) -> String { ) } +pub(crate) const SESSION_PROXY_DEDUP_WINDOW_SECONDS: i64 = 10 * 60; + +pub(crate) fn effective_usage_log_filter(log_alias: &str) -> String { + format!( + "NOT ( + {log_alias}.data_source IN ('session_log', 'codex_session', 'gemini_session') + AND EXISTS ( + SELECT 1 + FROM proxy_request_logs proxy_dedup + WHERE proxy_dedup.data_source = 'proxy' + AND proxy_dedup.app_type = {log_alias}.app_type + AND proxy_dedup.status_code >= 200 + AND proxy_dedup.status_code < 300 + AND proxy_dedup.input_tokens = {log_alias}.input_tokens + AND proxy_dedup.output_tokens = {log_alias}.output_tokens + AND proxy_dedup.cache_read_tokens = {log_alias}.cache_read_tokens + AND ( + proxy_dedup.cache_creation_tokens = {log_alias}.cache_creation_tokens + OR ( + {log_alias}.cache_creation_tokens = 0 + AND {log_alias}.data_source IN ('codex_session', 'gemini_session') + ) + ) + AND proxy_dedup.created_at BETWEEN + {log_alias}.created_at - {SESSION_PROXY_DEDUP_WINDOW_SECONDS} + AND {log_alias}.created_at + {SESSION_PROXY_DEDUP_WINDOW_SECONDS} + AND ( + LOWER(proxy_dedup.model) = LOWER({log_alias}.model) + OR LOWER(proxy_dedup.model) = 'unknown' + OR LOWER({log_alias}.model) = 'unknown' + ) + ) + )" + ) +} + +/// 跨源去重指纹键。 +/// +/// `cache_creation_tokens`:Codex/Gemini session 日志不暴露该字段,调用方传 0 +/// 表示"未知",匹配器会放行 proxy 侧任意 cache_creation_tokens 值。 +#[derive(Debug, Clone, Copy)] +pub(crate) struct DedupKey<'a> { + pub app_type: &'a str, + pub model: &'a str, + pub input_tokens: u32, + pub output_tokens: u32, + pub cache_read_tokens: u32, + pub cache_creation_tokens: u32, + pub created_at: i64, +} + +/// session 日志写入前的统一去重判定。 +/// +/// 命中以下任一条件即跳过插入:① `request_id` 已存在;② 时间窗口内存在 +/// 与 `key` 匹配的 proxy 日志(指纹去重)。 +pub(crate) fn should_skip_session_insert( + conn: &Connection, + request_id: &str, + key: &DedupKey, +) -> Result { + if proxy_request_id_exists(conn, request_id)? { + return Ok(true); + } + has_matching_proxy_usage_log(conn, key) +} + +fn proxy_request_id_exists(conn: &Connection, request_id: &str) -> Result { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM proxy_request_logs WHERE request_id = ?1)", + params![request_id], + |row| row.get::<_, bool>(0), + ) + .map_err(|e| AppError::Database(format!("查询 request_id 失败: {e}"))) +} + +pub(crate) fn has_matching_proxy_usage_log( + conn: &Connection, + key: &DedupKey, +) -> Result { + let allow_missing_cache_creation = + matches!(key.app_type, "codex" | "gemini") && key.cache_creation_tokens == 0; + + conn.query_row( + "SELECT EXISTS ( + SELECT 1 + FROM proxy_request_logs l + WHERE l.data_source = 'proxy' + AND l.app_type = ?1 + AND l.status_code >= 200 + AND l.status_code < 300 + AND l.input_tokens = ?3 + AND l.output_tokens = ?4 + AND l.cache_read_tokens = ?5 + AND (l.cache_creation_tokens = ?6 OR ?9 = 1) + AND l.created_at BETWEEN ?7 - ?8 AND ?7 + ?8 + AND ( + LOWER(l.model) = LOWER(?2) + OR LOWER(l.model) = 'unknown' + OR LOWER(?2) = 'unknown' + ) + )", + params![ + key.app_type, + key.model, + key.input_tokens as i64, + key.output_tokens as i64, + key.cache_read_tokens as i64, + key.cache_creation_tokens as i64, + key.created_at, + SESSION_PROXY_DEDUP_WINDOW_SECONDS, + allow_missing_cache_creation as i64, + ], + |row| row.get::<_, bool>(0), + ) + .map_err(|e| AppError::Database(format!("查询重复代理用量日志失败: {e}"))) +} + #[derive(Debug, Clone, Default)] struct RollupDateBounds { start: Option, @@ -231,19 +348,19 @@ impl Database { let conn = lock_conn!(self.conn); // Build detail WHERE clause - let mut conditions = Vec::new(); + let mut conditions = vec![effective_usage_log_filter("l")]; let mut params_vec: Vec> = Vec::new(); if let Some(start) = start_date { - conditions.push("created_at >= ?"); + conditions.push("l.created_at >= ?".to_string()); params_vec.push(Box::new(start)); } if let Some(end) = end_date { - conditions.push("created_at <= ?"); + conditions.push("l.created_at <= ?".to_string()); params_vec.push(Box::new(end)); } if let Some(at) = app_type { - conditions.push("app_type = ?"); + conditions.push("l.app_type = ?".to_string()); params_vec.push(Box::new(at.to_string())); } @@ -293,7 +410,7 @@ impl Database { COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens, COALESCE(SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END), 0) as success_count - FROM proxy_request_logs {where_clause}) d, + FROM proxy_request_logs l {where_clause}) d, (SELECT COALESCE(SUM(request_count), 0) as total_requests, COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost, @@ -369,23 +486,25 @@ impl Database { } let app_type_filter = if app_type.is_some() { - "AND app_type = ?4" + "AND l.app_type = ?4" } else { "" }; + let effective_filter = effective_usage_log_filter("l"); let sql = format!( "SELECT - CAST((created_at - ?1) / ?3 AS INTEGER) as bucket_idx, + CAST((l.created_at - ?1) / ?3 AS INTEGER) as bucket_idx, COUNT(*) as request_count, - COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost, - COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, - COALESCE(SUM(input_tokens), 0) as total_input_tokens, - COALESCE(SUM(output_tokens), 0) as total_output_tokens, - COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, - COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens - FROM proxy_request_logs - WHERE created_at >= ?1 AND created_at <= ?2 {app_type_filter} + COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost, + COALESCE(SUM(l.input_tokens + l.output_tokens), 0) as total_tokens, + COALESCE(SUM(l.input_tokens), 0) as total_input_tokens, + COALESCE(SUM(l.output_tokens), 0) as total_output_tokens, + COALESCE(SUM(l.cache_creation_tokens), 0) as total_cache_creation_tokens, + COALESCE(SUM(l.cache_read_tokens), 0) as total_cache_read_tokens + FROM proxy_request_logs l + WHERE l.created_at >= ?1 AND l.created_at <= ?2 + AND {effective_filter} {app_type_filter} GROUP BY bucket_idx ORDER BY bucket_idx ASC" ); @@ -456,23 +575,25 @@ impl Database { let bucket_count = (end_day.signed_duration_since(start_day).num_days() + 1) as usize; let app_type_filter = if app_type.is_some() { - "AND app_type = ?3" + "AND l.app_type = ?3" } else { "" }; + let effective_filter = effective_usage_log_filter("l"); let detail_sql = format!( "SELECT - date(created_at, 'unixepoch', 'localtime') as bucket_date, + date(l.created_at, 'unixepoch', 'localtime') as bucket_date, COUNT(*) as request_count, - COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost, - COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, - COALESCE(SUM(input_tokens), 0) as total_input_tokens, - COALESCE(SUM(output_tokens), 0) as total_output_tokens, - COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, - COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens - FROM proxy_request_logs - WHERE created_at >= ?1 AND created_at <= ?2 {app_type_filter} + COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost, + COALESCE(SUM(l.input_tokens + l.output_tokens), 0) as total_tokens, + COALESCE(SUM(l.input_tokens), 0) as total_input_tokens, + COALESCE(SUM(l.output_tokens), 0) as total_output_tokens, + COALESCE(SUM(l.cache_creation_tokens), 0) as total_cache_creation_tokens, + COALESCE(SUM(l.cache_read_tokens), 0) as total_cache_read_tokens + FROM proxy_request_logs l + WHERE l.created_at >= ?1 AND l.created_at <= ?2 + AND {effective_filter} {app_type_filter} GROUP BY bucket_date ORDER BY bucket_date ASC" ); @@ -623,18 +744,18 @@ impl Database { ) -> Result, AppError> { let conn = lock_conn!(self.conn); - let mut detail_conditions = Vec::new(); + let mut detail_conditions = vec![effective_usage_log_filter("l")]; let mut detail_params: Vec> = Vec::new(); if let Some(start) = start_date { - detail_conditions.push("l.created_at >= ?"); + detail_conditions.push("l.created_at >= ?".to_string()); detail_params.push(Box::new(start)); } if let Some(end) = end_date { - detail_conditions.push("l.created_at <= ?"); + detail_conditions.push("l.created_at <= ?".to_string()); detail_params.push(Box::new(end)); } if let Some(at) = app_type { - detail_conditions.push("l.app_type = ?"); + detail_conditions.push("l.app_type = ?".to_string()); detail_params.push(Box::new(at.to_string())); } let detail_where = if detail_conditions.is_empty() { @@ -747,18 +868,18 @@ impl Database { ) -> Result, AppError> { let conn = lock_conn!(self.conn); - let mut detail_conditions = Vec::new(); + let mut detail_conditions = vec![effective_usage_log_filter("l")]; let mut detail_params: Vec> = Vec::new(); if let Some(start) = start_date { - detail_conditions.push("l.created_at >= ?"); + detail_conditions.push("l.created_at >= ?".to_string()); detail_params.push(Box::new(start)); } if let Some(end) = end_date { - detail_conditions.push("l.created_at <= ?"); + detail_conditions.push("l.created_at <= ?".to_string()); detail_params.push(Box::new(end)); } if let Some(at) = app_type { - detail_conditions.push("l.app_type = ?"); + detail_conditions.push("l.app_type = ?".to_string()); detail_params.push(Box::new(at.to_string())); } let detail_where = if detail_conditions.is_empty() { @@ -855,31 +976,31 @@ impl Database { ) -> Result { let conn = lock_conn!(self.conn); - let mut conditions = Vec::new(); + let mut conditions = vec![effective_usage_log_filter("l")]; let mut params: Vec> = Vec::new(); if let Some(ref app_type) = filters.app_type { - conditions.push("l.app_type = ?"); + conditions.push("l.app_type = ?".to_string()); params.push(Box::new(app_type.clone())); } if let Some(ref provider_name) = filters.provider_name { - conditions.push("p.name LIKE ?"); + conditions.push("p.name LIKE ?".to_string()); params.push(Box::new(format!("%{provider_name}%"))); } if let Some(ref model) = filters.model { - conditions.push("l.model LIKE ?"); + conditions.push("l.model LIKE ?".to_string()); params.push(Box::new(format!("%{model}%"))); } if let Some(status) = filters.status_code { - conditions.push("l.status_code = ?"); + conditions.push("l.status_code = ?".to_string()); params.push(Box::new(status as i64)); } if let Some(start) = filters.start_date { - conditions.push("l.created_at >= ?"); + conditions.push("l.created_at >= ?".to_string()); params.push(Box::new(start)); } if let Some(end) = filters.end_date { - conditions.push("l.created_at <= ?"); + conditions.push("l.created_at <= ?".to_string()); params.push(Box::new(end)); } @@ -1351,6 +1472,48 @@ mod tests { } } + #[allow(clippy::too_many_arguments)] + fn insert_usage_log( + conn: &Connection, + request_id: &str, + app_type: &str, + provider_id: &str, + model: &str, + data_source: &str, + created_at: i64, + input_tokens: i64, + output_tokens: i64, + cache_read_tokens: i64, + cache_creation_tokens: i64, + status_code: i64, + total_cost_usd: &str, + ) -> Result<(), AppError> { + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, request_model, + input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, + input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, + total_cost_usd, latency_ms, status_code, created_at, data_source + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, '0', '0', '0', '0', ?, 100, ?, ?, ?)", + params![ + request_id, + provider_id, + app_type, + model, + model, + input_tokens, + output_tokens, + cache_read_tokens, + cache_creation_tokens, + total_cost_usd, + status_code, + created_at, + data_source + ], + )?; + Ok(()) + } + #[test] fn test_get_usage_summary() -> Result<(), AppError> { let db = Database::memory()?; @@ -1525,6 +1688,359 @@ mod tests { Ok(()) } + #[test] + fn test_effective_usage_dedup_prefers_proxy_for_session_sources() -> Result<(), AppError> { + let db = Database::memory()?; + + { + let conn = lock_conn!(db.conn); + insert_usage_log( + &conn, + "codex-proxy", + "codex", + "openai", + "GPT-5.4", + "proxy", + 10_000, + 100, + 20, + 10, + 7, + 200, + "0.10", + )?; + insert_usage_log( + &conn, + "codex-session-dup", + "codex", + "_codex_session", + "gpt-5.4", + "codex_session", + 10_060, + 100, + 20, + 10, + 0, + 200, + "0.10", + )?; + insert_usage_log( + &conn, + "claude-proxy", + "claude", + "openai-compatible", + "claude-sonnet-4-5", + "proxy", + 25_000, + 300, + 60, + 20, + 5, + 200, + "0.30", + )?; + insert_usage_log( + &conn, + "claude-session-dup", + "claude", + "_session", + "claude-sonnet-4-5", + "session_log", + 25_060, + 300, + 60, + 20, + 5, + 200, + "0.30", + )?; + insert_usage_log( + &conn, + "gemini-proxy", + "gemini", + "google", + "gemini-2.5-pro", + "proxy", + 20_000, + 200, + 40, + 30, + 0, + 200, + "0.20", + )?; + insert_usage_log( + &conn, + "gemini-session-dup", + "gemini", + "_gemini_session", + "gemini-2.5-pro", + "gemini_session", + 20_060, + 200, + 40, + 30, + 0, + 200, + "0.20", + )?; + insert_usage_log( + &conn, + "codex-session-only", + "codex", + "_codex_session", + "gpt-5.4", + "codex_session", + 30_000, + 50, + 5, + 0, + 0, + 200, + "0.02", + )?; + } + + let summary = db.get_usage_summary(None, None, None)?; + assert_eq!(summary.total_requests, 4); + assert_eq!(summary.total_input_tokens, 650); + assert_eq!(summary.total_output_tokens, 125); + assert_eq!(summary.total_cache_read_tokens, 60); + assert_eq!(summary.total_cache_creation_tokens, 12); + + let trends = db.get_daily_trends(Some(0), Some(40_000), None)?; + assert_eq!(trends.iter().map(|stat| stat.request_count).sum::(), 4); + + let provider_stats = db.get_provider_stats(None, None, None)?; + assert_eq!( + provider_stats + .iter() + .map(|stat| stat.request_count) + .sum::(), + 4 + ); + assert!(provider_stats + .iter() + .any(|stat| stat.provider_id == "_codex_session" && stat.request_count == 1)); + assert!(!provider_stats + .iter() + .any(|stat| stat.provider_id == "_gemini_session")); + assert!(!provider_stats + .iter() + .any(|stat| stat.provider_id == "_session")); + + let model_stats = db.get_model_stats(None, None, None)?; + assert_eq!( + model_stats + .iter() + .map(|stat| stat.request_count) + .sum::(), + 4 + ); + + let logs = db.get_request_logs(&LogFilters::default(), 0, 10)?; + let request_ids: Vec<&str> = logs + .data + .iter() + .map(|log| log.request_id.as_str()) + .collect(); + assert_eq!(logs.total, 4); + assert!(request_ids.contains(&"codex-proxy")); + assert!(request_ids.contains(&"claude-proxy")); + assert!(request_ids.contains(&"gemini-proxy")); + assert!(request_ids.contains(&"codex-session-only")); + assert!(!request_ids.contains(&"codex-session-dup")); + assert!(!request_ids.contains(&"claude-session-dup")); + assert!(!request_ids.contains(&"gemini-session-dup")); + + let breakdown = crate::services::session_usage::get_data_source_breakdown(&db)?; + let proxy_count = breakdown + .iter() + .find(|item| item.data_source == "proxy") + .map(|item| item.request_count); + let codex_session_count = breakdown + .iter() + .find(|item| item.data_source == "codex_session") + .map(|item| item.request_count); + let gemini_session_count = breakdown + .iter() + .find(|item| item.data_source == "gemini_session") + .map(|item| item.request_count); + let session_log_count = breakdown + .iter() + .find(|item| item.data_source == "session_log") + .map(|item| item.request_count); + assert_eq!(proxy_count, Some(3)); + assert_eq!(codex_session_count, Some(1)); + assert_eq!(gemini_session_count, None); + assert_eq!(session_log_count, None); + + Ok(()) + } + + #[test] + fn test_effective_usage_dedup_keeps_non_matching_session_rows() -> Result<(), AppError> { + let db = Database::memory()?; + + { + let conn = lock_conn!(db.conn); + insert_usage_log( + &conn, + "proxy-base", + "codex", + "openai", + "gpt-5.4", + "proxy", + 10_000, + 100, + 20, + 10, + 0, + 200, + "0.10", + )?; + insert_usage_log( + &conn, + "session-outside-window", + "codex", + "_codex_session", + "gpt-5.4", + "codex_session", + 10_601, + 100, + 20, + 10, + 0, + 200, + "0.10", + )?; + insert_usage_log( + &conn, + "session-token-mismatch", + "codex", + "_codex_session", + "gpt-5.4", + "codex_session", + 10_060, + 101, + 20, + 10, + 0, + 200, + "0.10", + )?; + insert_usage_log( + &conn, + "session-app-mismatch", + "gemini", + "_gemini_session", + "gpt-5.4", + "gemini_session", + 10_060, + 100, + 20, + 10, + 0, + 200, + "0.10", + )?; + insert_usage_log( + &conn, + "session-model-mismatch", + "codex", + "_codex_session", + "different-model", + "codex_session", + 10_060, + 100, + 20, + 10, + 0, + 200, + "0.10", + )?; + insert_usage_log( + &conn, + "proxy-error", + "codex", + "openai", + "gpt-5.4", + "proxy", + 20_000, + 300, + 60, + 0, + 0, + 500, + "0.00", + )?; + insert_usage_log( + &conn, + "session-matches-error-proxy", + "codex", + "_codex_session", + "gpt-5.4", + "codex_session", + 20_060, + 300, + 60, + 0, + 0, + 200, + "0.30", + )?; + insert_usage_log( + &conn, + "claude-proxy-cache-creation", + "claude", + "anthropic", + "claude-sonnet-4-5", + "proxy", + 30_000, + 100, + 20, + 10, + 5, + 200, + "0.10", + )?; + insert_usage_log( + &conn, + "claude-session-cache-creation-mismatch", + "claude", + "_session", + "claude-sonnet-4-5", + "session_log", + 30_060, + 100, + 20, + 10, + 0, + 200, + "0.10", + )?; + } + + let summary = db.get_usage_summary(None, None, None)?; + assert_eq!(summary.total_requests, 9); + + let logs = db.get_request_logs(&LogFilters::default(), 0, 10)?; + let request_ids: Vec<&str> = logs + .data + .iter() + .map(|log| log.request_id.as_str()) + .collect(); + assert_eq!(logs.total, 9); + assert!(request_ids.contains(&"session-outside-window")); + assert!(request_ids.contains(&"session-token-mismatch")); + assert!(request_ids.contains(&"session-app-mismatch")); + assert!(request_ids.contains(&"session-model-mismatch")); + assert!(request_ids.contains(&"session-matches-error-proxy")); + assert!(request_ids.contains(&"claude-session-cache-creation-mismatch")); + + Ok(()) + } + #[test] fn test_get_model_stats() -> Result<(), AppError> { let db = Database::memory()?; From f061b777b7b530eaf81dda301c729f3fd4f4f299 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 29 Apr 2026 22:38:29 +0800 Subject: [PATCH 095/156] feat(usage): add Hermes Agent tracking + fix zero-cost bug + perf Hermes: - Parse ~/.hermes/state.db sessions (incl. profiles/*/state.db) into proxy_request_logs with data_source='hermes_session', WAL-aware incremental sync, Hermes-reported cost preferred over model_pricing fallback Zero-cost bug (dashboard showed \$0 totals): - GPT-5.5 family default pricing (~83% of affected rows used GPT-5.5) - find_model_pricing_row: ASCII-lowercase normalization so "OpenAI/GPT-5.5@HIGH" matches seeded "gpt-5.5" - Startup cost backfill in async task: scan rows where total_cost <= 0 but tokens > 0, recompute via model_pricing in a single transaction Performance: - Add (app_type, created_at DESC) covering index for dashboard range queries - Add expression index on COALESCE(data_source, 'proxy') so dedup EXISTS subqueries use index lookup instead of full scan; drop superseded idx_request_logs_dedup_lookup Refactor: - row_to_request_log_detail helper (3-way de-dup; fixes cost_multiplier \"1\" vs \"1.0\" drift between callers) - Promote get_sync_state/update_sync_state to shared session_usage module (4 copies -> 1) - run_step helper in lib.rs replaces 9 if-let-Err blocks - maybe_backfill_log_costs returns bool to skip duplicate total_cost parsing in caller --- src-tauri/src/commands/usage.rs | 13 + src-tauri/src/database/schema.rs | 39 +- src-tauri/src/lib.rs | 86 +- src-tauri/src/services/mod.rs | 1 + src-tauri/src/services/session_usage.rs | 12 +- src-tauri/src/services/session_usage_codex.rs | 35 +- .../src/services/session_usage_gemini.rs | 35 +- .../src/services/session_usage_hermes.rs | 892 ++++++++++++++++++ src-tauri/src/services/usage_stats.rs | 315 +++++-- src/components/usage/DataSourceBar.tsx | 1 + src/components/usage/RequestLogTable.tsx | 1 + src/components/usage/UsageDashboard.tsx | 1 + src/i18n/locales/en.json | 6 +- src/i18n/locales/ja.json | 6 +- src/i18n/locales/zh.json | 6 +- src/types/usage.ts | 2 +- 16 files changed, 1248 insertions(+), 203 deletions(-) create mode 100644 src-tauri/src/services/session_usage_hermes.rs diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index 8c9047310..6e46acf2d 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -220,6 +220,19 @@ pub fn sync_session_usage( } } + // 同步 Hermes 使用数据 + match crate::services::session_usage_hermes::sync_hermes_usage(&state.db) { + Ok(hermes_result) => { + result.imported += hermes_result.imported; + result.skipped += hermes_result.skipped; + result.files_scanned += hermes_result.files_scanned; + result.errors.extend(hermes_result.errors); + } + Err(e) => { + result.errors.push(format!("Hermes 同步失败: {e}")); + } + } + Ok(result) } diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 208f79413..d28ccdca5 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -214,7 +214,7 @@ impl Database { [], ) .map_err(|e| AppError::Database(e.to_string()))?; - Self::create_request_logs_dedup_index_if_supported(conn)?; + Self::create_request_logs_usage_indexes_if_supported(conn)?; // 11. Model Pricing 表 conn.execute( @@ -1108,7 +1108,7 @@ impl Database { "data_source", "TEXT NOT NULL DEFAULT 'proxy'", )?; - Self::create_request_logs_dedup_index_if_supported(conn)?; + Self::create_request_logs_usage_indexes_if_supported(conn)?; } // 2. 创建会话日志同步状态表 @@ -1298,6 +1298,13 @@ impl Database { "0.30", "3.75", ), + // GPT-5.5 系列 + ("gpt-5.5", "GPT-5.5", "5", "30", "0.50", "0"), + ("gpt-5.5-low", "GPT-5.5", "5", "30", "0.50", "0"), + ("gpt-5.5-medium", "GPT-5.5", "5", "30", "0.50", "0"), + ("gpt-5.5-high", "GPT-5.5", "5", "30", "0.50", "0"), + ("gpt-5.5-xhigh", "GPT-5.5", "5", "30", "0.50", "0"), + ("gpt-5.5-minimal", "GPT-5.5", "5", "30", "0.50", "0"), // GPT-5.4 系列 ("gpt-5.4", "GPT-5.4", "2.50", "15", "0.25", "0"), ("gpt-5.4-mini", "GPT-5.4 Mini", "0.75", "4.50", "0.075", "0"), @@ -1910,11 +1917,22 @@ impl Database { Ok(()) } - fn create_request_logs_dedup_index_if_supported(conn: &Connection) -> Result<(), AppError> { + fn create_request_logs_usage_indexes_if_supported(conn: &Connection) -> Result<(), AppError> { if !Self::table_exists(conn, "proxy_request_logs")? { return Ok(()); } + let has_app_type = Self::has_column(conn, "proxy_request_logs", "app_type")?; + let has_created_at = Self::has_column(conn, "proxy_request_logs", "created_at")?; + if has_app_type && has_created_at { + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_request_logs_app_created_at + ON proxy_request_logs(app_type, created_at DESC)", + [], + ) + .map_err(|e| AppError::Database(format!("创建使用量应用时间索引失败: {e}")))?; + } + let required_columns = [ "app_type", "data_source", @@ -1930,13 +1948,20 @@ impl Database { } } + conn.execute("DROP INDEX IF EXISTS idx_request_logs_dedup_lookup", []) + .map_err(|e| AppError::Database(format!("删除旧使用量去重索引失败: {e}")))?; + + // 查询层为了兼容历史 NULL data_source 行,会使用 + // COALESCE(data_source, 'proxy')。普通 data_source 索引无法匹配该表达式, + // 会让跨源去重子查询退化成大量扫描;表达式索引让 SQLite 能按同一表达式查找。 conn.execute( - "CREATE INDEX IF NOT EXISTS idx_request_logs_dedup_lookup - ON proxy_request_logs(app_type, data_source, input_tokens, output_tokens, - cache_read_tokens, created_at, cache_creation_tokens)", + "CREATE INDEX IF NOT EXISTS idx_request_logs_dedup_lookup_expr + ON proxy_request_logs(app_type, COALESCE(data_source, 'proxy'), input_tokens, + output_tokens, cache_read_tokens, created_at, + cache_creation_tokens)", [], ) - .map_err(|e| AppError::Database(format!("创建使用量去重索引失败: {e}")))?; + .map_err(|e| AppError::Database(format!("创建使用量去重表达式索引失败: {e}")))?; Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9e7b937ce..d7e7498ba 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -924,28 +924,35 @@ pub fn run() { tauri::async_runtime::spawn(async move { const SESSION_SYNC_INTERVAL_SECS: u64 = 60; + fn run_step(name: &str, result: Result) { + if let Err(e) = result { + log::warn!("{name} failed: {e}"); + } + } + + let db = &db_for_session_sync; + // 首次同步 - if let Err(e) = - crate::services::session_usage::sync_claude_session_logs( - &db_for_session_sync, - ) - { - log::warn!("Session usage initial sync failed: {e}"); - } - if let Err(e) = - crate::services::session_usage_codex::sync_codex_usage( - &db_for_session_sync, - ) - { - log::warn!("Codex usage initial sync failed: {e}"); - } - if let Err(e) = - crate::services::session_usage_gemini::sync_gemini_usage( - &db_for_session_sync, - ) - { - log::warn!("Gemini usage initial sync failed: {e}"); - } + run_step( + "Usage cost startup backfill", + db.backfill_missing_usage_costs(), + ); + run_step( + "Session usage initial sync", + crate::services::session_usage::sync_claude_session_logs(db), + ); + run_step( + "Codex usage initial sync", + crate::services::session_usage_codex::sync_codex_usage(db), + ); + run_step( + "Gemini usage initial sync", + crate::services::session_usage_gemini::sync_gemini_usage(db), + ); + run_step( + "Hermes usage initial sync", + crate::services::session_usage_hermes::sync_hermes_usage(db), + ); // 定期同步 let mut interval = tokio::time::interval(std::time::Duration::from_secs( @@ -954,27 +961,22 @@ pub fn run() { interval.tick().await; // skip immediate first tick loop { interval.tick().await; - if let Err(e) = - crate::services::session_usage::sync_claude_session_logs( - &db_for_session_sync, - ) - { - log::warn!("Session usage periodic sync failed: {e}"); - } - if let Err(e) = - crate::services::session_usage_codex::sync_codex_usage( - &db_for_session_sync, - ) - { - log::warn!("Codex usage periodic sync failed: {e}"); - } - if let Err(e) = - crate::services::session_usage_gemini::sync_gemini_usage( - &db_for_session_sync, - ) - { - log::warn!("Gemini usage periodic sync failed: {e}"); - } + run_step( + "Session usage periodic sync", + crate::services::session_usage::sync_claude_session_logs(db), + ); + run_step( + "Codex usage periodic sync", + crate::services::session_usage_codex::sync_codex_usage(db), + ); + run_step( + "Gemini usage periodic sync", + crate::services::session_usage_gemini::sync_gemini_usage(db), + ); + run_step( + "Hermes usage periodic sync", + crate::services::session_usage_hermes::sync_hermes_usage(db), + ); } }); }); diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index d9fb5077c..20c13302f 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -12,6 +12,7 @@ pub mod proxy; pub mod session_usage; pub mod session_usage_codex; pub mod session_usage_gemini; +pub mod session_usage_hermes; pub mod skill; pub mod speedtest; pub mod stream_check; diff --git a/src-tauri/src/services/session_usage.rs b/src-tauri/src/services/session_usage.rs index 902b78cbc..5f9f0f618 100644 --- a/src-tauri/src/services/session_usage.rs +++ b/src-tauri/src/services/session_usage.rs @@ -308,8 +308,10 @@ fn sync_single_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppEr Ok((imported, skipped)) } -/// 获取文件的同步状态 -fn get_sync_state(db: &Database, file_path: &str) -> Result<(i64, i64), AppError> { +/// 获取 session_log_sync 表中某条目的同步进度。 +/// +/// Shared by all session_usage_* parsers. +pub(crate) fn get_sync_state(db: &Database, file_path: &str) -> Result<(i64, i64), AppError> { let conn = lock_conn!(db.conn); let result = conn.query_row( "SELECT last_modified, last_line_offset FROM session_log_sync WHERE file_path = ?1", @@ -319,8 +321,10 @@ fn get_sync_state(db: &Database, file_path: &str) -> Result<(i64, i64), AppError Ok(result.unwrap_or((0, 0))) } -/// 更新文件的同步状态 -fn update_sync_state( +/// 更新 session_log_sync 表中某条目的同步进度。 +/// +/// Shared by all session_usage_* parsers. +pub(crate) fn update_sync_state( db: &Database, file_path: &str, last_modified: i64, diff --git a/src-tauri/src/services/session_usage_codex.rs b/src-tauri/src/services/session_usage_codex.rs index b666634ff..ac4f81c09 100644 --- a/src-tauri/src/services/session_usage_codex.rs +++ b/src-tauri/src/services/session_usage_codex.rs @@ -18,7 +18,7 @@ use crate::database::{lock_conn, Database}; use crate::error::AppError; use crate::proxy::usage::calculator::{CostCalculator, ModelPricing}; use crate::proxy::usage::parser::TokenUsage; -use crate::services::session_usage::SessionSyncResult; +use crate::services::session_usage::{get_sync_state, update_sync_state, SessionSyncResult}; use crate::services::usage_stats::{should_skip_session_insert, DedupKey}; use rust_decimal::Decimal; use std::fs; @@ -538,39 +538,6 @@ fn insert_codex_session_entry( Ok(true) } -/// 获取文件的同步状态 -fn get_sync_state(db: &Database, file_path: &str) -> Result<(i64, i64), AppError> { - let conn = lock_conn!(db.conn); - let result = conn.query_row( - "SELECT last_modified, last_line_offset FROM session_log_sync WHERE file_path = ?1", - rusqlite::params![file_path], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), - ); - Ok(result.unwrap_or((0, 0))) -} - -/// 更新文件的同步状态 -fn update_sync_state( - db: &Database, - file_path: &str, - last_modified: i64, - last_offset: i64, -) -> Result<(), AppError> { - let now = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - - let conn = lock_conn!(db.conn); - conn.execute( - "INSERT OR REPLACE INTO session_log_sync (file_path, last_modified, last_line_offset, last_synced_at) - VALUES (?1, ?2, ?3, ?4)", - rusqlite::params![file_path, last_modified, last_offset, now], - ) - .map_err(|e| AppError::Database(format!("更新同步状态失败: {e}")))?; - Ok(()) -} - /// ��找 Codex 模型定价(带归一化) fn find_codex_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option { let normalized = normalize_codex_model(model_id); diff --git a/src-tauri/src/services/session_usage_gemini.rs b/src-tauri/src/services/session_usage_gemini.rs index e0049f91b..ef68d20da 100644 --- a/src-tauri/src/services/session_usage_gemini.rs +++ b/src-tauri/src/services/session_usage_gemini.rs @@ -18,7 +18,7 @@ use crate::error::AppError; use crate::gemini_config::get_gemini_dir; use crate::proxy::usage::calculator::{CostCalculator, ModelPricing}; use crate::proxy::usage::parser::TokenUsage; -use crate::services::session_usage::SessionSyncResult; +use crate::services::session_usage::{get_sync_state, update_sync_state, SessionSyncResult}; use crate::services::usage_stats::{should_skip_session_insert, DedupKey}; use rust_decimal::Decimal; use std::fs; @@ -356,39 +356,6 @@ fn insert_gemini_session_entry( Ok(conn.changes() > 0) } -/// 获取文件的同步状态 -fn get_sync_state(db: &Database, file_path: &str) -> Result<(i64, i64), AppError> { - let conn = lock_conn!(db.conn); - let result = conn.query_row( - "SELECT last_modified, last_line_offset FROM session_log_sync WHERE file_path = ?1", - rusqlite::params![file_path], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), - ); - Ok(result.unwrap_or((0, 0))) -} - -/// 更新文件的同步状态 -fn update_sync_state( - db: &Database, - file_path: &str, - last_modified: i64, - last_offset: i64, -) -> Result<(), AppError> { - let now = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - - let conn = lock_conn!(db.conn); - conn.execute( - "INSERT OR REPLACE INTO session_log_sync (file_path, last_modified, last_line_offset, last_synced_at) - VALUES (?1, ?2, ?3, ?4)", - rusqlite::params![file_path, last_modified, last_offset, now], - ) - .map_err(|e| AppError::Database(format!("更新同步状态失败: {e}")))?; - Ok(()) -} - /// 查找 Gemini 模型定价 fn find_gemini_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option { // 精确匹配 diff --git a/src-tauri/src/services/session_usage_hermes.rs b/src-tauri/src/services/session_usage_hermes.rs new file mode 100644 index 000000000..cec315c97 --- /dev/null +++ b/src-tauri/src/services/session_usage_hermes.rs @@ -0,0 +1,892 @@ +//! Hermes Agent session usage tracking. +//! +//! Reads token and cost totals from Hermes' local SQLite state database and +//! imports one usage row per session into `proxy_request_logs`. + +use crate::database::{lock_conn, Database}; +use crate::error::AppError; +use crate::hermes_config::get_hermes_dir; +use crate::proxy::usage::calculator::{CostCalculator, ModelPricing}; +use crate::proxy::usage::parser::TokenUsage; +use crate::services::session_usage::{get_sync_state, update_sync_state, SessionSyncResult}; +use crate::services::usage_stats::{has_matching_proxy_usage_log, DedupKey}; +use rusqlite::{Connection, OpenFlags}; +use rust_decimal::Decimal; +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +const HERMES_APP_TYPE: &str = "hermes"; +const HERMES_PROVIDER_ID: &str = "_hermes_session"; +const HERMES_DATA_SOURCE: &str = "hermes_session"; +const HERMES_SYNC_PREFIX: &str = "sqlite:"; +const WATERMARK_OVERLAP_MS: i64 = 300_000; +const ACTIVE_SESSION_LOOKBACK_MS: i64 = 24 * 60 * 60 * 1000; + +#[derive(Debug)] +struct HermesSessionUsage { + session_id: String, + model: String, + timestamp_ms: i64, + input_tokens: u32, + output_tokens: u32, + cache_read_tokens: u32, + cache_creation_tokens: u32, + total_cost_usd: Option, +} + +/// Sync Hermes usage data from state.db and profile state databases. +pub fn sync_hermes_usage(db: &Database) -> Result { + let hermes_dir = get_hermes_dir(); + let db_paths = collect_hermes_db_paths(&hermes_dir); + + let mut result = SessionSyncResult { + imported: 0, + skipped: 0, + files_scanned: db_paths.len() as u32, + errors: vec![], + }; + + for db_path in &db_paths { + match sync_single_hermes_db(db, db_path) { + Ok((imported, skipped)) => { + result.imported += imported; + result.skipped += skipped; + } + Err(e) => { + let msg = format!("Hermes 数据库同步失败 {}: {e}", db_path.display()); + log::warn!("[HERMES-SYNC] {msg}"); + result.errors.push(msg); + } + } + } + + if result.imported > 0 { + log::info!( + "[HERMES-SYNC] 同步完成: 导入 {} 条, 跳过 {} 条, 扫描 {} 个数据库", + result.imported, + result.skipped, + result.files_scanned + ); + } + + Ok(result) +} + +fn collect_hermes_db_paths(hermes_dir: &Path) -> Vec { + let mut paths = Vec::new(); + + let root_db = hermes_dir.join("state.db"); + if root_db.is_file() { + paths.push(root_db); + } + + let profiles_dir = hermes_dir.join("profiles"); + if let Ok(entries) = fs::read_dir(&profiles_dir) { + for entry in entries.flatten() { + let profile_db = entry.path().join("state.db"); + if profile_db.is_file() { + paths.push(profile_db); + } + } + } + + paths +} + +fn sync_single_hermes_db(db: &Database, db_path: &Path) -> Result<(u32, u32), AppError> { + let sync_key = hermes_sync_key(db_path); + let file_modified = sqlite_db_modified_ms(db_path); + let (last_modified, last_watermark) = get_sync_state(db, &sync_key)?; + + if file_modified <= last_modified { + return Ok((0, 0)); + } + + let conn = Connection::open_with_flags( + db_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|e| AppError::Database(format!("打开 Hermes 数据库失败: {e}")))?; + + ensure_sessions_table(&conn)?; + let columns = get_table_columns(&conn, "sessions")?; + validate_hermes_columns(&columns)?; + + let threshold = last_watermark.saturating_sub(WATERMARK_OVERLAP_MS); + let sessions = load_hermes_sessions(&conn, &columns, threshold)?; + + let mut imported = 0; + let mut skipped = 0; + let mut max_watermark = last_watermark; + + for session in &sessions { + max_watermark = max_watermark.max(session.timestamp_ms); + match insert_hermes_session_entry(db, session) { + Ok(true) => imported += 1, + Ok(false) => skipped += 1, + Err(e) => { + log::warn!("[HERMES-SYNC] 插入失败 ({}): {e}", session.session_id); + skipped += 1; + } + } + } + + update_sync_state(db, &sync_key, file_modified, max_watermark)?; + + Ok((imported, skipped)) +} + +fn hermes_sync_key(db_path: &Path) -> String { + let absolute = db_path + .canonicalize() + .unwrap_or_else(|_| db_path.to_path_buf()); + format!("{HERMES_SYNC_PREFIX}{}", absolute.display()) +} + +fn sqlite_db_modified_ms(db_path: &Path) -> i64 { + [ + db_path.to_path_buf(), + sqlite_sidecar_path(db_path, "-wal"), + sqlite_sidecar_path(db_path, "-shm"), + ] + .iter() + .filter_map(|path| fs::metadata(path).ok()) + .filter_map(|metadata| metadata.modified().ok()) + .filter_map(system_time_to_ms) + .max() + .unwrap_or(0) +} + +fn sqlite_sidecar_path(db_path: &Path, suffix: &str) -> PathBuf { + let mut raw = db_path.as_os_str().to_os_string(); + raw.push(suffix); + PathBuf::from(raw) +} + +fn system_time_to_ms(time: SystemTime) -> Option { + time.duration_since(SystemTime::UNIX_EPOCH) + .ok() + .map(|duration| duration.as_millis() as i64) +} + +fn ensure_sessions_table(conn: &Connection) -> Result<(), AppError> { + let has_sessions = conn + .query_row( + "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='sessions'", + [], + |row| row.get::<_, bool>(0), + ) + .unwrap_or(false); + + if has_sessions { + Ok(()) + } else { + Err(AppError::Database( + "Hermes 数据库缺少 sessions 表".to_string(), + )) + } +} + +fn get_table_columns(conn: &Connection, table: &str) -> Result, AppError> { + let mut stmt = conn + .prepare(&format!("PRAGMA table_info({table})")) + .map_err(|e| AppError::Database(format!("读取 Hermes 表结构失败: {e}")))?; + + let rows = stmt + .query_map([], |row| row.get::<_, String>(1)) + .map_err(|e| AppError::Database(format!("读取 Hermes 字段失败: {e}")))?; + + let mut columns = HashSet::new(); + for row in rows { + columns.insert(row.map_err(|e| AppError::Database(e.to_string()))?); + } + Ok(columns) +} + +fn validate_hermes_columns(columns: &HashSet) -> Result<(), AppError> { + if !columns.contains("id") { + return Err(AppError::Database( + "Hermes sessions 表缺少 id 字段".to_string(), + )); + } + + if !columns.contains("started_at") && !columns.contains("ended_at") { + return Err(AppError::Database( + "Hermes sessions 表缺少 started_at/ended_at 字段".to_string(), + )); + } + + Ok(()) +} + +fn load_hermes_sessions( + conn: &Connection, + columns: &HashSet, + threshold_ms: i64, +) -> Result, AppError> { + let timestamp_expr = timestamp_expr(columns); + let timestamp_ms_expr = + format!("CAST(ROUND(CAST(({timestamp_expr}) AS REAL) * 1000.0) AS INTEGER)"); + let active_session_clause = if columns.contains("ended_at") && columns.contains("started_at") { + format!( + " OR (ended_at IS NULL AND {timestamp_ms_expr} > ?1 - {ACTIVE_SESSION_LOOKBACK_MS})" + ) + } else { + String::new() + }; + + let sql = format!( + "SELECT + CAST(id AS TEXT) AS id, + {model_expr} AS model, + {timestamp_ms_expr} AS timestamp_ms, + {input_expr} AS input_tokens, + {output_expr} AS output_tokens, + {reasoning_expr} AS reasoning_tokens, + {cache_read_expr} AS cache_read_tokens, + {cache_write_expr} AS cache_write_tokens, + {actual_cost_expr} AS actual_cost_usd, + {estimated_cost_expr} AS estimated_cost_usd + FROM sessions + WHERE ({timestamp_ms_expr} > ?1{active_session_clause}) + ORDER BY {timestamp_ms_expr} ASC", + model_expr = text_expr(columns, "model", "unknown"), + input_expr = int_expr(columns, "input_tokens"), + output_expr = int_expr(columns, "output_tokens"), + reasoning_expr = int_expr(columns, "reasoning_tokens"), + cache_read_expr = int_expr(columns, "cache_read_tokens"), + cache_write_expr = int_expr(columns, "cache_write_tokens"), + actual_cost_expr = cost_expr(columns, "actual_cost_usd"), + estimated_cost_expr = cost_expr(columns, "estimated_cost_usd"), + ); + + let mut stmt = conn + .prepare(&sql) + .map_err(|e| AppError::Database(format!("准备 Hermes 查询失败: {e}")))?; + + let rows = stmt + .query_map([threshold_ms], |row| { + let output_tokens = get_i64(row, 4).saturating_add(get_i64(row, 5)); + let actual_cost: Option = row.get(8)?; + let estimated_cost: Option = row.get(9)?; + + Ok(HermesSessionUsage { + session_id: row.get(0)?, + model: row + .get::<_, Option>(1)? + .unwrap_or_else(|| "unknown".to_string()), + timestamp_ms: get_i64(row, 2), + input_tokens: to_u32(get_i64(row, 3)), + output_tokens: to_u32(output_tokens), + cache_read_tokens: to_u32(get_i64(row, 6)), + cache_creation_tokens: to_u32(get_i64(row, 7)), + total_cost_usd: actual_cost + .filter(|s| !s.trim().is_empty()) + .or_else(|| estimated_cost.filter(|s| !s.trim().is_empty())), + }) + }) + .map_err(|e| AppError::Database(format!("查询 Hermes sessions 失败: {e}")))?; + + let mut sessions = Vec::new(); + for row in rows { + let session = row.map_err(|e| AppError::Database(e.to_string()))?; + if session.session_id.trim().is_empty() || session.timestamp_ms <= 0 { + continue; + } + sessions.push(session); + } + + Ok(sessions) +} + +fn timestamp_expr(columns: &HashSet) -> String { + match (columns.contains("ended_at"), columns.contains("started_at")) { + (true, true) => "COALESCE(ended_at, started_at)".to_string(), + (true, false) => "ended_at".to_string(), + (false, true) => "started_at".to_string(), + (false, false) => "NULL".to_string(), + } +} + +fn text_expr(columns: &HashSet, column: &str, default: &str) -> String { + if columns.contains(column) { + format!("COALESCE(NULLIF(TRIM(CAST({column} AS TEXT)), ''), '{default}')") + } else { + format!("'{default}'") + } +} + +fn int_expr(columns: &HashSet, column: &str) -> String { + if columns.contains(column) { + format!("CAST(COALESCE({column}, 0) AS INTEGER)") + } else { + "0".to_string() + } +} + +fn cost_expr(columns: &HashSet, column: &str) -> String { + if columns.contains(column) { + format!("NULLIF(TRIM(CAST({column} AS TEXT)), '')") + } else { + "NULL".to_string() + } +} + +fn get_i64(row: &rusqlite::Row<'_>, index: usize) -> i64 { + row.get::<_, Option>(index).ok().flatten().unwrap_or(0) +} + +fn to_u32(value: i64) -> u32 { + value.max(0).min(u32::MAX as i64) as u32 +} + +fn insert_hermes_session_entry( + db: &Database, + session: &HermesSessionUsage, +) -> Result { + let conn = lock_conn!(db.conn); + let created_at = session.timestamp_ms / 1000; + let request_id = format!("hermes-session-{}", session.session_id); + + let dedup_key = DedupKey { + app_type: HERMES_APP_TYPE, + model: &session.model, + input_tokens: session.input_tokens, + output_tokens: session.output_tokens, + cache_read_tokens: session.cache_read_tokens, + cache_creation_tokens: session.cache_creation_tokens, + created_at, + }; + // Hermes rows use UPSERT so repeated request_ids can refresh estimated cost + // into actual cost. This check only prevents double-counting when a matching + // proxy log already covers the same usage window. + if has_matching_proxy_usage_log(&conn, &dedup_key)? { + return Ok(false); + } + + let (input_cost, output_cost, cache_read_cost, cache_creation_cost, total_cost) = + match &session.total_cost_usd { + Some(total) => ( + "0".to_string(), + "0".to_string(), + "0".to_string(), + "0".to_string(), + total.clone(), + ), + None => calculate_fallback_cost(&conn, session), + }; + + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, request_model, + input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, + input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd, + latency_ms, first_token_ms, status_code, error_message, session_id, + provider_type, is_streaming, cost_multiplier, created_at, data_source + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24) + ON CONFLICT(request_id) DO UPDATE SET + model = excluded.model, + request_model = excluded.request_model, + input_tokens = excluded.input_tokens, + output_tokens = excluded.output_tokens, + cache_read_tokens = excluded.cache_read_tokens, + cache_creation_tokens = excluded.cache_creation_tokens, + input_cost_usd = excluded.input_cost_usd, + output_cost_usd = excluded.output_cost_usd, + cache_read_cost_usd = excluded.cache_read_cost_usd, + cache_creation_cost_usd = excluded.cache_creation_cost_usd, + total_cost_usd = excluded.total_cost_usd, + created_at = excluded.created_at + WHERE model != excluded.model + OR request_model != excluded.request_model + OR input_tokens != excluded.input_tokens + OR output_tokens != excluded.output_tokens + OR cache_read_tokens != excluded.cache_read_tokens + OR cache_creation_tokens != excluded.cache_creation_tokens + OR input_cost_usd != excluded.input_cost_usd + OR output_cost_usd != excluded.output_cost_usd + OR cache_read_cost_usd != excluded.cache_read_cost_usd + OR cache_creation_cost_usd != excluded.cache_creation_cost_usd + OR total_cost_usd != excluded.total_cost_usd + OR created_at != excluded.created_at", + rusqlite::params![ + request_id, + HERMES_PROVIDER_ID, + HERMES_APP_TYPE, + session.model, + session.model, + session.input_tokens, + session.output_tokens, + session.cache_read_tokens, + session.cache_creation_tokens, + input_cost, + output_cost, + cache_read_cost, + cache_creation_cost, + total_cost, + 0i64, + Option::::None, + 200i64, + Option::::None, + session.session_id, + Some(HERMES_DATA_SOURCE), + 1i64, + "1.0", + created_at, + HERMES_DATA_SOURCE, + ], + ) + .map_err(|e| AppError::Database(format!("插入 Hermes 会话用量失败: {e}")))?; + + Ok(conn.changes() > 0) +} + +fn calculate_fallback_cost( + conn: &Connection, + session: &HermesSessionUsage, +) -> (String, String, String, String, String) { + let usage = TokenUsage { + input_tokens: session.input_tokens, + output_tokens: session.output_tokens, + cache_read_tokens: session.cache_read_tokens, + cache_creation_tokens: session.cache_creation_tokens, + model: Some(session.model.clone()), + message_id: None, + }; + + match find_hermes_pricing(conn, &session.model) { + Some(pricing) => { + let cost = CostCalculator::calculate(&usage, &pricing, Decimal::from(1)); + ( + cost.input_cost.to_string(), + cost.output_cost.to_string(), + cost.cache_read_cost.to_string(), + cost.cache_creation_cost.to_string(), + cost.total_cost.to_string(), + ) + } + None => ( + "0".to_string(), + "0".to_string(), + "0".to_string(), + "0".to_string(), + "0".to_string(), + ), + } +} + +fn find_hermes_pricing(conn: &Connection, model_id: &str) -> Option { + let candidates = pricing_candidates(model_id); + for candidate in &candidates { + if let Some(pricing) = try_find_pricing(conn, candidate) { + return Some(pricing); + } + } + + for candidate in &candidates { + let pattern = format!("{candidate}%"); + if let Ok((input, output, cache_read, cache_creation)) = conn.query_row( + "SELECT input_cost_per_million, output_cost_per_million, + cache_read_cost_per_million, cache_creation_cost_per_million + FROM model_pricing WHERE model_id LIKE ?1 LIMIT 1", + rusqlite::params![pattern], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }, + ) { + if let Ok(pricing) = + ModelPricing::from_strings(&input, &output, &cache_read, &cache_creation) + { + return Some(pricing); + } + } + } + + None +} + +fn pricing_candidates(model_id: &str) -> Vec { + let mut candidates = Vec::new(); + let raw = model_id.trim(); + if !raw.is_empty() { + candidates.push(raw.to_string()); + if let Some((_, tail)) = raw.rsplit_once('/') { + if !tail.is_empty() && tail != raw { + candidates.push(tail.to_string()); + } + } + } + candidates +} + +fn try_find_pricing(conn: &Connection, model_id: &str) -> Option { + conn.query_row( + "SELECT input_cost_per_million, output_cost_per_million, + cache_read_cost_per_million, cache_creation_cost_per_million + FROM model_pricing WHERE model_id = ?1", + rusqlite::params![model_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }, + ) + .ok() + .and_then(|(input, output, cache_read, cache_creation)| { + ModelPricing::from_strings(&input, &output, &cache_read, &cache_creation).ok() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::session_usage::get_data_source_breakdown; + use crate::services::usage_stats::LogFilters; + use std::thread; + use std::time::Duration; + use tempfile::tempdir; + + fn create_hermes_db(path: &Path) -> Connection { + let conn = Connection::open(path).expect("open hermes db"); + conn.execute( + "CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + model TEXT, + started_at REAL, + ended_at REAL, + input_tokens INTEGER, + output_tokens INTEGER, + cache_read_tokens INTEGER, + cache_write_tokens INTEGER, + reasoning_tokens INTEGER, + actual_cost_usd REAL, + estimated_cost_usd REAL + )", + [], + ) + .expect("create sessions"); + conn + } + + fn insert_session( + conn: &Connection, + id: &str, + ended_at: f64, + actual_cost: Option, + estimated_cost: Option, + ) { + conn.execute( + "INSERT INTO sessions ( + id, model, started_at, ended_at, input_tokens, output_tokens, + cache_read_tokens, cache_write_tokens, reasoning_tokens, + actual_cost_usd, estimated_cost_usd + ) VALUES (?1, 'openrouter/anthropic/claude-opus-4-7', ?2, ?3, 10, 20, 3, 4, 7, ?4, ?5)", + rusqlite::params![id, ended_at - 10.0, ended_at, actual_cost, estimated_cost], + ) + .expect("insert session"); + } + + fn insert_active_session(conn: &Connection, id: &str, started_at: f64) { + conn.execute( + "INSERT INTO sessions ( + id, model, started_at, ended_at, input_tokens, output_tokens, + cache_read_tokens, cache_write_tokens, reasoning_tokens, + actual_cost_usd, estimated_cost_usd + ) VALUES (?1, 'openrouter/anthropic/claude-opus-4-7', ?2, NULL, 10, 20, 3, 4, 7, 0.1, NULL)", + rusqlite::params![id, started_at], + ) + .expect("insert active session"); + } + + #[test] + fn test_ensure_sessions_table_errors_when_missing() { + let conn = Connection::open_in_memory().expect("open memory db"); + let err = ensure_sessions_table(&conn).expect_err("missing sessions should error"); + assert!(err.to_string().contains("缺少 sessions 表")); + } + + #[test] + fn test_validate_hermes_columns_rejects_missing_id() { + let columns = HashSet::from(["started_at".to_string()]); + let err = validate_hermes_columns(&columns).expect_err("missing id should error"); + assert!(err.to_string().contains("缺少 id 字段")); + } + + #[test] + fn test_validate_hermes_columns_rejects_missing_timestamp() { + let columns = HashSet::from(["id".to_string()]); + let err = validate_hermes_columns(&columns).expect_err("missing timestamp should error"); + assert!(err.to_string().contains("缺少 started_at/ended_at 字段")); + } + + #[test] + fn test_sync_hermes_session_imports_tokens_and_actual_cost() -> Result<(), AppError> { + let temp = tempdir().expect("tempdir"); + let db_path = temp.path().join("state.db"); + let hermes_conn = create_hermes_db(&db_path); + insert_session(&hermes_conn, "s1", 1000.0, Some(0.123456), Some(0.5)); + drop(hermes_conn); + + let db = Database::memory()?; + let (imported, skipped) = sync_single_hermes_db(&db, &db_path)?; + assert_eq!((imported, skipped), (1, 0)); + + let conn = lock_conn!(db.conn); + let row: (String, String, i64, i64, i64, String, String) = conn.query_row( + "SELECT app_type, data_source, input_tokens, output_tokens, cache_creation_tokens, total_cost_usd, provider_id + FROM proxy_request_logs WHERE request_id = 'hermes-session-s1'", + [], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + )) + }, + )?; + assert_eq!(row.0, "hermes"); + assert_eq!(row.1, HERMES_DATA_SOURCE); + assert_eq!(row.2, 10); + assert_eq!(row.3, 27); + assert_eq!(row.4, 4); + assert_eq!(row.5, "0.123456"); + assert_eq!(row.6, HERMES_PROVIDER_ID); + Ok(()) + } + + #[test] + fn test_load_hermes_sessions_limits_active_zombie_sessions() -> Result<(), AppError> { + let temp = tempdir().expect("tempdir"); + let db_path = temp.path().join("state.db"); + let hermes_conn = create_hermes_db(&db_path); + insert_active_session(&hermes_conn, "old-active", 1_000.0); + insert_active_session(&hermes_conn, "recent-active", 150_000.0); + + let columns = get_table_columns(&hermes_conn, "sessions")?; + let sessions = load_hermes_sessions(&hermes_conn, &columns, 200_000_000)?; + let ids: Vec<&str> = sessions + .iter() + .map(|session| session.session_id.as_str()) + .collect(); + + assert!(!ids.contains(&"old-active")); + assert!(ids.contains(&"recent-active")); + Ok(()) + } + + #[test] + fn test_sync_state_is_per_database_and_watermark_incremental() -> Result<(), AppError> { + let temp = tempdir().expect("tempdir"); + let db_path = temp.path().join("state.db"); + let hermes_conn = create_hermes_db(&db_path); + insert_session(&hermes_conn, "s1", 1000.0, Some(0.1), None); + drop(hermes_conn); + + let db = Database::memory()?; + let (imported, _) = sync_single_hermes_db(&db, &db_path)?; + assert_eq!(imported, 1); + + let sync_key = hermes_sync_key(&db_path); + { + let conn = lock_conn!(db.conn); + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM session_log_sync WHERE file_path = ?1", + [sync_key.as_str()], + |row| row.get(0), + )?; + assert_eq!(count, 1); + } + + thread::sleep(Duration::from_millis(5)); + let hermes_conn = Connection::open(&db_path).expect("reopen hermes db"); + insert_session(&hermes_conn, "s2", 1100.0, Some(0.2), None); + drop(hermes_conn); + + let (imported, skipped) = sync_single_hermes_db(&db, &db_path)?; + assert_eq!(imported, 1); + assert!(skipped >= 1); + + let conn = lock_conn!(db.conn); + let count: i64 = conn.query_row("SELECT COUNT(*) FROM session_log_sync", [], |row| { + row.get(0) + })?; + assert_eq!(count, 1); + let max_offset: i64 = conn.query_row( + "SELECT last_line_offset FROM session_log_sync WHERE file_path = ?1", + [sync_key.as_str()], + |row| row.get(0), + )?; + assert_eq!(max_offset, 1_100_000); + Ok(()) + } + + #[test] + fn test_sqlite_mtime_includes_wal_and_shm_sidecars() { + let temp = tempdir().expect("tempdir"); + let db_path = temp.path().join("state.db"); + fs::write(&db_path, b"db").expect("write db"); + let db_mtime = sqlite_db_modified_ms(&db_path); + thread::sleep(Duration::from_millis(20)); + let wal_path = sqlite_sidecar_path(&db_path, "-wal"); + fs::write(&wal_path, b"wal").expect("write wal"); + let with_wal = sqlite_db_modified_ms(&db_path); + assert!(with_wal >= db_mtime); + assert!(with_wal > 0); + } + + #[test] + fn test_fallback_cost_uses_model_pricing_when_hermes_cost_missing() -> Result<(), AppError> { + let temp = tempdir().expect("tempdir"); + let db_path = temp.path().join("state.db"); + let hermes_conn = create_hermes_db(&db_path); + insert_session(&hermes_conn, "s1", 1000.0, None, None); + drop(hermes_conn); + + let db = Database::memory()?; + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT OR REPLACE INTO model_pricing ( + model_id, display_name, input_cost_per_million, output_cost_per_million, + cache_read_cost_per_million, cache_creation_cost_per_million + ) VALUES ('claude-opus-4-7', 'Claude Opus 4.7', '10', '20', '1', '5')", + [], + )?; + } + + let (imported, _) = sync_single_hermes_db(&db, &db_path)?; + assert_eq!(imported, 1); + + let conn = lock_conn!(db.conn); + let total: String = conn.query_row( + "SELECT total_cost_usd FROM proxy_request_logs WHERE request_id = 'hermes-session-s1'", + [], + |row| row.get(0), + )?; + assert_ne!(total, "0"); + Ok(()) + } + + #[test] + fn test_estimated_cost_used_when_actual_cost_missing() -> Result<(), AppError> { + let temp = tempdir().expect("tempdir"); + let db_path = temp.path().join("state.db"); + let hermes_conn = create_hermes_db(&db_path); + insert_session(&hermes_conn, "s1", 1000.0, None, Some(0.456789)); + drop(hermes_conn); + + let db = Database::memory()?; + let (imported, _) = sync_single_hermes_db(&db, &db_path)?; + assert_eq!(imported, 1); + + let conn = lock_conn!(db.conn); + let total: String = conn.query_row( + "SELECT total_cost_usd FROM proxy_request_logs WHERE request_id = 'hermes-session-s1'", + [], + |row| row.get(0), + )?; + assert_eq!(total, "0.456789"); + Ok(()) + } + + #[test] + fn test_matching_proxy_log_skips_hermes_session_insert() -> Result<(), AppError> { + let temp = tempdir().expect("tempdir"); + let db_path = temp.path().join("state.db"); + let hermes_conn = create_hermes_db(&db_path); + insert_session(&hermes_conn, "s1", 1000.0, Some(0.1), None); + drop(hermes_conn); + + let db = Database::memory()?; + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, request_model, + input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, + total_cost_usd, latency_ms, status_code, created_at, data_source + ) VALUES ('proxy-1', 'p1', 'hermes', 'openrouter/anthropic/claude-opus-4-7', + 'openrouter/anthropic/claude-opus-4-7', 10, 27, 3, 4, '0.1', 100, 200, 1000, 'proxy')", + [], + )?; + } + + let (imported, skipped) = sync_single_hermes_db(&db, &db_path)?; + assert_eq!(imported, 0); + assert_eq!(skipped, 1); + + let conn = lock_conn!(db.conn); + let count: i64 = conn.query_row("SELECT COUNT(*) FROM proxy_request_logs", [], |row| { + row.get(0) + })?; + assert_eq!(count, 1); + Ok(()) + } + + #[test] + fn test_hermes_provider_name_data_source_and_app_filter() -> Result<(), AppError> { + let temp = tempdir().expect("tempdir"); + let db_path = temp.path().join("state.db"); + let hermes_conn = create_hermes_db(&db_path); + insert_session(&hermes_conn, "s1", 1000.0, Some(0.1), None); + drop(hermes_conn); + + let db = Database::memory()?; + sync_single_hermes_db(&db, &db_path)?; + + let provider_stats = db.get_provider_stats(None, None, Some("hermes"))?; + assert_eq!(provider_stats.len(), 1); + assert_eq!(provider_stats[0].provider_id, HERMES_PROVIDER_ID); + assert_eq!(provider_stats[0].provider_name, "Hermes (Session)"); + + let sources = get_data_source_breakdown(&db)?; + assert!(sources + .iter() + .any(|source| source.data_source == HERMES_DATA_SOURCE && source.request_count == 1)); + + let logs = db.get_request_logs( + &LogFilters { + app_type: Some("hermes".to_string()), + ..Default::default() + }, + 0, + 20, + )?; + assert_eq!(logs.total, 1); + assert_eq!( + logs.data[0].provider_name.as_deref(), + Some("Hermes (Session)") + ); + Ok(()) + } + + #[test] + fn test_collect_hermes_profile_databases() { + let temp = tempdir().expect("tempdir"); + fs::write(temp.path().join("state.db"), b"root").expect("write root"); + let profile_dir = temp.path().join("profiles").join("work"); + fs::create_dir_all(&profile_dir).expect("profile dir"); + fs::write(profile_dir.join("state.db"), b"profile").expect("write profile"); + + let paths = collect_hermes_db_paths(temp.path()); + assert_eq!(paths.len(), 2); + assert!(paths.iter().any(|path| path.ends_with("state.db"))); + assert!(paths + .iter() + .any(|path| path.to_string_lossy().contains("profiles/work/state.db"))); + } +} diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index 9faef593b..4cc929630 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -117,29 +117,84 @@ pub struct RequestLogDetail { pub data_source: Option, } +/// 把 24 列的查询结果映射为 `RequestLogDetail`。 +/// +/// 调用方的 SELECT **必须**按以下顺序返回 24 列: +/// `request_id, provider_id, provider_name, app_type, model, request_model, +/// cost_multiplier, input_tokens, output_tokens, cache_read_tokens, +/// cache_creation_tokens, input_cost_usd, output_cost_usd, cache_read_cost_usd, +/// cache_creation_cost_usd, total_cost_usd, is_streaming, latency_ms, +/// first_token_ms, duration_ms, status_code, error_message, created_at, +/// data_source` +/// +/// 不需要 provider_name 时(如 backfill)SELECT `NULL AS provider_name` 占位即可。 +fn row_to_request_log_detail(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(RequestLogDetail { + request_id: row.get(0)?, + provider_id: row.get(1)?, + provider_name: row.get(2)?, + app_type: row.get(3)?, + model: row.get(4)?, + request_model: row.get(5)?, + cost_multiplier: row + .get::<_, Option>(6)? + .unwrap_or_else(|| "1".to_string()), + input_tokens: row.get::<_, i64>(7)? as u32, + output_tokens: row.get::<_, i64>(8)? as u32, + cache_read_tokens: row.get::<_, i64>(9)? as u32, + cache_creation_tokens: row.get::<_, i64>(10)? as u32, + input_cost_usd: row.get(11)?, + output_cost_usd: row.get(12)?, + cache_read_cost_usd: row.get(13)?, + cache_creation_cost_usd: row.get(14)?, + total_cost_usd: row.get(15)?, + is_streaming: row.get::<_, i64>(16)? != 0, + latency_ms: row.get::<_, i64>(17)? as u64, + first_token_ms: row.get::<_, Option>(18)?.map(|v| v as u64), + duration_ms: row.get::<_, Option>(19)?.map(|v| v as u64), + status_code: row.get::<_, i64>(20)? as u16, + error_message: row.get(21)?, + created_at: row.get(22)?, + data_source: row.get(23)?, + }) +} + /// SQL fragment: resolve provider_name with fallback for session-based entries. -/// Session logs use placeholder provider_ids (_session, _codex_session, _gemini_session) -/// that don't exist in the providers table — this COALESCE gives them readable names. +/// Session logs use placeholder provider_ids (e.g., `_session`, `__session`) +/// that don't exist in the providers table — the CASE expression below is the +/// authoritative mapping from placeholder to readable name. fn provider_name_coalesce(log_alias: &str, provider_alias: &str) -> String { format!( "COALESCE({provider_alias}.name, CASE {log_alias}.provider_id \ WHEN '_session' THEN 'Claude (Session)' \ WHEN '_codex_session' THEN 'Codex (Session)' \ WHEN '_gemini_session' THEN 'Gemini (Session)' \ + WHEN '_hermes_session' THEN 'Hermes (Session)' \ ELSE {log_alias}.provider_id END)" ) } pub(crate) const SESSION_PROXY_DEDUP_WINDOW_SECONDS: i64 = 10 * 60; +/// SQL 片段:把指定别名的 `data_source` 包成 COALESCE,NULL 视作 'proxy'。 +/// +/// 防御 schema v9 之前可能写入的 NULL data_source 行(见 +/// `tests::create_legacy_nullable_logs_table`)。所有用到 data_source 的查询 +/// 都应通过此 helper 生成片段,避免遗漏。 +fn data_source_expr(log_alias: &str) -> String { + format!("COALESCE({log_alias}.data_source, 'proxy')") +} + pub(crate) fn effective_usage_log_filter(log_alias: &str) -> String { + let data_source = data_source_expr(log_alias); + let proxy_data_source = data_source_expr("proxy_dedup"); format!( "NOT ( - {log_alias}.data_source IN ('session_log', 'codex_session', 'gemini_session') + {data_source} IN ('session_log', 'codex_session', 'gemini_session', 'hermes_session') AND EXISTS ( SELECT 1 FROM proxy_request_logs proxy_dedup - WHERE proxy_dedup.data_source = 'proxy' + WHERE {proxy_data_source} = 'proxy' AND proxy_dedup.app_type = {log_alias}.app_type AND proxy_dedup.status_code >= 200 AND proxy_dedup.status_code < 300 @@ -150,7 +205,7 @@ pub(crate) fn effective_usage_log_filter(log_alias: &str) -> String { proxy_dedup.cache_creation_tokens = {log_alias}.cache_creation_tokens OR ( {log_alias}.cache_creation_tokens = 0 - AND {log_alias}.data_source IN ('codex_session', 'gemini_session') + AND {data_source} IN ('codex_session', 'gemini_session') ) ) AND proxy_dedup.created_at BETWEEN @@ -212,11 +267,12 @@ pub(crate) fn has_matching_proxy_usage_log( let allow_missing_cache_creation = matches!(key.app_type, "codex" | "gemini") && key.cache_creation_tokens == 0; - conn.query_row( + let l_data_source = data_source_expr("l"); + let sql = format!( "SELECT EXISTS ( SELECT 1 FROM proxy_request_logs l - WHERE l.data_source = 'proxy' + WHERE {l_data_source} = 'proxy' AND l.app_type = ?1 AND l.status_code >= 200 AND l.status_code < 300 @@ -230,7 +286,11 @@ pub(crate) fn has_matching_proxy_usage_log( OR LOWER(l.model) = 'unknown' OR LOWER(?2) = 'unknown' ) - )", + )" + ); + + conn.query_row( + &sql, params![ key.app_type, key.model, @@ -1043,36 +1103,7 @@ impl Database { let mut stmt = conn.prepare(&sql)?; let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); - let rows = stmt.query_map(params_refs.as_slice(), |row| { - Ok(RequestLogDetail { - request_id: row.get(0)?, - provider_id: row.get(1)?, - provider_name: row.get(2)?, - app_type: row.get(3)?, - model: row.get(4)?, - request_model: row.get(5)?, - cost_multiplier: row - .get::<_, Option>(6)? - .unwrap_or_else(|| "1".to_string()), - input_tokens: row.get::<_, i64>(7)? as u32, - output_tokens: row.get::<_, i64>(8)? as u32, - cache_read_tokens: row.get::<_, i64>(9)? as u32, - cache_creation_tokens: row.get::<_, i64>(10)? as u32, - input_cost_usd: row.get(11)?, - output_cost_usd: row.get(12)?, - cache_read_cost_usd: row.get(13)?, - cache_creation_cost_usd: row.get(14)?, - total_cost_usd: row.get(15)?, - is_streaming: row.get::<_, i64>(16)? != 0, - latency_ms: row.get::<_, i64>(17)? as u64, - first_token_ms: row.get::<_, Option>(18)?.map(|v| v as u64), - duration_ms: row.get::<_, Option>(19)?.map(|v| v as u64), - status_code: row.get::<_, i64>(20)? as u16, - error_message: row.get(21)?, - created_at: row.get(22)?, - data_source: row.get(23)?, - }) - })?; + let rows = stmt.query_map(params_refs.as_slice(), row_to_request_log_detail)?; let mut logs = Vec::new(); let mut provider_cache = HashMap::new(); @@ -1116,36 +1147,7 @@ impl Database { LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type WHERE l.request_id = ?" ); - let result = conn.query_row(&detail_sql, [request_id], |row| { - Ok(RequestLogDetail { - request_id: row.get(0)?, - provider_id: row.get(1)?, - provider_name: row.get(2)?, - app_type: row.get(3)?, - model: row.get(4)?, - request_model: row.get(5)?, - cost_multiplier: row - .get::<_, Option>(6)? - .unwrap_or_else(|| "1".to_string()), - input_tokens: row.get::<_, i64>(7)? as u32, - output_tokens: row.get::<_, i64>(8)? as u32, - cache_read_tokens: row.get::<_, i64>(9)? as u32, - cache_creation_tokens: row.get::<_, i64>(10)? as u32, - input_cost_usd: row.get(11)?, - output_cost_usd: row.get(12)?, - cache_read_cost_usd: row.get(13)?, - cache_creation_cost_usd: row.get(14)?, - total_cost_usd: row.get(15)?, - is_streaming: row.get::<_, i64>(16)? != 0, - latency_ms: row.get::<_, i64>(17)? as u64, - first_token_ms: row.get::<_, Option>(18)?.map(|v| v as u64), - duration_ms: row.get::<_, Option>(19)?.map(|v| v as u64), - status_code: row.get::<_, i64>(20)? as u16, - error_message: row.get(21)?, - created_at: row.get(22)?, - data_source: row.get(23)?, - }) - }); + let result = conn.query_row(&detail_sql, [request_id], row_to_request_log_detail); match result { Ok(mut detail) => { @@ -1276,27 +1278,80 @@ struct PricingInfo { } impl Database { + /// Recalculate stored zero-cost usage rows once pricing becomes available. + pub(crate) fn backfill_missing_usage_costs(&self) -> Result { + let conn = lock_conn!(self.conn); + Self::backfill_missing_usage_costs_on_conn(&conn) + } + + fn backfill_missing_usage_costs_on_conn(conn: &Connection) -> Result { + let mut logs = { + let mut stmt = conn.prepare( + "SELECT request_id, provider_id, NULL AS provider_name, app_type, model, request_model, + cost_multiplier, + input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, + input_cost_usd, output_cost_usd, cache_read_cost_usd, + cache_creation_cost_usd, total_cost_usd, is_streaming, latency_ms, + first_token_ms, duration_ms, status_code, error_message, created_at, + data_source + FROM proxy_request_logs + WHERE CAST(total_cost_usd AS REAL) <= 0 + AND (input_tokens > 0 OR output_tokens > 0 + OR cache_read_tokens > 0 OR cache_creation_tokens > 0)", + )?; + + let rows = stmt.query_map([], row_to_request_log_detail)?; + rows.collect::, _>>()? + }; + + if logs.is_empty() { + return Ok(0); + } + + let tx = conn + .unchecked_transaction() + .map_err(|e| AppError::Database(format!("启动用量成本回填事务失败: {e}")))?; + + let mut updated = 0u64; + let mut provider_cache = HashMap::new(); + let mut pricing_cache = HashMap::new(); + for log in &mut logs { + if Self::maybe_backfill_log_costs(&tx, log, &mut provider_cache, &mut pricing_cache)? { + updated += 1; + } + } + tx.commit() + .map_err(|e| AppError::Database(format!("提交用量成本回填事务失败: {e}")))?; + + if updated > 0 { + log::info!("已回填 {updated} 条缺失的用量成本"); + } + + Ok(updated) + } + + /// 尝试为单条 log 回填成本字段。返回是否实际写入(true=已 UPDATE,false=跳过)。 fn maybe_backfill_log_costs( conn: &Connection, log: &mut RequestLogDetail, provider_cache: &mut HashMap<(String, String), rust_decimal::Decimal>, pricing_cache: &mut HashMap, - ) -> Result<(), AppError> { - let total_cost = rust_decimal::Decimal::from_str(&log.total_cost_usd) + ) -> Result { + let existing_cost = rust_decimal::Decimal::from_str(&log.total_cost_usd) .unwrap_or(rust_decimal::Decimal::ZERO); - let has_cost = total_cost > rust_decimal::Decimal::ZERO; + let has_cost = existing_cost > rust_decimal::Decimal::ZERO; let has_usage = log.input_tokens > 0 || log.output_tokens > 0 || log.cache_read_tokens > 0 || log.cache_creation_tokens > 0; if has_cost || !has_usage { - return Ok(()); + return Ok(false); } let pricing = match Self::get_model_pricing_cached(conn, pricing_cache, &log.model)? { Some(info) => info, - None => return Ok(()), + None => return Ok(false), }; let multiplier = Self::get_cost_multiplier_cached( conn, @@ -1352,7 +1407,7 @@ impl Database { ) .map_err(|e| AppError::Database(format!("更新请求成本失败: {e}")))?; - Ok(()) + Ok(true) } fn get_cost_multiplier_cached( @@ -1431,7 +1486,8 @@ pub(crate) fn find_model_pricing_row( .next() .unwrap_or(model_id) .trim() - .replace('@', "-"); + .replace('@', "-") + .to_ascii_lowercase(); // 精确匹配清洗后的名称 let exact = conn @@ -1514,6 +1570,110 @@ mod tests { Ok(()) } + fn create_legacy_nullable_logs_table(conn: &Connection) -> Result<(), AppError> { + conn.execute( + "CREATE TABLE proxy_request_logs ( + request_id TEXT PRIMARY KEY, + app_type TEXT NOT NULL, + model TEXT NOT NULL, + input_tokens INTEGER NOT NULL, + output_tokens INTEGER NOT NULL, + cache_read_tokens INTEGER NOT NULL, + cache_creation_tokens INTEGER NOT NULL, + status_code INTEGER NOT NULL, + created_at INTEGER NOT NULL, + data_source TEXT + )", + [], + )?; + Ok(()) + } + + #[test] + fn test_effective_filter_keeps_legacy_null_data_source_proxy_rows() -> Result<(), AppError> { + let conn = Connection::open_in_memory()?; + create_legacy_nullable_logs_table(&conn)?; + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, app_type, model, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, status_code, created_at, data_source + ) VALUES ('legacy-proxy', 'codex', 'gpt-5.5', 10, 2, 1, 0, 200, 1000, NULL)", + [], + )?; + + let filter = effective_usage_log_filter("l"); + let sql = format!("SELECT COUNT(*) FROM proxy_request_logs l WHERE {filter}"); + let count: i64 = conn.query_row(&sql, [], |row| row.get(0))?; + assert_eq!(count, 1); + + Ok(()) + } + + #[test] + fn test_matching_proxy_log_treats_legacy_null_data_source_as_proxy() -> Result<(), AppError> { + let conn = Connection::open_in_memory()?; + create_legacy_nullable_logs_table(&conn)?; + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, app_type, model, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, status_code, created_at, data_source + ) VALUES ('legacy-proxy', 'codex', 'gpt-5.5', 10, 2, 1, 0, 200, 1000, NULL)", + [], + )?; + + let key = DedupKey { + app_type: "codex", + model: "gpt-5.5", + input_tokens: 10, + output_tokens: 2, + cache_read_tokens: 1, + cache_creation_tokens: 0, + created_at: 1000, + }; + assert!(has_matching_proxy_usage_log(&conn, &key)?); + + Ok(()) + } + + #[test] + fn test_backfill_missing_usage_costs_uses_new_gpt_5_5_pricing() -> Result<(), AppError> { + let db = Database::memory()?; + + { + let conn = lock_conn!(db.conn); + insert_usage_log( + &conn, + "codex-gpt-5-5-zero-cost", + "codex", + "_codex_session", + "gpt-5.5", + "codex_session", + 1000, + 1_000_000, + 1_000_000, + 0, + 0, + 200, + "0", + )?; + } + + assert_eq!(db.backfill_missing_usage_costs()?, 1); + + let conn = lock_conn!(db.conn); + let (input_cost, output_cost, total_cost): (String, String, String) = conn.query_row( + "SELECT input_cost_usd, output_cost_usd, total_cost_usd + FROM proxy_request_logs WHERE request_id = 'codex-gpt-5-5-zero-cost'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; + assert_eq!(input_cost, "5.000000"); + assert_eq!(output_cost, "30.000000"); + assert_eq!(total_cost, "35.000000"); + + Ok(()) + } + #[test] fn test_get_usage_summary() -> Result<(), AppError> { let db = Database::memory()?; @@ -2436,6 +2596,11 @@ mod tests { result.is_some(), "带 @ 分隔符的模型 gpt-5.2-codex@low 应能匹配到 gpt-5.2-codex-low" ); + let result = find_model_pricing_row(&conn, "OpenAI/GPT-5.5@HIGH")?; + assert!( + result.is_some(), + "大小写混合的 GPT-5.5 模型应能归一化匹配到 gpt-5.5-high" + ); // 测试不存在的模型 let result = find_model_pricing_row(&conn, "unknown-model-123")?; diff --git a/src/components/usage/DataSourceBar.tsx b/src/components/usage/DataSourceBar.tsx index 7dabc51a8..88e856d7a 100644 --- a/src/components/usage/DataSourceBar.tsx +++ b/src/components/usage/DataSourceBar.tsx @@ -17,6 +17,7 @@ const DATA_SOURCE_ICONS: Record = { codex_db: , codex_session: , gemini_session: , + hermes_session: , }; export function DataSourceBar({ refreshIntervalMs }: DataSourceBarProps) { diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx index f1f2835f9..0842a1855 100644 --- a/src/components/usage/RequestLogTable.tsx +++ b/src/components/usage/RequestLogTable.tsx @@ -141,6 +141,7 @@ export function RequestLogTable({ Claude Codex Gemini + Hermes diff --git a/src/components/usage/UsageDashboard.tsx b/src/components/usage/UsageDashboard.tsx index 97bc1a80c..ef5233a6f 100644 --- a/src/components/usage/UsageDashboard.tsx +++ b/src/components/usage/UsageDashboard.tsx @@ -35,6 +35,7 @@ const APP_FILTER_OPTIONS: AppTypeFilter[] = [ "claude", "codex", "gemini", + "hermes", ]; export function UsageDashboard() { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 766e018b6..26e4b09ed 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1120,7 +1120,8 @@ "all": "All", "claude": "Claude Code", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "hermes": "Hermes" }, "dataSources": "Data Sources", "dataSource": { @@ -1128,7 +1129,8 @@ "session_log": "Session Log", "codex_db": "Codex DB", "codex_session": "Codex Session", - "gemini_session": "Gemini Session" + "gemini_session": "Gemini Session", + "hermes_session": "Hermes Session" }, "sessionSync": { "trigger": "Sync session logs", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index a4e8e412f..a351f0d16 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1120,7 +1120,8 @@ "all": "すべて", "claude": "Claude Code", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "hermes": "Hermes" }, "dataSources": "データソース", "dataSource": { @@ -1128,7 +1129,8 @@ "session_log": "セッションログ", "codex_db": "Codex DB", "codex_session": "Codex セッション", - "gemini_session": "Gemini セッション" + "gemini_session": "Gemini セッション", + "hermes_session": "Hermes セッション" }, "sessionSync": { "trigger": "セッションログを同期", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index ae148fe8a..19e1c21cd 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1120,7 +1120,8 @@ "all": "全部", "claude": "Claude Code", "codex": "Codex", - "gemini": "Gemini" + "gemini": "Gemini", + "hermes": "Hermes" }, "dataSources": "数据来源", "dataSource": { @@ -1128,7 +1129,8 @@ "session_log": "会话日志", "codex_db": "Codex 数据库", "codex_session": "Codex 会话日志", - "gemini_session": "Gemini 会话日志" + "gemini_session": "Gemini 会话日志", + "hermes_session": "Hermes 会话日志" }, "sessionSync": { "trigger": "同步会话日志", diff --git a/src/types/usage.ts b/src/types/usage.ts index c595c1cf7..b48aa152d 100644 --- a/src/types/usage.ts +++ b/src/types/usage.ts @@ -129,7 +129,7 @@ export interface UsageRangeSelection { customEndDate?: number; } -export type AppTypeFilter = "all" | "claude" | "codex" | "gemini"; +export type AppTypeFilter = "all" | "claude" | "codex" | "gemini" | "hermes"; export interface StatsFilters { timeRange: UsageRangePreset; From eb304232b34a039836066432d3f67cb734dffacf Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 29 Apr 2026 22:57:23 +0800 Subject: [PATCH 096/156] docs(readme): add PatewayAI sponsor across zh/en/ja Also clean up a dangling tag in README_JA.md. --- README.md | 17 ++++++++++++----- README_JA.md | 19 ++++++++++++------- README_ZH.md | 17 ++++++++++++----- assets/partners/logos/pateway.png | Bin 0 -> 8228 bytes 4 files changed, 36 insertions(+), 17 deletions(-) create mode 100644 assets/partners/logos/pateway.png diff --git a/README.md b/README.md index dc07bab96..ec87bc1d9 100644 --- a/README.md +++ b/README.md @@ -46,17 +46,24 @@ MiniMax-M2.7 is a next-generation large language model designed for autonomous e Thanks to Shengsuanyun for sponsoring this project! Shengsuanyun is a super factory serving AI Native Teams — an industrial-grade AI task parallel execution platform. Its model marketplace aggregates Claude, ChatGPT, Gemini, and other domestic and international LLM and multimedia model capabilities with direct supply. Absolutely no reverse engineering or dilution — platform-wide model SLA availability reaches 99.7%, with monitoring dashboards showing green across the board. It also offers enterprise-grade custom gateways for fine-grained team cost and permission management, smart routing, security protection, and BYOK (Bring Your Own Key) hosting. The platform charges on a pay-per-use and tokens plan (coming soon) basis, with invoicing available. Register via this link as a new user to receive ¥10 in credits plus a 10% bonus on your first top-up. - -SiliconFlow -Thanks to SiliconFlow for sponsoring this project! SiliconFlow is a high-performance AI infrastructure and model API platform, providing fast and reliable access to language, speech, image, and video models in one place. With pay-as-you-go billing, broad multimodal model support, high-speed inference, and enterprise-grade stability, SiliconFlow helps developers and teams build and scale AI applications more efficiently. Register via this link and complete real-name verification to receive ¥16 in bonus credit, usable across models on the platform. SiliconFlow is also now compatible with OpenClaw, allowing users to connect a SiliconFlow API key and call major AI models for free. - - AICodeMirror Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support. Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for CC Switch users: register via this link to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off! + +PatewayAI +Thanks to PatewayAI for sponsoring this project! PatewayAI is an API relay service provider built for heavy AI developers, focused on directly relaying official high-quality model APIs. It offers the full Claude lineup and the Codex series, 100% sourced from official channels — no dilution, no fakes, verification welcome. Billing is transparent and every token-level invoice can be audited line by line. +It also supports enterprise-grade concurrency and provides a dedicated management platform for enterprise customers — formal contracts and invoicing are available; visit the official website for contact details. +Register now via this link to receive $3 in trial credit. Top-ups go as low as 60% of the original price, with a two-way referral bonus of up to $150! + + + +SiliconFlow +Thanks to SiliconFlow for sponsoring this project! SiliconFlow is a high-performance AI infrastructure and model API platform, providing fast and reliable access to language, speech, image, and video models in one place. With pay-as-you-go billing, broad multimodal model support, high-speed inference, and enterprise-grade stability, SiliconFlow helps developers and teams build and scale AI applications more efficiently. Register via this link and complete real-name verification to receive ¥16 in bonus credit, usable across models on the platform. SiliconFlow is also now compatible with OpenClaw, allowing users to connect a SiliconFlow API key and call major AI models for free. + + Cubence Thanks to Cubence for sponsoring this project! Cubence is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more with flexible billing options including pay-as-you-go and monthly plans. Cubence provides special discounts for CC Switch users: register using this link and enter the "CCSWITCH" promo code during recharge to get 10% off every top-up! diff --git a/README_JA.md b/README_JA.md index 92e18ba95..fbb3ba726 100644 --- a/README_JA.md +++ b/README_JA.md @@ -46,17 +46,24 @@ MiniMax-M2.7 は、自律的進化と実世界の生産性向上のために設 胜算雲(Shengsuanyun)のご支援に感謝します!胜算雲は AI ネイティブチーム向けのスーパーファクトリーであり、産業グレードの AI タスク並列実行プラットフォームです。モデルマーケットプレイスでは Claude、ChatGPT、Gemini をはじめとする国内外の LLM およびマルチメディアモデルの計算リソースを集約・直接提供。リバースエンジニアリングや品質低下は一切なく、プラットフォーム全体のモデル SLA 可用性は 99.7% に達し、監視ダッシュボードは常時グリーン表示です。さらにエンタープライズ向けカスタムゲートウェイを提供し、チームのきめ細かなコスト・権限管理、スマートルーティング、セキュリティ保護、BYOK(自社キー持ち込み)ホスティングを実現します。従量課金およびトークンプラン(近日公開)対応で、請求書発行にも対応。このリンクから新規登録すると 10 元分のクレジットと初回チャージ 10% ボーナスが付与されます。 - -SiliconFlow -SiliconFlow のご支援に感謝します!SiliconFlow は高性能 AI インフラストラクチャおよびモデル API プラットフォームで、言語・音声・画像・動画モデルへの高速かつ信頼性の高いアクセスをワンストップで提供します。従量課金制、豊富なマルチモーダルモデル対応、高速推論、エンタープライズグレードの安定性を備え、開発者やチームがより効率的に AI アプリケーションを構築・拡張できるようサポートします。このリンクから登録し、本人確認を完了すると、プラットフォーム内の全モデルで利用可能な ¥16 のボーナスクレジットが付与されます。SiliconFlow は OpenClaw にも対応しており、SiliconFlow の API キーを接続することで主要な AI モデルを無料で呼び出すことができます。 - - AICodeMirror AICodeMirror のご支援に感謝します!AICodeMirror は Claude Code / Codex / Gemini CLI の公式高安定リレーサービスを提供しており、エンタープライズ級の同時接続、迅速な請求書発行、24時間年中無休の専用テクニカルサポートを備えています。 Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / 2% / 9%、チャージ時にはさらに割引!AICodeMirror は CC Switch ユーザー向けに特別特典を用意:このリンクから登録すると初回チャージ 20% オフ、法人のお客様は最大 25% オフ! + +PatewayAI +PatewayAI のご支援に感謝します!PatewayAI はヘビーな AI 開発者向けに、公式直結の高品質モデル API 中継サービスを専門に提供するプロバイダーです。Claude シリーズ全モデルおよび Codex シリーズに対応し、100% 公式ソースから直接提供。混ぜ物・水増しは一切なく、検証も歓迎します。課金は透明で、トークン単位の請求書を 1 件ずつ照合可能です。 +エンタープライズ級の高同時接続にも対応し、法人のお客様には専用の管理プラットフォームを提供。正式契約および請求書発行に対応しており、詳細は公式サイトの連絡先よりお問い合わせください。 +現在、このリンクからご登録いただくと $3 のトライアルクレジットを進呈。チャージは最安で元価格の 60%、友達紹介は双方にボーナスが付与され、紹介報酬は最大 $150! + + + +SiliconFlow +SiliconFlow のご支援に感謝します!SiliconFlow は高性能 AI インフラストラクチャおよびモデル API プラットフォームで、言語・音声・画像・動画モデルへの高速かつ信頼性の高いアクセスをワンストップで提供します。従量課金制、豊富なマルチモーダルモデル対応、高速推論、エンタープライズグレードの安定性を備え、開発者やチームがより効率的に AI アプリケーションを構築・拡張できるようサポートします。このリンクから登録し、本人確認を完了すると、プラットフォーム内の全モデルで利用可能な ¥16 のボーナスクレジットが付与されます。SiliconFlow は OpenClaw にも対応しており、SiliconFlow の API キーを接続することで主要な AI モデルを無料で呼び出すことができます。 + + Cubence Cubence のご支援に感謝します!Cubence は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームで、従量課金や月額プランなど柔軟な料金体系を提供しています。CC Switch ユーザー向けの特別割引:このリンクで登録し、チャージ時に「CCSWITCH」クーポンを入力すると、毎回 10% オフになります! @@ -72,8 +79,6 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / Compshare のご支援に感謝します!Compshare は UCloud 傘下の AI クラウドプラットフォームで、国内外の安定した包括的なモデル API を 1 つのキーだけで利用可能。月額・都度課金のコストパフォーマンスに優れた国内モデル Coding Plan パッケージを提供し、公式リレーによる安定した海外モデルも利用できます。Claude Code、Codex および API アクセスに対応。エンタープライズ級の高同時接続、24 時間年中無休のテクニカルサポート、セルフサービス請求書発行に対応。こちらのリンクから登録すると、無料で 5 元分のプラットフォーム体験クレジットがもらえます! - - AICoding AICoding.sh のご支援に感謝します!AICoding.sh —— グローバル AI モデル API 超お得な中継サービス!Claude Code 81% オフ、GPT 99% オフ!数百社の企業に高コストパフォーマンスの AI サービスを提供。Claude Code、GPT、Gemini および国内主要モデルに対応、エンタープライズ級の高同時接続、迅速な請求書発行、24 時間年中無休の専属テクニカルサポート。こちらのリンクから登録した CC Switch ユーザーは、初回チャージ 10% オフ! diff --git a/README_ZH.md b/README_ZH.md index 966daca66..badf6116a 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -46,17 +46,24 @@ MiniMax M2.7 是 MiniMax 首个深度参与自我迭代的模型,可自主构 感谢胜算云赞助了本项目!胜算云是专为AI Native Teams服务的超级工厂,工业级AI任务并行执行平台,模型商城集采直供聚合接入了Claude、Chatgpt、Gemini等海内外LLM及图片视频多媒体模型算力,绝无逆向掺水、全站模型SLA可用性高达99.7%、监测接口日常全绿。更有企业级专属定制网关,实现团队精细化成本与权限管控,智能路由+安全防护+BYOK企业自带密钥托管。平台按量及tokens plan(即将上线)计费,可开票,使用此链接注册新用户可获10元模力及首充10%赠送。 - -SiliconFlow -感谢硅基流动赞助了本项目!硅基流动是一个高性能 AI 基础设施与模型 API 平台,一站式提供语言、语音、图像、视频等多模态模型的快速、可靠访问。平台支持按量计费、丰富的多模态模型选择、高速推理和企业级稳定性,帮助开发者和团队更高效地构建和扩展 AI 应用。通过此链接注册并完成实名认证,即可获得 ¥16 奖励金,可在平台内跨模型使用。硅基流动现已兼容 OpenClaw,用户可接入硅基流动 API Key 免费调用主流 AI 模型。 - - AICodeMirror 感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定中转服务,支持企业级高并发、极速开票、7×24 专属技术支持。 Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更有折上折!AICodeMirror 为 CCSwitch 的用户提供了特别福利,通过此链接注册的用户,可享受首充8折,企业客户最高可享 7.5 折! + +PatewayAI +感谢 PatewayAI 赞助了本项目!PatewayAI 是一家面向重度 AI 开发者、专注官方直连高品质模型 API 中转服务商。提供 Claude 全系列与 Codex 系列模型,100% 官方源直供,不掺假不注水,欢迎检验。计费透明,Token 级账单可逐笔核验。 +同时支持企业级高并发,并为企业客户提供了专业的管理平台,企业客户可签订正式合同并开具发票,更多详情进入官网获取联系方式。 +现在通过此链接注册即送 $3 试用额度,用户充值低至 6 折,邀请好友双向赠送,邀请奖励可达 $150! + + + +SiliconFlow +感谢硅基流动赞助了本项目!硅基流动是一个高性能 AI 基础设施与模型 API 平台,一站式提供语言、语音、图像、视频等多模态模型的快速、可靠访问。平台支持按量计费、丰富的多模态模型选择、高速推理和企业级稳定性,帮助开发者和团队更高效地构建和扩展 AI 应用。通过此链接注册并完成实名认证,即可获得 ¥16 奖励金,可在平台内跨模型使用。硅基流动现已兼容 OpenClaw,用户可接入硅基流动 API Key 免费调用主流 AI 模型。 + + Cubence 感谢 Cubence 赞助本项目!Cubence 是一家可靠高效的 API 中继服务提供商,提供对 Claude Code、Codex、Gemini 等模型的中继服务,并提供按量、包月等灵活的计费方式。Cubence 为 CC Switch 的用户提供了特别优惠:使用 此链接 注册,并在充值时输入 "CCSWITCH" 优惠码,每次充值均可享受九折优惠! diff --git a/assets/partners/logos/pateway.png b/assets/partners/logos/pateway.png new file mode 100644 index 0000000000000000000000000000000000000000..7ca3489a9248cd12668f05c73d1fe929ef1795c8 GIT binary patch literal 8228 zcmeHsiB}S9{5O^Nre@kTHE)`hsg;?gC6#ODl4zK_iCbnasJM#?qGgMjTj7?u<}QZh z0_Mu?S{j*}3!vhXp^*~d2CnFDy61iWhxa|_$AKBn%$#{jy=Tih@9( zgAfZ-I}m7ZHSqtd@SnhTKs_rG*olN%xI}0teOcJ8-&)mQXutgZ>c51K0-I;@dnkWB>(@Pb3@LP)ow_It%D5Lg`d9E* ziB12!jPV%9=bO8<@xaN>>#9#jE!aX@26ROzxy^A*n{3=2uTAwj}yj!fI#=0 zEo}Axk0&kZ033^X^uMS6|Co*J1Yj|@B_F>MO}sieF;RV7IE%K)BhSUwJuS7FCMP&U zu+(tVZ8_`O_iN}S%Ek95giM9yDcQfmcrkUdt_`Z#{0|NtU11mHm zIS{B_Lb>`FLva|My1^k=z;PZP9u6-nJ5(aOWu<$T6cKRyBG$sjMjm|!1S$cuCg0L! zx?hBY^RwF~`!}Uy<$DUrN)m}zM=C3U0amsuj ziV*xeXN zaJvK~RZP0Jy$&2tMYeI8+=e^f-9B#pIJdXrX=!yPf5cYm(dP2pf!?ycpnI7+JyH8K zsKdkdA(R?BG8RhFF7K8lVC^g7Rd}655fErS$$48x)*GdHjX-qT6>t;qXj{L=Jki7w zhBvt<=Z7)g>$1G=SRI0Z&*-SiJ}-vm!~$g}H%W;2xT@;ZDFzi* z%R^z79mzD6vPYa3Ht>_*)6 zR}1*Q6*m<~Y31;I+j^A7yzPt^$W6ohMj6XhXL`n9;s`-n1b2wy7=aCUxr~2#=!n_# zmjkQ--9nG3!m*jqY9Q>W)7X-|xwUC0>}{oLD0he2wL%jicK>9{rkQtWDC_~b!@ac| zYDN0|_lu~7nx$$1vxL%-`U9jqu6Dhe*!j^bbp4T#r z6tUC>BK!{9;$~NElwed$3Uid5tZk^gp&@PdN+#t4g**tW&Ci#U7BVjL-0hlKoaBap za?4Vjm!qSj0}P9scUpdUKtA5l&dbvHiEJB^jUZvVWlwP$eO8WXoKxP?@6{gk+iz@B z=TsZs%t&721vf`@hPb(->ll`Y9;z#^4bFwT)H$7Hlj*SLsj189_+~}3=PLp<{N34| zqizC8S9KXD!s3d)5yOOKNtBfQ2^5Rc({|0x&EM*~5&d_lFtP@(O)-_bIr&N;v@5Th zd&Maj^2>2}-eFZ;AQ1 z^@HPY;`5=CIhO z>>&G?kUXztX50R&j5|%PRffFZx*m8=?e^5&`n~hIKcIBO+#A020uLZO$h$jRywL(Z z^R1zaj%Kz%q4mtjUF{)8>Xd8H0?FmPZ;0ZWEzS+>`v65FeSMpz-Mv@1-5u}0z;a!3 zT%x&`99g`3vpf^Uaso;7Ol#bSUGTr>r8sMI;c&qiLc(*5gPaR*QSUncb5)O~axA!% zsL{g=3#7;A_yEVHh$h-2CxsKoS!1XU8BA`UuRYoo7RcrVHM_D05L*g`J}w)pt9LQ& zO|(*`PVqqDWbUUS&SLFx;n$3oUHz?<*}lKazp3{!=A3q9<%(fmBiNrh#X)CQMrX>Oky*jvI5pvJb|uVwHhr&@zlD zhr`f6Z(x`XltR1B1i=ChC{uRo$S@juT_t)wTtW{9M9{ z9e5^|JnCG9GSyKp7pDX+0`DIvhseL7s@B`kX!55r2Z5d|YirYElLec+z(5ARi;qs& z;&Yn4gky_bJr4stVroJnOhF{TWM#t*S~YBKs0X#zkrg|N>AtJIbjQwan0tR3(!IG6 zX+ne50{N;1S?KC|@Ra$s86zun|0#2QcG^O+sFh+pz59zx-X-n07JsVz+q*x9mzl1U zld5HCrtQ&kTC^&eT+W|=g&g0x?KeN%do6K1k#e{67nkb_6*`jgsI{X5(XQ@qE48q* zGc~_l3N%y?WeVLiTpwd;bGDX>Lx#9|KJI#z>9KoU*t%}76Lg~9HrlsU*=yp|d_BVt zt+Ad}-~?2n7Pq2pIf`5cDZU+ou5dRz88nbzdRp8g6U!dyp>$nN6(XwS_n9C3^FcQO zg3B96bXwmSZd!RI++$&N|DS#xanm)J<-^$5iQQ(`?JQ?ZI@T=Ov`m0};|@&w>7y zvYvPoYieL@QH;>k z*{IhS;Xw^3m%>@gAwxy9&DpASXIrV#+151rHmUf;-(6#WP&UFBTGYnKBc>hl{eA)N zfdE993BA?NTG38thPO@Txa4F5Uj~Qdrja}6e{q4>bp4P}M^;aOHhNvMjO()9HDeR0 zA7r%lMSW+XAdR`6;g8Zdb{0AtE|XB0J^JmkVeC2QwY?Ov?FM}hnd^3)7P@-qi${~m zyN2_`5Z8WOf7A+pdxC=`tW7twmXU3EB8<^#?~|&`iX0x>c6T)Up%lbD!AehXuJ~mA zN8`Gl1nb~&l(={5x-mSknL+dj{3Th+6>`LyS|}j9X5PrBna_n@mJ)=DcD-@KJAHfT zxOx1Sca1%yC4Vy5Sv;w$KYg-h_8oubErVm)s}IH(t3#hOGw@WF<-*3VVoh7AY~w?> zxcj_s{B3577ldkzH45r{#$NmSO=6*#q;h^u-%_r8Be=+3xw58^X!~il z*=Tg4S`B{GDdavU{+a&6UOWE1pSs4`{36=9havgpMY_()$#t_{6HP~Kv4^bq&8taQ z<^D3Wt<_0VVTBYvY!_v0ZK_ZWi7mA8%_Tp%|9k(FVkS#Ct)v^Ohgs7f99IiVEQ3=q zMh{c9N|RcZmwo^vNP$C8os3^)-XiVgYZN{a?P_v1=m5C+U@q36xQB z{;gAa2#@HQeULo0+{`i{^?0VHj6gHHL_FnR$sa)D_i+61@_O0xqhfyo5XK3!xeCFo zFV6f-dtLiMbg|05@sY%HDPBlQ3WD?w>@^+)PCKm1nEg6pGJ*WwfN;0Z7ZU93bP_pZ zb&)D(YN=p|9o!_G@Y?<`@z>KEn9R6K5UMC8!+X9%N}y!?DBE5_I7f>!5Toy8E0w&H zmf|5<+`H#k8Ikm>4{_3~0s0L9opGyV2#Ohms_o~Uu7C$Pj!cqh<+J;Z2z!lebH{%h zB1v2;P&9f=z?V;}E^VwYF)0Zlj2y6Jx0-De1k}%aHm2&K;mM6QYiKQ+)oJ2<5v7Ze z@3su;BC=P->Fz~GCbyEV+M#yfV|{1AipViBP}iV` zcA9p}X8X8kGH?v5hGKQ+UDt1tp81eY=BtR#qe?dJ&0D)|D5$4janV*=$aJTR!Qz05 zCYS=vRv(=}_orzRp_MQBtByS-tyPwf!ySSc0ksR#qzzje;?@L)ChJ|e+O|D&ROo(` zUIk(8ELG?l!%NjmR`=$3*ozgrz1%CQT5~b_b&lxsy$t~M;W@?6-NN*)kpf2QnLp$p ze=B?6xNc^WCG?m0Ms&HoEKUIn$h00}Uos|iL%q!Xi~l?REd?~gtI=~>X1aO4UXOOc z$F*b{sZ()YOpi+;sa#KbM}KcR`OVv-Y}yX0LU;g${dU=DWuraQ zbG=)Z=%zsp0tX5jvvsHJ%ZT6URj2+#-e2z$^0mxAgxw&_KU&{dVU3aN3oRmlzF?%* z$Dsv`)G($O6~EM?x~L{Xp#szWmU)~Hcu8u^+b|ButXpuraTFefSdo(1U^`Bb;~@U~vn7L@zWp(EzTMxqjJ3 zrIPiVJ@O<&Q#Dec&tn+hxJjaX#t>ai#fGcBVA{y7dH8$C-!yLC=~rJe^A)V)DxNMhpn-u=VT6KSJuSjV`wNuN_i1? zC;TZwVhSra> z%8mZ+)Du}F4})}>4h=?ec^^VtuQ&AjZAFR)H#6u7WkkcRH5N|Tg8gdzbKDdMd8+wL z&4~be>c4ZbRat5PUD??dVBitkILz1OiL4RBD5#y$OPB4*KfP`AW{20SRWEGSqH66j zDW*Oy-7l_7SkP8I@J`k}xE7^($CWT`GvfHfm90$Up#J2D3-cAdMdLv$6Ko75WP-^o zTQf-?x=4&?HW}Th>I)EdZ!MoO%snqP(*fXd3n?=X6<=z2aFlv(@Az~DYA!*95M7sd zcegG6MT^q8816}iw=;3tcYD4?aHnw^=dwk*P%)5}+ti^5@ujr+qJQ!|=NX+-(O2rU z#Y_lAk68YAO|@5@zJMaMRI}D(R|Q49);5Kg^?pN#WS)a-R?VUr%1Dh-Rd2^cRPc5U zG|}(EMrCV~1?{%c+zM;JsW<}SE#h38?Wno46BN%73DzLM8Irdw1vvl3nXV-3Ys)5P zL%xxKrm?Sc=S~*Cl;q<523G!0k8>IK{%l;9_3>QK4O5VslMGzy*g_Tuey*p-X@u!!xZs{WLwN5M`x(1qX}tX>r5Nh1US3 zB1|3-Oz;3p2V}2ObaGR;MXo^y_L520%$HvFmO;4TA^!NktUWW}UR5<-;J;=^toB>} zS>kE8ZV0i|_9Dzbl$>uFrYhIy9^S_S=n-oRrNOYi*5HC%9v!L<>GI7RJ^l2x>cW_M zNPylIpM-0-CZAa!LNuIXP&@$@j?Eii2tg4109A;@dNkWP++D9L4GSEUWP+!kL+pNI zIXCz92Gvz9mM=XzyC7$7L#IMhC+?<=N@G#}gax}m56gl0j?*_3umXo19*s*9!AEfh za$a-e(-GK^2)Cfj2Q#5DNHka^jM%5TmYSLuXaas0@)e9GPM$Zz&iW`Gicb14ZE9r_9(Q?FvC{1tX7)#Q zCN-J+%J<;YF41ANfX)KUs7nsjP@F{A80h%-wamjeUKhlv{OlS;+HE=N*}yY+;>$i? zYn)5d704jJaY4*V@6rwx`KZ=XF>_40>sU~dO^IuN<7}WPTp~3Pn4#Xe1_0%k(iNdW zU^!M#4QH`qKb7Qh$EU-?wd3drEZ0^_XMy*8Ro!C2EN70OQ0_g=ye0AMuB@>N(1rm} z|5#eq`x8JP(tDTyeK4vsf^g{!e}CdM)ukxef+m;9U%h;nXmQ}8MjqxayF*Wn>i^Ib?{kwi90nYh`}_F7pDT0 z7SLpKb91Lrw~4d`Ak@`__p%#|5zqV0(@QPPY*%}*Ij}$?Aj&eCOvPPa!{S0+%wWz&Wv4;>i+5iDu!_}91x5}V!{;!Xrw)%OLyn%RCNVy*xWy@0_+_;szT}g+_Qt7WHNcw>_AYifx-8!HEu_U{brZ9VeuL=uBf;eJ=Wi;DiY&K-#9Up z{07r8MUXN(G2Yxs(~p<1uyHj{L`|uz-EeQ+h@0AA;|QK)%D8fjGCs7TF(c@=NLxy#GbhiWWW_g z=kF^U@3i%7rJ_2&{G*e@SnT*b@~Yb2dj7@b^xf0yA+D{Jl#MU5dKSHxd0~jGIRfTN z-i@Q0{gh5@O18k3*SS-XP=a{}xMGv|5 zkw(z9h5$Dg1&-)#=2Jw2TM4*D23gh0~C3cM@Slv|%w_cdBi&4oJ!CirS>D z^7%A|tbMIa;G}LD;49QpKLw0g%K_vZNLzsPrsd{gdil|_02~tjRXL|R(jAN7(rU`}@ zr^Uzh%9ee*9NeLtRlDSNSyMQXPW3cQ*xBxRf3NhkN1#O|d;u|^jRUh^p~|SW1OMsH zX+4?&cu)2_C*H*jXmvHIgDk=D-zR>cYmxU>9@kO8YS{!JAiL!!b5Y$p^;5&=O)i4V zt#a6U-5Q@|N6^6xU-S{*gPPN~>Le}3>YRZ{KpE3rJWXG~KCpVa6f-dW zc|gxIuN`tjwJ3moxXSk|5Xd_XJ6nd9!Fw?zglzaFvxf5TbMFb$x_kc-_}nUwcxwgwK!CxE?iSsum?*H@t9u3kL_3QK#Z zzLUOjzAQnv5C>qP1;58$>$(hv?22yh3Xod?jbn?tiXic7Cspn4Y9RX6@I*kaKz_*5 z_{F^kBoz8kPgt2&0aRV)0KfqXg5*Gpdhq#bdjL^;cE-Wy8bQ;~O88d;@gxRjp1SbwYf-R-NCzd&!V0orgMD}vq!(q+X}($FcbD*64ChSa-du*z%!ci0ZKkKfNFWCmBIN$xoa1IFHF$waNw zws^hlujDg;8};bnMdLK)Tfh^GZ4)Ww-j*Q)TuM#ML|W*6YfFnQEUCoS$ZiEiZn&r|7ruT5<{&sFUC4vAZ@xpP2@#U5j9}fel790q5Hq5{-2+XPrG~W X>0Tf#^eJP21p`6MtWE2#-A?=;CF9gr literal 0 HcmV?d00001 From a1e6c3b65de4ccc2a91f54c5ca8564f331ccaf44 Mon Sep 17 00:00:00 2001 From: SaladDay <92240037+SaladDay@users.noreply.github.com> Date: Thu, 30 Apr 2026 09:54:25 +0800 Subject: [PATCH 097/156] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Codex=20=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E4=BE=9B=E5=BA=94=E5=95=86=E5=90=8E=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=8F=98=E5=8C=96=20(#2349)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Keep Codex history stable across provider switches * Restore template Codex provider id when backfilling live config Backfill writes the current Codex live config back to the previous provider's stored template after a switch. Because the live file now carries a normalized stable model_provider id, the previous provider's template would lose its own provider-specific id (and any matching [profiles.*] references) on every subsequent switch. Reverse the normalization at backfill time by rewriting model_provider, the active model_providers section, and matching profile references back to the template's original id. --------- Co-authored-by: Jason --- src-tauri/src/codex_config.rs | 511 ++++++++++++++++++++++++ src-tauri/src/services/config.rs | 2 +- src-tauri/src/services/provider/live.rs | 69 +++- src-tauri/src/services/proxy.rs | 168 +++++++- src-tauri/tests/import_export_sync.rs | 87 ++++ src-tauri/tests/provider_service.rs | 235 +++++++++++ 6 files changed, 1043 insertions(+), 29 deletions(-) diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 98f6d06a0..af02c1b3c 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -11,6 +11,20 @@ use std::fs; use std::path::Path; use toml_edit::DocumentMut; +pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "ccswitch"; + +/// Reserved built-in provider IDs from OpenAI Codex's config/model-provider +/// catalog. Keep in sync with Codex `RESERVED_MODEL_PROVIDER_IDS` and legacy +/// removed provider aliases. +const CODEX_RESERVED_MODEL_PROVIDER_IDS: &[&str] = &[ + "amazon-bedrock", + "openai", + "ollama", + "lmstudio", + "oss", + "ollama-chat", +]; + /// 获取 Codex 配置目录路径 pub fn get_codex_config_dir() -> PathBuf { if let Some(custom) = crate::settings::get_codex_override_dir() { @@ -137,6 +151,268 @@ pub fn read_and_validate_codex_config_text() -> Result { Ok(s) } +fn active_codex_model_provider_id(doc: &DocumentMut) -> Option { + doc.get("model_provider") + .and_then(|item| item.as_str()) + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(str::to_string) +} + +fn is_custom_codex_model_provider_id(id: &str) -> bool { + let id = id.trim(); + !id.is_empty() + && !CODEX_RESERVED_MODEL_PROVIDER_IDS + .iter() + .any(|reserved| reserved.eq_ignore_ascii_case(id)) +} + +fn stable_codex_model_provider_id_from_config(config_text: &str) -> Option { + let doc = config_text.parse::().ok()?; + let provider_id = active_codex_model_provider_id(&doc)?; + + if is_custom_codex_model_provider_id(&provider_id) { + Some(provider_id) + } else { + None + } +} + +fn codex_model_provider_id_with_table_from_config( + config_text: &str, +) -> Result, AppError> { + if config_text.trim().is_empty() { + return Ok(None); + } + + let doc = config_text + .parse::() + .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?; + let Some(provider_id) = active_codex_model_provider_id(&doc) else { + return Ok(None); + }; + + let has_provider_table = doc + .get("model_providers") + .and_then(|item| item.as_table()) + .and_then(|table| table.get(provider_id.as_str())) + .is_some(); + + Ok(has_provider_table.then_some(provider_id)) +} + +fn normalize_codex_live_config_model_provider_with_anchors<'a>( + config_text: &str, + anchor_config_texts: impl IntoIterator, +) -> Result { + if config_text.trim().is_empty() { + return Ok(config_text.to_string()); + } + + let mut doc = config_text + .parse::() + .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?; + + let Some(source_provider_id) = active_codex_model_provider_id(&doc) else { + return Ok(config_text.to_string()); + }; + + let has_source_provider_table = doc + .get("model_providers") + .and_then(|item| item.as_table()) + .and_then(|table| table.get(source_provider_id.as_str())) + .is_some(); + if !has_source_provider_table { + return Ok(config_text.to_string()); + } + + let stable_provider_id = anchor_config_texts + .into_iter() + .find_map(stable_codex_model_provider_id_from_config) + .or_else(|| { + is_custom_codex_model_provider_id(&source_provider_id) + .then(|| source_provider_id.clone()) + }) + .unwrap_or_else(|| CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string()); + + if stable_provider_id == source_provider_id { + return Ok(config_text.to_string()); + } + + if let Some(model_providers) = doc + .get_mut("model_providers") + .and_then(|item| item.as_table_mut()) + { + let Some(provider_table) = model_providers.remove(source_provider_id.as_str()) else { + return Ok(config_text.to_string()); + }; + model_providers[stable_provider_id.as_str()] = provider_table; + } + + rewrite_codex_profile_model_provider_refs(&mut doc, &source_provider_id, &stable_provider_id); + doc["model_provider"] = toml_edit::value(stable_provider_id.as_str()); + + Ok(doc.to_string()) +} + +fn rewrite_codex_profile_model_provider_refs( + doc: &mut DocumentMut, + source_provider_id: &str, + stable_provider_id: &str, +) { + let Some(profiles) = doc + .get_mut("profiles") + .and_then(|item| item.as_table_like_mut()) + else { + return; + }; + + let profile_keys: Vec = profiles.iter().map(|(key, _)| key.to_string()).collect(); + for profile_key in profile_keys { + let Some(profile_table) = profiles + .get_mut(&profile_key) + .and_then(|item| item.as_table_like_mut()) + else { + continue; + }; + + let references_source = profile_table + .get("model_provider") + .and_then(|item| item.as_str()) + == Some(source_provider_id); + if references_source { + profile_table.insert("model_provider", toml_edit::value(stable_provider_id)); + } + } +} + +/// Keep Codex's active `model_provider` stable across CC Switch provider changes. +/// +/// Codex stores and filters resume history by `model_provider`, so switching between +/// provider-specific ids like `rightcode` and `aihubmix` makes history appear to move. +/// We preserve an existing custom provider id when possible and only rewrite the +/// live config text that Codex sees at provider-driven write boundaries. +pub fn normalize_codex_settings_config_model_provider( + settings: &mut Value, + anchor_config_text: Option<&str>, +) -> Result<(), AppError> { + let Some(config_text) = settings + .get("config") + .and_then(|value| value.as_str()) + .map(str::to_string) + else { + return Ok(()); + }; + + let current_config_text = read_codex_config_text().ok(); + let anchors = anchor_config_text + .into_iter() + .chain(current_config_text.as_deref()); + let normalized = + normalize_codex_live_config_model_provider_with_anchors(&config_text, anchors)?; + + if let Some(obj) = settings.as_object_mut() { + obj.insert("config".to_string(), Value::String(normalized)); + } + + Ok(()) +} + +fn restore_codex_backfill_model_provider_id( + config_text: &str, + template_config_text: &str, +) -> Result { + let Some(template_provider_id) = + codex_model_provider_id_with_table_from_config(template_config_text)? + else { + return Ok(config_text.to_string()); + }; + + if config_text.trim().is_empty() { + return Ok(config_text.to_string()); + } + + let mut doc = config_text + .parse::() + .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?; + let Some(live_provider_id) = active_codex_model_provider_id(&doc) else { + return Ok(config_text.to_string()); + }; + + if live_provider_id == template_provider_id { + return Ok(config_text.to_string()); + } + + if let Some(model_providers) = doc + .get_mut("model_providers") + .and_then(|item| item.as_table_mut()) + { + let Some(provider_table) = model_providers.remove(live_provider_id.as_str()) else { + return Ok(config_text.to_string()); + }; + model_providers[template_provider_id.as_str()] = provider_table; + } else { + return Ok(config_text.to_string()); + } + + rewrite_codex_profile_model_provider_refs(&mut doc, &live_provider_id, &template_provider_id); + doc["model_provider"] = toml_edit::value(template_provider_id.as_str()); + + Ok(doc.to_string()) +} + +/// Convert a Codex live config that was normalized for history stability back +/// to the provider-specific id used by the stored provider template. +pub fn restore_codex_settings_config_model_provider_for_backfill( + settings: &mut Value, + template_settings: &Value, +) -> Result<(), AppError> { + let Some(config_text) = settings + .get("config") + .and_then(|value| value.as_str()) + .map(str::to_string) + else { + return Ok(()); + }; + let Some(template_config_text) = template_settings + .get("config") + .and_then(|value| value.as_str()) + else { + return Ok(()); + }; + + let restored = restore_codex_backfill_model_provider_id(&config_text, template_config_text)?; + if let Some(obj) = settings.as_object_mut() { + obj.insert("config".to_string(), Value::String(restored)); + } + + Ok(()) +} + +/// Atomically write Codex live config after normalizing provider-specific ids. +/// +/// Use this for provider-driven live writes. Keep `write_codex_live_atomic` available +/// for exact restore/backup paths that must preserve the config text byte-for-byte. +pub fn write_codex_live_atomic_with_stable_provider( + auth: &Value, + config_text_opt: Option<&str>, +) -> Result<(), AppError> { + match config_text_opt { + Some(config_text) => { + let mut settings = serde_json::Map::new(); + settings.insert("config".to_string(), Value::String(config_text.to_string())); + let mut settings = Value::Object(settings); + normalize_codex_settings_config_model_provider(&mut settings, None)?; + let config_text = settings + .get("config") + .and_then(|value| value.as_str()) + .unwrap_or(config_text); + write_codex_live_atomic(auth, Some(config_text)) + } + None => write_codex_live_atomic(auth, None), + } +} + /// Update a field in Codex config.toml using toml_edit (syntax-preserving). /// /// Supported fields: @@ -254,6 +530,241 @@ pub fn remove_codex_toml_base_url_if(toml_str: &str, predicate: impl Fn(&str) -> mod tests { use super::*; + #[test] + fn normalize_live_config_preserves_current_custom_model_provider_id() { + let current = r#"model_provider = "rightcode" + +[model_providers.rightcode] +name = "RightCode" +base_url = "https://rightcode.example/v1" +wire_api = "responses" +"#; + let target = r#"model_provider = "aihubmix" +model = "gpt-5.4" + +[model_providers.aihubmix] +name = "AiHubMix" +base_url = "https://aihubmix.example/v1" +wire_api = "responses" +requires_openai_auth = true + +[mcp_servers.context7] +command = "npx" +"#; + + let result = + normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap(); + let parsed: toml::Value = toml::from_str(&result).unwrap(); + + assert_eq!( + parsed.get("model_provider").and_then(|v| v.as_str()), + Some("rightcode") + ); + + let model_providers = parsed + .get("model_providers") + .and_then(|v| v.as_table()) + .expect("model_providers should exist"); + assert!( + model_providers.get("aihubmix").is_none(), + "source provider id should not remain in live config" + ); + + let stable_provider = model_providers + .get("rightcode") + .expect("stable provider table should exist"); + assert_eq!( + stable_provider.get("base_url").and_then(|v| v.as_str()), + Some("https://aihubmix.example/v1") + ); + assert!( + parsed.get("mcp_servers").is_some(), + "unrelated config should be preserved" + ); + } + + #[test] + fn normalize_live_config_uses_target_custom_provider_when_current_is_reserved() { + let current = r#"model_provider = "openai""#; + let target = r#"model_provider = "aihubmix" + +[model_providers.aihubmix] +name = "AiHubMix" +base_url = "https://aihubmix.example/v1" +wire_api = "responses" +"#; + + let result = + normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap(); + let parsed: toml::Value = toml::from_str(&result).unwrap(); + + assert_eq!( + parsed.get("model_provider").and_then(|v| v.as_str()), + Some("aihubmix") + ); + assert!( + parsed + .get("model_providers") + .and_then(|v| v.get("aihubmix")) + .is_some(), + "target provider id should be kept when there is no reusable live custom id" + ); + } + + #[test] + fn normalize_live_config_leaves_official_empty_config_unchanged() { + let current = r#"model_provider = "rightcode" + +[model_providers.rightcode] +base_url = "https://rightcode.example/v1" +"#; + + let result = + normalize_codex_live_config_model_provider_with_anchors("", Some(current)).unwrap(); + + assert_eq!(result, ""); + } + + #[test] + fn normalize_live_config_rewrites_matching_profile_model_provider_refs() { + let current = r#"model_provider = "session_anchor" + +[model_providers.session_anchor] +name = "Session Anchor" +base_url = "https://anchor.example/v1" +wire_api = "responses" +"#; + let target = r#"model_provider = "vendor_alpha" +model = "gpt-5.4" +profile = "work" + +[model_providers.vendor_alpha] +name = "Vendor Alpha" +base_url = "https://alpha.example/v1" +wire_api = "responses" + +[profiles.work] +model_provider = "vendor_alpha" +model = "gpt-5.4" +"#; + + let result = + normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap(); + let parsed: toml::Value = toml::from_str(&result).unwrap(); + + assert_eq!( + parsed.get("model_provider").and_then(|v| v.as_str()), + Some("session_anchor") + ); + assert_eq!( + parsed + .get("profiles") + .and_then(|v| v.get("work")) + .and_then(|v| v.get("model_provider")) + .and_then(|v| v.as_str()), + Some("session_anchor"), + "profile override matching the rewritten provider should stay valid" + ); + } + + #[test] + fn normalize_live_config_keeps_unrelated_profile_model_provider_refs() { + let current = r#"model_provider = "session_anchor" + +[model_providers.session_anchor] +name = "Session Anchor" +base_url = "https://anchor.example/v1" +wire_api = "responses" +"#; + let target = r#"model_provider = "vendor_alpha" +model = "gpt-5.4" + +[model_providers.vendor_alpha] +name = "Vendor Alpha" +base_url = "https://alpha.example/v1" +wire_api = "responses" + +[model_providers.local_profile] +name = "Local Profile" +base_url = "http://localhost:11434/v1" +wire_api = "responses" + +[profiles.local] +model_provider = "local_profile" +model = "local-model" +"#; + + let result = + normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap(); + let parsed: toml::Value = toml::from_str(&result).unwrap(); + + assert_eq!( + parsed + .get("profiles") + .and_then(|v| v.get("local")) + .and_then(|v| v.get("model_provider")) + .and_then(|v| v.as_str()), + Some("local_profile"), + "unrelated profile provider references should be preserved" + ); + assert!( + parsed + .get("model_providers") + .and_then(|v| v.get("local_profile")) + .is_some(), + "unrelated provider tables should also remain available" + ); + } + + #[test] + fn normalize_live_config_keeps_stable_provider_across_repeated_switches() { + let anchor = r#"model_provider = "session_anchor" + +[model_providers.session_anchor] +name = "Session Anchor" +base_url = "https://anchor.example/v1" +wire_api = "responses" +"#; + let first_target = r#"model_provider = "vendor_alpha" + +[model_providers.vendor_alpha] +name = "Vendor Alpha" +base_url = "https://alpha.example/v1" +wire_api = "responses" +"#; + let second_target = r#"model_provider = "vendor_beta" + +[model_providers.vendor_beta] +name = "Vendor Beta" +base_url = "https://beta.example/v1" +wire_api = "responses" +"#; + + let first = + normalize_codex_live_config_model_provider_with_anchors(first_target, Some(anchor)) + .unwrap(); + let second = normalize_codex_live_config_model_provider_with_anchors( + second_target, + Some(first.as_str()), + ) + .unwrap(); + let parsed: toml::Value = toml::from_str(&second).unwrap(); + + assert_eq!( + parsed.get("model_provider").and_then(|v| v.as_str()), + Some("session_anchor"), + "stable provider id should not drift across repeated switches" + ); + assert_eq!( + parsed + .get("model_providers") + .and_then(|v| v.get("session_anchor")) + .and_then(|v| v.get("base_url")) + .and_then(|v| v.as_str()), + Some("https://beta.example/v1") + ); + } + #[test] fn base_url_writes_into_correct_model_provider_section() { let input = r#"model_provider = "any" diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index 7c116c31c..ec282f372 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -156,7 +156,7 @@ impl ConfigService { } let cfg_text = settings.get("config").and_then(Value::as_str); - crate::codex_config::write_codex_live_atomic(auth, cfg_text)?; + crate::codex_config::write_codex_live_atomic_with_stable_provider(auth, cfg_text)?; // 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用 // sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空 // MCP 的启用/禁用应通过 McpService::toggle_app 进行 diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 2127b51d3..488e88fb4 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -8,7 +8,9 @@ use serde_json::{json, Value}; use toml_edit::{DocumentMut, Item, TableLike}; use crate::app_config::AppType; -use crate::codex_config::{get_codex_auth_path, get_codex_config_path}; +use crate::codex_config::{ + get_codex_auth_path, get_codex_config_path, write_codex_live_atomic_with_stable_provider, +}; use crate::config::{delete_file, get_claude_settings_path, read_json_file, write_json_file}; use crate::database::Database; use crate::error::AppError; @@ -528,29 +530,55 @@ pub(crate) fn strip_common_config_from_live_settings( app_type.as_str(), provider.id ); - return live_settings; + return restore_live_settings_for_provider_backfill(app_type, provider, live_settings); } }; - if !provider_uses_common_config(app_type, provider, snippet.as_deref()) { - return live_settings; - } - - let Some(snippet_text) = snippet.as_deref() else { - return live_settings; + let backfill_settings = if provider_uses_common_config(app_type, provider, snippet.as_deref()) { + match snippet.as_deref() { + Some(snippet_text) => { + match remove_common_config_from_settings(app_type, &live_settings, snippet_text) { + Ok(settings) => settings, + Err(err) => { + log::warn!( + "Failed to strip common config for {} provider '{}': {err}", + app_type.as_str(), + provider.id + ); + live_settings + } + } + } + None => live_settings, + } + } else { + live_settings }; - match remove_common_config_from_settings(app_type, &live_settings, snippet_text) { - Ok(settings) => settings, - Err(err) => { - log::warn!( - "Failed to strip common config for {} provider '{}': {err}", - app_type.as_str(), - provider.id - ); - live_settings - } + restore_live_settings_for_provider_backfill(app_type, provider, backfill_settings) +} + +fn restore_live_settings_for_provider_backfill( + app_type: &AppType, + provider: &Provider, + live_settings: Value, +) -> Value { + if !matches!(app_type, AppType::Codex) { + return live_settings; } + + let mut settings = live_settings; + if let Err(err) = crate::codex_config::restore_codex_settings_config_model_provider_for_backfill( + &mut settings, + &provider.settings_config, + ) { + log::warn!( + "Failed to restore Codex provider id while backfilling '{}': {err}", + provider.id + ); + } + + settings } pub(crate) fn normalize_provider_common_config_for_storage( @@ -683,10 +711,7 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re AppError::Config("Codex 供应商配置缺少 'config' 字段或不是字符串".to_string()) })?; - let auth_path = get_codex_auth_path(); - write_json_file(&auth_path, auth)?; - let config_path = get_codex_config_path(); - std::fs::write(&config_path, config_str).map_err(|e| AppError::io(&config_path, e))?; + write_codex_live_atomic_with_stable_provider(auth, Some(config_str))?; } AppType::Gemini => { // Delegate to write_gemini_live which handles env file writing correctly diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 0683153bc..2b7d2c414 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -1476,20 +1476,33 @@ impl ProxyService { .map_err(|e| format!("构建 {app_type} 有效配置失败: {e}"))?; if matches!(app_type_enum, AppType::Codex) { - let existing_backup = self + let existing_backup_value = self .db .get_live_backup(app_type) .await - .map_err(|e| format!("读取 {app_type} 现有备份失败: {e}"))?; + .map_err(|e| format!("读取 {app_type} 现有备份失败: {e}"))? + .map(|backup| { + serde_json::from_str::(&backup.original_config) + .map_err(|e| format!("解析 {app_type} 现有备份失败: {e}")) + }) + .transpose()?; - if let Some(existing_backup) = existing_backup { - let existing_value: Value = serde_json::from_str(&existing_backup.original_config) - .map_err(|e| format!("解析 {app_type} 现有备份失败: {e}"))?; + if let Some(existing_value) = existing_backup_value.as_ref() { Self::preserve_codex_mcp_servers_in_backup( &mut effective_settings, - &existing_value, + existing_value, )?; } + + let anchor_config_text = existing_backup_value + .as_ref() + .and_then(|value| value.get("config")) + .and_then(|value| value.as_str()); + crate::codex_config::normalize_codex_settings_config_model_provider( + &mut effective_settings, + anchor_config_text, + ) + .map_err(|e| format!("归一化 Codex restore backup 失败: {e}"))?; } let backup_json = match app_type_enum { @@ -1749,6 +1762,8 @@ impl ProxyService { let auth = config.get("auth"); let config_str = config.get("config").and_then(|v| v.as_str()); + // Proxy restore writes saved live backups verbatim. Provider-driven writes go + // through write_live_with_common_config(), which normalizes Codex provider ids. match (auth, config_str) { (Some(auth), Some(cfg)) => write_codex_live_atomic(auth, Some(cfg)) .map_err(|e| format!("写入 Codex 配置失败: {e}"))?, @@ -2693,6 +2708,147 @@ base_url = "https://new.example/v1" ); } + #[tokio::test] + #[serial] + async fn hot_switch_codex_provider_keeps_model_provider_stable_in_backup_and_restore() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + let service = ProxyService::new(db.clone()); + + let provider_a = Provider::with_id( + "a".to_string(), + "RightCode".to_string(), + json!({ + "auth": { + "OPENAI_API_KEY": "rightcode-key" + }, + "config": r#"model_provider = "rightcode" +model = "gpt-5.4" + +[model_providers.rightcode] +name = "RightCode" +base_url = "https://rightcode.example/v1" +wire_api = "responses" +requires_openai_auth = true +"# + }), + None, + ); + let provider_b = Provider::with_id( + "b".to_string(), + "AiHubMix".to_string(), + json!({ + "auth": { + "OPENAI_API_KEY": "aihubmix-key" + }, + "config": r#"model_provider = "aihubmix" +model = "gpt-5.4" + +[model_providers.aihubmix] +name = "AiHubMix" +base_url = "https://aihubmix.example/v1" +wire_api = "responses" +requires_openai_auth = true +"# + }), + None, + ); + + db.save_provider("codex", &provider_a) + .expect("save provider a"); + db.save_provider("codex", &provider_b) + .expect("save provider b"); + db.set_current_provider("codex", "a") + .expect("set current provider"); + crate::settings::set_current_provider(&AppType::Codex, Some("a")) + .expect("set local current provider"); + db.save_live_backup( + "codex", + &serde_json::to_string(&provider_a.settings_config).expect("serialize provider a"), + ) + .await + .expect("seed live backup"); + service + .write_codex_live(&json!({ + "auth": { + "OPENAI_API_KEY": PROXY_TOKEN_PLACEHOLDER + }, + "config": r#"model_provider = "rightcode" +model = "gpt-5.4" + +[model_providers.rightcode] +name = "RightCode" +base_url = "http://127.0.0.1:15721/v1" +wire_api = "responses" +requires_openai_auth = true +"# + })) + .expect("seed taken-over Codex live config"); + + service + .hot_switch_provider("codex", "b") + .await + .expect("hot switch Codex provider"); + + let backup = db + .get_live_backup("codex") + .await + .expect("get live backup") + .expect("backup exists"); + let stored: Value = + serde_json::from_str(&backup.original_config).expect("parse backup json"); + let backup_config = stored + .get("config") + .and_then(|v| v.as_str()) + .expect("backup config string"); + let parsed_backup: toml::Value = + toml::from_str(backup_config).expect("parse backup config"); + assert_eq!( + parsed_backup.get("model_provider").and_then(|v| v.as_str()), + Some("rightcode"), + "provider-derived restore backup should retain stable Codex model_provider" + ); + let backup_model_providers = parsed_backup + .get("model_providers") + .and_then(|v| v.as_table()) + .expect("backup model_providers"); + assert!(backup_model_providers.get("aihubmix").is_none()); + assert_eq!( + backup_model_providers + .get("rightcode") + .and_then(|v| v.get("base_url")) + .and_then(|v| v.as_str()), + Some("https://aihubmix.example/v1"), + "stable provider id should point at the hot-switched provider endpoint" + ); + + service + .restore_live_config_for_app_with_fallback(&AppType::Codex) + .await + .expect("restore Codex live config"); + + let live = service.read_codex_live().expect("read Codex live config"); + let live_config = live + .get("config") + .and_then(|v| v.as_str()) + .expect("live config string"); + let parsed_live: toml::Value = toml::from_str(live_config).expect("parse live config"); + assert_eq!( + parsed_live.get("model_provider").and_then(|v| v.as_str()), + Some("rightcode"), + "restored Codex live config should not switch history buckets" + ); + assert_eq!( + live.get("auth") + .and_then(|auth| auth.get("OPENAI_API_KEY")) + .and_then(|v| v.as_str()), + Some("aihubmix-key"), + "restore should still use the hot-switched provider auth" + ); + } + #[tokio::test] #[serial] async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() { diff --git a/src-tauri/tests/import_export_sync.rs b/src-tauri/tests/import_export_sync.rs index 2be08faff..7fb9e5aaf 100644 --- a/src-tauri/tests/import_export_sync.rs +++ b/src-tauri/tests/import_export_sync.rs @@ -139,6 +139,93 @@ fn sync_codex_provider_writes_auth_and_config() { assert_eq!(synced_cfg, toml_text); } +#[test] +fn sync_codex_provider_preserves_live_model_provider_id_for_history() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + + let legacy_auth = json!({ "OPENAI_API_KEY": "rightcode-key" }); + let legacy_config = r#"model_provider = "rightcode" +model = "gpt-5.4" + +[model_providers.rightcode] +name = "RightCode" +base_url = "https://rightcode.example/v1" +wire_api = "responses" +requires_openai_auth = true +"#; + cc_switch_lib::write_codex_live_atomic(&legacy_auth, Some(legacy_config)) + .expect("seed existing Codex live config"); + + let mut config = MultiAppConfig::default(); + let provider_config = json!({ + "auth": { + "OPENAI_API_KEY": "fresh-key" + }, + "config": r#"model_provider = "aihubmix" +model = "gpt-5.4" + +[model_providers.aihubmix] +name = "AiHubMix" +base_url = "https://aihubmix.example/v1" +wire_api = "responses" +requires_openai_auth = true +"# + }); + + let provider = Provider::with_id( + "codex-1".to_string(), + "Codex Test".to_string(), + provider_config, + None, + ); + + let manager = config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.providers.insert("codex-1".to_string(), provider); + manager.current = "codex-1".to_string(); + + ConfigService::sync_current_providers_to_live(&mut config).expect("sync codex live"); + + let toml_text = + fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml"); + let parsed: toml::Value = toml::from_str(&toml_text).expect("parse config.toml"); + + assert_eq!( + parsed.get("model_provider").and_then(|v| v.as_str()), + Some("rightcode"), + "legacy ConfigService sync should use the stable live provider id" + ); + + let model_providers = parsed + .get("model_providers") + .and_then(|v| v.as_table()) + .expect("model_providers should exist"); + assert!( + model_providers.get("aihubmix").is_none(), + "provider-specific target id should not be written to live config" + ); + assert_eq!( + model_providers + .get("rightcode") + .and_then(|v| v.get("base_url")) + .and_then(|v| v.as_str()), + Some("https://aihubmix.example/v1") + ); + + let synced_cfg = config + .get_manager(&AppType::Codex) + .and_then(|manager| manager.providers.get("codex-1")) + .and_then(|provider| provider.settings_config.get("config")) + .and_then(|v| v.as_str()) + .expect("synced config string"); + assert!( + synced_cfg.contains("[model_providers.rightcode]"), + "ConfigService keeps its existing behavior of syncing provider config from live" + ); +} + #[test] fn sync_enabled_to_codex_writes_enabled_servers() { let _guard = test_mutex().lock().expect("acquire test mutex"); diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index d9679f921..bb963be6d 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -239,6 +239,241 @@ command = "say" ); } +#[test] +fn provider_service_switch_codex_preserves_live_model_provider_id_for_history() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let legacy_auth = json!({ "OPENAI_API_KEY": "rightcode-key" }); + let legacy_config = r#"model_provider = "rightcode" +model = "gpt-5.4" + +[model_providers.rightcode] +name = "RightCode" +base_url = "https://rightcode.example/v1" +wire_api = "responses" +requires_openai_auth = true +"#; + write_codex_live_atomic(&legacy_auth, Some(legacy_config)) + .expect("seed existing codex live config"); + + let mut initial_config = MultiAppConfig::default(); + { + let manager = initial_config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.current = "old-provider".to_string(); + manager.providers.insert( + "old-provider".to_string(), + Provider::with_id( + "old-provider".to_string(), + "RightCode".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "stale"}, + "config": legacy_config + }), + None, + ), + ); + manager.providers.insert( + "new-provider".to_string(), + Provider::with_id( + "new-provider".to_string(), + "AiHubMix".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "fresh-key"}, + "config": r#"model_provider = "aihubmix" +model = "gpt-5.4" + +[model_providers.aihubmix] +name = "AiHubMix" +base_url = "https://aihubmix.example/v1" +wire_api = "responses" +requires_openai_auth = true +"# + }), + None, + ), + ); + } + + let state = create_test_state_with_config(&initial_config).expect("create test state"); + + ProviderService::switch(&state, AppType::Codex, "new-provider") + .expect("switch provider should succeed"); + + let config_text = + std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml"); + let parsed: toml::Value = toml::from_str(&config_text).expect("parse config.toml"); + + assert_eq!( + parsed.get("model_provider").and_then(|v| v.as_str()), + Some("rightcode"), + "live Codex model_provider should stay stable so resume history remains visible" + ); + + let model_providers = parsed + .get("model_providers") + .and_then(|v| v.as_table()) + .expect("model_providers table exists"); + assert!( + model_providers.get("aihubmix").is_none(), + "target provider-specific id should be rewritten in live config" + ); + assert_eq!( + model_providers + .get("rightcode") + .and_then(|v| v.get("base_url")) + .and_then(|v| v.as_str()), + Some("https://aihubmix.example/v1"), + "stable provider id should point at the newly selected supplier endpoint" + ); + + let providers = state + .db + .get_all_providers(AppType::Codex.as_str()) + .expect("read providers after switch"); + let new_config_text = providers + .get("new-provider") + .expect("new provider exists") + .settings_config + .get("config") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + assert!( + new_config_text.contains("[model_providers.aihubmix]"), + "stored provider template should remain provider-specific" + ); +} + +#[test] +fn provider_service_switch_codex_backfill_keeps_provider_specific_model_provider_id() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let legacy_auth = json!({ "OPENAI_API_KEY": "rightcode-key" }); + let provider_a_config = r#"model_provider = "rightcode" +model = "gpt-5.4" + +[model_providers.rightcode] +name = "RightCode" +base_url = "https://rightcode.example/v1" +wire_api = "responses" +requires_openai_auth = true +"#; + write_codex_live_atomic(&legacy_auth, Some(provider_a_config)) + .expect("seed existing codex live config"); + + let mut initial_config = MultiAppConfig::default(); + { + let manager = initial_config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.current = "provider-a".to_string(); + manager.providers.insert( + "provider-a".to_string(), + Provider::with_id( + "provider-a".to_string(), + "RightCode".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "rightcode-key"}, + "config": provider_a_config + }), + None, + ), + ); + manager.providers.insert( + "provider-b".to_string(), + Provider::with_id( + "provider-b".to_string(), + "AiHubMix".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "aihubmix-key"}, + "config": r#"model_provider = "aihubmix" +model = "gpt-5.4" +profile = "work" + +[model_providers.aihubmix] +name = "AiHubMix" +base_url = "https://aihubmix.example/v1" +wire_api = "responses" +requires_openai_auth = true + +[profiles.work] +model_provider = "aihubmix" +model = "gpt-5.4" +"# + }), + None, + ), + ); + manager.providers.insert( + "provider-c".to_string(), + Provider::with_id( + "provider-c".to_string(), + "Vendor C".to_string(), + json!({ + "auth": {"OPENAI_API_KEY": "vendor-c-key"}, + "config": r#"model_provider = "vendor_c" +model = "gpt-5.4" + +[model_providers.vendor_c] +name = "Vendor C" +base_url = "https://vendor-c.example/v1" +wire_api = "responses" +requires_openai_auth = true +"# + }), + None, + ), + ); + } + + let state = create_test_state_with_config(&initial_config).expect("create test state"); + + ProviderService::switch(&state, AppType::Codex, "provider-b") + .expect("switch to provider b should succeed"); + ProviderService::switch(&state, AppType::Codex, "provider-c") + .expect("switch to provider c should succeed"); + + let providers = state + .db + .get_all_providers(AppType::Codex.as_str()) + .expect("read providers after switches"); + let provider_b_config = providers + .get("provider-b") + .expect("provider b exists") + .settings_config + .get("config") + .and_then(|v| v.as_str()) + .expect("provider b config"); + let parsed: toml::Value = toml::from_str(provider_b_config).expect("parse provider b config"); + + assert_eq!( + parsed.get("model_provider").and_then(|v| v.as_str()), + Some("aihubmix"), + "backfill should restore provider b's storage-specific model_provider id" + ); + assert!( + parsed + .get("model_providers") + .and_then(|v| v.get("aihubmix")) + .is_some(), + "provider b should keep its own model_providers table after backfill" + ); + assert_eq!( + parsed + .get("profiles") + .and_then(|v| v.get("work")) + .and_then(|v| v.get("model_provider")) + .and_then(|v| v.as_str()), + Some("aihubmix"), + "profile overrides should be restored to provider b's storage-specific id" + ); +} + #[test] fn sync_current_provider_for_app_keeps_live_takeover_and_updates_restore_backup() { let _guard = test_mutex().lock().expect("acquire test mutex"); From 608ee35ecda84223dc8fa96a4dcad865e9def4f9 Mon Sep 17 00:00:00 2001 From: tison Date: Thu, 30 Apr 2026 10:33:50 +0800 Subject: [PATCH 098/156] feat: support launch warp and execute session (#2466) * feat: support launch warp and execute session Signed-off-by: tison * other wires Signed-off-by: tison * for launch with provider Signed-off-by: tison * fixup indirection Signed-off-by: tison * clippy Signed-off-by: tison * address comments Signed-off-by: tison --------- Signed-off-by: tison --- src-tauri/src/commands/misc.rs | 53 +++++++++++++++++++ src-tauri/src/session_manager/terminal/mod.rs | 44 +++++++++++++++ src/components/settings/TerminalSettings.tsx | 1 + src/i18n/locales/en.json | 3 +- src/i18n/locales/ja.json | 3 +- src/i18n/locales/zh.json | 3 +- 6 files changed, 104 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 8818ac26b..61b0b47d6 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -947,6 +947,7 @@ exec bash --norc --noprofile // Note: Kitty doesn't need the -e flag, others do let result = match terminal { "iterm2" => launch_macos_iterm2(&script_file), + "warp" => launch_macos_warp(&script_file), "alacritty" => launch_macos_open_app("Alacritty", &script_file, true), "kitty" => launch_macos_open_app("kitty", &script_file, false), "ghostty" => launch_macos_open_app("Ghostty", &script_file, true), @@ -1069,6 +1070,57 @@ fn launch_macos_open_app( Ok(()) } +#[cfg(target_os = "macos")] +fn launch_macos_warp(script_file: &std::path::Path) -> Result<(), String> { + use std::io::Write; + use std::os::unix::fs::PermissionsExt; + use std::process::Command; + + let mut cmd = Command::new("open"); + cmd.arg("-a").arg("Warp"); + + // Warp URI scheme cannot work well with script_file, because: + // + // 1. script_file's name ends up with .sh, so Warp would open the file rather than execute it + // 2. script_file has no execution permission, so we need to add one more indirection + let mut second_script_file = tempfile::Builder::new() + .disable_cleanup(true) + .permissions(std::fs::Permissions::from_mode(0o755)) + .tempfile() + .map_err(|e| format!("Failed to create temporary script file: {e}"))?; + + writeln!( + &mut second_script_file, + r#"#!/usr/bin/env sh + + rm -- "$0" + + exec bash {} + "#, + script_file.display(), + ) + .map_err(|e| format!("Failed to write to temporary script file for Warp: {e}"))?; + + let mut warp_url = url::Url::parse("warp://action/new_tab").unwrap(); + warp_url + .query_pairs_mut() + .append_pair("path", &second_script_file.path().to_string_lossy()); + let warp_url = warp_url.to_string(); + cmd.arg(warp_url); + + let output = cmd.output().map_err(|e| format!("启动 Warp 失败: {e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "Warp 启动失败 (exit code: {:?}): {}", + output.status.code(), + stderr + )); + } + + Ok(()) +} + /// Linux: 根据用户首选终端启动 #[cfg(target_os = "linux")] fn launch_linux_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> { @@ -1356,6 +1408,7 @@ read -n 1 -s let result = match terminal { "iterm2" => launch_macos_iterm2(&script_file), + "warp" => launch_macos_warp(&script_file), "alacritty" => launch_macos_open_app("Alacritty", &script_file, true), "kitty" => launch_macos_open_app("kitty", &script_file, false), "ghostty" => launch_macos_open_app("Ghostty", &script_file, true), diff --git a/src-tauri/src/session_manager/terminal/mod.rs b/src-tauri/src/session_manager/terminal/mod.rs index 085a86f0d..420eff9ba 100644 --- a/src-tauri/src/session_manager/terminal/mod.rs +++ b/src-tauri/src/session_manager/terminal/mod.rs @@ -22,6 +22,8 @@ pub fn launch_terminal( "wezterm" => launch_wezterm(command, cwd), "kaku" => launch_kaku(command, cwd), "alacritty" => launch_alacritty(command, cwd), + #[cfg(unix)] + "warp" => launch_warp(command, cwd), "custom" => launch_custom(command, cwd, custom_config), _ => Err(format!("Unsupported terminal target: {target}")), } @@ -201,6 +203,48 @@ fn build_wezterm_compatible_args_with_shell( args } +#[cfg(unix)] +fn launch_warp(command: &str, cwd: Option<&str>) -> Result<(), String> { + use std::io::Write; + use std::os::unix::fs::PermissionsExt; + + let cwd = cwd.ok_or("Failed to resume session without cwd")?; + + let mut script_file = tempfile::Builder::new() + .disable_cleanup(true) + .permissions(std::fs::Permissions::from_mode(0o755)) + .tempfile_in(cwd) + .map_err(|e| format!("Failed to create temporary script file for launching Warp: {e}"))?; + + writeln!( + &mut script_file, + r#"#!/usr/bin/env sh + + rm -- "$0" + + exec {command} + "#, + ) + .map_err(|e| format!("Failed to write to temporary script file for Warp: {e}"))?; + + let mut warp_url = url::Url::parse("warp://action/new_tab").unwrap(); + warp_url + .query_pairs_mut() + .append_pair("path", &script_file.path().to_string_lossy()); + let warp_url = warp_url.to_string(); + + let status = Command::new("open") + .args(["-a", "Warp", &warp_url]) + .status() + .map_err(|e| format!("Failed to launch Warp: {e}"))?; + + if status.success() { + Ok(()) + } else { + Err("Failed to launch Warp.".to_string()) + } +} + fn launch_alacritty(command: &str, cwd: Option<&str>) -> Result<(), String> { // Alacritty: open -na Alacritty --args --working-directory ... -e shell -c command let full_command = build_shell_command(command, None); diff --git a/src/components/settings/TerminalSettings.tsx b/src/components/settings/TerminalSettings.tsx index 4cf43d418..916b5fbea 100644 --- a/src/components/settings/TerminalSettings.tsx +++ b/src/components/settings/TerminalSettings.tsx @@ -17,6 +17,7 @@ const MACOS_TERMINALS = [ { value: "ghostty", labelKey: "settings.terminal.options.macos.ghostty" }, { value: "wezterm", labelKey: "settings.terminal.options.macos.wezterm" }, { value: "kaku", labelKey: "settings.terminal.options.macos.kaku" }, + { value: "warp", labelKey: "settings.terminal.options.macos.warp" }, ] as const; const WINDOWS_TERMINALS = [ diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 26e4b09ed..42939438e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -549,7 +549,8 @@ "kitty": "Kitty", "ghostty": "Ghostty", "wezterm": "WezTerm", - "kaku": "Kaku" + "kaku": "Kaku", + "warp": "Warp" }, "windows": { "cmd": "Command Prompt", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index a351f0d16..5c5a110c7 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -549,7 +549,8 @@ "kitty": "Kitty", "ghostty": "Ghostty", "wezterm": "WezTerm", - "kaku": "Kaku" + "kaku": "Kaku", + "warp": "Warp" }, "windows": { "cmd": "コマンドプロンプト", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 19e1c21cd..5add807a3 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -549,7 +549,8 @@ "kitty": "Kitty", "ghostty": "Ghostty", "wezterm": "WezTerm", - "kaku": "Kaku" + "kaku": "Kaku", + "warp": "Warp" }, "windows": { "cmd": "命令提示符", From 151af0ffa36d9e6bd24bb837d17cb8d656420044 Mon Sep 17 00:00:00 2001 From: tison Date: Thu, 30 Apr 2026 11:07:06 +0800 Subject: [PATCH 099/156] Fix log message for session usage codex (#2473) * Fix log message for session usage codex * Fix comments in session_usage_codex.rs --- src-tauri/src/services/session_usage_codex.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/services/session_usage_codex.rs b/src-tauri/src/services/session_usage_codex.rs index ac4f81c09..ee7d1bcff 100644 --- a/src-tauri/src/services/session_usage_codex.rs +++ b/src-tauri/src/services/session_usage_codex.rs @@ -172,7 +172,7 @@ pub fn sync_codex_usage(db: &Database) -> Result { if result.imported > 0 { log::info!( - "[CODEX-SYNC] 同步完成: 导入 {} 条, 跳过 {} 条, ��描 {} 个文件", + "[CODEX-SYNC] 同步完成: 导入 {} 条, 跳过 {} 条, 扫描 {} 个文件", result.imported, result.skipped, result.files_scanned @@ -186,7 +186,7 @@ pub fn sync_codex_usage(db: &Database) -> Result { fn collect_codex_session_files(codex_dir: &Path) -> Vec { let mut files = Vec::new(); - // 1. 扫描 sessions/YYYY/MM/DD/*.jsonl(日期分区目录�� + // 1. 扫描 sessions/YYYY/MM/DD/*.jsonl(日期分区目录) let sessions_dir = codex_dir.join("sessions"); if sessions_dir.is_dir() { collect_jsonl_recursive(&sessions_dir, &mut files, 0, 3); @@ -225,13 +225,13 @@ fn collect_jsonl_recursive(dir: &Path, files: &mut Vec, depth: u32, max } } -/// 同步单�� Codex JSONL 文件,返回 (imported, skipped) +/// 同步单个 Codex JSONL 文件,返回 (imported, skipped) fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppError> { let file_path_str = file_path.to_string_lossy().to_string(); // 获取文件元数据 let metadata = fs::metadata(file_path) - .map_err(|e| AppError::Config(format!("无法读取文���元数据: {e}")))?; + .map_err(|e| AppError::Config(format!("无法读取文件元数据: {e}")))?; let file_modified = metadata .modified() .ok() @@ -334,7 +334,7 @@ fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32), let info = match payload.get("info") { Some(i) if !i.is_null() => i, - _ => continue, // info 为 null 的首个事件跳�� + _ => continue, // 跳过 info 为 null 的首个事件 }; // 提取模型(token_count 事件也可能携带 model) @@ -538,7 +538,7 @@ fn insert_codex_session_entry( Ok(true) } -/// ��找 Codex 模型定价(带归一化) +/// 查找 Codex 模型定价(带归一化) fn find_codex_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option { let normalized = normalize_codex_model(model_id); From 8084bfafa846fd636afe0e1c3837ad80cd505de3 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 30 Apr 2026 13:53:32 +1000 Subject: [PATCH 100/156] fix(config): sort JSON keys alphabetically for deterministic output (#2469) * fix(config): sort JSON keys alphabetically for deterministic output Ensures settings.json keys are written in sorted order, preventing non-deterministic git diffs when switching configs. * test(config): add unit tests for sort_json_keys and fix formatting Cover top-level sort, nested recursion, array order preservation, primitive pass-through, empty collections, and the core determinism guarantee (different insertion orders must yield identical output). Also fix line-length in write_json_file flagged by `cargo fmt --check`. --------- Co-authored-by: Jason --- src-tauri/src/config.rs | 123 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 120 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 11a3c0758..0ad7a0d4b 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; @@ -159,15 +160,34 @@ pub fn read_json_file Deserialize<'a>>(path: &Path) -> Result Value { + match value { + Value::Object(map) => { + let mut sorted_map = Map::new(); + let mut keys: Vec<_> = map.keys().collect(); + keys.sort(); + for key in keys { + sorted_map.insert(key.clone(), sort_json_keys(&map[key])); + } + Value::Object(sorted_map) + } + Value::Array(arr) => Value::Array(arr.iter().map(sort_json_keys).collect()), + other => other.clone(), + } +} + +/// 写入 JSON 配置文件(键按字母排序,确保确定性输出) pub fn write_json_file(path: &Path, data: &T) -> Result<(), AppError> { // 确保目录存在 if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; } - let json = - serde_json::to_string_pretty(data).map_err(|e| AppError::JsonSerialize { source: e })?; + let value = serde_json::to_value(data).map_err(|e| AppError::JsonSerialize { source: e })?; + let sorted_value = sort_json_keys(&value); + let json = serde_json::to_string_pretty(&sorted_value) + .map_err(|e| AppError::JsonSerialize { source: e })?; atomic_write(path, json.as_bytes()) } @@ -271,6 +291,103 @@ mod tests { let override_dir = PathBuf::from("/"); assert!(derive_mcp_path_from_override(&override_dir).is_none()); } + + #[test] + fn sort_json_keys_sorts_top_level_object() { + let input = serde_json::json!({ + "z": 1, + "a": 2, + "m": 3, + }); + let sorted = sort_json_keys(&input); + let serialized = serde_json::to_string(&sorted).unwrap(); + assert_eq!(serialized, r#"{"a":2,"m":3,"z":1}"#); + } + + #[test] + fn sort_json_keys_recurses_into_nested_objects() { + let input = serde_json::json!({ + "outer_b": {"z": 1, "a": 2}, + "outer_a": {"y": 3, "b": 4}, + }); + let sorted = sort_json_keys(&input); + let serialized = serde_json::to_string(&sorted).unwrap(); + assert_eq!( + serialized, + r#"{"outer_a":{"b":4,"y":3},"outer_b":{"a":2,"z":1}}"# + ); + } + + #[test] + fn sort_json_keys_preserves_array_order() { + let input = serde_json::json!([3, 1, 2]); + let sorted = sort_json_keys(&input); + let serialized = serde_json::to_string(&sorted).unwrap(); + assert_eq!(serialized, "[3,1,2]"); + } + + #[test] + fn sort_json_keys_sorts_objects_inside_arrays_but_keeps_array_order() { + let input = serde_json::json!([ + {"z": 1, "a": 2}, + {"y": 3, "b": 4}, + ]); + let sorted = sort_json_keys(&input); + let serialized = serde_json::to_string(&sorted).unwrap(); + assert_eq!(serialized, r#"[{"a":2,"z":1},{"b":4,"y":3}]"#); + } + + #[test] + fn sort_json_keys_passes_through_primitives() { + let cases = vec![ + serde_json::json!("hello"), + serde_json::json!(42), + serde_json::json!(3.14), + serde_json::json!(true), + serde_json::json!(null), + ]; + for value in cases { + let sorted = sort_json_keys(&value); + assert_eq!(sorted, value); + } + } + + #[test] + fn sort_json_keys_handles_empty_collections() { + let empty_obj = serde_json::json!({}); + assert_eq!( + serde_json::to_string(&sort_json_keys(&empty_obj)).unwrap(), + "{}" + ); + + let empty_arr = serde_json::json!([]); + assert_eq!( + serde_json::to_string(&sort_json_keys(&empty_arr)).unwrap(), + "[]" + ); + } + + #[test] + fn sort_json_keys_produces_identical_output_for_different_insertion_orders() { + // 核心保证:同一逻辑配置无论键的插入顺序如何,写出的字节序列必须一致。 + let mut a = Map::new(); + a.insert("env".to_string(), serde_json::json!({"PATH": "/usr/bin"})); + a.insert("model".to_string(), serde_json::json!("claude-sonnet-4-5")); + a.insert("permissions".to_string(), serde_json::json!({"allow": []})); + + let mut b = Map::new(); + b.insert("permissions".to_string(), serde_json::json!({"allow": []})); + b.insert("model".to_string(), serde_json::json!("claude-sonnet-4-5")); + b.insert("env".to_string(), serde_json::json!({"PATH": "/usr/bin"})); + + let sorted_a = sort_json_keys(&Value::Object(a)); + let sorted_b = sort_json_keys(&Value::Object(b)); + + assert_eq!( + serde_json::to_string(&sorted_a).unwrap(), + serde_json::to_string(&sorted_b).unwrap(), + ); + } } /// 复制文件 From 7c8720bd3f37d3e80f0c434f77af78c3895d6d5d Mon Sep 17 00:00:00 2001 From: Hulk Date: Thu, 30 Apr 2026 12:03:46 +0800 Subject: [PATCH 101/156] fix(terminal): prevent iTerm fallback on cold launch (#2448) --- src-tauri/src/commands/misc.rs | 86 +++++++++++++++++++++++++++++----- 1 file changed, 74 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 61b0b47d6..40a27c80c 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -1002,21 +1002,46 @@ end tell"#, /// macOS: iTerm2 #[cfg(target_os = "macos")] -fn launch_macos_iterm2(script_file: &std::path::Path) -> Result<(), String> { - use std::process::Command; - - let applescript = format!( - r#"tell application "iTerm" - activate - tell current window - create tab with default profile - tell current session - write text "bash '{}'" - end tell +fn build_macos_iterm2_applescript(script_file: &std::path::Path) -> String { + format!( + r#"set launcher_script to "bash '{}'" +set was_running to application "iTerm" is running +tell application "iTerm" + if was_running then + activate + if (count of windows) = 0 then + create window with default profile + else + tell current window + create tab with default profile + end tell + end if + else + activate + set waited to 0 + repeat while (count of windows) = 0 + delay 0.1 + set waited to waited + 1 + if waited >= 30 then exit repeat + end repeat + if (count of windows) = 0 then + create window with default profile + end if + end if + tell current session of current window + write text launcher_script end tell end tell"#, script_file.display() - ); + ) +} + +/// macOS: iTerm2 +#[cfg(target_os = "macos")] +fn launch_macos_iterm2(script_file: &std::path::Path) -> Result<(), String> { + use std::process::Command; + + let applescript = build_macos_iterm2_applescript(script_file); let output = Command::new("osascript") .arg("-e") @@ -1727,6 +1752,43 @@ mod tests { assert_eq!(command, "cd '/tmp/project O'\"'\"'Brien' || exit 1\n"); } + #[cfg(target_os = "macos")] + #[test] + fn iterm2_applescript_cold_start_avoids_current_window_before_one_exists() { + let script = build_macos_iterm2_applescript(Path::new("/tmp/cc_switch_launcher.sh")); + + let cold_start_branch = script + .split("else\n activate") + .nth(1) + .expect("cold start branch should be present") + .split(" end if\n tell current session") + .next() + .expect("cold start branch should end before writing command"); + + assert!(cold_start_branch.contains("repeat while (count of windows) = 0")); + assert!(cold_start_branch.contains("create window with default profile")); + assert!(!cold_start_branch.contains("tell current window")); + assert!(!cold_start_branch.contains("create tab with default profile")); + } + + #[cfg(target_os = "macos")] + #[test] + fn iterm2_applescript_keeps_new_tab_behavior_for_existing_windows() { + let script = build_macos_iterm2_applescript(Path::new("/tmp/cc_switch_launcher.sh")); + + let running_branch = script + .split("if was_running then") + .nth(1) + .expect("already-running branch should be present") + .split("else\n activate") + .next() + .expect("already-running branch should end before cold start branch"); + + assert!(running_branch.contains("if (count of windows) = 0 then")); + assert!(running_branch.contains("create window with default profile")); + assert!(running_branch.contains("create tab with default profile")); + } + #[test] fn build_windows_cwd_command_str_uses_cd_for_drive_paths() { let command = build_windows_cwd_command_str(r"C:\work\repo"); From 15497b0e4112b2c2f2c4e2d9dc63d5cf1f63b787 Mon Sep 17 00:00:00 2001 From: Cao Xin <1584164717@qq.com> Date: Thu, 30 Apr 2026 12:22:21 +0800 Subject: [PATCH 102/156] fix(session): hide Codex subagent sessions (#2445) --- .../src/session_manager/providers/codex.rs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src-tauri/src/session_manager/providers/codex.rs b/src-tauri/src/session_manager/providers/codex.rs index 5d3c7d364..5cab9bf57 100644 --- a/src-tauri/src/session_manager/providers/codex.rs +++ b/src-tauri/src/session_manager/providers/codex.rs @@ -143,6 +143,9 @@ fn parse_session(path: &Path) -> Option { } if value.get("type").and_then(Value::as_str) == Some("session_meta") { if let Some(payload) = value.get("payload") { + if is_subagent_source(payload.get("source")) { + return None; + } if session_id.is_none() { session_id = payload .get("id") @@ -239,6 +242,13 @@ fn parse_session(path: &Path) -> Option { }) } +fn is_subagent_source(source: Option<&Value>) -> bool { + source + .and_then(|value| value.as_object()) + .map(|source| source.contains_key("subagent")) + .unwrap_or(false) +} + fn infer_session_id_from_filename(path: &Path) -> Option { let file_name = path.file_name()?.to_string_lossy(); UUID_RE.find(&file_name).map(|mat| mat.as_str().to_string()) @@ -328,6 +338,22 @@ mod tests { assert_eq!(meta.title.as_deref(), Some("Fix the login bug")); } + #[test] + fn parse_session_skips_subagent_sessions() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session.jsonl"); + std::fs::write( + &path, + concat!( + "{\"timestamp\":\"2026-04-28T10:00:00Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"subagent-id\",\"cwd\":\"/tmp/project\",\"originator\":\"codex-tui\",\"source\":{\"subagent\":{\"thread_spawn\":{\"parent_thread_id\":\"parent-id\",\"depth\":1,\"agent_role\":\"explorer\"}}}}}\n", + "{\"timestamp\":\"2026-04-28T10:00:01Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"Inspect the project\"}}\n" + ), + ) + .expect("write"); + + assert!(parse_session(&path).is_none()); + } + #[test] fn parse_session_falls_back_to_dir_basename() { let temp = tempdir().expect("tempdir"); From 1c692694052f3ac45b8b3a85f3f4054a92bab674 Mon Sep 17 00:00:00 2001 From: eclipsehx <1240204622@qq.com> Date: Thu, 30 Apr 2026 12:45:42 +0800 Subject: [PATCH 103/156] fix(codex): skip environment_context injection when extracting session title (#2439) Co-authored-by: xiaohan1 --- .../src/session_manager/providers/codex.rs | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/session_manager/providers/codex.rs b/src-tauri/src/session_manager/providers/codex.rs index 5cab9bf57..05775ebdc 100644 --- a/src-tauri/src/session_manager/providers/codex.rs +++ b/src-tauri/src/session_manager/providers/codex.rs @@ -173,7 +173,10 @@ fn parse_session(path: &Path) -> Option { { let text = payload.get("content").map(extract_text).unwrap_or_default(); let trimmed = text.trim(); - if !trimmed.is_empty() && !trimmed.starts_with("# AGENTS.md") { + if !trimmed.is_empty() + && !trimmed.starts_with("# AGENTS.md") + && !trimmed.starts_with("") + { first_user_message = Some(trimmed.to_string()); } } @@ -354,6 +357,25 @@ mod tests { assert!(parse_session(&path).is_none()); } + #[test] + fn parse_session_skips_environment_context_injection() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session.jsonl"); + std::fs::write( + &path, + concat!( + "{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n", + "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"\\n /tmp/project\\n\"}}\n", + "{\"timestamp\":\"2026-03-06T21:50:14Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"Fix the login bug\"}}\n" + ), + ) + .expect("write"); + + let meta = parse_session(&path).unwrap(); + // Should skip environment_context injection and use the real user message + assert_eq!(meta.title.as_deref(), Some("Fix the login bug")); + } + #[test] fn parse_session_falls_back_to_dir_basename() { let temp = tempdir().expect("tempdir"); From 7965862e668458eb9401a6fe16045a7e0bf20f78 Mon Sep 17 00:00:00 2001 From: ayxwi <274959179+xwil1@users.noreply.github.com> Date: Thu, 30 Apr 2026 21:59:47 +0800 Subject: [PATCH 104/156] Make import existing side-effect free (#2429) Co-authored-by: Xvvln <3369759202@qq.com> --- src-tauri/src/services/mcp.rs | 20 +++--- src-tauri/src/services/skill.rs | 13 ---- src-tauri/tests/mcp_commands.rs | 104 ++++++++++++++++++++++++++++++++ src-tauri/tests/skill_sync.rs | 47 +++++++++++++++ 4 files changed, 161 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index a0026dd87..78f92c029 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -268,8 +268,8 @@ impl McpService { 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)?; + // 导入是读取已有配置,不应反向写回任何应用的 live 配置。 + // 显式编辑、启用/禁用或手动同步时再执行写回。 } } } @@ -306,8 +306,8 @@ impl McpService { 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)?; + // 导入是读取已有配置,不应反向写回任何应用的 live 配置。 + // 显式编辑、启用/禁用或手动同步时再执行写回。 } } } @@ -344,8 +344,8 @@ impl McpService { 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)?; + // 导入是读取已有配置,不应反向写回任何应用的 live 配置。 + // 显式编辑、启用/禁用或手动同步时再执行写回。 } } } @@ -382,8 +382,8 @@ impl McpService { 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)?; + // 导入是读取已有配置,不应反向写回任何应用的 live 配置。 + // 显式编辑、启用/禁用或手动同步时再执行写回。 } } } @@ -420,8 +420,8 @@ impl McpService { 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)?; + // 导入是读取已有配置,不应反向写回任何应用的 live 配置。 + // 显式编辑、启用/禁用或手动同步时再执行写回。 } } } diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 06d159677..7c2515ebf 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -1536,19 +1536,6 @@ impl SkillService { // 保存到数据库 db.save_skill(&skill)?; - // 同步到已启用的应用目录(创建 symlink 或复制文件) - for app in AppType::all() { - if skill.apps.is_enabled_for(&app) { - if let Err(e) = Self::sync_to_app_dir(&skill.directory, &app) { - log::warn!( - "导入后同步 Skill '{}' 到 {:?} 失败: {e:#}", - skill.directory, - app - ); - } - } - } - imported.push(skill); } diff --git a/src-tauri/tests/mcp_commands.rs b/src-tauri/tests/mcp_commands.rs index 5cd31f615..242b6e835 100644 --- a/src-tauri/tests/mcp_commands.rs +++ b/src-tauri/tests/mcp_commands.rs @@ -150,6 +150,110 @@ fn import_mcp_from_claude_creates_config_and_enables_servers() { ); } +#[test] +fn import_mcp_from_codex_does_not_rewrite_codex_config() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let codex_dir = home.join(".codex"); + fs::create_dir_all(&codex_dir).expect("create codex dir"); + let config_path = codex_dir.join("config.toml"); + let original = r#"# keep user formatting intact +model = "gpt-5" + +[mcp.servers.legacy] +type = "stdio" +command = "echo" + +[mcp_servers.echo] +type = "stdio" +command = "echo" +"#; + fs::write(&config_path, original).expect("seed codex config"); + + let state = create_test_state().expect("create test state"); + let changed = McpService::import_from_codex(&state).expect("import from codex"); + assert!(changed > 0, "should import servers from Codex config"); + + let after = fs::read_to_string(&config_path).expect("read codex config"); + assert_eq!( + after, original, + "importing from Codex should not rewrite ~/.codex/config.toml" + ); +} + +#[test] +fn import_mcp_from_claude_does_not_sync_existing_codex_enabled_server() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let codex_dir = home.join(".codex"); + fs::create_dir_all(&codex_dir).expect("create codex dir"); + let codex_config_path = codex_dir.join("config.toml"); + let codex_original = r#"[mcp.servers.keep_me] +type = "stdio" +command = "echo" +"#; + fs::write(&codex_config_path, codex_original).expect("seed codex config"); + + let claude_json = json!({ + "mcpServers": { + "shared": { + "type": "stdio", + "command": "echo" + } + } + }); + fs::write( + get_claude_mcp_path(), + serde_json::to_string_pretty(&claude_json).expect("serialize claude mcp"), + ) + .expect("seed claude mcp"); + + let state = create_test_state().expect("create test state"); + state + .db + .save_mcp_server(&McpServer { + id: "shared".to_string(), + name: "shared".to_string(), + server: json!({ + "type": "stdio", + "command": "echo" + }), + apps: McpApps { + claude: false, + codex: true, + gemini: false, + opencode: false, + hermes: false, + }, + description: None, + homepage: None, + docs: None, + tags: Vec::new(), + }) + .expect("seed existing mcp server"); + + let changed = McpService::import_from_claude(&state).expect("import from claude"); + assert_eq!(changed, 0, "existing server should not count as new"); + + let after = fs::read_to_string(&codex_config_path).expect("read codex config"); + assert_eq!( + after, codex_original, + "importing from Claude should not sync an existing Codex-enabled server" + ); + + let servers = state.db.get_all_mcp_servers().expect("get all mcp servers"); + let shared = servers.get("shared").expect("shared server exists"); + assert!( + shared.apps.claude, + "import should enable Claude in database" + ); + assert!(shared.apps.codex, "existing Codex flag should be preserved"); +} + #[test] fn import_mcp_from_claude_invalid_json_preserves_state() { use support::create_test_state; diff --git a/src-tauri/tests/skill_sync.rs b/src-tauri/tests/skill_sync.rs index bf960bbe7..fcba98a7a 100644 --- a/src-tauri/tests/skill_sync.rs +++ b/src-tauri/tests/skill_sync.rs @@ -72,6 +72,53 @@ fn import_from_apps_respects_explicit_app_selection() { ); } +#[test] +fn import_from_apps_does_not_rewrite_selected_app_directory() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let ssot_skill_dir = home.join(".cc-switch").join("skills").join("codex-skill"); + write_skill(&ssot_skill_dir, "Stale SSOT Skill"); + fs::write(ssot_skill_dir.join("prompt.md"), "stale ssot").expect("write stale ssot prompt"); + + let codex_skill_dir = home.join(".codex").join("skills").join("codex-skill"); + write_skill(&codex_skill_dir, "Live Codex Skill"); + fs::write(codex_skill_dir.join("prompt.md"), "live codex").expect("write live codex prompt"); + + let state = create_test_state().expect("create test state"); + + let imported = SkillService::import_from_apps( + &state.db, + vec![ImportSkillSelection { + directory: "codex-skill".to_string(), + apps: SkillApps { + codex: true, + ..Default::default() + }, + }], + ) + .expect("import skills"); + + assert_eq!(imported.len(), 1, "expected exactly one imported skill"); + assert!( + imported[0].apps.codex, + "import should preserve the selected Codex app state" + ); + assert_eq!( + fs::read_to_string(codex_skill_dir.join("prompt.md")).expect("read live codex prompt"), + "live codex", + "import should not replace the app skill directory with SSOT contents" + ); + assert!( + !fs::symlink_metadata(&codex_skill_dir) + .expect("read codex skill metadata") + .file_type() + .is_symlink(), + "import should not replace the app skill directory with a managed symlink" + ); +} + #[test] fn sync_to_app_removes_disabled_and_orphaned_ssot_symlinks() { let _guard = test_mutex().lock().expect("acquire test mutex"); From 693c36a12a0106177b7b6ca6d00ab98d6c84e00f Mon Sep 17 00:00:00 2001 From: magucas Date: Thu, 30 Apr 2026 22:37:54 +0800 Subject: [PATCH 105/156] =?UTF-8?q?fix(dashscope):=20enhance=20usage=20par?= =?UTF-8?q?sing=20robustness=20to=20prevent=20VSCode=20cr=E2=80=A6=20(#242?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dashscope): enhance usage parsing robustness to prevent VSCode crashes Enhanced build_anthropic_usage_from_responses() to handle null, missing, empty, and partial usage fields gracefully. This prevents VSCode Extension crashes with "Cannot read properties of null (reading 'output_tokens')" when connecting to DashScope (Alibaba Cloud Bailian) models. Changes: - Added defensive null checks and empty object detection - Implemented OpenAI field name fallbacks (prompt_tokens/completion_tokens) - Added comprehensive logging for malformed usage scenarios - Fixed streaming SSE event handlers with null-safe usage access - Preserved cache token fields even when input/output tokens are missing This ensures the proxy never crashes on malformed Responses API usage objects, returning valid Anthropic-compatible usage structures (input_tokens/output_tokens) in all cases. * fix(proxy): tighten Responses API usage fix per review - Drop redundant fallback in streaming.rs Chat Completions path; the existing if-let-Some guard already prevents usage:null, so the extra layer was dead code and caused a fmt-breaking indentation issue. - Demote partial-usage warn to debug. Streaming chunks legitimately arrive with partial token counts and the warn-level log was noisy. - Rewrite CHANGELOG entry: reference #2422, broaden scope from DashScope-only to all api_format=openai_responses users (Codex OAuth is the strongest signal; DashScope compatible-mode/v1/responses is the original report). - cargo fmt to clear 12 formatting differences vs main. --------- Co-authored-by: Jason --- CHANGELOG.md | 4 + .../proxy/providers/streaming_responses.rs | 16 +- .../proxy/providers/transform_responses.rs | 170 +++++++++++++++++- 3 files changed, 180 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0cc2989c..795d7f5fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **OpenAI Responses API usage parsing robustness**: Hardened `build_anthropic_usage_from_responses()` and the Responses → Anthropic SSE translator so a missing or malformed upstream `usage` no longer produces `"usage": null` in `message_delta`. This unblocks strict Anthropic clients (notably the VSCode Claude Code extension) that crashed with "Cannot read properties of null (reading 'output_tokens')" against providers such as Codex OAuth and DashScope's `compatible-mode/v1/responses` endpoint. Added OpenAI field-name fallbacks (`prompt_tokens` / `completion_tokens`), null/empty/partial object handling, and preserved cache token fields even when input/output tokens are missing (#2422). + ## [3.14.1] - 2026-04-23 Development since v3.14.0 focuses on Codex OAuth stability, tray usage visibility, Skills import/install reliability, Gemini session restore paths, and simplifying Hermes configuration health handling. diff --git a/src-tauri/src/proxy/providers/streaming_responses.rs b/src-tauri/src/proxy/providers/streaming_responses.rs index 1eba5033e..c7f63a14e 100644 --- a/src-tauri/src/proxy/providers/streaming_responses.rs +++ b/src-tauri/src/proxy/providers/streaming_responses.rs @@ -170,9 +170,12 @@ pub fn create_anthropic_sse_stream_from_responses) -> Value { let u = match usage { - Some(v) if !v.is_null() => v, + Some(v) if !v.is_null() && v.is_object() => v, _ => { return json!({ "input_tokens": 0, @@ -232,15 +248,56 @@ pub(crate) fn build_anthropic_usage_from_responses(usage: Option<&Value>) -> Val } }; - let input = u.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0); - let output = u.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0); + // Detect empty object {} and log warning + if u.as_object().map(|obj| obj.is_empty()).unwrap_or(false) { + log::warn!("[Responses] Empty usage object received, using defaults"); + return json!({ + "input_tokens": 0, + "output_tokens": 0 + }); + } + + // Extract input_tokens with OpenAI field name fallback + // Priority: input_tokens (Anthropic) → prompt_tokens (OpenAI) → 0 + let input = u + .get("input_tokens") + .and_then(|v| v.as_u64()) + .or_else(|| { + let prompt_tokens = u.get("prompt_tokens").and_then(|v| v.as_u64()); + if prompt_tokens.is_some() { + log::debug!( + "[Responses] Using OpenAI field name fallback 'prompt_tokens' for input_tokens" + ); + } + prompt_tokens + }) + .unwrap_or(0); + + // Extract output_tokens with OpenAI field name fallback + // Priority: output_tokens (Anthropic) → completion_tokens (OpenAI) → 0 + let output = u.get("output_tokens") + .and_then(|v| v.as_u64()) + .or_else(|| { + let completion_tokens = u.get("completion_tokens").and_then(|v| v.as_u64()); + if completion_tokens.is_some() { + log::debug!("[Responses] Using OpenAI field name fallback 'completion_tokens' for output_tokens"); + } + completion_tokens + }) + .unwrap_or(0); + + // Log if only one field present (partial object). Streaming chunks legitimately + // arrive with partial usage, so this stays at debug level to avoid noise. + if (input == 0 && output > 0) || (input > 0 && output == 0) { + log::debug!("[Responses] Partial usage object: {:?}", u); + } let mut result = json!({ "input_tokens": input, "output_tokens": output }); - // Step 1: OpenAI nested details as fallback + // Step 1: OpenAI nested details as fallback for cache tokens // OpenAI Responses API: input_tokens_details.cached_tokens if let Some(cached) = u .pointer("/input_tokens_details/cached_tokens") @@ -259,6 +316,7 @@ pub(crate) fn build_anthropic_usage_from_responses(usage: Option<&Value>) -> Val } // Step 2: Direct Anthropic-style fields override (authoritative if present) + // These preserve cache tokens even if input/output_tokens are missing if let Some(v) = u.get("cache_read_input_tokens") { result["cache_read_input_tokens"] = v.clone(); } @@ -1352,4 +1410,106 @@ mod tests { "非 Codex OAuth 路径下 tools 在客户端未送时不应被注入" ); } + + // ==================== Usage Field Robustness Tests ==================== + + #[test] + fn test_build_usage_from_null_parameter() { + let result = build_anthropic_usage_from_responses(None); + assert_eq!(result["input_tokens"], json!(0)); + assert_eq!(result["output_tokens"], json!(0)); + } + + #[test] + fn test_build_usage_from_null_json_value() { + let result = build_anthropic_usage_from_responses(Some(&json!(null))); + assert_eq!(result["input_tokens"], json!(0)); + assert_eq!(result["output_tokens"], json!(0)); + } + + #[test] + fn test_build_usage_from_empty_object() { + let result = build_anthropic_usage_from_responses(Some(&json!({}))); + assert_eq!(result["input_tokens"], json!(0)); + assert_eq!(result["output_tokens"], json!(0)); + } + + #[test] + fn test_build_usage_from_partial_input_only() { + let result = build_anthropic_usage_from_responses(Some(&json!({ + "input_tokens": 100 + }))); + assert_eq!(result["input_tokens"], json!(100)); + assert_eq!(result["output_tokens"], json!(0)); + } + + #[test] + fn test_build_usage_from_partial_output_only() { + let result = build_anthropic_usage_from_responses(Some(&json!({ + "output_tokens": 50 + }))); + assert_eq!(result["input_tokens"], json!(0)); + assert_eq!(result["output_tokens"], json!(50)); + } + + #[test] + fn test_build_usage_with_openai_field_names() { + let result = build_anthropic_usage_from_responses(Some(&json!({ + "prompt_tokens": 120, + "completion_tokens": 45 + }))); + assert_eq!(result["input_tokens"], json!(120)); + assert_eq!(result["output_tokens"], json!(45)); + } + + #[test] + fn test_build_usage_anthropic_names_precedence() { + let result = build_anthropic_usage_from_responses(Some(&json!({ + "input_tokens": 100, + "prompt_tokens": 120, + "output_tokens": 50, + "completion_tokens": 45 + }))); + assert_eq!(result["input_tokens"], json!(100)); // Anthropic name takes precedence + assert_eq!(result["output_tokens"], json!(50)); // Anthropic name takes precedence + } + + #[test] + fn test_build_usage_cache_tokens_from_nested_details() { + let result = build_anthropic_usage_from_responses(Some(&json!({ + "input_tokens": 100, + "output_tokens": 50, + "input_tokens_details": { + "cached_tokens": 80 + } + }))); + assert_eq!(result["input_tokens"], json!(100)); + assert_eq!(result["output_tokens"], json!(50)); + assert_eq!(result["cache_read_input_tokens"], json!(80)); + } + + #[test] + fn test_build_usage_cache_tokens_direct_override() { + let result = build_anthropic_usage_from_responses(Some(&json!({ + "input_tokens": 100, + "output_tokens": 50, + "input_tokens_details": { + "cached_tokens": 80 + }, + "cache_read_input_tokens": 100 + }))); + assert_eq!(result["cache_read_input_tokens"], json!(100)); // Direct field overrides nested + } + + #[test] + fn test_build_usage_cache_tokens_without_input_output() { + let result = build_anthropic_usage_from_responses(Some(&json!({ + "cache_read_input_tokens": 60, + "cache_creation_input_tokens": 20 + }))); + assert_eq!(result["input_tokens"], json!(0)); + assert_eq!(result["output_tokens"], json!(0)); + assert_eq!(result["cache_read_input_tokens"], json!(60)); + assert_eq!(result["cache_creation_input_tokens"], json!(20)); + } } From fafc122d82ff7d40073fd51cc641241c0fbfc8b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B6=82=E7=91=9C?= <861506831@qq.com> Date: Thu, 30 Apr 2026 22:38:34 +0800 Subject: [PATCH 106/156] fix(coding-plan): correct zhipu weekly tier name by reset time (#2420) Zhipu's `data.limits[]` returns 1 entry for legacy plans (subscribed before 2026-02-12) and 2 entries for current plans. Previously every TOKENS_LIMIT entry was hardcoded as `five_hour`, so the weekly bucket was rendered with the 5-hour i18n label. Sort TOKENS_LIMIT entries by nextResetTime ascending and assign `five_hour` to index 0, `weekly_limit` to index 1. Legacy plans naturally degrade to a single five_hour tier. Also harden the parser: case-insensitive type match (defends against upstream casing changes), reuse TIER_FIVE_HOUR/TIER_WEEKLY_LIMIT constants, and add 8 unit tests covering both plan shapes plus defensive edge cases. --- src-tauri/src/services/coding_plan.rs | 219 ++++++++++++++++++++++---- 1 file changed, 190 insertions(+), 29 deletions(-) diff --git a/src-tauri/src/services/coding_plan.rs b/src-tauri/src/services/coding_plan.rs index 565ee2cf0..c03345b11 100644 --- a/src-tauri/src/services/coding_plan.rs +++ b/src-tauri/src/services/coding_plan.rs @@ -3,7 +3,9 @@ //! 支持 Kimi For Coding、智谱 GLM、MiniMax 的 Token Plan 额度查询。 //! 复用 subscription 模块的 SubscriptionQuota / QuotaTier 类型。 -use super::subscription::{CredentialStatus, QuotaTier, SubscriptionQuota}; +use super::subscription::{ + CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_WEEKLY_LIMIT, +}; use std::time::{SystemTime, UNIX_EPOCH}; // ── 供应商检测 ────────────────────────────────────────────── @@ -181,6 +183,60 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota { // ── 智谱 GLM ──────────────────────────────────────────────── +/// 把智谱 `data` 里的 `limits[]` 解析成 tier 列表。 +/// +/// 按 `nextResetTime` 升序后:第 0 条 = 五小时桶(`five_hour`)、 +/// 第 1 条 = 每周桶(`weekly_limit`)。老套餐(2026-02-12 前订阅)只回 1 条 +/// `TOKENS_LIMIT`,自然降级为仅展示 `five_hour`;新套餐回 2 条。 +/// 缺失 `nextResetTime` 时按 `i64::MAX` 排到末位。 +fn parse_zhipu_token_tiers(data: &serde_json::Value) -> Vec { + let mut token_limits: Vec<(i64, f64, Option)> = Vec::new(); + if let Some(limits) = data.get("limits").and_then(|v| v.as_array()) { + for limit_item in limits { + let limit_type = limit_item + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or(""); + // 大小写不敏感比较:上游若把 "TOKENS_LIMIT" 改成小写或驼峰,依然能识别 + if !limit_type.eq_ignore_ascii_case("TOKENS_LIMIT") { + continue; + } + let percentage = limit_item + .get("percentage") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let reset_ms = limit_item + .get("nextResetTime") + .and_then(|v| v.as_i64()) + .unwrap_or(i64::MAX); + let reset_iso = if reset_ms == i64::MAX { + None + } else { + millis_to_iso8601(reset_ms) + }; + token_limits.push((reset_ms, percentage, reset_iso)); + } + } + token_limits.sort_by_key(|(reset, _, _)| *reset); + + token_limits + .into_iter() + .enumerate() + .filter_map(|(idx, (_, percentage, resets_at))| { + let name = match idx { + 0 => TIER_FIVE_HOUR, + 1 => TIER_WEEKLY_LIMIT, + _ => return None, // 智谱当前最多两条 TOKENS_LIMIT,多余的忽略 + }; + Some(QuotaTier { + name: name.to_string(), + utilization: percentage, + resets_at, + }) + }) + .collect() +} + async fn query_zhipu(api_key: &str) -> SubscriptionQuota { let client = crate::proxy::http_client::get(); @@ -237,34 +293,7 @@ async fn query_zhipu(api_key: &str) -> SubscriptionQuota { None => return make_error("Missing 'data' field in response".to_string()), }; - let mut tiers = Vec::new(); - - if let Some(limits) = data.get("limits").and_then(|v| v.as_array()) { - for limit_item in limits { - let limit_type = limit_item - .get("type") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let percentage = limit_item - .get("percentage") - .and_then(|v| v.as_f64()) - .unwrap_or(0.0); - let next_reset = limit_item - .get("nextResetTime") - .and_then(|v| v.as_i64()) - .and_then(millis_to_iso8601); - - if limit_type != "TOKENS_LIMIT" { - continue; - } - - tiers.push(QuotaTier { - name: "five_hour".to_string(), - utilization: percentage, - resets_at: next_reset, - }); - } - } + let tiers = parse_zhipu_token_tiers(data); // 套餐等级存入 credential_message let level = data @@ -449,3 +478,135 @@ pub async fn get_coding_plan_quota( Ok(quota) } + +#[cfg(test)] +mod tests { + use super::{parse_zhipu_token_tiers, TIER_FIVE_HOUR, TIER_WEEKLY_LIMIT}; + use serde_json::json; + + #[test] + fn zhipu_new_plan_two_tiers_sorted_by_reset_time() { + // 新套餐:两条 TOKENS_LIMIT,nextResetTime 较近的归 five_hour、较远的归 weekly_limit。 + // 故意把"周限"放数组前面,验证不依赖输入顺序。 + let data = json!({ + "limits": [ + { "type": "TOKENS_LIMIT", "percentage": 53.0, "nextResetTime": 2_000_000_000_000_i64 }, + { "type": "TOKENS_LIMIT", "percentage": 44.0, "nextResetTime": 1_000_000_000_000_i64 }, + { "type": "TIME_LIMIT", "percentage": 7.0 }, + ] + }); + let tiers = parse_zhipu_token_tiers(&data); + assert_eq!(tiers.len(), 2); + assert_eq!(tiers[0].name, TIER_FIVE_HOUR); + assert_eq!(tiers[0].utilization, 44.0); + assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT); + assert_eq!(tiers[1].utilization, 53.0); + } + + #[test] + fn zhipu_old_plan_single_tier_falls_back_to_five_hour() { + // 老套餐(2026-02-12 前订阅):仅一条 TOKENS_LIMIT,无周限。 + let data = json!({ + "limits": [ + { + "type": "TOKENS_LIMIT", + "percentage": 2.0, + "nextResetTime": 1_774_967_594_803_i64 + }, + { "type": "TIME_LIMIT", "percentage": 0.0 } + ] + }); + let tiers = parse_zhipu_token_tiers(&data); + assert_eq!(tiers.len(), 1); + assert_eq!(tiers[0].name, TIER_FIVE_HOUR); + assert_eq!(tiers[0].utilization, 2.0); + } + + #[test] + fn zhipu_no_token_limits_returns_empty() { + let data = json!({ "limits": [{ "type": "TIME_LIMIT", "percentage": 5.0 }] }); + assert!(parse_zhipu_token_tiers(&data).is_empty()); + } + + #[test] + fn zhipu_missing_reset_time_sorts_last() { + // 防御性:没有 nextResetTime 的条目排到末位,避免抢占 five_hour 槽位。 + let data = json!({ + "limits": [ + { "type": "TOKENS_LIMIT", "percentage": 99.0 }, + { "type": "TOKENS_LIMIT", "percentage": 10.0, "nextResetTime": 1_000_000_000_000_i64 } + ] + }); + let tiers = parse_zhipu_token_tiers(&data); + assert_eq!(tiers.len(), 2); + assert_eq!(tiers[0].name, TIER_FIVE_HOUR); + assert_eq!(tiers[0].utilization, 10.0); + assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT); + assert_eq!(tiers[1].utilization, 99.0); + assert!(tiers[1].resets_at.is_none()); + } + + #[test] + fn zhipu_type_is_case_insensitive() { + // 防御性:上游若把 "TOKENS_LIMIT" 改成 "tokens_limit"(仅大小写变化)仍能识别。 + // 注意:分隔符差异(如 "TokensLimit" 去掉下划线)不在兼容范围。 + let data = json!({ + "limits": [ + { "type": "tokens_limit", "percentage": 12.0, "nextResetTime": 1_000_000_000_000_i64 }, + { "type": "Tokens_Limit", "percentage": 34.0, "nextResetTime": 2_000_000_000_000_i64 } + ] + }); + let tiers = parse_zhipu_token_tiers(&data); + assert_eq!(tiers.len(), 2); + assert_eq!(tiers[0].name, TIER_FIVE_HOUR); + assert_eq!(tiers[0].utilization, 12.0); + assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT); + assert_eq!(tiers[1].utilization, 34.0); + } + + #[test] + fn zhipu_invalid_percentage_falls_back_to_zero() { + // percentage 为字符串或 null 时不应崩溃,按 0 处理(仍展示 tier,但用量为 0)。 + let data = json!({ + "limits": [ + { "type": "TOKENS_LIMIT", "percentage": "invalid", "nextResetTime": 1_000_000_000_000_i64 }, + { "type": "TOKENS_LIMIT", "percentage": null, "nextResetTime": 2_000_000_000_000_i64 } + ] + }); + let tiers = parse_zhipu_token_tiers(&data); + assert_eq!(tiers.len(), 2); + assert_eq!(tiers[0].utilization, 0.0); + assert_eq!(tiers[1].utilization, 0.0); + } + + #[test] + fn zhipu_extreme_percentage_values_pass_through() { + // 负数 / 超 100 不做范围裁剪——下游渲染层负责显示策略,解析层只负责忠实搬运。 + let data = json!({ + "limits": [ + { "type": "TOKENS_LIMIT", "percentage": -5.0, "nextResetTime": 1_000_000_000_000_i64 }, + { "type": "TOKENS_LIMIT", "percentage": 150.0, "nextResetTime": 2_000_000_000_000_i64 } + ] + }); + let tiers = parse_zhipu_token_tiers(&data); + assert_eq!(tiers.len(), 2); + assert_eq!(tiers[0].utilization, -5.0); + assert_eq!(tiers[1].utilization, 150.0); + } + + #[test] + fn zhipu_more_than_two_token_limits_keeps_first_two() { + // 防御性:智谱当前最多两条 TOKENS_LIMIT,若上游意外增加第三条应被丢弃,避免命名空缺。 + let data = json!({ + "limits": [ + { "type": "TOKENS_LIMIT", "percentage": 1.0, "nextResetTime": 1_000_000_000_000_i64 }, + { "type": "TOKENS_LIMIT", "percentage": 2.0, "nextResetTime": 2_000_000_000_000_i64 }, + { "type": "TOKENS_LIMIT", "percentage": 3.0, "nextResetTime": 3_000_000_000_000_i64 } + ] + }); + let tiers = parse_zhipu_token_tiers(&data); + assert_eq!(tiers.len(), 2); + assert_eq!(tiers[0].name, TIER_FIVE_HOUR); + assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT); + } +} From 7f0c7b11e5e14303a17de238baf51b0cb6724435 Mon Sep 17 00:00:00 2001 From: Coconut-Fish <78026974+Coconut-Fish@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:41:54 +0800 Subject: [PATCH 107/156] feat(tray): add tooltip to tray icon for better user experience (#2417) Co-authored-by: Copilot --- src-tauri/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d7e7498ba..f882e21eb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -748,6 +748,7 @@ pub fn run() { // 构建托盘 let mut tray_builder = TrayIconBuilder::with_id(tray::TRAY_ID) + .tooltip("CC Switch") // 鼠标悬停提示 .on_tray_icon_event(|tray, event| match event { // 鼠标悬停/点击到托盘图标时,后台异步刷新用量缓存, // 让用户下一次(或快速打开菜单的那一刻)看到较新的数字。 From db66348ff8716cca317541dbedc744b756ca504f Mon Sep 17 00:00:00 2001 From: jimmy Date: Fri, 1 May 2026 17:35:18 +0800 Subject: [PATCH 108/156] feat(providers): add Baidu Qianfan Coding Plan for Claude Code (#2322) * feat(providers): add baidu qianfan coding plan presets * refactor(providers): align qianfan presets with existing format * chore(providers): narrow qianfan coding plan scope --- src-tauri/src/services/stream_check.rs | 25 +++++++++++++++++++++++++ src/config/claudeProviderPresets.ts | 20 ++++++++++++++++++++ src/hooks/useStreamCheck.ts | 14 ++++++++++++++ src/i18n/locales/en.json | 2 ++ src/i18n/locales/ja.json | 2 ++ src/i18n/locales/zh.json | 2 ++ 6 files changed, 65 insertions(+) diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 4befbefe1..25859a460 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -789,6 +789,15 @@ impl StreamCheckService { return None; } let lower = body.to_lowercase(); + let qianfan_quota_indicators = [ + "coding_plan_hour_quota_exceeded", + "coding_plan_week_quota_exceeded", + "coding_plan_month_quota_exceeded", + ]; + if qianfan_quota_indicators.iter().any(|s| lower.contains(s)) { + return Some("quotaExceeded"); + } + // 必须提到 "model",避免通用 404 / 400 被误判 if !lower.contains("model") { return None; @@ -1733,6 +1742,22 @@ mod tests { ); } + #[test] + fn test_detect_qianfan_coding_plan_quota_errors() { + let cases = [ + r#"{"error":{"code":"coding_plan_hour_quota_exceeded","message":"hour quota exceeded"}}"#, + r#"{"error":{"code":"coding_plan_week_quota_exceeded","message":"week quota exceeded"}}"#, + r#"{"error":{"code":"coding_plan_month_quota_exceeded","message":"month quota exceeded"}}"#, + ]; + + for body in cases { + assert_eq!( + StreamCheckService::detect_error_category(429, body), + Some("quotaExceeded") + ); + } + } + #[test] fn test_get_os_name() { let os_name = StreamCheckService::get_os_name(); diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index b9ba96c50..6618e54d9 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -181,6 +181,26 @@ export const providerPresets: ProviderPreset[] = [ icon: "zhipu", iconColor: "#0F62FE", }, + { + name: "Baidu Qianfan Coding Plan", + websiteUrl: "https://cloud.baidu.com/product/qianfan_modelbuilder", + apiKeyUrl: + "https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application", + settingsConfig: { + env: { + ANTHROPIC_BASE_URL: "https://qianfan.baidubce.com/anthropic/coding", + ANTHROPIC_AUTH_TOKEN: "", + ANTHROPIC_MODEL: "qianfan-code-latest", + ANTHROPIC_DEFAULT_HAIKU_MODEL: "qianfan-code-latest", + ANTHROPIC_DEFAULT_SONNET_MODEL: "qianfan-code-latest", + ANTHROPIC_DEFAULT_OPUS_MODEL: "qianfan-code-latest", + }, + }, + category: "cn_official", + endpointCandidates: ["https://qianfan.baidubce.com/anthropic/coding"], + icon: "baidu", + iconColor: "#2932E1", + }, { name: "Bailian", websiteUrl: "https://bailian.console.aliyun.com", diff --git a/src/hooks/useStreamCheck.ts b/src/hooks/useStreamCheck.ts index 196e65e9f..547865603 100644 --- a/src/hooks/useStreamCheck.ts +++ b/src/hooks/useStreamCheck.ts @@ -62,6 +62,20 @@ export function useStreamCheck(appId: AppId) { closeButton: true, }, ); + } else if (result.errorCategory === "quotaExceeded") { + toast.warning( + t("streamCheck.quotaExceeded", { + providerName: providerName, + defaultValue: `${providerName} Coding Plan quota has been exceeded`, + }), + { + description: t("streamCheck.quotaExceededHint", { + defaultValue: "", + }), + duration: 10000, + closeButton: true, + }, + ); } else { const httpStatus = result.httpStatus; const hintKey = httpStatus diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 42939438e..a05e508b3 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2240,6 +2240,8 @@ "error": "{{providerName}} check error: {{error}}", "modelNotFound": "{{providerName}} test model {{model}} does not exist or has been deprecated", "modelNotFoundHint": "This model may have been retired by the provider. Update the default test model in \"Model Test Config\".", + "quotaExceeded": "{{providerName}} Coding Plan quota has been exceeded", + "quotaExceededHint": "Baidu Qianfan returned a Coding Plan quota-limit error. Wait for the 5-hour, weekly, or monthly quota refresh, or adjust the plan in the Qianfan console.", "httpHint": { "400": "Provider rejected request format. Health check probe may differ from actual usage.", "401": "API key may be invalid, or provider uses OAuth auth. Check failure doesn't mean it's unusable.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 5c5a110c7..2c2cfa33e 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2240,6 +2240,8 @@ "error": "{{providerName}} のチェックでエラーが発生しました: {{error}}", "modelNotFound": "{{providerName}} のテストモデル {{model}} は存在しないか廃止されています", "modelNotFoundHint": "このモデルはプロバイダーにより廃止された可能性があります。「モデルテスト設定」でデフォルトのテストモデルを更新してください。", + "quotaExceeded": "{{providerName}} の Coding Plan 利用枠を超過しました", + "quotaExceededHint": "Baidu Qianfan が Coding Plan の利用枠超過エラーを返しました。5時間・週次・月次の利用枠更新を待つか、Qianfan コンソールでプランを確認してください。", "httpHint": { "400": "リクエスト形式が拒否されました。ヘルスチェックの形式は実際の使用と異なる場合があります。", "401": "APIキーが無効か、OAuthなどの認証方式を使用しています。チェック失敗は実際に使えないことを意味しません。", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 5add807a3..38ed5a9c5 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -2240,6 +2240,8 @@ "error": "{{providerName}} 检查出错: {{error}}", "modelNotFound": "{{providerName}} 测试模型 {{model}} 不存在或已下架", "modelNotFoundHint": "该模型可能已被供应商弃用。请在\"模型测试配置\"中更新默认测试模型。", + "quotaExceeded": "{{providerName}} Coding Plan 额度已用尽", + "quotaExceededHint": "百度千帆返回了 Coding Plan 超额错误。请等待 5 小时/每周/每月额度刷新,或在千帆控制台切换套餐。", "httpHint": { "400": "供应商拒绝了请求格式。健康检查的探测格式可能与实际使用不同。", "401": "API Key 可能无效,或供应商使用 OAuth 等认证方式。检查失败不代表实际不可用。", From 064b339babea9db04abd8840c59509e9e413ce6d Mon Sep 17 00:00:00 2001 From: makoMakoGo <48956204+makoMakoGo@users.noreply.github.com> Date: Fri, 1 May 2026 17:41:32 +0800 Subject: [PATCH 109/156] fix(claude): persist max effort via env (#2493) --- docs/user-manual/en/2-providers/2.1-add.md | 2 +- docs/user-manual/ja/2-providers/2.1-add.md | 2 +- docs/user-manual/zh/2-providers/2.1-add.md | 2 +- .../providers/forms/CommonConfigEditor.tsx | 8 +- tests/components/CommonConfigEditor.test.tsx | 117 ++++++++++++++++++ 5 files changed, 125 insertions(+), 6 deletions(-) create mode 100644 tests/components/CommonConfigEditor.test.tsx diff --git a/docs/user-manual/en/2-providers/2.1-add.md b/docs/user-manual/en/2-providers/2.1-add.md index 87e58f6ab..49960efdc 100644 --- a/docs/user-manual/en/2-providers/2.1-add.md +++ b/docs/user-manual/en/2-providers/2.1-add.md @@ -509,7 +509,7 @@ When editing Claude providers, a set of **quick toggles** is available above the | **Hide Attribution** | Clears commit/PR attribution metadata | Sets `attribution: {commit: "", pr: ""}` | | **Enable Teammates** | Enables the agent teams feature | Sets `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"` | | **Enable Tool Search** | Enables tool search functionality | Sets `env.ENABLE_TOOL_SEARCH = "true"` | -| **Max Effort** | Sets effort level to max | Sets `effortLevel = "max"` | +| **Max Effort** | Sets effort level to max | Sets `env.CLAUDE_CODE_EFFORT_LEVEL = "max"` | | **Disable Auto Upgrade** | Prevents Claude Code auto-updates | Sets `env.DISABLE_AUTOUPDATER = "1"` | When a toggle is unchecked, its corresponding config entry is removed entirely. Changes are reflected in the JSON editor in real-time. diff --git a/docs/user-manual/ja/2-providers/2.1-add.md b/docs/user-manual/ja/2-providers/2.1-add.md index f2447eb85..d8bc7e058 100644 --- a/docs/user-manual/ja/2-providers/2.1-add.md +++ b/docs/user-manual/ja/2-providers/2.1-add.md @@ -509,7 +509,7 @@ Claude プロバイダーの編集時、JSON エディタの上部に **クイ | **帰属情報を非表示** | コミット/PR の帰属メタデータをクリア | `attribution: {commit: "", pr: ""}` を設定 | | **チームメイトを有効化** | エージェントチーム機能を有効化 | `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"` を設定 | | **ツール検索を有効化** | ツール検索機能を有効化 | `env.ENABLE_TOOL_SEARCH = "true"` を設定 | -| **最大強度思考** | エフォートレベルを max に設定 | `effortLevel = "max"` を設定 | +| **最大強度思考** | エフォートレベルを max に設定 | `env.CLAUDE_CODE_EFFORT_LEVEL = "max"` を設定 | | **自動アップグレードを無効化** | Claude Code の自動更新を防止 | `env.DISABLE_AUTOUPDATER = "1"` を設定 | トグルのチェックを外すと、対応する設定エントリが完全に削除されます。変更は JSON エディタにリアルタイムで反映されます。 diff --git a/docs/user-manual/zh/2-providers/2.1-add.md b/docs/user-manual/zh/2-providers/2.1-add.md index d4d624990..ea1fd02cb 100644 --- a/docs/user-manual/zh/2-providers/2.1-add.md +++ b/docs/user-manual/zh/2-providers/2.1-add.md @@ -509,7 +509,7 @@ v3.13.0 起新增的高级选项。默认情况下,CC Switch 会把配置的 ` | **隐藏署名** | 清除提交/PR 的署名元数据 | 设置 `attribution: {commit: "", pr: ""}` | | **启用 Teammates** | 启用 Agent 团队功能 | 设置 `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"` | | **启用工具搜索** | 启用工具搜索功能 | 设置 `env.ENABLE_TOOL_SEARCH = "true"` | -| **最大强度思考** | 将 effort 级别设为 max | 设置 `effortLevel = "max"` | +| **最大强度思考** | 将 effort 级别设为 max | 设置 `env.CLAUDE_CODE_EFFORT_LEVEL = "max"` | | **禁用自动更新** | 阻止 Claude Code 自动更新 | 设置 `env.DISABLE_AUTOUPDATER = "1"` | 取消勾选开关时,对应的配置项会被完全移除。更改会实时反映在 JSON 编辑器中。 diff --git a/src/components/providers/forms/CommonConfigEditor.tsx b/src/components/providers/forms/CommonConfigEditor.tsx index 62b8aeb06..a83536945 100644 --- a/src/components/providers/forms/CommonConfigEditor.tsx +++ b/src/components/providers/forms/CommonConfigEditor.tsx @@ -81,7 +81,7 @@ export function CommonConfigEditor({ enableToolSearch: config?.env?.ENABLE_TOOL_SEARCH === "true" || config?.env?.ENABLE_TOOL_SEARCH === "1", - effortMax: config?.effortLevel === "max", + effortMax: config?.env?.CLAUDE_CODE_EFFORT_LEVEL === "max", disableAutoUpgrade: config?.env?.DISABLE_AUTOUPDATER === "1" || config?.env?.DISABLE_AUTOUPDATER === 1, @@ -129,10 +129,12 @@ export function CommonConfigEditor({ } break; case "effortMax": + if (!config.env) config.env = {}; if (checked) { - config.effortLevel = "max"; + config.env.CLAUDE_CODE_EFFORT_LEVEL = "max"; } else { - delete config.effortLevel; + delete config.env.CLAUDE_CODE_EFFORT_LEVEL; + if (Object.keys(config.env).length === 0) delete config.env; } break; case "disableAutoUpgrade": diff --git a/tests/components/CommonConfigEditor.test.tsx b/tests/components/CommonConfigEditor.test.tsx new file mode 100644 index 000000000..a4b0c9ceb --- /dev/null +++ b/tests/components/CommonConfigEditor.test.tsx @@ -0,0 +1,117 @@ +import type { ReactNode } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { CommonConfigEditor } from "@/components/providers/forms/CommonConfigEditor"; + +vi.mock("@/components/common/FullScreenPanel", () => ({ + FullScreenPanel: ({ + isOpen, + title, + onClose, + children, + footer, + }: { + isOpen: boolean; + title: string; + onClose: () => void; + children: ReactNode; + footer?: ReactNode; + }) => + isOpen ? ( +
+ +

{title}

+
{children}
+
{footer}
+
+ ) : null, +})); + +vi.mock("@/components/JsonEditor", () => ({ + default: ({ + value, + onChange, + }: { + value: string; + onChange: (value: string) => void; + }) => ( +