feat(hermes): add streaming config form

This commit is contained in:
晴天
2026-05-24 08:40:53 +08:00
parent 1883e18f02
commit a1307716dd
9 changed files with 656 additions and 6 deletions

View File

@@ -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 = [

View File

@@ -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<String>,
strict: bool,
) -> Result<String, String> {
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<Value, String> {
}))
}
#[tauri::command]
pub fn hermes_streaming_config_read() -> Result<Value, String> {
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<Value, String> {
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};

View File

@@ -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,

View File

@@ -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 `
<div class="hm-panel hm-config-runtime-panel">
<div class="hm-panel-header">
@@ -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 `
<div class="hm-panel hm-config-runtime-panel hm-config-compression-panel">
<div class="hm-panel-header">
@@ -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 `
<div class="hm-panel hm-config-runtime-panel hm-config-guardrails-panel">
<div class="hm-panel-header">
@@ -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 `
<div class="hm-panel hm-config-runtime-panel hm-config-memory-panel">
<div class="hm-panel-header">
@@ -300,6 +314,58 @@ export function render() {
`
}
function renderStreamingPanel() {
const disabled = loading || saving || streamingLoading || streamingSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving
return `
<div class="hm-panel hm-config-runtime-panel hm-config-streaming-panel">
<div class="hm-panel-header">
<div>
<div class="hm-panel-title">${t('engine.hermesStreamingConfigTitle')}</div>
<div class="hm-channel-panel-desc">${t('engine.hermesStreamingConfigDesc')}</div>
</div>
<div class="hm-panel-actions">
<span class="hm-muted">${streamingSaving ? t('engine.hermesConfigStatusSaving') : streamingLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesStreamingConfigStatusReady')}</span>
<button class="hm-btn hm-btn--cta hm-btn--sm" id="hm-streaming-save" ${disabled ? 'disabled' : ''}>${t('engine.hermesStreamingConfigSave')}</button>
</div>
</div>
<div class="hm-panel-body">
${renderError(streamingError)}
<div class="hm-config-check-grid">
<label class="hm-channel-check">
<input id="hm-streaming-enabled" type="checkbox" ${streamingValues.enabled ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
<span>${t('engine.hermesStreamingConfigEnabled')}</span>
</label>
</div>
<div class="hm-config-runtime-grid hm-config-streaming-grid">
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesStreamingConfigTransport')}</span>
<select id="hm-streaming-transport" class="hm-input" ${disabled ? 'disabled' : ''}>
${STREAMING_TRANSPORTS.map(mode => option(`engine.hermesStreamingConfigTransport_${mode}`, mode, streamingValues.transport)).join('')}
</select>
</label>
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesStreamingConfigEditInterval')}</span>
<input id="hm-streaming-edit-interval" class="hm-input" type="number" inputmode="decimal" min="0.05" max="60" step="0.05" value="${esc(streamingValues.editInterval)}" ${disabled ? 'disabled' : ''}>
</label>
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesStreamingConfigBufferThreshold')}</span>
<input id="hm-streaming-buffer-threshold" class="hm-input" type="number" inputmode="numeric" min="1" max="5000" step="1" value="${esc(streamingValues.bufferThreshold)}" ${disabled ? 'disabled' : ''}>
</label>
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesStreamingConfigFreshFinalAfterSeconds')}</span>
<input id="hm-streaming-fresh-final-after-seconds" class="hm-input" type="number" inputmode="decimal" min="0" max="86400" step="1" value="${esc(streamingValues.freshFinalAfterSeconds)}" ${disabled ? 'disabled' : ''}>
</label>
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesStreamingConfigCursor')}</span>
<input id="hm-streaming-cursor" class="hm-input" value="${esc(streamingValues.cursor)}" ${disabled ? 'disabled' : ''}>
</label>
</div>
<div class="hm-channel-footnote">${t('engine.hermesStreamingConfigFootnote')}</div>
</div>
</div>
`
}
function draw() {
el.innerHTML = `
<div class="hm-hero">
@@ -315,6 +381,7 @@ export function render() {
</div>
${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

View File

@@ -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;
}

View File

@@ -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 }),

View File

@@ -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', '結構化設定'),

View File

@@ -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) {

View File

@@ -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/,
)
})