From 5dd6f1be4028166f91a2c87d27e1adc91ee87a96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Sun, 24 May 2026 07:16:00 +0800 Subject: [PATCH] feat(hermes): add compression config form --- scripts/dev-api.js | 70 +++++++ src-tauri/src/commands/hermes.rs | 261 ++++++++++++++++++++++++ src-tauri/src/lib.rs | 2 + src/engines/hermes/pages/config.js | 117 ++++++++++- src/engines/hermes/style/hermes.css | 8 + src/lib/tauri-api.js | 2 + src/locales/modules/engine.js | 14 ++ tests/hermes-compression-config.test.js | 88 ++++++++ 8 files changed, 560 insertions(+), 2 deletions(-) create mode 100644 tests/hermes-compression-config.test.js diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 13a286d..a144a5d 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -3340,6 +3340,24 @@ function parseHermesInteger(value, key, fallback, min, max, strict = false) { return parsed } +function parseHermesFloat(value, key, fallback, min, max, strict = false) { + const raw = String(value ?? '').trim() + if (!raw) { + if (strict) throw new Error(`${key} 不能为空`) + return fallback + } + const parsed = Number.parseFloat(raw) + if (!Number.isFinite(parsed)) { + if (strict) throw new Error(`${key} 必须是数字`) + return fallback + } + if (parsed < min || parsed > max) { + if (strict) throw new Error(`${key} 必须在 ${min}-${max} 范围内`) + return fallback + } + return Number(parsed.toFixed(4)) +} + function readHermesBool(value, fallback) { if (typeof value === 'boolean') return value if (typeof value === 'string') { @@ -3354,6 +3372,37 @@ function formHermesBool(form, key, fallback) { return readHermesBool(form?.[key], fallback) } +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) + ? root.compression + : {} + return { + enabled: readHermesBool(compression.enabled, true), + threshold: parseHermesFloat(compression.threshold, 'compression.threshold', 0.5, 0.1, 0.95, false), + targetRatio: parseHermesFloat(compression.target_ratio, 'compression.target_ratio', 0.2, 0.1, 0.8, false), + protectLastN: parseHermesInteger(compression.protect_last_n, 'compression.protect_last_n', 20, 1, 500, false), + protectFirstN: parseHermesInteger(compression.protect_first_n, 'compression.protect_first_n', 3, 0, 100, false), + abortOnSummaryFailure: readHermesBool(compression.abort_on_summary_failure, false), + } +} + +export function mergeHermesCompressionConfig(config = {}, form = {}) { + const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {}) + const currentValues = buildHermesCompressionConfigValues(next) + const compression = next.compression && typeof next.compression === 'object' && !Array.isArray(next.compression) + ? mergeConfigsPreservingFields(next.compression, {}) + : {} + compression.enabled = formHermesBool(form, 'enabled', currentValues.enabled) + compression.threshold = parseHermesFloat(Object.hasOwn(form, 'threshold') ? form.threshold : currentValues.threshold, 'compression.threshold', 0.5, 0.1, 0.95, true) + compression.target_ratio = parseHermesFloat(Object.hasOwn(form, 'targetRatio') ? form.targetRatio : currentValues.targetRatio, 'compression.target_ratio', 0.2, 0.1, 0.8, true) + compression.protect_last_n = parseHermesInteger(Object.hasOwn(form, 'protectLastN') ? form.protectLastN : currentValues.protectLastN, 'compression.protect_last_n', 20, 1, 500, true) + compression.protect_first_n = parseHermesInteger(Object.hasOwn(form, 'protectFirstN') ? form.protectFirstN : currentValues.protectFirstN, 'compression.protect_first_n', 3, 0, 100, true) + compression.abort_on_summary_failure = formHermesBool(form, 'abortOnSummaryFailure', currentValues.abortOnSummaryFailure) + next.compression = compression + 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) @@ -9576,6 +9625,27 @@ const handlers = { } }, + hermes_compression_config_read() { + const { configPath, exists, config } = readHermesConfigYamlObject() + return { + exists, + configPath, + values: buildHermesCompressionConfigValues(config), + } + }, + + hermes_compression_config_save({ form } = {}) { + const { configPath, config } = readHermesConfigYamlObject() + const next = mergeHermesCompressionConfig(config, form || {}) + const backup = writeHermesConfigYamlObject(configPath, next) + return { + ok: true, + configPath, + backup, + values: buildHermesCompressionConfigValues(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 07808ee..81e798c 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -2879,6 +2879,18 @@ fn form_i64(form: &Value, key: &str) -> Option { } } +fn form_f64(form: &Value, key: &str) -> Option { + let value = form.get(key)?; + if let Some(value) = value.as_f64() { + value.is_finite().then_some(value) + } else { + value + .as_str() + .and_then(|value| value.trim().parse::().ok()) + .filter(|value| value.is_finite()) + } +} + fn form_string_or_default(form: &Value, key: &str, default_value: &str) -> String { form_string(form, key) .map(|value| value.trim().to_string()) @@ -2987,12 +2999,30 @@ fn yaml_i64_field(map: &serde_yaml::Mapping, key: &str) -> Option { } } +fn yaml_f64_field(map: &serde_yaml::Mapping, key: &str) -> Option { + let value = yaml_get(map, key)?; + if let Some(value) = value.as_f64() { + value.is_finite().then_some(value) + } else { + value + .as_str() + .and_then(|value| value.trim().parse::().ok()) + .filter(|value| value.is_finite()) + } +} + fn bounded_hermes_i64(value: Option, fallback: i64, min: i64, max: i64) -> i64 { value .filter(|value| *value >= min && *value <= max) .unwrap_or(fallback) } +fn bounded_hermes_f64(value: Option, fallback: f64, min: f64, max: f64) -> f64 { + value + .filter(|value| value.is_finite() && *value >= min && *value <= max) + .unwrap_or(fallback) +} + fn validate_hermes_i64( value: Option, key: &str, @@ -3007,6 +3037,132 @@ fn validate_hermes_i64( Ok(value) } +fn validate_hermes_f64( + value: Option, + key: &str, + fallback: f64, + min: f64, + max: f64, +) -> Result { + let value = value.unwrap_or(fallback); + if !value.is_finite() || value < min || value > max { + return Err(format!("{key} 必须在 {min}-{max} 范围内")); + } + Ok((value * 10_000.0).round() / 10_000.0) +} + +fn build_hermes_compression_config_values(config: &serde_yaml::Value) -> Value { + let root = config.as_mapping(); + let compression = root.and_then(|map| yaml_get_mapping(map, "compression")); + let enabled = compression + .and_then(|map| yaml_bool_field(map, "enabled")) + .unwrap_or(true); + let threshold = compression + .map(|map| bounded_hermes_f64(yaml_f64_field(map, "threshold"), 0.5, 0.1, 0.95)) + .unwrap_or(0.5); + let target_ratio = compression + .map(|map| bounded_hermes_f64(yaml_f64_field(map, "target_ratio"), 0.2, 0.1, 0.8)) + .unwrap_or(0.2); + let protect_last_n = compression + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "protect_last_n"), 20, 1, 500)) + .unwrap_or(20); + let protect_first_n = compression + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "protect_first_n"), 3, 0, 100)) + .unwrap_or(3); + let abort_on_summary_failure = compression + .and_then(|map| yaml_bool_field(map, "abort_on_summary_failure")) + .unwrap_or(false); + + serde_json::json!({ + "enabled": enabled, + "threshold": threshold, + "targetRatio": target_ratio, + "protectLastN": protect_last_n, + "protectFirstN": protect_first_n, + "abortOnSummaryFailure": abort_on_summary_failure, + }) +} + +fn merge_hermes_compression_config( + config: &mut serde_yaml::Value, + form: &Value, +) -> Result<(), String> { + let current = build_hermes_compression_config_values(config); + let enabled = + form_bool(form, "enabled").unwrap_or_else(|| current["enabled"].as_bool().unwrap_or(true)); + let threshold = validate_hermes_f64( + if form.get("threshold").is_some() { + form_f64(form, "threshold") + } else { + Some(current["threshold"].as_f64().unwrap_or(0.5)) + }, + "compression.threshold", + 0.5, + 0.1, + 0.95, + )?; + let target_ratio = validate_hermes_f64( + if form.get("targetRatio").is_some() { + form_f64(form, "targetRatio") + } else { + Some(current["targetRatio"].as_f64().unwrap_or(0.2)) + }, + "compression.target_ratio", + 0.2, + 0.1, + 0.8, + )?; + let protect_last_n = validate_hermes_i64( + if form.get("protectLastN").is_some() { + form_i64(form, "protectLastN") + } else { + Some(current["protectLastN"].as_i64().unwrap_or(20)) + }, + "compression.protect_last_n", + 20, + 1, + 500, + )?; + let protect_first_n = validate_hermes_i64( + if form.get("protectFirstN").is_some() { + form_i64(form, "protectFirstN") + } else { + Some(current["protectFirstN"].as_i64().unwrap_or(3)) + }, + "compression.protect_first_n", + 3, + 0, + 100, + )?; + let abort_on_summary_failure = form_bool(form, "abortOnSummaryFailure") + .unwrap_or_else(|| current["abortOnSummaryFailure"].as_bool().unwrap_or(false)); + + let root = ensure_yaml_object(config)?; + let compression = yaml_child_object(root, "compression")?; + compression.insert(yaml_key("enabled"), serde_yaml::Value::Bool(enabled)); + compression.insert( + yaml_key("threshold"), + serde_yaml::Value::Number(threshold.into()), + ); + compression.insert( + yaml_key("target_ratio"), + serde_yaml::Value::Number(target_ratio.into()), + ); + compression.insert( + yaml_key("protect_last_n"), + serde_yaml::Value::Number(protect_last_n.into()), + ); + compression.insert( + yaml_key("protect_first_n"), + serde_yaml::Value::Number(protect_first_n.into()), + ); + compression.insert( + yaml_key("abort_on_summary_failure"), + serde_yaml::Value::Bool(abort_on_summary_failure), + ); + 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")); @@ -3817,6 +3973,30 @@ pub fn hermes_session_runtime_config_save(form: Value) -> Result })) } +#[tauri::command] +pub fn hermes_compression_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_compression_config_values(&config), + })) +} + +#[tauri::command] +pub fn hermes_compression_config_save(form: Value) -> Result { + let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?; + merge_hermes_compression_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_compression_config_values(&config), + })) +} + // --------------------------------------------------------------------------- // hermes_read_config — 读取 Hermes config.yaml + .env // --------------------------------------------------------------------------- @@ -8794,6 +8974,87 @@ streaming: } } +#[cfg(test)] +mod hermes_compression_config_tests { + use super::{build_hermes_compression_config_values, merge_hermes_compression_config}; + use serde_json::json; + + #[test] + fn compression_values_have_upstream_defaults() { + let config: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let values = build_hermes_compression_config_values(&config); + assert_eq!(values["enabled"], true); + assert_eq!(values["threshold"], 0.5); + assert_eq!(values["targetRatio"], 0.2); + assert_eq!(values["protectLastN"], 20); + assert_eq!(values["protectFirstN"], 3); + assert_eq!(values["abortOnSummaryFailure"], false); + } + + #[test] + fn merge_compression_config_preserves_unrelated_yaml() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +model: + provider: anthropic +compression: + enabled: true + threshold: 0.5 + custom_flag: keep-me +streaming: + enabled: true +"#, + ) + .unwrap(); + + merge_hermes_compression_config( + &mut config, + &json!({ + "enabled": false, + "threshold": "0.7", + "targetRatio": "0.4", + "protectLastN": "28", + "protectFirstN": "0", + "abortOnSummaryFailure": true, + }), + ) + .unwrap(); + + assert_eq!(config["model"]["provider"].as_str(), Some("anthropic")); + assert_eq!(config["streaming"]["enabled"].as_bool(), Some(true)); + assert_eq!(config["compression"]["enabled"].as_bool(), Some(false)); + assert_eq!(config["compression"]["threshold"].as_f64(), Some(0.7)); + assert_eq!(config["compression"]["target_ratio"].as_f64(), Some(0.4)); + assert_eq!(config["compression"]["protect_last_n"].as_i64(), Some(28)); + assert_eq!(config["compression"]["protect_first_n"].as_i64(), Some(0)); + assert_eq!( + config["compression"]["abort_on_summary_failure"].as_bool(), + Some(true) + ); + assert_eq!( + config["compression"]["custom_flag"].as_str(), + Some("keep-me") + ); + } + + #[test] + fn merge_compression_config_rejects_invalid_values() { + let mut config: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let err = + merge_hermes_compression_config(&mut config, &json!({ "threshold": 0 })).unwrap_err(); + assert!(err.contains("compression.threshold")); + let err = merge_hermes_compression_config(&mut config, &json!({ "targetRatio": 0.05 })) + .unwrap_err(); + assert!(err.contains("compression.target_ratio")); + let err = merge_hermes_compression_config(&mut config, &json!({ "protectLastN": 0 })) + .unwrap_err(); + assert!(err.contains("compression.protect_last_n")); + let err = merge_hermes_compression_config(&mut config, &json!({ "protectFirstN": -1 })) + .unwrap_err(); + assert!(err.contains("compression.protect_first_n")); + } +} + #[cfg(test)] mod hermes_channel_tests { use super::{ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2f7121d..4d71e16 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -259,6 +259,8 @@ pub fn run() { hermes::hermes_channel_config_save, hermes::hermes_session_runtime_config_read, hermes::hermes_session_runtime_config_save, + hermes::hermes_compression_config_read, + hermes::hermes_compression_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 a59659d..fded1de 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -14,6 +14,15 @@ const SESSION_RUNTIME_DEFAULTS = { threadSessionsPerUser: false, } +const COMPRESSION_DEFAULTS = { + enabled: true, + threshold: 0.5, + targetRatio: 0.2, + protectLastN: 20, + protectFirstN: 3, + abortOnSummaryFailure: false, +} + const SESSION_RESET_MODES = ['both', 'idle', 'daily', 'none'] export function render() { @@ -22,12 +31,16 @@ export function render() { el.dataset.engine = 'hermes' let yaml = '' let runtimeValues = { ...SESSION_RUNTIME_DEFAULTS } + let compressionValues = { ...COMPRESSION_DEFAULTS } let loading = true let runtimeLoading = true + let compressionLoading = true let saving = false let runtimeSaving = false + let compressionSaving = false let error = null let runtimeError = null + let compressionError = null function esc(value) { return String(value || '') @@ -38,7 +51,7 @@ export function render() { } function isBusy() { - return loading || runtimeLoading || saving || runtimeSaving + return loading || runtimeLoading || compressionLoading || saving || runtimeSaving || compressionSaving } function option(labelKey, value, selected) { @@ -55,7 +68,7 @@ export function render() { } function renderRuntimePanel() { - const disabled = loading || saving || runtimeLoading || runtimeSaving + const disabled = loading || saving || runtimeLoading || runtimeSaving || compressionSaving return `
@@ -102,6 +115,56 @@ export function render() { ` } + function renderCompressionPanel() { + const disabled = loading || saving || compressionLoading || compressionSaving || runtimeSaving + return ` +
+
+
+
${t('engine.hermesCompressionTitle')}
+
${t('engine.hermesCompressionDesc')}
+
+
+ ${compressionSaving ? t('engine.hermesConfigStatusSaving') : compressionLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesCompressionStatusReady')} + +
+
+
+ ${renderError(compressionError)} +
+ + +
+
+ + + + +
+
${t('engine.hermesCompressionFootnote')}
+
+
+ ` + } + function draw() { el.innerHTML = `
@@ -117,6 +180,7 @@ export function render() {
${renderRuntimePanel()} + ${renderCompressionPanel()}
@@ -137,6 +201,7 @@ export function render() { el.querySelector('#hm-config-reload')?.addEventListener('click', load) el.querySelector('#hm-config-save')?.addEventListener('click', save) el.querySelector('#hm-runtime-save')?.addEventListener('click', saveRuntime) + el.querySelector('#hm-compression-save')?.addEventListener('click', saveCompression) } async function loadRaw() { @@ -149,11 +214,18 @@ export function render() { runtimeValues = { ...SESSION_RUNTIME_DEFAULTS, ...(data?.values || {}) } } + async function loadCompression() { + const data = await api.hermesCompressionConfigRead() + compressionValues = { ...COMPRESSION_DEFAULTS, ...(data?.values || {}) } + } + async function load() { loading = true runtimeLoading = true + compressionLoading = true error = null runtimeError = null + compressionError = null draw() try { await loadRaw() @@ -170,6 +242,14 @@ export function render() { runtimeLoading = false draw() } + try { + await loadCompression() + } catch (err) { + compressionError = humanizeError(err, t('engine.hermesCompressionLoadFailed') || 'Load compression config failed') + } finally { + compressionLoading = false + draw() + } } async function refreshRawAfterStructuredSave() { @@ -194,6 +274,9 @@ export function render() { try { await loadRuntime() } catch {} + try { + await loadCompression() + } catch {} } catch (err) { error = humanizeError(err, t('engine.hermesConfigSaveFailed') || 'Save failed') toast(error, 'error') @@ -232,6 +315,36 @@ export function render() { } } + async function saveCompression() { + const form = { + enabled: !!el.querySelector('#hm-compression-enabled')?.checked, + threshold: el.querySelector('#hm-compression-threshold')?.value || '0.5', + targetRatio: el.querySelector('#hm-compression-target-ratio')?.value || '0.2', + protectLastN: el.querySelector('#hm-compression-protect-last-n')?.value || '20', + protectFirstN: el.querySelector('#hm-compression-protect-first-n')?.value || '3', + abortOnSummaryFailure: !!el.querySelector('#hm-compression-abort-on-summary-failure')?.checked, + } + compressionSaving = true + compressionError = null + draw() + try { + const result = await api.hermesCompressionConfigSave(form) + compressionValues = { ...COMPRESSION_DEFAULTS, ...(result?.values || form) } + await refreshRawAfterStructuredSave() + const backup = result?.backup || '' + toast({ + message: t('engine.hermesCompressionSaveSuccess'), + hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '', + }, 'success') + } catch (err) { + compressionError = humanizeError(err, t('engine.hermesCompressionSaveFailed') || 'Save compression config failed') + toast(compressionError, 'error') + } finally { + compressionSaving = false + draw() + } + } + draw() load() return el diff --git a/src/engines/hermes/style/hermes.css b/src/engines/hermes/style/hermes.css index 5ec61de..5f2ad5b 100644 --- a/src/engines/hermes/style/hermes.css +++ b/src/engines/hermes/style/hermes.css @@ -6753,6 +6753,10 @@ body[data-active-engine="hermes"][data-theme="dark"] { gap: 16px; align-items: end; } +[data-engine="hermes"] .hm-config-compression-grid { + grid-template-columns: repeat(4, minmax(140px, 1fr)); + margin-top: 18px; +} [data-engine="hermes"] .hm-config-check-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -6876,6 +6880,7 @@ body[data-active-engine="hermes"][data-theme="dark"] { grid-template-columns: repeat(2, minmax(0, 1fr)); } [data-engine="hermes"] .hm-config-runtime-grid, + [data-engine="hermes"] .hm-config-compression-grid, [data-engine="hermes"] .hm-config-check-grid { grid-template-columns: 1fr; } @@ -6941,6 +6946,9 @@ body[data-active-engine="hermes"][data-theme="dark"] { width: 100%; flex-wrap: wrap; } + [data-engine="hermes"] .hm-config-runtime-panel .hm-panel-actions .hm-muted { + width: 100%; + } [data-engine="hermes"] .hm-config-runtime-panel .hm-panel-actions .hm-btn { width: 100%; } diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index e22a9c7..0b90089 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -511,6 +511,8 @@ export const api = { hermesChannelConfigSave: (platform, form) => invoke('hermes_channel_config_save', { platform, form }), hermesSessionRuntimeConfigRead: () => invoke('hermes_session_runtime_config_read'), hermesSessionRuntimeConfigSave: (form) => invoke('hermes_session_runtime_config_save', { form }), + hermesCompressionConfigRead: () => invoke('hermes_compression_config_read'), + hermesCompressionConfigSave: (form) => invoke('hermes_compression_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 65d2ec0..0e6ef1c 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -498,6 +498,20 @@ 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.', '建議保持群聊隔離開啟。關閉後,同一群組/頻道會共用上下文和中斷狀態。'), + 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', '結構化設定'), + hermesCompressionSave: _('保存压缩配置', 'Save compression settings', '儲存壓縮設定'), + hermesCompressionSaveSuccess: _('压缩配置已保存,建议重启 Hermes Gateway 生效', 'Compression settings saved. Restart Hermes Gateway to take effect.', '壓縮設定已儲存,建議重啟 Hermes Gateway 生效'), + hermesCompressionLoadFailed: _('加载压缩配置失败', 'Load compression settings failed', '載入壓縮設定失敗'), + hermesCompressionSaveFailed: _('保存压缩配置失败', 'Save compression settings failed', '儲存壓縮設定失敗'), + hermesCompressionEnabled: _('启用自动压缩', 'Enable automatic compression', '啟用自動壓縮'), + hermesCompressionThreshold: _('触发阈值', 'Trigger threshold', '觸發閾值'), + hermesCompressionTargetRatio: _('压缩目标比例', 'Target ratio', '壓縮目標比例'), + hermesCompressionProtectLastN: _('保护最近消息数', 'Protect latest messages', '保護最近訊息數'), + hermesCompressionProtectFirstN: _('保护开头消息数', 'Protect first messages', '保護開頭訊息數'), + hermesCompressionAbortOnSummaryFailure: _('摘要失败时中止回复', 'Abort when summarization fails', '摘要失敗時中止回覆'), + hermesCompressionFootnote: _('阈值和目标比例越低,压缩越早、越激进。建议先使用默认值,再根据真实 Gateway 日志调整。', 'Lower thresholds and target ratios compress earlier and more aggressively. Start with the defaults, then tune with real Gateway logs.', '閾值和目標比例越低,壓縮越早、越激進。建議先使用預設值,再根據真實 Gateway 日誌調整。'), // Batch 1 §E: 会话导出 sessionsExport: _('导出', 'Export', '匯出'), sessionsExportSuccess: _('已导出', 'Exported', '已匯出'), diff --git a/tests/hermes-compression-config.test.js b/tests/hermes-compression-config.test.js new file mode 100644 index 0000000..c7a1510 --- /dev/null +++ b/tests/hermes-compression-config.test.js @@ -0,0 +1,88 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildHermesCompressionConfigValues, + mergeHermesCompressionConfig, +} from '../scripts/dev-api.js' + +test('Hermes 压缩配置读取会提供上游默认值', () => { + const values = buildHermesCompressionConfigValues({}) + + assert.deepEqual(values, { + enabled: true, + threshold: 0.5, + targetRatio: 0.2, + protectLastN: 20, + protectFirstN: 3, + abortOnSummaryFailure: false, + }) +}) + +test('Hermes 压缩配置读取会回显 YAML 中的压缩字段', () => { + const values = buildHermesCompressionConfigValues({ + compression: { + enabled: false, + threshold: 0.65, + target_ratio: 0.35, + protect_last_n: 30, + protect_first_n: 0, + abort_on_summary_failure: true, + }, + }) + + assert.equal(values.enabled, false) + assert.equal(values.threshold, 0.65) + assert.equal(values.targetRatio, 0.35) + assert.equal(values.protectLastN, 30) + assert.equal(values.protectFirstN, 0) + assert.equal(values.abortOnSummaryFailure, true) +}) + +test('Hermes 压缩配置保存会保留无关 YAML 并写入 snake_case 字段', () => { + const next = mergeHermesCompressionConfig({ + model: { provider: 'anthropic', default: 'claude-sonnet-4-6' }, + compression: { + enabled: true, + threshold: 0.5, + custom_flag: 'keep-me', + }, + streaming: { enabled: true }, + }, { + enabled: false, + threshold: '0.7', + targetRatio: '0.4', + protectLastN: '28', + protectFirstN: '0', + abortOnSummaryFailure: true, + }) + + assert.deepEqual(next.model, { provider: 'anthropic', default: 'claude-sonnet-4-6' }) + assert.deepEqual(next.streaming, { enabled: true }) + assert.equal(next.compression.enabled, false) + assert.equal(next.compression.threshold, 0.7) + assert.equal(next.compression.target_ratio, 0.4) + assert.equal(next.compression.protect_last_n, 28) + assert.equal(next.compression.protect_first_n, 0) + assert.equal(next.compression.abort_on_summary_failure, true) + assert.equal(next.compression.custom_flag, 'keep-me') +}) + +test('Hermes 压缩配置保存会拒绝越界比例和消息数量', () => { + assert.throws( + () => mergeHermesCompressionConfig({}, { threshold: '0' }), + /compression\.threshold/, + ) + assert.throws( + () => mergeHermesCompressionConfig({}, { targetRatio: '0.05' }), + /compression\.target_ratio/, + ) + assert.throws( + () => mergeHermesCompressionConfig({}, { protectLastN: '0' }), + /compression\.protect_last_n/, + ) + assert.throws( + () => mergeHermesCompressionConfig({}, { protectFirstN: '-1' }), + /compression\.protect_first_n/, + ) +})