From 8c963cd3d4211c34060ad5ee35ee6ea1baf1e98b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Sun, 24 May 2026 22:31:52 +0800 Subject: [PATCH] feat(hermes): add human delay settings --- scripts/dev-api.js | 59 +++++++ src-tauri/src/commands/hermes.rs | 201 ++++++++++++++++++++++++ src-tauri/src/lib.rs | 2 + src/engines/hermes/pages/config.js | 98 +++++++++++- src/lib/tauri-api.js | 2 + src/locales/modules/engine.js | 14 ++ tests/hermes-config-page-ui.test.js | 12 ++ tests/hermes-human-delay-config.test.js | 65 ++++++++ 8 files changed, 452 insertions(+), 1 deletion(-) create mode 100644 tests/hermes-human-delay-config.test.js diff --git a/scripts/dev-api.js b/scripts/dev-api.js index c0f3ad3..7a4985f 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -3731,6 +3731,44 @@ export function mergeHermesSecurityConfig(config = {}, form = {}) { return next } +function normalizeHermesHumanDelayMode(value, strict = false) { + const mode = String(value ?? '').trim().toLowerCase() || 'off' + if (['off', 'natural', 'custom'].includes(mode)) return mode + if (strict) throw new Error('human_delay.mode 必须是 off、natural 或 custom') + return 'off' +} + +export function buildHermesHumanDelayConfigValues(config = {}) { + const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} + const humanDelay = root.human_delay && typeof root.human_delay === 'object' && !Array.isArray(root.human_delay) + ? root.human_delay + : {} + const minMs = parseHermesInteger(humanDelay.min_ms, 'human_delay.min_ms', 800, 0, 60000, false) + const maxMs = parseHermesInteger(humanDelay.max_ms, 'human_delay.max_ms', 2500, 0, 60000, false) + return { + humanDelayMode: normalizeHermesHumanDelayMode(humanDelay.mode, false), + humanDelayMinMs: minMs, + humanDelayMaxMs: Math.max(maxMs, minMs), + } +} + +export function mergeHermesHumanDelayConfig(config = {}, form = {}) { + const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {}) + const currentValues = buildHermesHumanDelayConfigValues(next) + const humanDelay = next.human_delay && typeof next.human_delay === 'object' && !Array.isArray(next.human_delay) + ? mergeConfigsPreservingFields(next.human_delay, {}) + : {} + const mode = normalizeHermesHumanDelayMode(Object.hasOwn(form, 'humanDelayMode') ? form.humanDelayMode : currentValues.humanDelayMode, true) + const minMs = parseHermesInteger(Object.hasOwn(form, 'humanDelayMinMs') ? form.humanDelayMinMs : currentValues.humanDelayMinMs, 'human_delay.min_ms', 800, 0, 60000, true) + const maxMs = parseHermesInteger(Object.hasOwn(form, 'humanDelayMaxMs') ? form.humanDelayMaxMs : currentValues.humanDelayMaxMs, 'human_delay.max_ms', 2500, 0, 60000, true) + if (maxMs < minMs) throw new Error('human_delay.max_ms 不能小于 min_ms') + humanDelay.mode = mode + humanDelay.min_ms = minMs + humanDelay.max_ms = maxMs + next.human_delay = humanDelay + return next +} + export function buildHermesStreamingConfigValues(config = {}) { const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} const streaming = hermesStreamingConfigSource(root) @@ -10279,6 +10317,27 @@ const handlers = { } }, + hermes_human_delay_config_read() { + const { configPath, exists, config } = readHermesConfigYamlObject() + return { + exists, + configPath, + values: buildHermesHumanDelayConfigValues(config), + } + }, + + hermes_human_delay_config_save({ form } = {}) { + const { configPath, config } = readHermesConfigYamlObject() + const next = mergeHermesHumanDelayConfig(config, form || {}) + const backup = writeHermesConfigYamlObject(configPath, next) + return { + ok: true, + configPath, + backup, + values: buildHermesHumanDelayConfigValues(next), + } + }, + hermes_streaming_config_read() { const { configPath, exists, config } = readHermesConfigYamlObject() return { diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index ae4b163..7e604f8 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -3984,6 +3984,92 @@ fn merge_hermes_security_config( Ok(()) } +fn normalize_hermes_human_delay_mode( + value: Option, + strict: bool, +) -> Result { + let mode = value.unwrap_or_default().trim().to_ascii_lowercase(); + let mode = if mode.is_empty() { + "off".to_string() + } else { + mode + }; + if matches!(mode.as_str(), "off" | "natural" | "custom") { + return Ok(mode); + } + if strict { + Err("human_delay.mode 必须是 off、natural 或 custom".to_string()) + } else { + Ok("off".to_string()) + } +} + +fn build_hermes_human_delay_config_values(config: &serde_yaml::Value) -> Value { + let root = config.as_mapping(); + let human_delay = root.and_then(|map| yaml_get_mapping(map, "human_delay")); + let mode = human_delay + .and_then(|map| yaml_string_field(map, "mode")) + .and_then(|value| normalize_hermes_human_delay_mode(Some(value), false).ok()) + .unwrap_or_else(|| "off".to_string()); + let min_ms = human_delay + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "min_ms"), 800, 0, 60000)) + .unwrap_or(800); + let max_ms = human_delay + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "max_ms"), 2500, 0, 60000)) + .unwrap_or(2500) + .max(min_ms); + + serde_json::json!({ + "humanDelayMode": mode, + "humanDelayMinMs": min_ms, + "humanDelayMaxMs": max_ms, + }) +} + +fn merge_hermes_human_delay_config( + config: &mut serde_yaml::Value, + form: &Value, +) -> Result<(), String> { + let current = build_hermes_human_delay_config_values(config); + let mode = normalize_hermes_human_delay_mode( + form_string(form, "humanDelayMode") + .or_else(|| current["humanDelayMode"].as_str().map(ToString::to_string)), + true, + )?; + let min_ms = validate_hermes_i64( + if form.get("humanDelayMinMs").is_some() { + form_i64(form, "humanDelayMinMs") + } else { + Some(current["humanDelayMinMs"].as_i64().unwrap_or(800)) + }, + "human_delay.min_ms", + 800, + 0, + 60000, + )?; + let max_ms = validate_hermes_i64( + if form.get("humanDelayMaxMs").is_some() { + form_i64(form, "humanDelayMaxMs") + } else { + Some(current["humanDelayMaxMs"].as_i64().unwrap_or(2500)) + }, + "human_delay.max_ms", + 2500, + 0, + 60000, + )?; + if max_ms < min_ms { + return Err("human_delay.max_ms 不能小于 min_ms".to_string()); + } + + let root = ensure_yaml_object(config)?; + let human_delay = yaml_child_object(root, "human_delay")?; + human_delay.insert(yaml_key("mode"), serde_yaml::Value::String(mode)); + human_delay.insert(yaml_key("min_ms"), serde_yaml::Value::Number(min_ms.into())); + human_delay.insert(yaml_key("max_ms"), serde_yaml::Value::Number(max_ms.into())); + Ok(()) +} + fn normalize_hermes_streaming_transport( value: Option, strict: bool, @@ -5550,6 +5636,30 @@ pub fn hermes_security_config_save(form: Value) -> Result { })) } +#[tauri::command] +pub fn hermes_human_delay_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_human_delay_config_values(&config), + })) +} + +#[tauri::command] +pub fn hermes_human_delay_config_save(form: Value) -> Result { + let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?; + merge_hermes_human_delay_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_human_delay_config_values(&config), + })) +} + #[tauri::command] pub fn hermes_streaming_config_read() -> Result { let (config_path, exists, config) = read_hermes_channel_yaml_config()?; @@ -11643,6 +11753,97 @@ memory: } } +#[cfg(test)] +mod hermes_human_delay_config_tests { + use super::{build_hermes_human_delay_config_values, merge_hermes_human_delay_config}; + use serde_json::json; + + #[test] + fn human_delay_values_have_upstream_defaults() { + let config: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let values = build_hermes_human_delay_config_values(&config); + assert_eq!(values["humanDelayMode"], "off"); + assert_eq!(values["humanDelayMinMs"], 800); + assert_eq!(values["humanDelayMaxMs"], 2500); + } + + #[test] + fn human_delay_values_normalize_existing_fields() { + let config: serde_yaml::Value = serde_yaml::from_str( + r#" +human_delay: + mode: CUSTOM + min_ms: 1200 + max_ms: 3600 +"#, + ) + .unwrap(); + let values = build_hermes_human_delay_config_values(&config); + assert_eq!(values["humanDelayMode"], "custom"); + assert_eq!(values["humanDelayMinMs"], 1200); + assert_eq!(values["humanDelayMaxMs"], 3600); + } + + #[test] + fn merge_human_delay_config_preserves_unknown_fields() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +model: + provider: anthropic +human_delay: + mode: off + custom_flag: keep-delay +streaming: + enabled: true +memory: + memory_enabled: true +"#, + ) + .unwrap(); + + merge_hermes_human_delay_config( + &mut config, + &json!({ + "humanDelayMode": "custom", + "humanDelayMinMs": "900", + "humanDelayMaxMs": "2400", + }), + ) + .unwrap(); + + assert_eq!(config["model"]["provider"].as_str(), Some("anthropic")); + assert_eq!(config["streaming"]["enabled"].as_bool(), Some(true)); + assert_eq!(config["memory"]["memory_enabled"].as_bool(), Some(true)); + assert_eq!( + config["human_delay"]["custom_flag"].as_str(), + Some("keep-delay") + ); + assert_eq!(config["human_delay"]["mode"].as_str(), Some("custom")); + assert_eq!(config["human_delay"]["min_ms"].as_i64(), Some(900)); + assert_eq!(config["human_delay"]["max_ms"].as_i64(), Some(2400)); + } + + #[test] + fn merge_human_delay_config_rejects_invalid_values() { + let mut config = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + let err = + merge_hermes_human_delay_config(&mut config, &json!({ "humanDelayMode": "slow" })) + .unwrap_err(); + assert!(err.contains("human_delay.mode")); + + let err = merge_hermes_human_delay_config( + &mut config, + &json!({ + "humanDelayMode": "custom", + "humanDelayMinMs": 3000, + "humanDelayMaxMs": 1000, + }), + ) + .unwrap_err(); + assert!(err.contains("human_delay.max_ms")); + } +} + #[cfg(test)] mod hermes_security_config_tests { use super::{build_hermes_security_config_values, merge_hermes_security_config}; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fbd9f84..3885913 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -273,6 +273,8 @@ pub fn run() { hermes::hermes_unauthorized_dm_config_save, hermes::hermes_security_config_read, hermes::hermes_security_config_save, + hermes::hermes_human_delay_config_read, + hermes::hermes_human_delay_config_save, hermes::hermes_streaming_config_read, hermes::hermes_streaming_config_save, hermes::hermes_execution_limits_config_read, diff --git a/src/engines/hermes/pages/config.js b/src/engines/hermes/pages/config.js index 6eed013..dc11149 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -63,6 +63,12 @@ const SECURITY_DEFAULTS = { tirithFailOpen: true, } +const HUMAN_DELAY_DEFAULTS = { + humanDelayMode: 'off', + humanDelayMinMs: 800, + humanDelayMaxMs: 2500, +} + const STREAMING_DEFAULTS = { enabled: false, transport: 'edit', @@ -103,6 +109,7 @@ const STREAMING_TRANSPORTS = ['edit', 'auto', 'draft', 'off'] const CODE_EXECUTION_MODES = ['project', 'strict'] const TERMINAL_BACKENDS = ['local', 'ssh', 'docker', 'singularity', 'modal', 'daytona', 'vercel_sandbox'] const UNAUTHORIZED_DM_BEHAVIORS = ['pair', 'ignore'] +const HUMAN_DELAY_MODES = ['off', 'natural', 'custom'] export function render() { const el = document.createElement('div') @@ -117,6 +124,7 @@ export function render() { let quickCommandsValues = { ...QUICK_COMMANDS_DEFAULTS } let unauthorizedDmValues = { ...UNAUTHORIZED_DM_DEFAULTS } let securityValues = { ...SECURITY_DEFAULTS } + let humanDelayValues = { ...HUMAN_DELAY_DEFAULTS } let streamingValues = { ...STREAMING_DEFAULTS } let executionLimitsValues = { ...EXECUTION_LIMITS_DEFAULTS } let terminalValues = { ...TERMINAL_DEFAULTS } @@ -129,6 +137,7 @@ export function render() { let quickCommandsLoading = true let unauthorizedDmLoading = true let securityLoading = true + let humanDelayLoading = true let streamingLoading = true let executionLimitsLoading = true let terminalLoading = true @@ -141,6 +150,7 @@ export function render() { let quickCommandsSaving = false let unauthorizedDmSaving = false let securitySaving = false + let humanDelaySaving = false let streamingSaving = false let executionLimitsSaving = false let terminalSaving = false @@ -153,6 +163,7 @@ export function render() { let quickCommandsError = null let unauthorizedDmError = null let securityError = null + let humanDelayError = null let streamingError = null let executionLimitsError = null let terminalError = null @@ -166,7 +177,7 @@ export function render() { } function isBusy() { - return loading || runtimeLoading || compressionLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || quickCommandsLoading || unauthorizedDmLoading || securityLoading || streamingLoading || executionLimitsLoading || terminalLoading || saving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || securitySaving || streamingSaving || executionLimitsSaving || terminalSaving + return loading || runtimeLoading || compressionLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || quickCommandsLoading || unauthorizedDmLoading || securityLoading || humanDelayLoading || streamingLoading || executionLimitsLoading || terminalLoading || saving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || securitySaving || humanDelaySaving || streamingSaving || executionLimitsSaving || terminalSaving } function option(labelKey, value, selected) { @@ -522,6 +533,44 @@ export function render() { ` } + function renderHumanDelayConfigPanel() { + const disabled = loading || saving || humanDelayLoading || humanDelaySaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || securitySaving || streamingSaving || executionLimitsSaving || terminalSaving + return ` +
+
+
+
${t('engine.hermesHumanDelayConfigTitle')}
+
${t('engine.hermesHumanDelayConfigDesc')}
+
+
+ ${humanDelaySaving ? t('engine.hermesConfigStatusSaving') : humanDelayLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesHumanDelayConfigStatusReady')} + +
+
+
+ ${renderError(humanDelayError)} +
+ + + +
+
${t('engine.hermesHumanDelayConfigFootnote')}
+
+
+ ` + } + function renderStreamingPanel() { const disabled = loading || saving || streamingLoading || streamingSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || securitySaving || executionLimitsSaving || terminalSaving return ` @@ -742,6 +791,7 @@ export function render() { ${renderQuickCommandsConfigPanel()} ${renderUnauthorizedDmConfigPanel()} ${renderSecurityConfigPanel()} + ${renderHumanDelayConfigPanel()}
@@ -769,6 +819,7 @@ export function render() { el.querySelector('#hm-quick-commands-save')?.addEventListener('click', saveQuickCommandsConfig) el.querySelector('#hm-unauthorized-dm-save')?.addEventListener('click', saveUnauthorizedDmConfig) el.querySelector('#hm-security-save')?.addEventListener('click', saveSecurityConfig) + el.querySelector('#hm-human-delay-save')?.addEventListener('click', saveHumanDelayConfig) el.querySelector('#hm-streaming-save')?.addEventListener('click', saveStreaming) el.querySelector('#hm-execution-limits-save')?.addEventListener('click', saveExecutionLimits) el.querySelector('#hm-terminal-save')?.addEventListener('click', saveTerminal) @@ -819,6 +870,11 @@ export function render() { securityValues = { ...SECURITY_DEFAULTS, ...(data?.values || {}) } } + async function loadHumanDelayConfig() { + const data = await api.hermesHumanDelayConfigRead() + humanDelayValues = { ...HUMAN_DELAY_DEFAULTS, ...(data?.values || {}) } + } + async function loadStreaming() { const data = await api.hermesStreamingConfigRead() streamingValues = { ...STREAMING_DEFAULTS, ...(data?.values || {}) } @@ -844,6 +900,7 @@ export function render() { quickCommandsLoading = true unauthorizedDmLoading = true securityLoading = true + humanDelayLoading = true streamingLoading = true executionLimitsLoading = true terminalLoading = true @@ -856,6 +913,7 @@ export function render() { quickCommandsError = null unauthorizedDmError = null securityError = null + humanDelayError = null streamingError = null executionLimitsError = null terminalError = null @@ -955,6 +1013,14 @@ export function render() { securityLoading = false draw() } + try { + await loadHumanDelayConfig() + } catch (err) { + humanDelayError = humanizeError(err, t('engine.hermesHumanDelayConfigLoadFailed') || 'Load human delay config failed') + } finally { + humanDelayLoading = false + draw() + } } async function refreshRawAfterStructuredSave() { @@ -1000,6 +1066,9 @@ export function render() { try { await loadSecurityConfig() } catch {} + try { + await loadHumanDelayConfig() + } catch {} try { await loadStreaming() } catch {} @@ -1243,6 +1312,33 @@ export function render() { } } + async function saveHumanDelayConfig() { + const form = { + humanDelayMode: el.querySelector('#hm-human-delay-mode')?.value || 'off', + humanDelayMinMs: el.querySelector('#hm-human-delay-min-ms')?.value || '800', + humanDelayMaxMs: el.querySelector('#hm-human-delay-max-ms')?.value || '2500', + } + humanDelaySaving = true + humanDelayError = null + draw() + try { + const result = await api.hermesHumanDelayConfigSave(form) + humanDelayValues = { ...HUMAN_DELAY_DEFAULTS, ...(result?.values || form) } + await refreshRawAfterStructuredSave() + const backup = result?.backup || '' + toast({ + message: t('engine.hermesHumanDelayConfigSaveSuccess'), + hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '', + }, 'success') + } catch (err) { + humanDelayError = humanizeError(err, t('engine.hermesHumanDelayConfigSaveFailed') || 'Save human delay config failed') + toast(humanDelayError, 'error') + } finally { + humanDelaySaving = false + draw() + } + } + async function saveStreaming() { const form = { enabled: !!el.querySelector('#hm-streaming-enabled')?.checked, diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 4cdca44..5936425 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -525,6 +525,8 @@ export const api = { hermesUnauthorizedDmConfigSave: (form) => invoke('hermes_unauthorized_dm_config_save', { form }), hermesSecurityConfigRead: () => invoke('hermes_security_config_read'), hermesSecurityConfigSave: (form) => invoke('hermes_security_config_save', { form }), + hermesHumanDelayConfigRead: () => invoke('hermes_human_delay_config_read'), + hermesHumanDelayConfigSave: (form) => invoke('hermes_human_delay_config_save', { form }), hermesStreamingConfigRead: () => invoke('hermes_streaming_config_read'), hermesStreamingConfigSave: (form) => invoke('hermes_streaming_config_save', { form }), hermesExecutionLimitsConfigRead: () => invoke('hermes_execution_limits_config_read'), diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index 043a839..f08bdcb 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -640,6 +640,20 @@ export default { hermesUnauthorizedDmConfigBehavior_pair: _('回复配对码', 'Reply with pairing code', '回覆配對碼'), hermesUnauthorizedDmConfigBehavior_ignore: _('静默忽略', 'Silently ignore', '靜默忽略'), hermesUnauthorizedDmConfigFootnote: _('pair 是默认值,会拒绝访问但在私信中回复一次性配对码;ignore 会静默丢弃陌生私信。平台级覆盖仍可在渠道配置或 raw YAML 中单独设置。', 'pair is the default: Hermes denies access but replies with a one-time pairing code in DMs. ignore silently drops unknown DMs. Platform-level overrides can still be set in channel settings or raw YAML.', 'pair 是預設值,會拒絕存取但在私訊中回覆一次性配對碼;ignore 會靜默丟棄陌生私訊。平台級覆蓋仍可在頻道設定或 raw YAML 中單獨設定。'), + hermesHumanDelayConfigTitle: _('响应节奏', 'Response pacing', '回應節奏'), + hermesHumanDelayConfigDesc: _('控制消息平台回复分块之间的等待时间,降低刷屏或模拟更自然发送节奏。', 'Control the wait time between reply chunks on messaging platforms to reduce flooding or mimic a more natural sending rhythm.', '控制訊息平台回覆分塊之間的等待時間,降低刷屏或模擬更自然的傳送節奏。'), + hermesHumanDelayConfigStatusReady: _('结构化配置', 'structured settings', '結構化設定'), + hermesHumanDelayConfigSave: _('保存响应节奏', 'Save pacing', '儲存回應節奏'), + hermesHumanDelayConfigSaveSuccess: _('响应节奏已保存,建议重启 Hermes Gateway 生效', 'Response pacing saved. Restart Hermes Gateway to take effect.', '回應節奏已儲存,建議重啟 Hermes Gateway 生效'), + hermesHumanDelayConfigLoadFailed: _('加载响应节奏失败', 'Load response pacing failed', '載入回應節奏失敗'), + hermesHumanDelayConfigSaveFailed: _('保存响应节奏失败', 'Save response pacing failed', '儲存回應節奏失敗'), + hermesHumanDelayConfigMode: _('节奏模式', 'Pacing mode', '節奏模式'), + hermesHumanDelayConfigMode_off: _('关闭', 'Off', '關閉'), + hermesHumanDelayConfigMode_natural: _('自然节奏', 'Natural pacing', '自然節奏'), + hermesHumanDelayConfigMode_custom: _('自定义范围', 'Custom range', '自訂範圍'), + hermesHumanDelayConfigMinMs: _('最小延迟 ms', 'Minimum delay ms', '最小延遲 ms'), + hermesHumanDelayConfigMaxMs: _('最大延迟 ms', 'Maximum delay ms', '最大延遲 ms'), + hermesHumanDelayConfigFootnote: _('natural 使用 800-2500ms;custom 使用下方范围。Signal 等平台可能忽略或仅部分支持该设置。', 'natural uses 800-2500ms; custom uses the range below. Platforms such as Signal may ignore or only partially support this setting.', 'natural 使用 800-2500ms;custom 使用下方範圍。Signal 等平台可能忽略或僅部分支援此設定。'), hermesSecurityConfigTitle: _('Tirith 安全扫描', 'Tirith security scanning', 'Tirith 安全掃描'), hermesSecurityConfigDesc: _('控制终端命令执行前的 Tirith 内容扫描,拦截明显的 URL 伪装、管道执行和注入风险。', 'Control Tirith content scanning before terminal commands run to catch obvious URL spoofing, pipe-to-shell, and injection risks.', '控制終端命令執行前的 Tirith 內容掃描,攔截明顯的 URL 偽裝、管道執行和注入風險。'), hermesSecurityConfigStatusReady: _('结构化配置', 'structured settings', '結構化設定'), diff --git a/tests/hermes-config-page-ui.test.js b/tests/hermes-config-page-ui.test.js index 4ced529..0579d06 100644 --- a/tests/hermes-config-page-ui.test.js +++ b/tests/hermes-config-page-ui.test.js @@ -79,6 +79,17 @@ test('Hermes 配置页会暴露 Tirith 安全扫描结构化配置字段', () => } }) +test('Hermes 配置页会暴露响应节奏结构化配置字段', () => { + for (const id of [ + 'hm-human-delay-save', + 'hm-human-delay-mode', + 'hm-human-delay-min-ms', + 'hm-human-delay-max-ms', + ]) { + assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`) + } +}) + test('Hermes 配置页会暴露网关流式结构化配置字段', () => { for (const id of [ 'hm-streaming-save', @@ -141,6 +152,7 @@ test('Hermes 配置页新增结构化配置不会暴露翻译 key', () => { key.includes('QuickCommandsConfig') || key.includes('UnauthorizedDmConfig') || key.includes('SecurityConfig') || + key.includes('HumanDelayConfig') || key.includes('StreamingConfig') || key.includes('ExecutionLimits') || key.includes('TerminalConfig') diff --git a/tests/hermes-human-delay-config.test.js b/tests/hermes-human-delay-config.test.js new file mode 100644 index 0000000..352a385 --- /dev/null +++ b/tests/hermes-human-delay-config.test.js @@ -0,0 +1,65 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildHermesHumanDelayConfigValues, + mergeHermesHumanDelayConfig, +} from '../scripts/dev-api.js' + +test('Hermes 响应节奏配置读取会提供上游默认值', () => { + const values = buildHermesHumanDelayConfigValues({}) + + assert.deepEqual(values, { + humanDelayMode: 'off', + humanDelayMinMs: 800, + humanDelayMaxMs: 2500, + }) +}) + +test('Hermes 响应节奏配置读取会规范化已有字段', () => { + const values = buildHermesHumanDelayConfigValues({ + human_delay: { + mode: 'CUSTOM', + min_ms: 1200, + max_ms: 3600, + }, + }) + + assert.equal(values.humanDelayMode, 'custom') + assert.equal(values.humanDelayMinMs, 1200) + assert.equal(values.humanDelayMaxMs, 3600) +}) + +test('Hermes 响应节奏配置保存会保留无关 YAML 并写入 human_delay', () => { + const next = mergeHermesHumanDelayConfig({ + model: { provider: 'anthropic' }, + human_delay: { mode: 'off', custom_flag: 'keep-delay' }, + streaming: { enabled: true }, + }, { + humanDelayMode: 'custom', + humanDelayMinMs: '900', + humanDelayMaxMs: '2400', + }) + + assert.deepEqual(next.model, { provider: 'anthropic' }) + assert.deepEqual(next.streaming, { enabled: true }) + assert.equal(next.human_delay.custom_flag, 'keep-delay') + assert.equal(next.human_delay.mode, 'custom') + assert.equal(next.human_delay.min_ms, 900) + assert.equal(next.human_delay.max_ms, 2400) +}) + +test('Hermes 响应节奏配置保存会拒绝非法模式和反向范围', () => { + assert.throws( + () => mergeHermesHumanDelayConfig({}, { humanDelayMode: 'slow' }), + /human_delay\.mode/, + ) + assert.throws( + () => mergeHermesHumanDelayConfig({}, { + humanDelayMode: 'custom', + humanDelayMinMs: 3000, + humanDelayMaxMs: 1000, + }), + /human_delay\.max_ms/, + ) +})