From d245d2e320a2fd8130054de7e06fa9cf1cce604d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Sun, 24 May 2026 23:03:41 +0800 Subject: [PATCH] feat(hermes): add display reliability settings --- scripts/dev-api.js | 95 ++++++ src-tauri/src/commands/hermes.rs | 443 ++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 2 + src/engines/hermes/pages/config.js | 136 ++++++++- src/lib/tauri-api.js | 2 + src/locales/modules/engine.js | 38 +++ tests/hermes-config-page-ui.test.js | 17 ++ tests/hermes-display-config.test.js | 107 +++++++ 8 files changed, 839 insertions(+), 1 deletion(-) create mode 100644 tests/hermes-display-config.test.js diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 7a4985f..b621960 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -3326,6 +3326,9 @@ const HERMES_CODE_EXECUTION_MODES = new Set(['project', 'strict']) const HERMES_TERMINAL_BACKENDS = new Set(['local', 'ssh', 'docker', 'singularity', 'modal', 'daytona', 'vercel_sandbox']) const HERMES_DISPLAY_TOOL_PROGRESS_VALUES = new Set(['off', 'new', 'all', 'verbose']) const HERMES_DISPLAY_STREAMING_VALUES = new Set(['inherit', 'true', 'false']) +const HERMES_DISPLAY_RESUME_VALUES = new Set(['full', 'minimal']) +const HERMES_DISPLAY_LANGUAGE_VALUES = new Set(['en', 'zh', 'zh-hant', 'ja', 'de', 'es', 'fr', 'tr', 'uk', 'af', 'ko', 'it', 'ga', 'pt', 'ru', 'hu']) +const HERMES_RUNTIME_FOOTER_FIELDS = new Set(['model', 'context_pct', 'cwd', 'duration', 'tokens', 'cost']) function parseHermesInteger(value, key, fallback, min, max, strict = false) { const raw = String(value ?? '').trim() @@ -3413,6 +3416,34 @@ function normalizeHermesDisplayStreaming(value, strict = false, key = 'display.s return 'inherit' } +function normalizeHermesDisplayResume(value, strict = false) { + const mode = String(value ?? '').trim().toLowerCase() || 'full' + if (HERMES_DISPLAY_RESUME_VALUES.has(mode)) return mode + if (strict) throw new Error('display.resume_display 必须是 full 或 minimal') + return 'full' +} + +function normalizeHermesDisplayLanguage(value, strict = false) { + const language = String(value ?? '').trim().toLowerCase() || 'en' + if (HERMES_DISPLAY_LANGUAGE_VALUES.has(language)) return language + if (strict) throw new Error('display.language 不在支持列表中') + return 'en' +} + +function normalizeHermesRuntimeFooterFields(value, strict = false) { + const items = Array.isArray(value) + ? value + : String(value ?? '').split(/\r?\n|,/) + const normalized = [...new Set(items.map(item => String(item ?? '').trim()).filter(Boolean))] + if (!normalized.length) return ['model', 'context_pct', 'cwd'] + const invalid = normalized.find(item => !HERMES_RUNTIME_FOOTER_FIELDS.has(item)) + if (invalid) { + if (strict) throw new Error(`display.runtime_footer.fields 包含不支持的字段: ${invalid}`) + return ['model', 'context_pct', 'cwd'] + } + return normalized +} + function hermesDisplayConfigParts(config = {}, platform = '') { const display = config?.display && typeof config.display === 'object' && !Array.isArray(config.display) ? config.display @@ -3426,6 +3457,49 @@ function hermesDisplayConfigParts(config = {}, platform = '') { return { display, platformDisplay } } +export function buildHermesDisplayConfigValues(config = {}) { + const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} + const display = root.display && typeof root.display === 'object' && !Array.isArray(root.display) + ? root.display + : {} + const runtimeFooter = display.runtime_footer && typeof display.runtime_footer === 'object' && !Array.isArray(display.runtime_footer) + ? display.runtime_footer + : {} + return { + displayToolProgress: normalizeHermesDisplayToolProgress(display.tool_progress, false), + displayToolProgressCommand: readHermesBool(display.tool_progress_command, false), + displayInterimAssistantMessages: readHermesBool(display.interim_assistant_messages, true), + displayRuntimeFooterEnabled: readHermesBool(runtimeFooter.enabled, false), + displayRuntimeFooterFields: normalizeHermesRuntimeFooterFields(runtimeFooter.fields, false).join('\n'), + displayFileMutationVerifier: readHermesBool(display.file_mutation_verifier, true), + displayLanguage: normalizeHermesDisplayLanguage(display.language, false), + displayResumeDisplay: normalizeHermesDisplayResume(display.resume_display, false), + } +} + +export function mergeHermesDisplayConfig(config = {}, form = {}) { + const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {}) + const currentValues = buildHermesDisplayConfigValues(next) + const display = next.display && typeof next.display === 'object' && !Array.isArray(next.display) + ? mergeConfigsPreservingFields(next.display, {}) + : {} + const runtimeFooter = display.runtime_footer && typeof display.runtime_footer === 'object' && !Array.isArray(display.runtime_footer) + ? mergeConfigsPreservingFields(display.runtime_footer, {}) + : {} + + display.tool_progress = normalizeHermesDisplayToolProgress(Object.hasOwn(form, 'displayToolProgress') ? form.displayToolProgress : currentValues.displayToolProgress, true, 'display.tool_progress') + display.tool_progress_command = formHermesBool(form, 'displayToolProgressCommand', currentValues.displayToolProgressCommand) + display.interim_assistant_messages = formHermesBool(form, 'displayInterimAssistantMessages', currentValues.displayInterimAssistantMessages) + runtimeFooter.enabled = formHermesBool(form, 'displayRuntimeFooterEnabled', currentValues.displayRuntimeFooterEnabled) + runtimeFooter.fields = normalizeHermesRuntimeFooterFields(Object.hasOwn(form, 'displayRuntimeFooterFields') ? form.displayRuntimeFooterFields : currentValues.displayRuntimeFooterFields, true) + display.runtime_footer = runtimeFooter + display.file_mutation_verifier = formHermesBool(form, 'displayFileMutationVerifier', currentValues.displayFileMutationVerifier) + display.language = normalizeHermesDisplayLanguage(Object.hasOwn(form, 'displayLanguage') ? form.displayLanguage : currentValues.displayLanguage, true) + display.resume_display = normalizeHermesDisplayResume(Object.hasOwn(form, 'displayResumeDisplay') ? form.displayResumeDisplay : currentValues.displayResumeDisplay, true) + next.display = display + return next +} + function putHermesChannelDisplayFields(form, config, platform) { const { display, platformDisplay } = hermesDisplayConfigParts(config, platform) const legacyToolProgress = display.tool_progress_overrides && typeof display.tool_progress_overrides === 'object' && !Array.isArray(display.tool_progress_overrides) @@ -10338,6 +10412,27 @@ const handlers = { } }, + hermes_display_config_read() { + const { configPath, exists, config } = readHermesConfigYamlObject() + return { + exists, + configPath, + values: buildHermesDisplayConfigValues(config), + } + }, + + hermes_display_config_save({ form } = {}) { + const { configPath, config } = readHermesConfigYamlObject() + const next = mergeHermesDisplayConfig(config, form || {}) + const backup = writeHermesConfigYamlObject(configPath, next) + return { + ok: true, + configPath, + backup, + values: buildHermesDisplayConfigValues(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 7e604f8..599ebe9 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -4004,6 +4004,263 @@ fn normalize_hermes_human_delay_mode( } } +const HERMES_DISPLAY_LANGUAGE_VALUES: &[&str] = &[ + "en", "zh", "zh-hant", "ja", "de", "es", "fr", "tr", "uk", "af", "ko", "it", "ga", "pt", "ru", + "hu", +]; + +const HERMES_RUNTIME_FOOTER_FIELDS: &[&str] = + &["model", "context_pct", "cwd", "duration", "tokens", "cost"]; + +fn normalize_hermes_display_language( + value: Option, + strict: bool, +) -> Result { + let language = value.unwrap_or_default().trim().to_ascii_lowercase(); + let language = if language.is_empty() { + "en".to_string() + } else { + language + }; + if HERMES_DISPLAY_LANGUAGE_VALUES.contains(&language.as_str()) { + Ok(language) + } else if strict { + Err("display.language 不在支持列表中".to_string()) + } else { + Ok("en".to_string()) + } +} + +fn normalize_hermes_display_resume(value: Option, strict: bool) -> Result { + let mode = value.unwrap_or_default().trim().to_ascii_lowercase(); + let mode = if mode.is_empty() { + "full".to_string() + } else { + mode + }; + if matches!(mode.as_str(), "full" | "minimal") { + Ok(mode) + } else if strict { + Err("display.resume_display 必须是 full 或 minimal".to_string()) + } else { + Ok("full".to_string()) + } +} + +fn normalize_hermes_runtime_footer_fields_text( + value: Option, + strict: bool, +) -> Result, String> { + let fields = match value { + Some(value) => { + let text = value.trim().to_string(); + if text.contains('\n') || text.contains(',') { + text.split(['\n', ',']) + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(ToString::to_string) + .collect::>() + } else if text.is_empty() { + Vec::new() + } else { + vec![text] + } + } + None => Vec::new(), + }; + let fields = if fields.is_empty() { + vec![ + "model".to_string(), + "context_pct".to_string(), + "cwd".to_string(), + ] + } else { + fields + }; + if let Some(invalid) = fields + .iter() + .find(|item| !HERMES_RUNTIME_FOOTER_FIELDS.contains(&item.as_str())) + { + if strict { + return Err(format!( + "display.runtime_footer.fields 包含不支持的字段: {invalid}" + )); + } + return Ok(vec![ + "model".to_string(), + "context_pct".to_string(), + "cwd".to_string(), + ]); + } + Ok(fields) +} + +fn normalize_hermes_runtime_footer_fields( + value: Option<&serde_yaml::Value>, + strict: bool, +) -> Result, String> { + let fields = match value { + Some(serde_yaml::Value::Sequence(items)) => items + .iter() + .filter_map(|item| item.as_str().map(str::trim)) + .filter(|item| !item.is_empty()) + .map(ToString::to_string) + .collect::>(), + Some(serde_yaml::Value::String(text)) => text + .split(['\n', ',']) + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(ToString::to_string) + .collect::>(), + _ => Vec::new(), + }; + normalize_hermes_runtime_footer_fields_text( + if fields.is_empty() { + None + } else { + Some(fields.join("\n")) + }, + strict, + ) +} + +fn build_hermes_display_config_values(config: &serde_yaml::Value) -> Value { + let root = config.as_mapping(); + let display = root.and_then(|map| yaml_get_mapping(map, "display")); + let runtime_footer = display.and_then(|map| yaml_get_mapping(map, "runtime_footer")); + let runtime_footer_fields = normalize_hermes_runtime_footer_fields( + runtime_footer.and_then(|map| yaml_get(map, "fields")), + false, + ) + .unwrap_or_else(|_| { + vec![ + "model".to_string(), + "context_pct".to_string(), + "cwd".to_string(), + ] + }); + + serde_json::json!({ + "displayToolProgress": normalize_hermes_display_tool_progress( + display.and_then(|map| yaml_string_field(map, "tool_progress")), + false, + "display.tool_progress", + ).unwrap_or_else(|_| "all".to_string()), + "displayToolProgressCommand": display.and_then(|map| yaml_bool_field(map, "tool_progress_command")).unwrap_or(false), + "displayInterimAssistantMessages": display.and_then(|map| yaml_bool_field(map, "interim_assistant_messages")).unwrap_or(true), + "displayRuntimeFooterEnabled": runtime_footer.and_then(|map| yaml_bool_field(map, "enabled")).unwrap_or(false), + "displayRuntimeFooterFields": runtime_footer_fields.join("\n"), + "displayFileMutationVerifier": display.and_then(|map| yaml_bool_field(map, "file_mutation_verifier")).unwrap_or(true), + "displayLanguage": normalize_hermes_display_language( + display.and_then(|map| yaml_string_field(map, "language")), + false, + ).unwrap_or_else(|_| "en".to_string()), + "displayResumeDisplay": normalize_hermes_display_resume( + display.and_then(|map| yaml_string_field(map, "resume_display")), + false, + ).unwrap_or_else(|_| "full".to_string()), + }) +} + +fn merge_hermes_display_config(config: &mut serde_yaml::Value, form: &Value) -> Result<(), String> { + let current = build_hermes_display_config_values(config); + let tool_progress = normalize_hermes_display_tool_progress( + form_string(form, "displayToolProgress").or_else(|| { + current["displayToolProgress"] + .as_str() + .map(ToString::to_string) + }), + true, + "display.tool_progress", + )?; + let runtime_footer_fields = normalize_hermes_runtime_footer_fields_text( + form.get("displayRuntimeFooterFields") + .and_then(|value| value.as_str().map(ToString::to_string)) + .or_else(|| { + current["displayRuntimeFooterFields"] + .as_str() + .map(ToString::to_string) + }), + true, + )?; + + let display = yaml_child_object(ensure_yaml_object(config)?, "display")?; + display.insert( + yaml_key("tool_progress"), + serde_yaml::Value::String(tool_progress), + ); + display.insert( + yaml_key("tool_progress_command"), + serde_yaml::Value::Bool( + form_bool(form, "displayToolProgressCommand").unwrap_or_else(|| { + current["displayToolProgressCommand"] + .as_bool() + .unwrap_or(false) + }), + ), + ); + display.insert( + yaml_key("interim_assistant_messages"), + serde_yaml::Value::Bool( + form_bool(form, "displayInterimAssistantMessages").unwrap_or_else(|| { + current["displayInterimAssistantMessages"] + .as_bool() + .unwrap_or(true) + }), + ), + ); + display.insert( + yaml_key("file_mutation_verifier"), + serde_yaml::Value::Bool( + form_bool(form, "displayFileMutationVerifier").unwrap_or_else(|| { + current["displayFileMutationVerifier"] + .as_bool() + .unwrap_or(true) + }), + ), + ); + display.insert( + yaml_key("language"), + serde_yaml::Value::String(normalize_hermes_display_language( + form_string(form, "displayLanguage") + .or_else(|| current["displayLanguage"].as_str().map(ToString::to_string)), + true, + )?), + ); + display.insert( + yaml_key("resume_display"), + serde_yaml::Value::String(normalize_hermes_display_resume( + form_string(form, "displayResumeDisplay").or_else(|| { + current["displayResumeDisplay"] + .as_str() + .map(ToString::to_string) + }), + true, + )?), + ); + let runtime_footer = yaml_child_object(display, "runtime_footer")?; + runtime_footer.insert( + yaml_key("enabled"), + serde_yaml::Value::Bool( + form_bool(form, "displayRuntimeFooterEnabled").unwrap_or_else(|| { + current["displayRuntimeFooterEnabled"] + .as_bool() + .unwrap_or(false) + }), + ), + ); + runtime_footer.insert( + yaml_key("fields"), + serde_yaml::Value::Sequence( + runtime_footer_fields + .into_iter() + .map(serde_yaml::Value::String) + .collect(), + ), + ); + Ok(()) +} + 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")); @@ -5636,6 +5893,29 @@ pub fn hermes_security_config_save(form: Value) -> Result { })) } +#[tauri::command] +pub fn hermes_display_config_read() -> Result { + let (config_path, exists, config) = read_hermes_channel_yaml_config()?; + Ok(serde_json::json!({ + "exists": exists, + "configPath": config_path.to_string_lossy(), + "values": build_hermes_display_config_values(&config), + })) +} + +#[tauri::command] +pub fn hermes_display_config_save(form: Value) -> Result { + let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?; + merge_hermes_display_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_display_config_values(&config), + })) +} + #[tauri::command] pub fn hermes_human_delay_config_read() -> Result { let (config_path, exists, config) = read_hermes_channel_yaml_config()?; @@ -11844,6 +12124,169 @@ memory: } } +#[cfg(test)] +mod hermes_display_config_tests { + use super::{build_hermes_display_config_values, merge_hermes_display_config}; + use serde_json::json; + + #[test] + fn display_values_have_upstream_defaults() { + let config: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let values = build_hermes_display_config_values(&config); + assert_eq!(values["displayToolProgress"], "all"); + assert_eq!(values["displayToolProgressCommand"], false); + assert_eq!(values["displayInterimAssistantMessages"], true); + assert_eq!(values["displayRuntimeFooterEnabled"], false); + assert_eq!( + values["displayRuntimeFooterFields"], + "model\ncontext_pct\ncwd" + ); + assert_eq!(values["displayFileMutationVerifier"], true); + assert_eq!(values["displayLanguage"], "en"); + assert_eq!(values["displayResumeDisplay"], "full"); + } + + #[test] + fn display_values_normalize_existing_fields() { + let config: serde_yaml::Value = serde_yaml::from_str( + r#" +display: + tool_progress: VERBOSE + tool_progress_command: true + interim_assistant_messages: false + runtime_footer: + enabled: true + fields: + - model + - duration + - cost + file_mutation_verifier: false + language: ZH + resume_display: minimal +"#, + ) + .unwrap(); + let values = build_hermes_display_config_values(&config); + assert_eq!(values["displayToolProgress"], "verbose"); + assert_eq!(values["displayToolProgressCommand"], true); + assert_eq!(values["displayInterimAssistantMessages"], false); + assert_eq!(values["displayRuntimeFooterEnabled"], true); + assert_eq!( + values["displayRuntimeFooterFields"], + "model\nduration\ncost" + ); + assert_eq!(values["displayFileMutationVerifier"], false); + assert_eq!(values["displayLanguage"], "zh"); + assert_eq!(values["displayResumeDisplay"], "minimal"); + } + + #[test] + fn merge_display_config_preserves_unknown_fields() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +model: + provider: anthropic +display: + skin: midnight + runtime_footer: + enabled: false + custom_flag: keep-footer + platforms: + telegram: + tool_progress: new +memory: + memory_enabled: true +"#, + ) + .unwrap(); + + merge_hermes_display_config( + &mut config, + &json!({ + "displayToolProgress": "off", + "displayToolProgressCommand": true, + "displayInterimAssistantMessages": false, + "displayRuntimeFooterEnabled": true, + "displayRuntimeFooterFields": "model\ncontext_pct\nduration", + "displayFileMutationVerifier": true, + "displayLanguage": "zh-hant", + "displayResumeDisplay": "minimal", + }), + ) + .unwrap(); + + assert_eq!(config["model"]["provider"].as_str(), Some("anthropic")); + assert_eq!(config["memory"]["memory_enabled"].as_bool(), Some(true)); + assert_eq!(config["display"]["skin"].as_str(), Some("midnight")); + assert_eq!( + config["display"]["platforms"]["telegram"]["tool_progress"].as_str(), + Some("new") + ); + assert_eq!(config["display"]["tool_progress"].as_str(), Some("off")); + assert_eq!( + config["display"]["tool_progress_command"].as_bool(), + Some(true) + ); + assert_eq!( + config["display"]["interim_assistant_messages"].as_bool(), + Some(false) + ); + assert_eq!( + config["display"]["runtime_footer"]["enabled"].as_bool(), + Some(true) + ); + assert_eq!( + config["display"]["runtime_footer"]["custom_flag"].as_str(), + Some("keep-footer") + ); + assert_eq!( + config["display"]["runtime_footer"]["fields"] + .as_sequence() + .unwrap() + .iter() + .filter_map(|item| item.as_str()) + .collect::>(), + vec!["model", "context_pct", "duration"] + ); + assert_eq!( + config["display"]["file_mutation_verifier"].as_bool(), + Some(true) + ); + assert_eq!(config["display"]["language"].as_str(), Some("zh-hant")); + assert_eq!( + config["display"]["resume_display"].as_str(), + Some("minimal") + ); + } + + #[test] + fn merge_display_config_rejects_invalid_values() { + let mut config = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + let err = merge_hermes_display_config( + &mut config, + &json!({ "displayToolProgress": "everything" }), + ) + .unwrap_err(); + assert!(err.contains("display.tool_progress")); + + let err = + merge_hermes_display_config(&mut config, &json!({ "displayResumeDisplay": "compact" })) + .unwrap_err(); + assert!(err.contains("display.resume_display")); + + let err = merge_hermes_display_config(&mut config, &json!({ "displayLanguage": "cn" })) + .unwrap_err(); + assert!(err.contains("display.language")); + + let err = merge_hermes_display_config( + &mut config, + &json!({ "displayRuntimeFooterFields": "model\npassword" }), + ) + .unwrap_err(); + assert!(err.contains("display.runtime_footer.fields")); + } +} + #[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 3885913..e2d9931 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_display_config_read, + hermes::hermes_display_config_save, hermes::hermes_human_delay_config_read, hermes::hermes_human_delay_config_save, hermes::hermes_streaming_config_read, diff --git a/src/engines/hermes/pages/config.js b/src/engines/hermes/pages/config.js index dc11149..3b99bfc 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -63,6 +63,17 @@ const SECURITY_DEFAULTS = { tirithFailOpen: true, } +const DISPLAY_DEFAULTS = { + displayToolProgress: 'all', + displayToolProgressCommand: false, + displayInterimAssistantMessages: true, + displayRuntimeFooterEnabled: false, + displayRuntimeFooterFields: 'model\ncontext_pct\ncwd', + displayFileMutationVerifier: true, + displayLanguage: 'en', + displayResumeDisplay: 'full', +} + const HUMAN_DELAY_DEFAULTS = { humanDelayMode: 'off', humanDelayMinMs: 800, @@ -109,6 +120,9 @@ 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 DISPLAY_TOOL_PROGRESS_VALUES = ['off', 'new', 'all', 'verbose'] +const DISPLAY_LANGUAGE_VALUES = ['en', 'zh', 'zh-hant', 'ja', 'de', 'es', 'fr', 'tr', 'uk', 'af', 'ko', 'it', 'ga', 'pt', 'ru', 'hu'] +const DISPLAY_RESUME_VALUES = ['full', 'minimal'] const HUMAN_DELAY_MODES = ['off', 'natural', 'custom'] export function render() { @@ -124,6 +138,7 @@ export function render() { let quickCommandsValues = { ...QUICK_COMMANDS_DEFAULTS } let unauthorizedDmValues = { ...UNAUTHORIZED_DM_DEFAULTS } let securityValues = { ...SECURITY_DEFAULTS } + let displayValues = { ...DISPLAY_DEFAULTS } let humanDelayValues = { ...HUMAN_DELAY_DEFAULTS } let streamingValues = { ...STREAMING_DEFAULTS } let executionLimitsValues = { ...EXECUTION_LIMITS_DEFAULTS } @@ -137,6 +152,7 @@ export function render() { let quickCommandsLoading = true let unauthorizedDmLoading = true let securityLoading = true + let displayLoading = true let humanDelayLoading = true let streamingLoading = true let executionLimitsLoading = true @@ -150,6 +166,7 @@ export function render() { let quickCommandsSaving = false let unauthorizedDmSaving = false let securitySaving = false + let displaySaving = false let humanDelaySaving = false let streamingSaving = false let executionLimitsSaving = false @@ -163,6 +180,7 @@ export function render() { let quickCommandsError = null let unauthorizedDmError = null let securityError = null + let displayError = null let humanDelayError = null let streamingError = null let executionLimitsError = null @@ -177,7 +195,7 @@ export function render() { } function isBusy() { - 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 + return loading || runtimeLoading || compressionLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || quickCommandsLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || streamingLoading || executionLimitsLoading || terminalLoading || saving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || streamingSaving || executionLimitsSaving || terminalSaving } function option(labelKey, value, selected) { @@ -533,6 +551,70 @@ export function render() { ` } + function renderDisplayConfigPanel() { + const disabled = loading || saving || displayLoading || displaySaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || securitySaving || humanDelaySaving || streamingSaving || executionLimitsSaving || terminalSaving + return ` +
+
+
+
${t('engine.hermesDisplayConfigTitle')}
+
${t('engine.hermesDisplayConfigDesc')}
+
+
+ ${displaySaving ? t('engine.hermesConfigStatusSaving') : displayLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesDisplayConfigStatusReady')} + +
+
+
+ ${renderError(displayError)} +
+ + + + +
+
+ + + + +
+
${t('engine.hermesDisplayConfigFootnote')}
+
+
+ ` + } + function renderHumanDelayConfigPanel() { const disabled = loading || saving || humanDelayLoading || humanDelaySaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || securitySaving || streamingSaving || executionLimitsSaving || terminalSaving return ` @@ -791,6 +873,7 @@ export function render() { ${renderQuickCommandsConfigPanel()} ${renderUnauthorizedDmConfigPanel()} ${renderSecurityConfigPanel()} + ${renderDisplayConfigPanel()} ${renderHumanDelayConfigPanel()}
@@ -819,6 +902,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-display-save')?.addEventListener('click', saveDisplayConfig) 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) @@ -870,6 +954,11 @@ export function render() { securityValues = { ...SECURITY_DEFAULTS, ...(data?.values || {}) } } + async function loadDisplayConfig() { + const data = await api.hermesDisplayConfigRead() + displayValues = { ...DISPLAY_DEFAULTS, ...(data?.values || {}) } + } + async function loadHumanDelayConfig() { const data = await api.hermesHumanDelayConfigRead() humanDelayValues = { ...HUMAN_DELAY_DEFAULTS, ...(data?.values || {}) } @@ -900,6 +989,7 @@ export function render() { quickCommandsLoading = true unauthorizedDmLoading = true securityLoading = true + displayLoading = true humanDelayLoading = true streamingLoading = true executionLimitsLoading = true @@ -913,6 +1003,7 @@ export function render() { quickCommandsError = null unauthorizedDmError = null securityError = null + displayError = null humanDelayError = null streamingError = null executionLimitsError = null @@ -1013,6 +1104,14 @@ export function render() { securityLoading = false draw() } + try { + await loadDisplayConfig() + } catch (err) { + displayError = humanizeError(err, t('engine.hermesDisplayConfigLoadFailed') || 'Load display config failed') + } finally { + displayLoading = false + draw() + } try { await loadHumanDelayConfig() } catch (err) { @@ -1066,6 +1165,9 @@ export function render() { try { await loadSecurityConfig() } catch {} + try { + await loadDisplayConfig() + } catch {} try { await loadHumanDelayConfig() } catch {} @@ -1312,6 +1414,38 @@ export function render() { } } + async function saveDisplayConfig() { + const form = { + displayToolProgress: el.querySelector('#hm-display-tool-progress')?.value || 'all', + displayToolProgressCommand: !!el.querySelector('#hm-display-tool-progress-command')?.checked, + displayInterimAssistantMessages: !!el.querySelector('#hm-display-interim-assistant-messages')?.checked, + displayRuntimeFooterEnabled: !!el.querySelector('#hm-display-runtime-footer-enabled')?.checked, + displayRuntimeFooterFields: el.querySelector('#hm-display-runtime-footer-fields')?.value || 'model\ncontext_pct\ncwd', + displayFileMutationVerifier: !!el.querySelector('#hm-display-file-mutation-verifier')?.checked, + displayLanguage: el.querySelector('#hm-display-language')?.value || 'en', + displayResumeDisplay: el.querySelector('#hm-display-resume-display')?.value || 'full', + } + displaySaving = true + displayError = null + draw() + try { + const result = await api.hermesDisplayConfigSave(form) + displayValues = { ...DISPLAY_DEFAULTS, ...(result?.values || form) } + await refreshRawAfterStructuredSave() + const backup = result?.backup || '' + toast({ + message: t('engine.hermesDisplayConfigSaveSuccess'), + hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '', + }, 'success') + } catch (err) { + displayError = humanizeError(err, t('engine.hermesDisplayConfigSaveFailed') || 'Save display config failed') + toast(displayError, 'error') + } finally { + displaySaving = false + draw() + } + } + async function saveHumanDelayConfig() { const form = { humanDelayMode: el.querySelector('#hm-human-delay-mode')?.value || 'off', diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 5936425..66787b4 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 }), + hermesDisplayConfigRead: () => invoke('hermes_display_config_read'), + hermesDisplayConfigSave: (form) => invoke('hermes_display_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'), diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index f08bdcb..2ac4b43 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -640,6 +640,44 @@ 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 中單獨設定。'), + hermesDisplayConfigTitle: _('全局显示与可靠性', 'Global display and reliability', '全域顯示與可靠性'), + hermesDisplayConfigDesc: _('控制消息平台和 CLI 的默认进度展示、静态提示语言、运行信息页脚,以及文件写入失败校验。', 'Control default progress display, static prompt language, runtime footer, and failed file-mutation verification for messaging platforms and CLI.', '控制訊息平台和 CLI 的預設進度顯示、靜態提示語言、執行資訊頁腳,以及檔案寫入失敗校驗。'), + hermesDisplayConfigStatusReady: _('结构化配置', 'structured settings', '結構化設定'), + hermesDisplayConfigSave: _('保存显示设置', 'Save display settings', '儲存顯示設定'), + hermesDisplayConfigSaveSuccess: _('显示与可靠性配置已保存,建议重启 Hermes Gateway 生效', 'Display and reliability settings saved. Restart Hermes Gateway to take effect.', '顯示與可靠性設定已儲存,建議重啟 Hermes Gateway 生效'), + hermesDisplayConfigLoadFailed: _('加载显示与可靠性配置失败', 'Load display and reliability settings failed', '載入顯示與可靠性設定失敗'), + hermesDisplayConfigSaveFailed: _('保存显示与可靠性配置失败', 'Save display and reliability settings failed', '儲存顯示與可靠性設定失敗'), + hermesDisplayConfigToolProgress: _('默认工具进度', 'Default tool progress', '預設工具進度'), + hermesDisplayConfigToolProgress_off: _('关闭', 'Off', '關閉'), + hermesDisplayConfigToolProgress_new: _('工具变化时显示', 'Only when tool changes', '工具變化時顯示'), + hermesDisplayConfigToolProgress_all: _('显示每次工具调用', 'Show every tool call', '顯示每次工具呼叫'), + hermesDisplayConfigToolProgress_verbose: _('详细显示参数和结果', 'Verbose args and results', '詳細顯示參數與結果'), + hermesDisplayConfigToolProgressCommand: _('在消息平台启用 /verbose 命令', 'Enable /verbose on messaging platforms', '在訊息平台啟用 /verbose 命令'), + hermesDisplayConfigInterimAssistantMessages: _('发送中途状态消息', 'Send interim assistant updates', '傳送中途狀態訊息'), + hermesDisplayConfigRuntimeFooterEnabled: _('在最终回复追加运行信息', 'Append runtime footer to final replies', '在最終回覆追加執行資訊'), + hermesDisplayConfigRuntimeFooterFields: _('运行信息字段(每行一个)', 'Runtime footer fields, one per line', '執行資訊欄位(每行一個)'), + hermesDisplayConfigFileMutationVerifier: _('启用文件写入失败校验', 'Enable failed file-mutation verifier', '啟用檔案寫入失敗校驗'), + hermesDisplayConfigLanguage: _('静态提示语言', 'Static prompt language', '靜態提示語言'), + hermesDisplayConfigLanguage_en: _('英语', 'English', '英語'), + hermesDisplayConfigLanguage_zh: _('简体中文', 'Simplified Chinese', '簡體中文'), + 'hermesDisplayConfigLanguage_zh-hant': _('繁体中文', 'Traditional Chinese', '繁體中文'), + hermesDisplayConfigLanguage_ja: _('日语', 'Japanese', '日語'), + hermesDisplayConfigLanguage_de: _('德语', 'German', '德語'), + hermesDisplayConfigLanguage_es: _('西班牙语', 'Spanish', '西班牙語'), + hermesDisplayConfigLanguage_fr: _('法语', 'French', '法語'), + hermesDisplayConfigLanguage_tr: _('土耳其语', 'Turkish', '土耳其語'), + hermesDisplayConfigLanguage_uk: _('乌克兰语', 'Ukrainian', '烏克蘭語'), + hermesDisplayConfigLanguage_af: _('南非荷兰语', 'Afrikaans', '南非荷蘭語'), + hermesDisplayConfigLanguage_ko: _('韩语', 'Korean', '韓語'), + hermesDisplayConfigLanguage_it: _('意大利语', 'Italian', '義大利語'), + hermesDisplayConfigLanguage_ga: _('爱尔兰语', 'Irish', '愛爾蘭語'), + hermesDisplayConfigLanguage_pt: _('葡萄牙语', 'Portuguese', '葡萄牙語'), + hermesDisplayConfigLanguage_ru: _('俄语', 'Russian', '俄語'), + hermesDisplayConfigLanguage_hu: _('匈牙利语', 'Hungarian', '匈牙利語'), + hermesDisplayConfigResumeDisplay: _('恢复会话展示', 'Resume display', '恢復會話顯示'), + hermesDisplayConfigResumeDisplay_full: _('显示完整上下文', 'Show full context', '顯示完整上下文'), + hermesDisplayConfigResumeDisplay_minimal: _('仅显示一行摘要', 'Show one-line summary', '僅顯示一行摘要'), + hermesDisplayConfigFootnote: _('这里写入全局 display 配置;平台级覆盖仍在渠道页管理。display.streaming 是 CLI-only,本面板不会把它误写成 Gateway 全局流式设置。运行信息字段支持 model、context_pct、cwd、duration、tokens、cost。', 'This writes global display settings; per-platform overrides remain in channel settings. display.streaming is CLI-only, so this panel does not write it as a global Gateway streaming setting. Runtime footer fields support model, context_pct, cwd, duration, tokens, and cost.', '這裡寫入全域 display 設定;平台級覆蓋仍在頻道頁管理。display.streaming 是 CLI-only,本面板不會把它誤寫成 Gateway 全域串流設定。執行資訊欄位支援 model、context_pct、cwd、duration、tokens、cost。'), 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', '結構化設定'), diff --git a/tests/hermes-config-page-ui.test.js b/tests/hermes-config-page-ui.test.js index 0579d06..46a7b5c 100644 --- a/tests/hermes-config-page-ui.test.js +++ b/tests/hermes-config-page-ui.test.js @@ -90,6 +90,22 @@ test('Hermes 配置页会暴露响应节奏结构化配置字段', () => { } }) +test('Hermes 配置页会暴露全局显示与可靠性结构化配置字段', () => { + for (const id of [ + 'hm-display-save', + 'hm-display-tool-progress', + 'hm-display-tool-progress-command', + 'hm-display-interim-assistant-messages', + 'hm-display-runtime-footer-enabled', + 'hm-display-runtime-footer-fields', + 'hm-display-file-mutation-verifier', + 'hm-display-language', + 'hm-display-resume-display', + ]) { + assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`) + } +}) + test('Hermes 配置页会暴露网关流式结构化配置字段', () => { for (const id of [ 'hm-streaming-save', @@ -153,6 +169,7 @@ test('Hermes 配置页新增结构化配置不会暴露翻译 key', () => { key.includes('UnauthorizedDmConfig') || key.includes('SecurityConfig') || key.includes('HumanDelayConfig') || + key.includes('DisplayConfig') || key.includes('StreamingConfig') || key.includes('ExecutionLimits') || key.includes('TerminalConfig') diff --git a/tests/hermes-display-config.test.js b/tests/hermes-display-config.test.js new file mode 100644 index 0000000..01fb168 --- /dev/null +++ b/tests/hermes-display-config.test.js @@ -0,0 +1,107 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildHermesDisplayConfigValues, + mergeHermesDisplayConfig, +} from '../scripts/dev-api.js' + +test('Hermes 显示配置读取会提供上游默认值', () => { + const values = buildHermesDisplayConfigValues({}) + + assert.deepEqual(values, { + displayToolProgress: 'all', + displayToolProgressCommand: false, + displayInterimAssistantMessages: true, + displayRuntimeFooterEnabled: false, + displayRuntimeFooterFields: 'model\ncontext_pct\ncwd', + displayFileMutationVerifier: true, + displayLanguage: 'en', + displayResumeDisplay: 'full', + }) +}) + +test('Hermes 显示配置读取会规范化已有字段', () => { + const values = buildHermesDisplayConfigValues({ + display: { + tool_progress: 'VERBOSE', + tool_progress_command: true, + interim_assistant_messages: false, + runtime_footer: { + enabled: true, + fields: ['model', 'duration', 'cost'], + }, + file_mutation_verifier: false, + language: 'ZH', + resume_display: 'minimal', + }, + }) + + assert.equal(values.displayToolProgress, 'verbose') + assert.equal(values.displayToolProgressCommand, true) + assert.equal(values.displayInterimAssistantMessages, false) + assert.equal(values.displayRuntimeFooterEnabled, true) + assert.equal(values.displayRuntimeFooterFields, 'model\nduration\ncost') + assert.equal(values.displayFileMutationVerifier, false) + assert.equal(values.displayLanguage, 'zh') + assert.equal(values.displayResumeDisplay, 'minimal') +}) + +test('Hermes 显示配置保存会保留未知 YAML 并写入 display', () => { + const next = mergeHermesDisplayConfig({ + model: { provider: 'anthropic' }, + display: { + skin: 'midnight', + runtime_footer: { + enabled: false, + custom_flag: 'keep-footer', + }, + platforms: { + telegram: { tool_progress: 'new' }, + }, + }, + memory: { memory_enabled: true }, + }, { + displayToolProgress: 'off', + displayToolProgressCommand: 'true', + displayInterimAssistantMessages: false, + displayRuntimeFooterEnabled: true, + displayRuntimeFooterFields: 'model\ncontext_pct\nduration', + displayFileMutationVerifier: true, + displayLanguage: 'zh-hant', + displayResumeDisplay: 'minimal', + }) + + assert.deepEqual(next.model, { provider: 'anthropic' }) + assert.deepEqual(next.memory, { memory_enabled: true }) + assert.equal(next.display.skin, 'midnight') + assert.deepEqual(next.display.platforms.telegram, { tool_progress: 'new' }) + assert.equal(next.display.tool_progress, 'off') + assert.equal(next.display.tool_progress_command, true) + assert.equal(next.display.interim_assistant_messages, false) + assert.equal(next.display.runtime_footer.enabled, true) + assert.deepEqual(next.display.runtime_footer.fields, ['model', 'context_pct', 'duration']) + assert.equal(next.display.runtime_footer.custom_flag, 'keep-footer') + assert.equal(next.display.file_mutation_verifier, true) + assert.equal(next.display.language, 'zh-hant') + assert.equal(next.display.resume_display, 'minimal') +}) + +test('Hermes 显示配置保存会拒绝非法枚举和页脚字段', () => { + assert.throws( + () => mergeHermesDisplayConfig({}, { displayToolProgress: 'everything' }), + /display\.tool_progress/, + ) + assert.throws( + () => mergeHermesDisplayConfig({}, { displayResumeDisplay: 'compact' }), + /display\.resume_display/, + ) + assert.throws( + () => mergeHermesDisplayConfig({}, { displayLanguage: 'cn' }), + /display\.language/, + ) + assert.throws( + () => mergeHermesDisplayConfig({}, { displayRuntimeFooterFields: 'model\npassword' }), + /display\.runtime_footer\.fields/, + ) +})