From a1307716dd0fd0ddcc29674f8f0cb51c94968090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Sun, 24 May 2026 08:40:53 +0800 Subject: [PATCH] feat(hermes): add streaming config form --- scripts/dev-api.js | 70 ++++++ src-tauri/src/commands/hermes.rs | 301 ++++++++++++++++++++++++++ src-tauri/src/lib.rs | 2 + src/engines/hermes/pages/config.js | 126 ++++++++++- src/engines/hermes/style/hermes.css | 5 + src/lib/tauri-api.js | 2 + src/locales/modules/engine.js | 18 ++ tests/hermes-config-page-ui.test.js | 16 +- tests/hermes-streaming-config.test.js | 122 +++++++++++ 9 files changed, 656 insertions(+), 6 deletions(-) create mode 100644 tests/hermes-streaming-config.test.js diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 2343c2b..5acd56f 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -3321,6 +3321,7 @@ function normalizeHermesPlatform(platform) { } const HERMES_SESSION_RESET_MODES = new Set(['both', 'idle', 'daily', 'none']) +const HERMES_STREAMING_TRANSPORTS = new Set(['auto', 'draft', 'edit', 'off']) function parseHermesInteger(value, key, fallback, min, max, strict = false) { const raw = String(value ?? '').trim() @@ -3372,6 +3373,25 @@ function formHermesBool(form, key, fallback) { return readHermesBool(form?.[key], fallback) } +function normalizeHermesStreamingTransport(value, strict = false) { + const transport = String(value ?? '').trim().toLowerCase() || 'edit' + if (HERMES_STREAMING_TRANSPORTS.has(transport)) return transport + if (strict) throw new Error('streaming.transport 必须是 auto、draft、edit 或 off') + return 'edit' +} + +function hermesStreamingConfigSource(root) { + if (root.streaming && typeof root.streaming === 'object' && !Array.isArray(root.streaming)) { + return root.streaming + } + const gateway = root.gateway && typeof root.gateway === 'object' && !Array.isArray(root.gateway) + ? root.gateway + : {} + return gateway.streaming && typeof gateway.streaming === 'object' && !Array.isArray(gateway.streaming) + ? gateway.streaming + : {} +} + export function buildHermesCompressionConfigValues(config = {}) { const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} const compression = root.compression && typeof root.compression === 'object' && !Array.isArray(root.compression) @@ -3482,6 +3502,35 @@ export function mergeHermesMemoryConfig(config = {}, form = {}) { return next } +export function buildHermesStreamingConfigValues(config = {}) { + const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} + const streaming = hermesStreamingConfigSource(root) + return { + enabled: readHermesBool(streaming.enabled, false), + transport: normalizeHermesStreamingTransport(streaming.transport, false), + editInterval: parseHermesFloat(streaming.edit_interval, 'streaming.edit_interval', 0.8, 0.05, 60, false), + bufferThreshold: parseHermesInteger(streaming.buffer_threshold, 'streaming.buffer_threshold', 24, 1, 5000, false), + cursor: typeof streaming.cursor === 'string' ? streaming.cursor : ' ▉', + freshFinalAfterSeconds: parseHermesFloat(streaming.fresh_final_after_seconds, 'streaming.fresh_final_after_seconds', 60, 0, 86400, false), + } +} + +export function mergeHermesStreamingConfig(config = {}, form = {}) { + const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {}) + const currentValues = buildHermesStreamingConfigValues(next) + const streaming = next.streaming && typeof next.streaming === 'object' && !Array.isArray(next.streaming) + ? mergeConfigsPreservingFields(next.streaming, {}) + : {} + streaming.enabled = formHermesBool(form, 'enabled', currentValues.enabled) + streaming.transport = normalizeHermesStreamingTransport(Object.hasOwn(form, 'transport') ? form.transport : currentValues.transport, true) + streaming.edit_interval = parseHermesFloat(Object.hasOwn(form, 'editInterval') ? form.editInterval : currentValues.editInterval, 'streaming.edit_interval', 0.8, 0.05, 60, true) + streaming.buffer_threshold = parseHermesInteger(Object.hasOwn(form, 'bufferThreshold') ? form.bufferThreshold : currentValues.bufferThreshold, 'streaming.buffer_threshold', 24, 1, 5000, true) + streaming.cursor = Object.hasOwn(form, 'cursor') ? String(form.cursor ?? '') : currentValues.cursor + streaming.fresh_final_after_seconds = parseHermesFloat(Object.hasOwn(form, 'freshFinalAfterSeconds') ? form.freshFinalAfterSeconds : currentValues.freshFinalAfterSeconds, 'streaming.fresh_final_after_seconds', 60, 0, 86400, true) + next.streaming = streaming + return next +} + export function buildHermesSessionRuntimeConfigValues(config = {}) { const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} const sessionReset = root.session_reset && typeof root.session_reset === 'object' && !Array.isArray(root.session_reset) @@ -9767,6 +9816,27 @@ const handlers = { } }, + hermes_streaming_config_read() { + const { configPath, exists, config } = readHermesConfigYamlObject() + return { + exists, + configPath, + values: buildHermesStreamingConfigValues(config), + } + }, + + hermes_streaming_config_save({ form } = {}) { + const { configPath, config } = readHermesConfigYamlObject() + const next = mergeHermesStreamingConfig(config, form || {}) + const backup = writeHermesConfigYamlObject(configPath, next) + return { + ok: true, + configPath, + backup, + values: buildHermesStreamingConfigValues(next), + } + }, + // P1-3 lazy_deps: Web 模式下不能调 venv python,但仍提供 feature 列表 + 提示用户走桌面端装 hermes_lazy_deps_features() { const features = [ diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 383f7ed..2461ab6 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -3414,6 +3414,149 @@ fn merge_hermes_memory_config(config: &mut serde_yaml::Value, form: &Value) -> R Ok(()) } +fn normalize_hermes_streaming_transport( + value: Option, + strict: bool, +) -> Result { + let transport = value.unwrap_or_default().trim().to_ascii_lowercase(); + let transport = if transport.is_empty() { + "edit".to_string() + } else { + transport + }; + if matches!(transport.as_str(), "auto" | "draft" | "edit" | "off") { + return Ok(transport); + } + if strict { + Err("streaming.transport 必须是 auto、draft、edit 或 off".to_string()) + } else { + Ok("edit".to_string()) + } +} + +fn hermes_streaming_config_source(config: &serde_yaml::Value) -> Option<&serde_yaml::Mapping> { + let root = config.as_mapping()?; + if let Some(streaming) = yaml_get_mapping(root, "streaming") { + return Some(streaming); + } + let gateway = yaml_get_mapping(root, "gateway")?; + yaml_get_mapping(gateway, "streaming") +} + +fn build_hermes_streaming_config_values(config: &serde_yaml::Value) -> Value { + let streaming = hermes_streaming_config_source(config); + let enabled = streaming + .and_then(|map| yaml_bool_field(map, "enabled")) + .unwrap_or(false); + let transport = normalize_hermes_streaming_transport( + streaming.and_then(|map| yaml_string_field(map, "transport")), + false, + ) + .unwrap_or_else(|_| "edit".to_string()); + let edit_interval = streaming + .map(|map| bounded_hermes_f64(yaml_f64_field(map, "edit_interval"), 0.8, 0.05, 60.0)) + .unwrap_or(0.8); + let buffer_threshold = streaming + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "buffer_threshold"), 24, 1, 5000)) + .unwrap_or(24); + let cursor = streaming + .and_then(|map| yaml_string_field(map, "cursor")) + .unwrap_or_else(|| " ▉".to_string()); + let fresh_final_after_seconds = streaming + .map(|map| { + bounded_hermes_f64( + yaml_f64_field(map, "fresh_final_after_seconds"), + 60.0, + 0.0, + 86400.0, + ) + }) + .unwrap_or(60.0); + + serde_json::json!({ + "enabled": enabled, + "transport": transport, + "editInterval": edit_interval, + "bufferThreshold": buffer_threshold, + "cursor": cursor, + "freshFinalAfterSeconds": fresh_final_after_seconds, + }) +} + +fn merge_hermes_streaming_config( + config: &mut serde_yaml::Value, + form: &Value, +) -> Result<(), String> { + let current = build_hermes_streaming_config_values(config); + let enabled = + form_bool(form, "enabled").unwrap_or_else(|| current["enabled"].as_bool().unwrap_or(false)); + let transport = normalize_hermes_streaming_transport( + if form.get("transport").is_some() { + form_string(form, "transport") + } else { + current["transport"].as_str().map(ToString::to_string) + }, + true, + )?; + let edit_interval = validate_hermes_f64( + if form.get("editInterval").is_some() { + form_f64(form, "editInterval") + } else { + Some(current["editInterval"].as_f64().unwrap_or(0.8)) + }, + "streaming.edit_interval", + 0.8, + 0.05, + 60.0, + )?; + let buffer_threshold = validate_hermes_i64( + if form.get("bufferThreshold").is_some() { + form_i64(form, "bufferThreshold") + } else { + Some(current["bufferThreshold"].as_i64().unwrap_or(24)) + }, + "streaming.buffer_threshold", + 24, + 1, + 5000, + )?; + let cursor = if form.get("cursor").is_some() { + form_string(form, "cursor").unwrap_or_default() + } else { + current["cursor"].as_str().unwrap_or(" ▉").to_string() + }; + let fresh_final_after_seconds = validate_hermes_f64( + if form.get("freshFinalAfterSeconds").is_some() { + form_f64(form, "freshFinalAfterSeconds") + } else { + Some(current["freshFinalAfterSeconds"].as_f64().unwrap_or(60.0)) + }, + "streaming.fresh_final_after_seconds", + 60.0, + 0.0, + 86400.0, + )?; + + let root = ensure_yaml_object(config)?; + let streaming = yaml_child_object(root, "streaming")?; + streaming.insert(yaml_key("enabled"), serde_yaml::Value::Bool(enabled)); + streaming.insert(yaml_key("transport"), serde_yaml::Value::String(transport)); + streaming.insert( + yaml_key("edit_interval"), + serde_yaml::Value::Number(edit_interval.into()), + ); + streaming.insert( + yaml_key("buffer_threshold"), + serde_yaml::Value::Number(buffer_threshold.into()), + ); + streaming.insert(yaml_key("cursor"), serde_yaml::Value::String(cursor)); + streaming.insert( + yaml_key("fresh_final_after_seconds"), + serde_yaml::Value::Number(fresh_final_after_seconds.into()), + ); + Ok(()) +} + fn build_hermes_session_runtime_config_values(config: &serde_yaml::Value) -> Value { let root = config.as_mapping(); let session_reset = root.and_then(|map| yaml_get_mapping(map, "session_reset")); @@ -4296,6 +4439,30 @@ pub fn hermes_memory_config_save(form: Value) -> Result { })) } +#[tauri::command] +pub fn hermes_streaming_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_streaming_config_values(&config), + })) +} + +#[tauri::command] +pub fn hermes_streaming_config_save(form: Value) -> Result { + let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?; + merge_hermes_streaming_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_streaming_config_values(&config), + })) +} + // --------------------------------------------------------------------------- // hermes_read_config — 读取 Hermes config.yaml + .env // --------------------------------------------------------------------------- @@ -9482,6 +9649,140 @@ streaming: } } +#[cfg(test)] +mod hermes_streaming_config_tests { + use super::{build_hermes_streaming_config_values, merge_hermes_streaming_config}; + use serde_json::json; + + #[test] + fn streaming_values_have_upstream_defaults() { + let config: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let values = build_hermes_streaming_config_values(&config); + assert_eq!(values["enabled"], false); + assert_eq!(values["transport"], "edit"); + assert_eq!(values["editInterval"], 0.8); + assert_eq!(values["bufferThreshold"], 24); + assert_eq!(values["cursor"], " ▉"); + assert_eq!(values["freshFinalAfterSeconds"], 60.0); + } + + #[test] + fn streaming_values_prefer_top_level_and_fallback_to_gateway() { + let fallback: serde_yaml::Value = serde_yaml::from_str( + r#" +gateway: + streaming: + enabled: true + transport: draft + edit_interval: 0.25 + buffer_threshold: 11 + cursor: "..." + fresh_final_after_seconds: 0 +"#, + ) + .unwrap(); + let values = build_hermes_streaming_config_values(&fallback); + assert_eq!(values["enabled"], true); + assert_eq!(values["transport"], "draft"); + assert_eq!(values["editInterval"], 0.25); + assert_eq!(values["bufferThreshold"], 11); + assert_eq!(values["cursor"], "..."); + assert_eq!(values["freshFinalAfterSeconds"], 0.0); + + let top_level: serde_yaml::Value = serde_yaml::from_str( + r#" +streaming: + enabled: false + transport: auto + edit_interval: 0.5 + buffer_threshold: 40 + cursor: ">" + fresh_final_after_seconds: 120 +gateway: + streaming: + enabled: true + transport: draft +"#, + ) + .unwrap(); + let values = build_hermes_streaming_config_values(&top_level); + assert_eq!(values["enabled"], false); + assert_eq!(values["transport"], "auto"); + assert_eq!(values["editInterval"], 0.5); + assert_eq!(values["bufferThreshold"], 40); + assert_eq!(values["cursor"], ">"); + assert_eq!(values["freshFinalAfterSeconds"], 120.0); + } + + #[test] + fn merge_streaming_config_preserves_unrelated_yaml() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +model: + provider: anthropic +streaming: + enabled: false + custom_flag: keep-me +gateway: + streaming: + enabled: false + legacy_flag: keep-nested +display: + streaming: true +"#, + ) + .unwrap(); + + merge_hermes_streaming_config( + &mut config, + &json!({ + "enabled": true, + "transport": "draft", + "editInterval": "0.35", + "bufferThreshold": "48", + "cursor": "", + "freshFinalAfterSeconds": "0", + }), + ) + .unwrap(); + + assert_eq!(config["model"]["provider"].as_str(), Some("anthropic")); + assert_eq!(config["display"]["streaming"].as_bool(), Some(true)); + assert_eq!( + config["gateway"]["streaming"]["legacy_flag"].as_str(), + Some("keep-nested") + ); + assert_eq!(config["streaming"]["enabled"].as_bool(), Some(true)); + assert_eq!(config["streaming"]["transport"].as_str(), Some("draft")); + assert_eq!(config["streaming"]["edit_interval"].as_f64(), Some(0.35)); + assert_eq!(config["streaming"]["buffer_threshold"].as_i64(), Some(48)); + assert_eq!(config["streaming"]["cursor"].as_str(), Some("")); + assert_eq!( + config["streaming"]["fresh_final_after_seconds"].as_f64(), + Some(0.0) + ); + assert_eq!(config["streaming"]["custom_flag"].as_str(), Some("keep-me")); + } + + #[test] + fn merge_streaming_config_rejects_invalid_values() { + let mut config = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + let err = merge_hermes_streaming_config(&mut config, &json!({ "transport": "invalid" })) + .unwrap_err(); + assert!(err.contains("streaming.transport")); + let err = merge_hermes_streaming_config(&mut config, &json!({ "editInterval": 0.01 })) + .unwrap_err(); + assert!(err.contains("streaming.edit_interval")); + let err = merge_hermes_streaming_config(&mut config, &json!({ "bufferThreshold": 0 })) + .unwrap_err(); + assert!(err.contains("streaming.buffer_threshold")); + let err = + merge_hermes_streaming_config(&mut config, &json!({ "freshFinalAfterSeconds": -1 })) + .unwrap_err(); + assert!(err.contains("streaming.fresh_final_after_seconds")); + } +} + #[cfg(test)] mod hermes_memory_config_tests { use super::{build_hermes_memory_config_values, merge_hermes_memory_config}; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 507a706..299cf69 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -265,6 +265,8 @@ pub fn run() { hermes::hermes_tool_loop_guardrails_config_save, hermes::hermes_memory_config_read, hermes::hermes_memory_config_save, + hermes::hermes_streaming_config_read, + hermes::hermes_streaming_config_save, hermes::hermes_lazy_deps_features, hermes::hermes_lazy_deps_status, hermes::hermes_lazy_deps_ensure, diff --git a/src/engines/hermes/pages/config.js b/src/engines/hermes/pages/config.js index 5b866dc..d0fc831 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -42,7 +42,17 @@ const MEMORY_DEFAULTS = { nudgeInterval: 10, } +const STREAMING_DEFAULTS = { + enabled: false, + transport: 'edit', + editInterval: 0.8, + bufferThreshold: 24, + cursor: ' ▉', + freshFinalAfterSeconds: 60, +} + const SESSION_RESET_MODES = ['both', 'idle', 'daily', 'none'] +const STREAMING_TRANSPORTS = ['edit', 'auto', 'draft', 'off'] export function render() { const el = document.createElement('div') @@ -53,21 +63,25 @@ export function render() { let compressionValues = { ...COMPRESSION_DEFAULTS } let toolGuardrailsValues = { ...TOOL_GUARDRAILS_DEFAULTS } let memoryValues = { ...MEMORY_DEFAULTS } + let streamingValues = { ...STREAMING_DEFAULTS } let loading = true let runtimeLoading = true let compressionLoading = true let toolGuardrailsLoading = true let memoryLoading = true + let streamingLoading = true let saving = false let runtimeSaving = false let compressionSaving = false let toolGuardrailsSaving = false let memorySaving = false + let streamingSaving = false let error = null let runtimeError = null let compressionError = null let toolGuardrailsError = null let memoryError = null + let streamingError = null function esc(value) { return String(value ?? '') @@ -78,7 +92,7 @@ export function render() { } function isBusy() { - return loading || runtimeLoading || compressionLoading || toolGuardrailsLoading || memoryLoading || saving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving + return loading || runtimeLoading || compressionLoading || toolGuardrailsLoading || memoryLoading || streamingLoading || saving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || streamingSaving } function option(labelKey, value, selected) { @@ -95,7 +109,7 @@ export function render() { } function renderRuntimePanel() { - const disabled = loading || saving || runtimeLoading || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving + const disabled = loading || saving || runtimeLoading || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || streamingSaving return `
@@ -143,7 +157,7 @@ export function render() { } function renderCompressionPanel() { - const disabled = loading || saving || compressionLoading || compressionSaving || runtimeSaving || toolGuardrailsSaving || memorySaving + const disabled = loading || saving || compressionLoading || compressionSaving || runtimeSaving || toolGuardrailsSaving || memorySaving || streamingSaving return `
@@ -193,7 +207,7 @@ export function render() { } function renderToolGuardrailsPanel() { - const disabled = loading || saving || toolGuardrailsLoading || toolGuardrailsSaving || runtimeSaving || compressionSaving || memorySaving + const disabled = loading || saving || toolGuardrailsLoading || toolGuardrailsSaving || runtimeSaving || compressionSaving || memorySaving || streamingSaving return `
@@ -255,7 +269,7 @@ export function render() { } function renderMemoryPanel() { - const disabled = loading || saving || memoryLoading || memorySaving || runtimeSaving || compressionSaving || toolGuardrailsSaving + const disabled = loading || saving || memoryLoading || memorySaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || streamingSaving return `
@@ -300,6 +314,58 @@ export function render() { ` } + function renderStreamingPanel() { + const disabled = loading || saving || streamingLoading || streamingSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving + return ` +
+
+
+
${t('engine.hermesStreamingConfigTitle')}
+
${t('engine.hermesStreamingConfigDesc')}
+
+
+ ${streamingSaving ? t('engine.hermesConfigStatusSaving') : streamingLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesStreamingConfigStatusReady')} + +
+
+
+ ${renderError(streamingError)} +
+ +
+
+ + + + + +
+
${t('engine.hermesStreamingConfigFootnote')}
+
+
+ ` + } + function draw() { el.innerHTML = `
@@ -315,6 +381,7 @@ export function render() {
${renderRuntimePanel()} + ${renderStreamingPanel()} ${renderCompressionPanel()} ${renderToolGuardrailsPanel()} ${renderMemoryPanel()} @@ -341,6 +408,7 @@ export function render() { el.querySelector('#hm-compression-save')?.addEventListener('click', saveCompression) el.querySelector('#hm-tool-guardrails-save')?.addEventListener('click', saveToolGuardrails) el.querySelector('#hm-memory-save')?.addEventListener('click', saveMemory) + el.querySelector('#hm-streaming-save')?.addEventListener('click', saveStreaming) } async function loadRaw() { @@ -368,17 +436,24 @@ export function render() { memoryValues = { ...MEMORY_DEFAULTS, ...(data?.values || {}) } } + async function loadStreaming() { + const data = await api.hermesStreamingConfigRead() + streamingValues = { ...STREAMING_DEFAULTS, ...(data?.values || {}) } + } + async function load() { loading = true runtimeLoading = true compressionLoading = true toolGuardrailsLoading = true memoryLoading = true + streamingLoading = true error = null runtimeError = null compressionError = null toolGuardrailsError = null memoryError = null + streamingError = null draw() try { await loadRaw() @@ -411,6 +486,14 @@ export function render() { toolGuardrailsLoading = false draw() } + try { + await loadStreaming() + } catch (err) { + streamingError = humanizeError(err, t('engine.hermesStreamingConfigLoadFailed') || 'Load streaming config failed') + } finally { + streamingLoading = false + draw() + } try { await loadMemory() } catch (err) { @@ -452,6 +535,9 @@ export function render() { try { await loadMemory() } catch {} + try { + await loadStreaming() + } catch {} } catch (err) { error = humanizeError(err, t('engine.hermesConfigSaveFailed') || 'Save failed') toast(error, 'error') @@ -581,6 +667,36 @@ export function render() { } } + async function saveStreaming() { + const form = { + enabled: !!el.querySelector('#hm-streaming-enabled')?.checked, + transport: el.querySelector('#hm-streaming-transport')?.value || 'edit', + editInterval: el.querySelector('#hm-streaming-edit-interval')?.value || '0.8', + bufferThreshold: el.querySelector('#hm-streaming-buffer-threshold')?.value || '24', + cursor: el.querySelector('#hm-streaming-cursor')?.value ?? ' ▉', + freshFinalAfterSeconds: el.querySelector('#hm-streaming-fresh-final-after-seconds')?.value || '60', + } + streamingSaving = true + streamingError = null + draw() + try { + const result = await api.hermesStreamingConfigSave(form) + streamingValues = { ...STREAMING_DEFAULTS, ...(result?.values || form) } + await refreshRawAfterStructuredSave() + const backup = result?.backup || '' + toast({ + message: t('engine.hermesStreamingConfigSaveSuccess'), + hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '', + }, 'success') + } catch (err) { + streamingError = humanizeError(err, t('engine.hermesStreamingConfigSaveFailed') || 'Save streaming config failed') + toast(streamingError, 'error') + } finally { + streamingSaving = false + draw() + } + } + draw() load() return el diff --git a/src/engines/hermes/style/hermes.css b/src/engines/hermes/style/hermes.css index 2339221..83738b9 100644 --- a/src/engines/hermes/style/hermes.css +++ b/src/engines/hermes/style/hermes.css @@ -6765,6 +6765,10 @@ body[data-active-engine="hermes"][data-theme="dark"] { grid-template-columns: repeat(3, minmax(160px, 1fr)); margin-top: 18px; } +[data-engine="hermes"] .hm-config-streaming-grid { + grid-template-columns: repeat(5, minmax(140px, 1fr)); + margin-top: 18px; +} [data-engine="hermes"] .hm-config-subtitle { margin-top: 20px; color: var(--hm-text-secondary); @@ -6898,6 +6902,7 @@ body[data-active-engine="hermes"][data-theme="dark"] { [data-engine="hermes"] .hm-config-compression-grid, [data-engine="hermes"] .hm-config-guardrails-grid, [data-engine="hermes"] .hm-config-memory-grid, + [data-engine="hermes"] .hm-config-streaming-grid, [data-engine="hermes"] .hm-config-check-grid { grid-template-columns: 1fr; } diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index d77a4a3..b03a926 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -517,6 +517,8 @@ export const api = { hermesToolLoopGuardrailsConfigSave: (form) => invoke('hermes_tool_loop_guardrails_config_save', { form }), hermesMemoryConfigRead: () => invoke('hermes_memory_config_read'), hermesMemoryConfigSave: (form) => invoke('hermes_memory_config_save', { form }), + hermesStreamingConfigRead: () => invoke('hermes_streaming_config_read'), + hermesStreamingConfigSave: (form) => invoke('hermes_streaming_config_save', { form }), hermesLazyDepsFeatures: () => cachedInvoke('hermes_lazy_deps_features', {}, 600000), hermesLazyDepsStatus: (features) => invoke('hermes_lazy_deps_status', { features }), hermesLazyDepsEnsure: (feature) => invoke('hermes_lazy_deps_ensure', { feature }), diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index 119d562..9303a7f 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -498,6 +498,24 @@ export default { hermesGroupSessionsPerUser: _('群聊按成员隔离会话', 'Isolate group sessions per user', '群聊依成員隔離會話'), hermesThreadSessionsPerUser: _('线程也按成员隔离', 'Isolate thread sessions per user', '討論串也依成員隔離'), hermesSessionRuntimeFootnote: _('推荐保持群聊隔离开启。关闭后,同一群/频道会共用上下文和中断状态。', 'Keeping group isolation on is recommended. Turning it off shares context and interrupt state across the same group or channel.', '建議保持群聊隔離開啟。關閉後,同一群組/頻道會共用上下文和中斷狀態。'), + hermesStreamingConfigTitle: _('网关流式输出', 'Gateway streaming output', '閘道流式輸出'), + hermesStreamingConfigDesc: _('控制 Hermes Gateway 回复时是否边生成边更新消息,以及消息刷新节奏。适合需要更快看到长回复进度的渠道。', 'Control whether Hermes Gateway updates messages while replies are generated, plus the refresh cadence. Useful when channels need quicker progress for long replies.', '控制 Hermes Gateway 回覆時是否邊生成邊更新訊息,以及訊息刷新節奏。適合需要更快看到長回覆進度的渠道。'), + hermesStreamingConfigStatusReady: _('结构化配置', 'structured settings', '結構化設定'), + hermesStreamingConfigSave: _('保存流式配置', 'Save streaming settings', '儲存流式設定'), + hermesStreamingConfigSaveSuccess: _('流式配置已保存,建议重启 Hermes Gateway 生效', 'Streaming settings saved. Restart Hermes Gateway to take effect.', '流式設定已儲存,建議重啟 Hermes Gateway 生效'), + hermesStreamingConfigLoadFailed: _('加载流式配置失败', 'Load streaming settings failed', '載入流式設定失敗'), + hermesStreamingConfigSaveFailed: _('保存流式配置失败', 'Save streaming settings failed', '儲存流式設定失敗'), + hermesStreamingConfigEnabled: _('启用流式输出', 'Enable streaming output', '啟用流式輸出'), + hermesStreamingConfigTransport: _('消息更新方式', 'Message update mode', '訊息更新方式'), + hermesStreamingConfigTransport_edit: _('编辑原消息', 'Edit original message', '編輯原訊息'), + hermesStreamingConfigTransport_auto: _('自动选择', 'Auto select', '自動選擇'), + hermesStreamingConfigTransport_draft: _('草稿式更新', 'Draft updates', '草稿式更新'), + hermesStreamingConfigTransport_off: _('关闭更新', 'Disable updates', '關閉更新'), + hermesStreamingConfigEditInterval: _('消息编辑间隔(秒)', 'Message edit interval (s)', '訊息編輯間隔(秒)'), + hermesStreamingConfigBufferThreshold: _('触发刷新字符数', 'Refresh trigger characters', '觸發刷新字元數'), + hermesStreamingConfigFreshFinalAfterSeconds: _('长回复完成新消息时间(秒)', 'Fresh final message after (s)', '長回覆完成新訊息時間(秒)'), + hermesStreamingConfigCursor: _('生成中标记', 'In-progress marker', '生成中標記'), + hermesStreamingConfigFootnote: _('这里写入顶层 streaming 配置;旧版 gateway.streaming、display.streaming 和其他高级字段会保留在 raw YAML 中。将“长回复完成新消息时间”设为 0 可关闭完成后新消息。', 'This writes the top-level streaming settings. Legacy gateway.streaming, display.streaming, and other advanced fields are preserved in raw YAML. Set fresh final message time to 0 to disable the final follow-up message.', '這裡寫入頂層 streaming 設定;舊版 gateway.streaming、display.streaming 和其他進階欄位會保留在 raw YAML 中。將「長回覆完成新訊息時間」設為 0 可關閉完成後新訊息。'), hermesCompressionTitle: _('上下文压缩', 'Context compression', '上下文壓縮'), hermesCompressionDesc: _('控制长对话何时触发压缩、压缩目标和保留范围,降低上下文过长导致的失败与费用浪费。', 'Control when long conversations are compressed, the target size, and protected message ranges to reduce failures and wasted cost from oversized context.', '控制長對話何時觸發壓縮、壓縮目標和保留範圍,降低上下文過長導致的失敗與費用浪費。'), hermesCompressionStatusReady: _('结构化配置', 'structured settings', '結構化設定'), diff --git a/tests/hermes-config-page-ui.test.js b/tests/hermes-config-page-ui.test.js index 6f0145f..534cfdc 100644 --- a/tests/hermes-config-page-ui.test.js +++ b/tests/hermes-config-page-ui.test.js @@ -38,12 +38,26 @@ test('Hermes 配置页会暴露记忆结构化配置字段', () => { } }) +test('Hermes 配置页会暴露网关流式结构化配置字段', () => { + for (const id of [ + 'hm-streaming-save', + 'hm-streaming-enabled', + 'hm-streaming-transport', + 'hm-streaming-edit-interval', + 'hm-streaming-buffer-threshold', + 'hm-streaming-cursor', + 'hm-streaming-fresh-final-after-seconds', + ]) { + assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`) + } +}) + test('Hermes 配置页数值输入会保留 0 值显示', () => { assert.doesNotMatch(source, /String\(value \|\| ''\)/, 'esc(value) 不能把合法 0 渲染为空字符串') }) test('Hermes 配置页新增结构化配置不会暴露翻译 key', () => { - const keys = new Set(extractEngineKeys().filter(key => key.includes('ToolGuardrails') || key.includes('MemoryConfig'))) + const keys = new Set(extractEngineKeys().filter(key => key.includes('ToolGuardrails') || key.includes('MemoryConfig') || key.includes('StreamingConfig'))) assert.ok(keys.size > 0, '应能提取新增结构化配置用到的 engine 翻译 key') for (const key of keys) { diff --git a/tests/hermes-streaming-config.test.js b/tests/hermes-streaming-config.test.js new file mode 100644 index 0000000..77364ba --- /dev/null +++ b/tests/hermes-streaming-config.test.js @@ -0,0 +1,122 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildHermesStreamingConfigValues, + mergeHermesStreamingConfig, +} from '../scripts/dev-api.js' + +test('Hermes 网关流式配置读取会提供上游默认值', () => { + const values = buildHermesStreamingConfigValues({}) + + assert.deepEqual(values, { + enabled: false, + transport: 'edit', + editInterval: 0.8, + bufferThreshold: 24, + cursor: ' ▉', + freshFinalAfterSeconds: 60, + }) +}) + +test('Hermes 网关流式配置读取会优先使用顶层 streaming 并兼容 gateway.streaming', () => { + const fallbackValues = buildHermesStreamingConfigValues({ + gateway: { + streaming: { + enabled: true, + transport: 'draft', + edit_interval: 0.25, + buffer_threshold: 11, + cursor: '...', + fresh_final_after_seconds: 0, + }, + }, + }) + + assert.equal(fallbackValues.enabled, true) + assert.equal(fallbackValues.transport, 'draft') + assert.equal(fallbackValues.editInterval, 0.25) + assert.equal(fallbackValues.bufferThreshold, 11) + assert.equal(fallbackValues.cursor, '...') + assert.equal(fallbackValues.freshFinalAfterSeconds, 0) + + const topLevelValues = buildHermesStreamingConfigValues({ + streaming: { + enabled: false, + transport: 'auto', + edit_interval: 0.5, + buffer_threshold: 40, + cursor: '>', + fresh_final_after_seconds: 120, + }, + gateway: { + streaming: { + enabled: true, + transport: 'draft', + }, + }, + }) + + assert.equal(topLevelValues.enabled, false) + assert.equal(topLevelValues.transport, 'auto') + assert.equal(topLevelValues.editInterval, 0.5) + assert.equal(topLevelValues.bufferThreshold, 40) + assert.equal(topLevelValues.cursor, '>') + assert.equal(topLevelValues.freshFinalAfterSeconds, 120) +}) + +test('Hermes 网关流式配置保存会写入顶层 streaming 并保留无关 YAML', () => { + const next = mergeHermesStreamingConfig({ + model: { provider: 'anthropic' }, + streaming: { + enabled: false, + custom_flag: 'keep-me', + }, + gateway: { + streaming: { + enabled: false, + legacy_flag: 'keep-nested', + }, + }, + display: { + streaming: true, + }, + }, { + enabled: true, + transport: 'draft', + editInterval: '0.35', + bufferThreshold: '48', + cursor: '', + freshFinalAfterSeconds: '0', + }) + + assert.deepEqual(next.model, { provider: 'anthropic' }) + assert.equal(next.display.streaming, true) + assert.equal(next.gateway.streaming.legacy_flag, 'keep-nested') + assert.equal(next.streaming.enabled, true) + assert.equal(next.streaming.transport, 'draft') + assert.equal(next.streaming.edit_interval, 0.35) + assert.equal(next.streaming.buffer_threshold, 48) + assert.equal(next.streaming.cursor, '') + assert.equal(next.streaming.fresh_final_after_seconds, 0) + assert.equal(next.streaming.custom_flag, 'keep-me') +}) + +test('Hermes 网关流式配置保存会拒绝非法传输模式和越界节奏', () => { + assert.throws( + () => mergeHermesStreamingConfig({}, { transport: 'invalid' }), + /streaming\.transport/, + ) + assert.throws( + () => mergeHermesStreamingConfig({}, { editInterval: '0.01' }), + /streaming\.edit_interval/, + ) + assert.throws( + () => mergeHermesStreamingConfig({}, { bufferThreshold: '0' }), + /streaming\.buffer_threshold/, + ) + assert.throws( + () => mergeHermesStreamingConfig({}, { freshFinalAfterSeconds: '-1' }), + /streaming\.fresh_final_after_seconds/, + ) +})