From 3bb3c9a7c37eb1c175a52b2230bc8f4ec16f6a90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Wed, 27 May 2026 04:33:03 +0800 Subject: [PATCH] feat(hermes): add web tool backend controls --- scripts/dev-api.js | 61 +++++++ src-tauri/src/commands/hermes.rs | 236 ++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 2 + src/engines/hermes/pages/config.js | 103 +++++++++++- src/lib/tauri-api.js | 2 + src/locales/modules/engine.js | 22 +++ tests/hermes-config-page-ui.test.js | 12 ++ tests/hermes-web-config.test.js | 90 +++++++++++ 8 files changed, 525 insertions(+), 3 deletions(-) create mode 100644 tests/hermes-web-config.test.js diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 5630dd9..fb809ba 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -3328,6 +3328,7 @@ const HERMES_TERMINAL_MODAL_MODES = new Set(['auto', 'managed', 'direct']) const HERMES_TERMINAL_VERCEL_RUNTIMES = new Set(['node24', 'node22', 'python3.13']) const HERMES_BROWSER_ENGINES = new Set(['auto', 'lightpanda', 'chrome']) const HERMES_BROWSER_DIALOG_POLICIES = new Set(['must_respond', 'auto_dismiss', 'auto_accept']) +const HERMES_WEB_BACKENDS = new Set(['tavily', 'firecrawl', 'parallel', 'exa', 'searxng', 'brave', 'brave_free', 'ddgs', 'xai', 'native']) const HERMES_STT_PROVIDERS = new Set(['auto', 'local', 'groq', 'openai', 'mistral']) const HERMES_STT_LOCAL_MODELS = new Set(['tiny', 'base', 'small', 'medium', 'large-v3', 'turbo']) const HERMES_STT_OPENAI_MODELS = new Set(['whisper-1', 'gpt-4o-mini-transcribe', 'gpt-4o-transcribe']) @@ -3488,6 +3489,14 @@ function normalizeHermesBrowserDialogPolicy(value, strict = false) { return 'must_respond' } +function normalizeHermesWebBackend(value, key, strict = false) { + const backend = String(value ?? '').trim().toLowerCase() + if (!backend) return '' + if (HERMES_WEB_BACKENDS.has(backend)) return backend + if (strict) throw new Error(`${key} 必须为空或 tavily、firecrawl、parallel、exa、searxng、brave、brave_free、ddgs、xai、native`) + return '' +} + function normalizeHermesSttProvider(value, strict = false) { const provider = String(value ?? '').trim().toLowerCase() || 'auto' if (HERMES_STT_PROVIDERS.has(provider)) return provider @@ -5495,6 +5504,37 @@ export function mergeHermesBrowserConfig(config = {}, form = {}) { return next } +export function buildHermesWebConfigValues(config = {}) { + const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} + const web = root.web && typeof root.web === 'object' && !Array.isArray(root.web) + ? root.web + : {} + return { + webBackend: normalizeHermesWebBackend(web.backend, 'web.backend', false), + webSearchBackend: normalizeHermesWebBackend(web.search_backend, 'web.search_backend', false), + webExtractBackend: normalizeHermesWebBackend(web.extract_backend, 'web.extract_backend', false), + } +} + +export function mergeHermesWebConfig(config = {}, form = {}) { + const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {}) + const currentValues = buildHermesWebConfigValues(next) + const web = next.web && typeof next.web === 'object' && !Array.isArray(next.web) + ? mergeConfigsPreservingFields(next.web, {}) + : {} + const backend = normalizeHermesWebBackend(Object.hasOwn(form, 'webBackend') ? form.webBackend : currentValues.webBackend, 'web.backend', true) + const searchBackend = normalizeHermesWebBackend(Object.hasOwn(form, 'webSearchBackend') ? form.webSearchBackend : currentValues.webSearchBackend, 'web.search_backend', true) + const extractBackend = normalizeHermesWebBackend(Object.hasOwn(form, 'webExtractBackend') ? form.webExtractBackend : currentValues.webExtractBackend, 'web.extract_backend', true) + if (backend) web.backend = backend + else delete web.backend + if (searchBackend) web.search_backend = searchBackend + else delete web.search_backend + if (extractBackend) web.extract_backend = extractBackend + else delete web.extract_backend + next.web = web + return next +} + export function buildHermesSttConfigValues(config = {}) { const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} const stt = root.stt && typeof root.stt === 'object' && !Array.isArray(root.stt) @@ -12777,6 +12817,27 @@ const handlers = { } }, + hermes_web_config_read() { + const { configPath, exists, config } = readHermesConfigYamlObject() + return { + exists, + configPath, + values: buildHermesWebConfigValues(config), + } + }, + + hermes_web_config_save({ form } = {}) { + const { configPath, config } = readHermesConfigYamlObject() + const next = mergeHermesWebConfig(config, form || {}) + const backup = writeHermesConfigYamlObject(configPath, next) + return { + ok: true, + configPath, + backup, + values: buildHermesWebConfigValues(next), + } + }, + hermes_stt_config_read() { const { configPath, exists, config } = readHermesConfigYamlObject() return { diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index be1eb6c..3e2d4a7 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -7159,6 +7159,37 @@ fn normalize_hermes_browser_dialog_policy( } } +fn normalize_hermes_web_backend( + value: Option, + key: &str, + strict: bool, +) -> Result { + let backend = value.unwrap_or_default().trim().to_ascii_lowercase(); + if backend.is_empty() { + return Ok(String::new()); + } + if matches!( + backend.as_str(), + "tavily" + | "firecrawl" + | "parallel" + | "exa" + | "searxng" + | "brave" + | "brave_free" + | "ddgs" + | "xai" + | "native" + ) { + return Ok(backend); + } + if strict { + Err(format!("{key} 必须为空或 tavily、firecrawl、parallel、exa、searxng、brave、brave_free、ddgs、xai、native")) + } else { + Ok(String::new()) + } +} + fn normalize_hermes_stt_provider(value: Option, strict: bool) -> Result { let provider = value.unwrap_or_default().trim().to_ascii_lowercase(); let provider = if provider.is_empty() { @@ -8421,6 +8452,77 @@ fn merge_hermes_browser_config(config: &mut serde_yaml::Value, form: &Value) -> Ok(()) } +fn build_hermes_web_config_values(config: &serde_yaml::Value) -> Value { + let root = config.as_mapping(); + let web = root.and_then(|map| yaml_get_mapping(map, "web")); + let web_backend = normalize_hermes_web_backend( + web.and_then(|map| yaml_string_field(map, "backend")), + "web.backend", + false, + ) + .unwrap_or_default(); + let web_search_backend = normalize_hermes_web_backend( + web.and_then(|map| yaml_string_field(map, "search_backend")), + "web.search_backend", + false, + ) + .unwrap_or_default(); + let web_extract_backend = normalize_hermes_web_backend( + web.and_then(|map| yaml_string_field(map, "extract_backend")), + "web.extract_backend", + false, + ) + .unwrap_or_default(); + + serde_json::json!({ + "webBackend": web_backend, + "webSearchBackend": web_search_backend, + "webExtractBackend": web_extract_backend, + }) +} + +fn merge_hermes_web_config(config: &mut serde_yaml::Value, form: &Value) -> Result<(), String> { + let current = build_hermes_web_config_values(config); + let web_backend = normalize_hermes_web_backend( + if form.get("webBackend").is_some() { + form_string(form, "webBackend") + } else { + current["webBackend"].as_str().map(ToString::to_string) + }, + "web.backend", + true, + )?; + let web_search_backend = normalize_hermes_web_backend( + if form.get("webSearchBackend").is_some() { + form_string(form, "webSearchBackend") + } else { + current["webSearchBackend"] + .as_str() + .map(ToString::to_string) + }, + "web.search_backend", + true, + )?; + let web_extract_backend = normalize_hermes_web_backend( + if form.get("webExtractBackend").is_some() { + form_string(form, "webExtractBackend") + } else { + current["webExtractBackend"] + .as_str() + .map(ToString::to_string) + }, + "web.extract_backend", + true, + )?; + + let root = ensure_yaml_object(config)?; + let web = yaml_child_object(root, "web")?; + set_optional_yaml_string(web, "backend", web_backend); + set_optional_yaml_string(web, "search_backend", web_search_backend); + set_optional_yaml_string(web, "extract_backend", web_extract_backend); + Ok(()) +} + fn build_hermes_stt_config_values(config: &serde_yaml::Value) -> Value { let root = config.as_mapping(); let stt = root.and_then(|map| yaml_get_mapping(map, "stt")); @@ -11050,6 +11152,30 @@ pub fn hermes_browser_config_save(form: Value) -> Result { })) } +#[tauri::command] +pub fn hermes_web_config_read() -> Result { + let (config_path, exists, config) = read_hermes_channel_yaml_config()?; + ensure_yaml_object(&mut config.clone())?; + Ok(serde_json::json!({ + "exists": exists, + "configPath": config_path.to_string_lossy(), + "values": build_hermes_web_config_values(&config), + })) +} + +#[tauri::command] +pub fn hermes_web_config_save(form: Value) -> Result { + let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?; + merge_hermes_web_config(&mut config, &form)?; + let backup = write_hermes_yaml_config(&config_path, &config)?; + Ok(serde_json::json!({ + "ok": true, + "configPath": config_path.to_string_lossy(), + "backup": backup, + "values": build_hermes_web_config_values(&config), + })) +} + #[tauri::command] pub fn hermes_stt_config_read() -> Result { let (config_path, exists, config) = read_hermes_channel_yaml_config()?; @@ -17533,6 +17659,116 @@ browser: } } +#[cfg(test)] +mod hermes_web_config_tests { + use super::{build_hermes_web_config_values, merge_hermes_web_config}; + use serde_json::json; + + #[test] + fn web_values_have_upstream_defaults() { + let config: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let values = build_hermes_web_config_values(&config); + assert_eq!(values["webBackend"], ""); + assert_eq!(values["webSearchBackend"], ""); + assert_eq!(values["webExtractBackend"], ""); + } + + #[test] + fn web_values_read_yaml_fields() { + let config: serde_yaml::Value = serde_yaml::from_str( + r#" +web: + backend: tavily + search_backend: searxng + extract_backend: firecrawl +"#, + ) + .unwrap(); + let values = build_hermes_web_config_values(&config); + assert_eq!(values["webBackend"], "tavily"); + assert_eq!(values["webSearchBackend"], "searxng"); + assert_eq!(values["webExtractBackend"], "firecrawl"); + } + + #[test] + fn merge_web_config_preserves_unknown_fields() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +model: + provider: anthropic +web: + backend: tavily + search_backend: searxng + extract_backend: firecrawl + custom_flag: keep-web +streaming: + enabled: true +"#, + ) + .unwrap(); + + merge_hermes_web_config( + &mut config, + &json!({ + "webBackend": "parallel", + "webSearchBackend": "exa", + "webExtractBackend": "native", + }), + ) + .unwrap(); + + assert_eq!(config["model"]["provider"].as_str(), Some("anthropic")); + assert_eq!(config["streaming"]["enabled"].as_bool(), Some(true)); + assert_eq!(config["web"]["backend"].as_str(), Some("parallel")); + assert_eq!(config["web"]["search_backend"].as_str(), Some("exa")); + assert_eq!(config["web"]["extract_backend"].as_str(), Some("native")); + assert_eq!(config["web"]["custom_flag"].as_str(), Some("keep-web")); + } + + #[test] + fn merge_web_config_removes_empty_optional_fields() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +web: + backend: tavily + search_backend: searxng + extract_backend: firecrawl + custom_flag: keep-web +"#, + ) + .unwrap(); + + merge_hermes_web_config( + &mut config, + &json!({ + "webBackend": " ", + "webSearchBackend": "", + "webExtractBackend": " ", + }), + ) + .unwrap(); + + assert_eq!(config["web"]["custom_flag"].as_str(), Some("keep-web")); + assert!(config["web"].get("backend").is_none()); + assert!(config["web"].get("search_backend").is_none()); + assert!(config["web"].get("extract_backend").is_none()); + } + + #[test] + fn merge_web_config_rejects_invalid_backends() { + let mut config = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + let err = + merge_hermes_web_config(&mut config, &json!({ "webBackend": "unsafe" })).unwrap_err(); + assert!(err.contains("web.backend")); + let err = merge_hermes_web_config(&mut config, &json!({ "webSearchBackend": "unsafe" })) + .unwrap_err(); + assert!(err.contains("web.search_backend")); + let err = merge_hermes_web_config(&mut config, &json!({ "webExtractBackend": "unsafe" })) + .unwrap_err(); + assert!(err.contains("web.extract_backend")); + } +} + #[cfg(test)] mod hermes_stt_config_tests { use super::{build_hermes_stt_config_values, merge_hermes_stt_config}; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 26aea4a..abdba0e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -327,6 +327,8 @@ pub fn run() { hermes::hermes_privacy_config_save, hermes::hermes_browser_config_read, hermes::hermes_browser_config_save, + hermes::hermes_web_config_read, + hermes::hermes_web_config_save, hermes::hermes_stt_config_read, hermes::hermes_stt_config_save, hermes::hermes_tts_voice_config_read, diff --git a/src/engines/hermes/pages/config.js b/src/engines/hermes/pages/config.js index 8a75faf..9176895 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -294,6 +294,12 @@ const BROWSER_DEFAULTS = { browserDialogTimeout: 300, } +const WEB_DEFAULTS = { + webBackend: '', + webSearchBackend: '', + webExtractBackend: '', +} + const STT_DEFAULTS = { sttEnabled: true, sttProvider: 'auto', @@ -361,6 +367,7 @@ const TERMINAL_MODAL_MODES = ['auto', 'managed', 'direct'] const TERMINAL_VERCEL_RUNTIMES = ['node24', 'node22', 'python3.13'] const BROWSER_ENGINES = ['auto', 'lightpanda', 'chrome'] const BROWSER_DIALOG_POLICIES = ['must_respond', 'auto_dismiss', 'auto_accept'] +const WEB_BACKENDS = ['', 'tavily', 'firecrawl', 'parallel', 'exa', 'searxng', 'brave', 'brave_free', 'ddgs', 'xai', 'native'] const STT_PROVIDERS = ['auto', 'local', 'groq', 'openai', 'mistral'] const STT_LOCAL_MODELS = ['tiny', 'base', 'small', 'medium', 'large-v3', 'turbo'] const STT_OPENAI_MODELS = ['whisper-1', 'gpt-4o-mini-transcribe', 'gpt-4o-transcribe'] @@ -426,6 +433,7 @@ export function render() { let approvalsValues = { ...APPROVALS_DEFAULTS } let privacyValues = { ...PRIVACY_DEFAULTS } let browserValues = { ...BROWSER_DEFAULTS } + let webValues = { ...WEB_DEFAULTS } let sttValues = { ...STT_DEFAULTS } let ttsVoiceValues = { ...TTS_VOICE_DEFAULTS } let terminalValues = { ...TERMINAL_DEFAULTS } @@ -465,6 +473,7 @@ export function render() { let approvalsLoading = true let privacyLoading = true let browserLoading = true + let webLoading = true let sttLoading = true let ttsVoiceLoading = true let terminalLoading = true @@ -504,6 +513,7 @@ export function render() { let approvalsSaving = false let privacySaving = false let browserSaving = false + let webSaving = false let sttSaving = false let ttsVoiceSaving = false let terminalSaving = false @@ -543,6 +553,7 @@ export function render() { let approvalsError = null let privacyError = null let browserError = null + let webError = null let sttError = null let ttsVoiceError = null let terminalError = null @@ -556,7 +567,7 @@ export function render() { } function isBusy() { - return loading || runtimeLoading || sessionsMaintenanceLoading || updatesLoading || compressionLoading || promptCachingLoading || openrouterCacheLoading || providerRoutingLoading || auxiliaryLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || curatorLoading || quickCommandsLoading || modelLoading || modelAliasesLoading || hooksLoading || providerOverridesLoading || mcpServersLoading || agentToolsetsLoading || platformToolsetsLoading || agentRuntimeLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || kanbanLoading || streamingLoading || executionLimitsLoading || ioSafetyLoading || checkpointsLoading || cronLoading || loggingLoading || approvalsLoading || privacyLoading || browserLoading || sttLoading || ttsVoiceLoading || terminalLoading || saving || runtimeSaving || sessionsMaintenanceSaving || updatesSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || curatorSaving || quickCommandsSaving || modelSaving || modelAliasesSaving || hooksSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || platformToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || kanbanSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || privacySaving || browserSaving || sttSaving || ttsVoiceSaving || terminalSaving + return loading || runtimeLoading || sessionsMaintenanceLoading || updatesLoading || compressionLoading || promptCachingLoading || openrouterCacheLoading || providerRoutingLoading || auxiliaryLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || curatorLoading || quickCommandsLoading || modelLoading || modelAliasesLoading || hooksLoading || providerOverridesLoading || mcpServersLoading || agentToolsetsLoading || platformToolsetsLoading || agentRuntimeLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || kanbanLoading || streamingLoading || executionLimitsLoading || ioSafetyLoading || checkpointsLoading || cronLoading || loggingLoading || approvalsLoading || privacyLoading || browserLoading || webLoading || sttLoading || ttsVoiceLoading || terminalLoading || saving || runtimeSaving || sessionsMaintenanceSaving || updatesSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || curatorSaving || quickCommandsSaving || modelSaving || modelAliasesSaving || hooksSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || platformToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || kanbanSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || privacySaving || browserSaving || webSaving || sttSaving || ttsVoiceSaving || terminalSaving } function option(labelKey, value, selected) { @@ -2180,7 +2191,7 @@ export function render() { } function renderBrowserPanel() { - const disabled = loading || saving || browserLoading || browserSaving || approvalsSaving || cronSaving || loggingSaving || privacySaving || sttSaving || terminalSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || providerOverridesSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving + const disabled = loading || saving || browserLoading || browserSaving || webSaving || approvalsSaving || cronSaving || loggingSaving || privacySaving || sttSaving || terminalSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || providerOverridesSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving return `
@@ -2245,8 +2256,50 @@ export function render() { ` } + function renderWebConfigPanel() { + const disabled = loading || saving || webLoading || webSaving || browserSaving || approvalsSaving || cronSaving || loggingSaving || privacySaving || sttSaving || terminalSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || providerOverridesSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving + return ` +
+
+
+
${t('engine.hermesWebConfigTitle')}
+
${t('engine.hermesWebConfigDesc')}
+
+
+ ${webSaving ? t('engine.hermesConfigStatusSaving') : webLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesWebConfigStatusReady')} + +
+
+
+ ${renderError(webError)} +
+ + + +
+
${t('engine.hermesWebConfigFootnote')}
+
+
+ ` + } + function renderSttPanel() { - const disabled = loading || saving || sttLoading || sttSaving || approvalsSaving || cronSaving || loggingSaving || privacySaving || browserSaving || terminalSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || providerOverridesSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving + const disabled = loading || saving || sttLoading || sttSaving || webSaving || approvalsSaving || cronSaving || loggingSaving || privacySaving || browserSaving || terminalSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || providerOverridesSaving || agentToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving return `
@@ -2574,6 +2627,7 @@ export function render() { ${renderApprovalsPanel()} ${renderPrivacyPanel()} ${renderBrowserPanel()} + ${renderWebConfigPanel()} ${renderSttPanel()} ${renderTtsVoicePanel()} ${renderCompressionPanel()} @@ -2653,6 +2707,7 @@ export function render() { el.querySelector('#hm-approvals-save')?.addEventListener('click', saveApprovalsConfig) el.querySelector('#hm-privacy-save')?.addEventListener('click', savePrivacyConfig) el.querySelector('#hm-browser-save')?.addEventListener('click', saveBrowserConfig) + el.querySelector('#hm-web-config-save')?.addEventListener('click', saveWebConfig) el.querySelector('#hm-stt-save')?.addEventListener('click', saveSttConfig) el.querySelector('#hm-tts-voice-save')?.addEventListener('click', saveTtsVoiceConfig) el.querySelector('#hm-terminal-save')?.addEventListener('click', saveTerminal) @@ -2838,6 +2893,11 @@ export function render() { browserValues = { ...BROWSER_DEFAULTS, ...(data?.values || {}) } } + async function loadWebConfig() { + const data = await api.hermesWebConfigRead() + webValues = { ...WEB_DEFAULTS, ...(data?.values || {}) } + } + async function loadSttConfig() { const data = await api.hermesSttConfigRead() sttValues = { ...STT_DEFAULTS, ...(data?.values || {}) } @@ -2890,6 +2950,7 @@ export function render() { approvalsLoading = true privacyLoading = true browserLoading = true + webLoading = true sttLoading = true ttsVoiceLoading = true terminalLoading = true @@ -2928,6 +2989,7 @@ export function render() { approvalsError = null privacyError = null browserError = null + webError = null sttError = null ttsVoiceError = null terminalError = null @@ -3083,6 +3145,14 @@ export function render() { browserLoading = false draw() } + try { + await loadWebConfig() + } catch (err) { + webError = humanizeError(err, t('engine.hermesWebConfigLoadFailed') || 'Load web tool config failed') + } finally { + webLoading = false + draw() + } try { await loadSttConfig() } catch (err) { @@ -4401,6 +4471,33 @@ export function render() { } } + async function saveWebConfig() { + const form = { + webBackend: el.querySelector('#hm-web-backend')?.value || '', + webSearchBackend: el.querySelector('#hm-web-search-backend')?.value || '', + webExtractBackend: el.querySelector('#hm-web-extract-backend')?.value || '', + } + webSaving = true + webError = null + draw() + try { + const result = await api.hermesWebConfigSave(form) + webValues = { ...WEB_DEFAULTS, ...(result?.values || form) } + await refreshRawAfterStructuredSave() + const backup = result?.backup || '' + toast({ + message: t('engine.hermesWebConfigSaveSuccess'), + hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '', + }, 'success') + } catch (err) { + webError = humanizeError(err, t('engine.hermesWebConfigSaveFailed') || 'Save web tool config failed') + toast(webError, 'error') + } finally { + webSaving = false + draw() + } + } + async function saveSttConfig() { const form = { sttEnabled: !!el.querySelector('#hm-stt-enabled')?.checked, diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index b4bfc57..53732ca 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -579,6 +579,8 @@ export const api = { hermesPrivacyConfigSave: (form) => invoke('hermes_privacy_config_save', { form }), hermesBrowserConfigRead: () => invoke('hermes_browser_config_read'), hermesBrowserConfigSave: (form) => invoke('hermes_browser_config_save', { form }), + hermesWebConfigRead: () => invoke('hermes_web_config_read'), + hermesWebConfigSave: (form) => invoke('hermes_web_config_save', { form }), hermesSttConfigRead: () => invoke('hermes_stt_config_read'), hermesSttConfigSave: (form) => invoke('hermes_stt_config_save', { form }), hermesTtsVoiceConfigRead: () => invoke('hermes_tts_voice_config_read'), diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index 14109b3..9cb97a6 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -717,6 +717,28 @@ export default { hermesBrowserConfigDialogPolicy_auto_accept: _('自动确认弹窗', 'Auto accept dialogs', '自動確認彈窗'), hermesBrowserConfigDialogTimeout: _('弹窗等待超时秒数', 'Dialog wait timeout seconds', '彈窗等待逾時秒數'), hermesBrowserConfigFootnote: _('Lightpanda 导航更快但不支持截图;录制会把 WebM 写入 Hermes browser_recordings 目录,请只在需要审计时开启。允许私网地址会放开 localhost / 192.168 等目标;CDP 地址留空表示自动创建浏览器;Camofox 嵌套高级字段会保留在 raw YAML 中。', 'Lightpanda navigates faster but does not support screenshots. Recording writes WebM files into the Hermes browser_recordings directory, so enable it only for audits. Allowing private URLs opens localhost / 192.168-style targets. Empty CDP URL means Hermes launches a browser automatically. Nested Camofox advanced fields stay in raw YAML.', 'Lightpanda 導覽更快但不支援截圖;錄製會把 WebM 寫入 Hermes browser_recordings 目錄,請只在需要稽核時開啟。允許私網位址會放開 localhost / 192.168 等目標;CDP 位址留空表示自動建立瀏覽器;Camofox 巢狀進階欄位會保留在 raw YAML 中。'), + hermesWebConfigTitle: _('Web 工具后端', 'Web tool backends', 'Web 工具後端'), + hermesWebConfigDesc: _('控制 web_search / web_extract 的默认和分能力后端,只保存后端选择,不保存 API Key。', 'Control default and per-capability backends for web_search / web_extract. This stores backend choices only, not API keys.', '控制 web_search / web_extract 的預設和分能力後端,只儲存後端選擇,不儲存 API Key。'), + hermesWebConfigStatusReady: _('结构化配置', 'structured settings', '結構化設定'), + hermesWebConfigSave: _('保存 Web 工具配置', 'Save web tool settings', '儲存 Web 工具設定'), + hermesWebConfigSaveSuccess: _('Web 工具后端已保存,建议重启 Hermes Gateway 生效', 'Web tool backend settings saved. Restart Hermes Gateway to take effect.', 'Web 工具後端已儲存,建議重啟 Hermes Gateway 生效'), + hermesWebConfigLoadFailed: _('加载 Web 工具配置失败', 'Load web tool settings failed', '載入 Web 工具設定失敗'), + hermesWebConfigSaveFailed: _('保存 Web 工具配置失败', 'Save web tool settings failed', '儲存 Web 工具設定失敗'), + hermesWebConfigBackend: _('默认后端', 'Default backend', '預設後端'), + hermesWebConfigSearchBackend: _('搜索后端', 'Search backend', '搜尋後端'), + hermesWebConfigExtractBackend: _('提取后端', 'Extract backend', '提取後端'), + hermesWebConfigBackend_auto: _('自动选择', 'Auto select', '自動選擇'), + hermesWebConfigBackend_tavily: _('Tavily', 'Tavily', 'Tavily'), + hermesWebConfigBackend_firecrawl: _('Firecrawl', 'Firecrawl', 'Firecrawl'), + hermesWebConfigBackend_parallel: _('Parallel', 'Parallel', 'Parallel'), + hermesWebConfigBackend_exa: _('Exa', 'Exa', 'Exa'), + hermesWebConfigBackend_searxng: _('SearXNG', 'SearXNG', 'SearXNG'), + hermesWebConfigBackend_brave: _('Brave Search', 'Brave Search', 'Brave Search'), + hermesWebConfigBackend_brave_free: _('Brave Free', 'Brave Free', 'Brave Free'), + hermesWebConfigBackend_ddgs: _('DuckDuckGo', 'DuckDuckGo', 'DuckDuckGo'), + hermesWebConfigBackend_xai: _('xAI Search', 'xAI Search', 'xAI Search'), + hermesWebConfigBackend_native: _('原生提取', 'Native extraction', '原生提取'), + hermesWebConfigFootnote: _('这里写入 web.backend、web.search_backend 和 web.extract_backend。留空会删除覆盖,让 Hermes 按密钥、可用 provider 和上游默认策略自动选择;未知 web 字段会保留在 raw YAML 中。', 'This writes web.backend, web.search_backend, and web.extract_backend. Leaving a value empty removes the override so Hermes can auto-select based on keys, available providers, and upstream defaults. Unknown web fields stay in raw YAML.', '這裡寫入 web.backend、web.search_backend 和 web.extract_backend。留空會刪除覆蓋,讓 Hermes 按密鑰、可用 provider 和上游預設策略自動選擇;未知 web 欄位會保留在 raw YAML 中。'), hermesSttConfigTitle: _('语音转写', 'Speech transcription', '語音轉寫'), hermesSttConfigDesc: _('控制消息平台语音消息是否自动转写,以及本地、OpenAI 和 Mistral 转写模型。适合需要处理语音反馈的渠道。', 'Control automatic voice-message transcription for messaging platforms, plus local, OpenAI, and Mistral transcription models. Useful for channels that receive voice feedback.', '控制訊息平台語音訊息是否自動轉寫,以及本機、OpenAI 和 Mistral 轉寫模型。適合需要處理語音回饋的渠道。'), hermesSttConfigStatusReady: _('结构化配置', 'structured settings', '結構化設定'), diff --git a/tests/hermes-config-page-ui.test.js b/tests/hermes-config-page-ui.test.js index 1aa1e1d..2784c70 100644 --- a/tests/hermes-config-page-ui.test.js +++ b/tests/hermes-config-page-ui.test.js @@ -431,6 +431,17 @@ test('Hermes 配置页会暴露浏览器基础结构化配置字段', () => { } }) +test('Hermes 配置页会暴露 Web 工具后端结构化配置字段', () => { + for (const id of [ + 'hm-web-config-save', + 'hm-web-backend', + 'hm-web-search-backend', + 'hm-web-extract-backend', + ]) { + assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`) + } +}) + test('Hermes 配置页会暴露终端执行结构化配置字段', () => { for (const id of [ 'hm-terminal-save', @@ -553,6 +564,7 @@ test('Hermes 配置页新增结构化配置不会暴露翻译 key', () => { key.includes('ExecutionLimits') || key.includes('PrivacyConfig') || key.includes('BrowserConfig') || + key.includes('WebConfig') || key.includes('TerminalConfig') || key.includes('SttConfig') || key.includes('KanbanConfig') || diff --git a/tests/hermes-web-config.test.js b/tests/hermes-web-config.test.js new file mode 100644 index 0000000..db56ee3 --- /dev/null +++ b/tests/hermes-web-config.test.js @@ -0,0 +1,90 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildHermesWebConfigValues, + mergeHermesWebConfig, +} from '../scripts/dev-api.js' + +test('Hermes Web 工具配置读取会提供上游默认值', () => { + const values = buildHermesWebConfigValues({}) + + assert.deepEqual(values, { + webBackend: '', + webSearchBackend: '', + webExtractBackend: '', + }) +}) + +test('Hermes Web 工具配置读取会回显 YAML 字段', () => { + const values = buildHermesWebConfigValues({ + web: { + backend: 'tavily', + search_backend: 'searxng', + extract_backend: 'firecrawl', + }, + }) + + assert.equal(values.webBackend, 'tavily') + assert.equal(values.webSearchBackend, 'searxng') + assert.equal(values.webExtractBackend, 'firecrawl') +}) + +test('Hermes Web 工具配置保存会保留未知字段并写入上游结构', () => { + const next = mergeHermesWebConfig({ + model: { provider: 'anthropic' }, + web: { + backend: 'tavily', + search_backend: 'searxng', + extract_backend: 'firecrawl', + custom_flag: 'keep-web', + }, + streaming: { enabled: true }, + }, { + webBackend: 'parallel', + webSearchBackend: 'exa', + webExtractBackend: 'native', + }) + + assert.deepEqual(next.model, { provider: 'anthropic' }) + assert.deepEqual(next.streaming, { enabled: true }) + assert.equal(next.web.backend, 'parallel') + assert.equal(next.web.search_backend, 'exa') + assert.equal(next.web.extract_backend, 'native') + assert.equal(next.web.custom_flag, 'keep-web') +}) + +test('Hermes Web 工具配置保存空值会移除可选字段', () => { + const next = mergeHermesWebConfig({ + web: { + backend: 'tavily', + search_backend: 'searxng', + extract_backend: 'firecrawl', + custom_flag: 'keep-web', + }, + }, { + webBackend: ' ', + webSearchBackend: '', + webExtractBackend: ' ', + }) + + assert.equal(next.web.custom_flag, 'keep-web') + assert.equal(Object.hasOwn(next.web, 'backend'), false) + assert.equal(Object.hasOwn(next.web, 'search_backend'), false) + assert.equal(Object.hasOwn(next.web, 'extract_backend'), false) +}) + +test('Hermes Web 工具配置保存会拒绝非法后端', () => { + assert.throws( + () => mergeHermesWebConfig({}, { webBackend: 'unsafe' }), + /web\.backend/, + ) + assert.throws( + () => mergeHermesWebConfig({}, { webSearchBackend: 'unsafe' }), + /web\.search_backend/, + ) + assert.throws( + () => mergeHermesWebConfig({}, { webExtractBackend: 'unsafe' }), + /web\.extract_backend/, + ) +})