diff --git a/scripts/dev-api.js b/scripts/dev-api.js index fb809ba..6cb7408 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -3329,6 +3329,8 @@ 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_LSP_WAIT_MODES = new Set(['document', 'full']) +const HERMES_LSP_INSTALL_STRATEGIES = new Set(['auto', 'manual', 'off']) 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']) @@ -3497,6 +3499,20 @@ function normalizeHermesWebBackend(value, key, strict = false) { return '' } +function normalizeHermesLspWaitMode(value, strict = false) { + const mode = String(value ?? '').trim().toLowerCase() || 'document' + if (HERMES_LSP_WAIT_MODES.has(mode)) return mode + if (strict) throw new Error('lsp.wait_mode 必须是 document 或 full') + return 'document' +} + +function normalizeHermesLspInstallStrategy(value, strict = false) { + const strategy = String(value ?? '').trim().toLowerCase() || 'auto' + if (HERMES_LSP_INSTALL_STRATEGIES.has(strategy)) return strategy + if (strict) throw new Error('lsp.install_strategy 必须是 auto、manual 或 off') + return 'auto' +} + function normalizeHermesSttProvider(value, strict = false) { const provider = String(value ?? '').trim().toLowerCase() || 'auto' if (HERMES_STT_PROVIDERS.has(provider)) return provider @@ -5535,6 +5551,33 @@ export function mergeHermesWebConfig(config = {}, form = {}) { return next } +export function buildHermesLspConfigValues(config = {}) { + const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} + const lsp = root.lsp && typeof root.lsp === 'object' && !Array.isArray(root.lsp) + ? root.lsp + : {} + return { + lspEnabled: readHermesBool(lsp.enabled, true), + lspWaitMode: normalizeHermesLspWaitMode(lsp.wait_mode, false), + lspWaitTimeout: parseHermesFloat(lsp.wait_timeout, 'lsp.wait_timeout', 5, 0.1, 120, false), + lspInstallStrategy: normalizeHermesLspInstallStrategy(lsp.install_strategy, false), + } +} + +export function mergeHermesLspConfig(config = {}, form = {}) { + const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {}) + const currentValues = buildHermesLspConfigValues(next) + const lsp = next.lsp && typeof next.lsp === 'object' && !Array.isArray(next.lsp) + ? mergeConfigsPreservingFields(next.lsp, {}) + : {} + lsp.enabled = formHermesBool(form, 'lspEnabled', currentValues.lspEnabled) + lsp.wait_mode = normalizeHermesLspWaitMode(Object.hasOwn(form, 'lspWaitMode') ? form.lspWaitMode : currentValues.lspWaitMode, true) + lsp.wait_timeout = parseHermesFloat(Object.hasOwn(form, 'lspWaitTimeout') ? form.lspWaitTimeout : currentValues.lspWaitTimeout, 'lsp.wait_timeout', 5, 0.1, 120, true) + lsp.install_strategy = normalizeHermesLspInstallStrategy(Object.hasOwn(form, 'lspInstallStrategy') ? form.lspInstallStrategy : currentValues.lspInstallStrategy, true) + next.lsp = lsp + 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) @@ -12838,6 +12881,27 @@ const handlers = { } }, + hermes_lsp_config_read() { + const { configPath, exists, config } = readHermesConfigYamlObject() + return { + exists, + configPath, + values: buildHermesLspConfigValues(config), + } + }, + + hermes_lsp_config_save({ form } = {}) { + const { configPath, config } = readHermesConfigYamlObject() + const next = mergeHermesLspConfig(config, form || {}) + const backup = writeHermesConfigYamlObject(configPath, next) + return { + ok: true, + configPath, + backup, + values: buildHermesLspConfigValues(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 3e2d4a7..8547945 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -7190,6 +7190,43 @@ fn normalize_hermes_web_backend( } } +fn normalize_hermes_lsp_wait_mode(value: Option, strict: bool) -> Result { + let mode = value.unwrap_or_default().trim().to_ascii_lowercase(); + let mode = if mode.is_empty() { + "document".to_string() + } else { + mode + }; + if matches!(mode.as_str(), "document" | "full") { + return Ok(mode); + } + if strict { + Err("lsp.wait_mode 必须是 document 或 full".to_string()) + } else { + Ok("document".to_string()) + } +} + +fn normalize_hermes_lsp_install_strategy( + value: Option, + strict: bool, +) -> Result { + let strategy = value.unwrap_or_default().trim().to_ascii_lowercase(); + let strategy = if strategy.is_empty() { + "auto".to_string() + } else { + strategy + }; + if matches!(strategy.as_str(), "auto" | "manual" | "off") { + return Ok(strategy); + } + if strict { + Err("lsp.install_strategy 必须是 auto、manual 或 off".to_string()) + } else { + Ok("auto".to_string()) + } +} + 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() { @@ -8523,6 +8560,86 @@ fn merge_hermes_web_config(config: &mut serde_yaml::Value, form: &Value) -> Resu Ok(()) } +fn build_hermes_lsp_config_values(config: &serde_yaml::Value) -> Value { + let root = config.as_mapping(); + let lsp = root.and_then(|map| yaml_get_mapping(map, "lsp")); + let lsp_enabled = lsp + .and_then(|map| yaml_bool_field(map, "enabled")) + .unwrap_or(true); + let lsp_wait_mode = normalize_hermes_lsp_wait_mode( + lsp.and_then(|map| yaml_string_field(map, "wait_mode")), + false, + ) + .unwrap_or_else(|_| "document".to_string()); + let lsp_wait_timeout = lsp + .map(|map| bounded_hermes_f64(yaml_f64_field(map, "wait_timeout"), 5.0, 0.1, 120.0)) + .unwrap_or(5.0); + let lsp_install_strategy = normalize_hermes_lsp_install_strategy( + lsp.and_then(|map| yaml_string_field(map, "install_strategy")), + false, + ) + .unwrap_or_else(|_| "auto".to_string()); + + serde_json::json!({ + "lspEnabled": lsp_enabled, + "lspWaitMode": lsp_wait_mode, + "lspWaitTimeout": lsp_wait_timeout, + "lspInstallStrategy": lsp_install_strategy, + }) +} + +fn merge_hermes_lsp_config(config: &mut serde_yaml::Value, form: &Value) -> Result<(), String> { + let current = build_hermes_lsp_config_values(config); + let lsp_enabled = form_bool(form, "lspEnabled") + .unwrap_or_else(|| current["lspEnabled"].as_bool().unwrap_or(true)); + let lsp_wait_mode = normalize_hermes_lsp_wait_mode( + if form.get("lspWaitMode").is_some() { + form_string(form, "lspWaitMode") + } else { + current["lspWaitMode"].as_str().map(ToString::to_string) + }, + true, + )?; + let lsp_wait_timeout = validate_hermes_f64( + if form.get("lspWaitTimeout").is_some() { + form_f64(form, "lspWaitTimeout") + } else { + current["lspWaitTimeout"].as_f64() + }, + "lsp.wait_timeout", + 5.0, + 0.1, + 120.0, + )?; + let lsp_install_strategy = normalize_hermes_lsp_install_strategy( + if form.get("lspInstallStrategy").is_some() { + form_string(form, "lspInstallStrategy") + } else { + current["lspInstallStrategy"] + .as_str() + .map(ToString::to_string) + }, + true, + )?; + + let root = ensure_yaml_object(config)?; + let lsp = yaml_child_object(root, "lsp")?; + lsp.insert(yaml_key("enabled"), serde_yaml::Value::Bool(lsp_enabled)); + lsp.insert( + yaml_key("wait_mode"), + serde_yaml::Value::String(lsp_wait_mode), + ); + lsp.insert( + yaml_key("wait_timeout"), + serde_yaml::Value::Number(serde_yaml::Number::from(lsp_wait_timeout)), + ); + lsp.insert( + yaml_key("install_strategy"), + serde_yaml::Value::String(lsp_install_strategy), + ); + 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")); @@ -11176,6 +11293,30 @@ pub fn hermes_web_config_save(form: Value) -> Result { })) } +#[tauri::command] +pub fn hermes_lsp_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_lsp_config_values(&config), + })) +} + +#[tauri::command] +pub fn hermes_lsp_config_save(form: Value) -> Result { + let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?; + merge_hermes_lsp_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_lsp_config_values(&config), + })) +} + #[tauri::command] pub fn hermes_stt_config_read() -> Result { let (config_path, exists, config) = read_hermes_channel_yaml_config()?; @@ -17769,6 +17910,106 @@ web: } } +#[cfg(test)] +mod hermes_lsp_config_tests { + use super::{build_hermes_lsp_config_values, merge_hermes_lsp_config}; + use serde_json::json; + + #[test] + fn lsp_values_have_upstream_defaults() { + let config: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let values = build_hermes_lsp_config_values(&config); + assert_eq!(values["lspEnabled"], true); + assert_eq!(values["lspWaitMode"], "document"); + assert_eq!(values["lspWaitTimeout"], 5.0); + assert_eq!(values["lspInstallStrategy"], "auto"); + } + + #[test] + fn lsp_values_read_yaml_fields() { + let config: serde_yaml::Value = serde_yaml::from_str( + r#" +lsp: + enabled: false + wait_mode: full + wait_timeout: 12.5 + install_strategy: manual + servers: + pyright: + disabled: true +"#, + ) + .unwrap(); + let values = build_hermes_lsp_config_values(&config); + assert_eq!(values["lspEnabled"], false); + assert_eq!(values["lspWaitMode"], "full"); + assert_eq!(values["lspWaitTimeout"], 12.5); + assert_eq!(values["lspInstallStrategy"], "manual"); + } + + #[test] + fn merge_lsp_config_preserves_unknown_fields() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +model: + provider: anthropic +lsp: + enabled: false + wait_mode: full + wait_timeout: 12.5 + install_strategy: manual + servers: + pyright: + disabled: true + custom_flag: keep-lsp +streaming: + enabled: true +"#, + ) + .unwrap(); + + merge_hermes_lsp_config( + &mut config, + &json!({ + "lspEnabled": true, + "lspWaitMode": "document", + "lspWaitTimeout": 7.5, + "lspInstallStrategy": "off", + }), + ) + .unwrap(); + + assert_eq!(config["model"]["provider"].as_str(), Some("anthropic")); + assert_eq!(config["streaming"]["enabled"].as_bool(), Some(true)); + assert_eq!(config["lsp"]["enabled"].as_bool(), Some(true)); + assert_eq!(config["lsp"]["wait_mode"].as_str(), Some("document")); + assert_eq!(config["lsp"]["wait_timeout"].as_f64(), Some(7.5)); + assert_eq!(config["lsp"]["install_strategy"].as_str(), Some("off")); + assert_eq!( + config["lsp"]["servers"]["pyright"]["disabled"].as_bool(), + Some(true) + ); + assert_eq!(config["lsp"]["custom_flag"].as_str(), Some("keep-lsp")); + } + + #[test] + fn merge_lsp_config_rejects_invalid_values() { + let mut config = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + let err = merge_hermes_lsp_config(&mut config, &json!({ "lspWaitMode": "workspace" })) + .unwrap_err(); + assert!(err.contains("lsp.wait_mode")); + let err = merge_hermes_lsp_config(&mut config, &json!({ "lspInstallStrategy": "unsafe" })) + .unwrap_err(); + assert!(err.contains("lsp.install_strategy")); + let err = + merge_hermes_lsp_config(&mut config, &json!({ "lspWaitTimeout": 0 })).unwrap_err(); + assert!(err.contains("lsp.wait_timeout")); + let err = + merge_hermes_lsp_config(&mut config, &json!({ "lspWaitTimeout": 120.5 })).unwrap_err(); + assert!(err.contains("lsp.wait_timeout")); + } +} + #[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 abdba0e..9e5847f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -329,6 +329,8 @@ pub fn run() { hermes::hermes_browser_config_save, hermes::hermes_web_config_read, hermes::hermes_web_config_save, + hermes::hermes_lsp_config_read, + hermes::hermes_lsp_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 9176895..a5c6ef6 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -300,6 +300,13 @@ const WEB_DEFAULTS = { webExtractBackend: '', } +const LSP_DEFAULTS = { + lspEnabled: true, + lspWaitMode: 'document', + lspWaitTimeout: 5, + lspInstallStrategy: 'auto', +} + const STT_DEFAULTS = { sttEnabled: true, sttProvider: 'auto', @@ -368,6 +375,8 @@ 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 LSP_WAIT_MODES = ['document', 'full'] +const LSP_INSTALL_STRATEGIES = ['auto', 'manual', 'off'] 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'] @@ -434,6 +443,7 @@ export function render() { let privacyValues = { ...PRIVACY_DEFAULTS } let browserValues = { ...BROWSER_DEFAULTS } let webValues = { ...WEB_DEFAULTS } + let lspValues = { ...LSP_DEFAULTS } let sttValues = { ...STT_DEFAULTS } let ttsVoiceValues = { ...TTS_VOICE_DEFAULTS } let terminalValues = { ...TERMINAL_DEFAULTS } @@ -474,6 +484,7 @@ export function render() { let privacyLoading = true let browserLoading = true let webLoading = true + let lspLoading = true let sttLoading = true let ttsVoiceLoading = true let terminalLoading = true @@ -514,6 +525,7 @@ export function render() { let privacySaving = false let browserSaving = false let webSaving = false + let lspSaving = false let sttSaving = false let ttsVoiceSaving = false let terminalSaving = false @@ -554,6 +566,7 @@ export function render() { let privacyError = null let browserError = null let webError = null + let lspError = null let sttError = null let ttsVoiceError = null let terminalError = null @@ -567,7 +580,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 || 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 + 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 || lspLoading || 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 || lspSaving || sttSaving || ttsVoiceSaving || terminalSaving } function option(labelKey, value, selected) { @@ -2257,7 +2270,7 @@ 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 + const disabled = loading || saving || webLoading || webSaving || browserSaving || lspSaving || 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 `
@@ -2298,8 +2311,54 @@ export function render() { ` } + function renderLspConfigPanel() { + const disabled = loading || saving || lspLoading || lspSaving || 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.hermesLspConfigTitle')}
+
${t('engine.hermesLspConfigDesc')}
+
+
+ ${lspSaving ? t('engine.hermesConfigStatusSaving') : lspLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesLspConfigStatusReady')} + +
+
+
+ ${renderError(lspError)} +
+ +
+
+ + + +
+
${t('engine.hermesLspConfigFootnote')}
+
+
+ ` + } + function renderSttPanel() { - 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 + const disabled = loading || saving || sttLoading || sttSaving || webSaving || lspSaving || 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 `
@@ -2628,6 +2687,7 @@ export function render() { ${renderPrivacyPanel()} ${renderBrowserPanel()} ${renderWebConfigPanel()} + ${renderLspConfigPanel()} ${renderSttPanel()} ${renderTtsVoicePanel()} ${renderCompressionPanel()} @@ -2708,6 +2768,7 @@ export function render() { 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-lsp-save')?.addEventListener('click', saveLspConfig) 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) @@ -2898,6 +2959,11 @@ export function render() { webValues = { ...WEB_DEFAULTS, ...(data?.values || {}) } } + async function loadLspConfig() { + const data = await api.hermesLspConfigRead() + lspValues = { ...LSP_DEFAULTS, ...(data?.values || {}) } + } + async function loadSttConfig() { const data = await api.hermesSttConfigRead() sttValues = { ...STT_DEFAULTS, ...(data?.values || {}) } @@ -2951,6 +3017,7 @@ export function render() { privacyLoading = true browserLoading = true webLoading = true + lspLoading = true sttLoading = true ttsVoiceLoading = true terminalLoading = true @@ -2990,6 +3057,7 @@ export function render() { privacyError = null browserError = null webError = null + lspError = null sttError = null ttsVoiceError = null terminalError = null @@ -3153,6 +3221,14 @@ export function render() { webLoading = false draw() } + try { + await loadLspConfig() + } catch (err) { + lspError = humanizeError(err, t('engine.hermesLspConfigLoadFailed') || 'Load LSP config failed') + } finally { + lspLoading = false + draw() + } try { await loadSttConfig() } catch (err) { @@ -4498,6 +4574,34 @@ export function render() { } } + async function saveLspConfig() { + const form = { + lspEnabled: !!el.querySelector('#hm-lsp-enabled')?.checked, + lspWaitMode: el.querySelector('#hm-lsp-wait-mode')?.value || 'document', + lspWaitTimeout: el.querySelector('#hm-lsp-wait-timeout')?.value || '5', + lspInstallStrategy: el.querySelector('#hm-lsp-install-strategy')?.value || 'auto', + } + lspSaving = true + lspError = null + draw() + try { + const result = await api.hermesLspConfigSave(form) + lspValues = { ...LSP_DEFAULTS, ...(result?.values || form) } + await refreshRawAfterStructuredSave() + const backup = result?.backup || '' + toast({ + message: t('engine.hermesLspConfigSaveSuccess'), + hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '', + }, 'success') + } catch (err) { + lspError = humanizeError(err, t('engine.hermesLspConfigSaveFailed') || 'Save LSP config failed') + toast(lspError, 'error') + } finally { + lspSaving = 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 53732ca..0f556d3 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -581,6 +581,8 @@ export const api = { hermesBrowserConfigSave: (form) => invoke('hermes_browser_config_save', { form }), hermesWebConfigRead: () => invoke('hermes_web_config_read'), hermesWebConfigSave: (form) => invoke('hermes_web_config_save', { form }), + hermesLspConfigRead: () => invoke('hermes_lsp_config_read'), + hermesLspConfigSave: (form) => invoke('hermes_lsp_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 9cb97a6..392cb20 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -739,6 +739,23 @@ export default { 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 中。'), + hermesLspConfigTitle: _('LSP 语义诊断', 'LSP semantic diagnostics', 'LSP 語意診斷'), + hermesLspConfigDesc: _('控制写文件和补丁后的语言服务器诊断等待、工作区扫描和缺失服务器自动安装策略。', 'Control language-server diagnostic waits, workspace scans, and missing-server install strategy after file writes and patches.', '控制寫檔和補丁後的語言伺服器診斷等待、工作區掃描和缺失伺服器自動安裝策略。'), + hermesLspConfigStatusReady: _('结构化配置', 'structured settings', '結構化設定'), + hermesLspConfigSave: _('保存 LSP 配置', 'Save LSP settings', '儲存 LSP 設定'), + hermesLspConfigSaveSuccess: _('LSP 语义诊断配置已保存,建议重启 Hermes Gateway 生效', 'LSP semantic diagnostic settings saved. Restart Hermes Gateway to take effect.', 'LSP 語意診斷設定已儲存,建議重啟 Hermes Gateway 生效'), + hermesLspConfigLoadFailed: _('加载 LSP 语义诊断配置失败', 'Load LSP semantic diagnostic settings failed', '載入 LSP 語意診斷設定失敗'), + hermesLspConfigSaveFailed: _('保存 LSP 语义诊断配置失败', 'Save LSP semantic diagnostic settings failed', '儲存 LSP 語意診斷設定失敗'), + hermesLspConfigEnabled: _('启用 LSP 语义诊断', 'Enable LSP semantic diagnostics', '啟用 LSP 語意診斷'), + hermesLspConfigWaitMode: _('诊断等待模式', 'Diagnostic wait mode', '診斷等待模式'), + hermesLspConfigWaitMode_document: _('仅当前文件', 'Current file only', '僅目前檔案'), + hermesLspConfigWaitMode_full: _('工作区诊断', 'Workspace diagnostics', '工作區診斷'), + hermesLspConfigWaitTimeout: _('诊断等待秒数', 'Diagnostic wait seconds', '診斷等待秒數'), + hermesLspConfigInstallStrategy: _('缺失服务器处理', 'Missing server handling', '缺失伺服器處理'), + hermesLspConfigInstallStrategy_auto: _('自动安装', 'Auto install', '自動安裝'), + hermesLspConfigInstallStrategy_manual: _('仅使用 PATH', 'Use PATH only', '僅使用 PATH'), + hermesLspConfigInstallStrategy_off: _('关闭自动安装', 'Disable auto install', '關閉自動安裝'), + hermesLspConfigFootnote: _('这里写入 lsp.enabled、lsp.wait_mode、lsp.wait_timeout 和 lsp.install_strategy。高级 lsp.servers 覆盖保留在 raw YAML;远程或沙箱后端是否触发宿主 LSP 以 Hermes 运行时工作目录和 Git workspace 检测为准。', 'This writes lsp.enabled, lsp.wait_mode, lsp.wait_timeout, and lsp.install_strategy. Advanced lsp.servers overrides stay in raw YAML. Whether remote or sandbox backends trigger host LSP depends on the Hermes runtime cwd and Git workspace detection.', '這裡寫入 lsp.enabled、lsp.wait_mode、lsp.wait_timeout 和 lsp.install_strategy。進階 lsp.servers 覆蓋保留在 raw YAML;遠端或沙箱後端是否觸發宿主 LSP 以 Hermes 執行時工作目錄和 Git workspace 偵測為準。'), 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 2784c70..f6c1437 100644 --- a/tests/hermes-config-page-ui.test.js +++ b/tests/hermes-config-page-ui.test.js @@ -442,6 +442,18 @@ test('Hermes 配置页会暴露 Web 工具后端结构化配置字段', () => { } }) +test('Hermes 配置页会暴露 LSP 语义诊断结构化配置字段', () => { + for (const id of [ + 'hm-lsp-save', + 'hm-lsp-enabled', + 'hm-lsp-wait-mode', + 'hm-lsp-wait-timeout', + 'hm-lsp-install-strategy', + ]) { + assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`) + } +}) + test('Hermes 配置页会暴露终端执行结构化配置字段', () => { for (const id of [ 'hm-terminal-save', diff --git a/tests/hermes-lsp-config.test.js b/tests/hermes-lsp-config.test.js new file mode 100644 index 0000000..293bf82 --- /dev/null +++ b/tests/hermes-lsp-config.test.js @@ -0,0 +1,91 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildHermesLspConfigValues, + mergeHermesLspConfig, +} from '../scripts/dev-api.js' + +test('Hermes LSP 配置读取会提供上游默认值', () => { + const values = buildHermesLspConfigValues({}) + + assert.deepEqual(values, { + lspEnabled: true, + lspWaitMode: 'document', + lspWaitTimeout: 5, + lspInstallStrategy: 'auto', + }) +}) + +test('Hermes LSP 配置读取会回显 YAML 字段并保留复杂 servers 给 raw YAML', () => { + const values = buildHermesLspConfigValues({ + lsp: { + enabled: false, + wait_mode: 'full', + wait_timeout: 12.5, + install_strategy: 'manual', + servers: { + pyright: { + disabled: true, + }, + }, + }, + }) + + assert.equal(values.lspEnabled, false) + assert.equal(values.lspWaitMode, 'full') + assert.equal(values.lspWaitTimeout, 12.5) + assert.equal(values.lspInstallStrategy, 'manual') +}) + +test('Hermes LSP 配置保存会保留未知字段并写入上游结构', () => { + const next = mergeHermesLspConfig({ + model: { provider: 'anthropic' }, + lsp: { + enabled: false, + wait_mode: 'full', + wait_timeout: 12.5, + install_strategy: 'manual', + servers: { + pyright: { + disabled: true, + }, + }, + custom_flag: 'keep-lsp', + }, + streaming: { enabled: true }, + }, { + lspEnabled: true, + lspWaitMode: 'document', + lspWaitTimeout: 7.5, + lspInstallStrategy: 'off', + }) + + assert.deepEqual(next.model, { provider: 'anthropic' }) + assert.deepEqual(next.streaming, { enabled: true }) + assert.equal(next.lsp.enabled, true) + assert.equal(next.lsp.wait_mode, 'document') + assert.equal(next.lsp.wait_timeout, 7.5) + assert.equal(next.lsp.install_strategy, 'off') + assert.deepEqual(next.lsp.servers, { pyright: { disabled: true } }) + assert.equal(next.lsp.custom_flag, 'keep-lsp') +}) + +test('Hermes LSP 配置保存会拒绝非法枚举和超时', () => { + assert.throws( + () => mergeHermesLspConfig({}, { lspWaitMode: 'workspace' }), + /lsp\.wait_mode/, + ) + assert.throws( + () => mergeHermesLspConfig({}, { lspInstallStrategy: 'unsafe' }), + /lsp\.install_strategy/, + ) + assert.throws( + () => mergeHermesLspConfig({}, { lspWaitTimeout: 0 }), + /lsp\.wait_timeout/, + ) + assert.throws( + () => mergeHermesLspConfig({}, { lspWaitTimeout: 120.5 }), + /lsp\.wait_timeout/, + ) +})