feat(hermes): add display reliability settings

This commit is contained in:
晴天
2026-05-24 23:03:41 +08:00
parent 8c963cd3d4
commit d245d2e320
8 changed files with 839 additions and 1 deletions

View File

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

View File

@@ -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<String>,
strict: bool,
) -> Result<String, String> {
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<String>, strict: bool) -> Result<String, String> {
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<String>,
strict: bool,
) -> Result<Vec<String>, 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::<Vec<_>>()
} 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<Vec<String>, 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::<Vec<_>>(),
Some(serde_yaml::Value::String(text)) => text
.split(['\n', ','])
.map(str::trim)
.filter(|item| !item.is_empty())
.map(ToString::to_string)
.collect::<Vec<_>>(),
_ => 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<Value, String> {
}))
}
#[tauri::command]
pub fn hermes_display_config_read() -> Result<Value, String> {
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<Value, String> {
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<Value, String> {
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<_>>(),
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};

View File

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

View File

@@ -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 `
<div class="hm-panel hm-config-runtime-panel hm-config-display-panel">
<div class="hm-panel-header">
<div>
<div class="hm-panel-title">${t('engine.hermesDisplayConfigTitle')}</div>
<div class="hm-channel-panel-desc">${t('engine.hermesDisplayConfigDesc')}</div>
</div>
<div class="hm-panel-actions">
<span class="hm-muted">${displaySaving ? t('engine.hermesConfigStatusSaving') : displayLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesDisplayConfigStatusReady')}</span>
<button class="hm-btn hm-btn--cta hm-btn--sm" id="hm-display-save" ${disabled ? 'disabled' : ''}>${t('engine.hermesDisplayConfigSave')}</button>
</div>
</div>
<div class="hm-panel-body">
${renderError(displayError)}
<div class="hm-config-runtime-grid hm-config-display-grid">
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesDisplayConfigToolProgress')}</span>
<select id="hm-display-tool-progress" class="hm-input" ${disabled ? 'disabled' : ''}>
${DISPLAY_TOOL_PROGRESS_VALUES.map(mode => option(`engine.hermesDisplayConfigToolProgress_${mode}`, mode, displayValues.displayToolProgress)).join('')}
</select>
</label>
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesDisplayConfigLanguage')}</span>
<select id="hm-display-language" class="hm-input" ${disabled ? 'disabled' : ''}>
${DISPLAY_LANGUAGE_VALUES.map(mode => option(`engine.hermesDisplayConfigLanguage_${mode}`, mode, displayValues.displayLanguage)).join('')}
</select>
</label>
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesDisplayConfigResumeDisplay')}</span>
<select id="hm-display-resume-display" class="hm-input" ${disabled ? 'disabled' : ''}>
${DISPLAY_RESUME_VALUES.map(mode => option(`engine.hermesDisplayConfigResumeDisplay_${mode}`, mode, displayValues.displayResumeDisplay)).join('')}
</select>
</label>
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesDisplayConfigRuntimeFooterFields')}</span>
<textarea id="hm-display-runtime-footer-fields" class="hm-input" ${disabled ? 'disabled' : ''} style="min-height:96px;resize:vertical">${esc(displayValues.displayRuntimeFooterFields)}</textarea>
</label>
</div>
<div class="hm-config-check-grid">
<label class="hm-channel-check">
<input id="hm-display-tool-progress-command" type="checkbox" ${displayValues.displayToolProgressCommand ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
<span>${t('engine.hermesDisplayConfigToolProgressCommand')}</span>
</label>
<label class="hm-channel-check">
<input id="hm-display-interim-assistant-messages" type="checkbox" ${displayValues.displayInterimAssistantMessages ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
<span>${t('engine.hermesDisplayConfigInterimAssistantMessages')}</span>
</label>
<label class="hm-channel-check">
<input id="hm-display-runtime-footer-enabled" type="checkbox" ${displayValues.displayRuntimeFooterEnabled ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
<span>${t('engine.hermesDisplayConfigRuntimeFooterEnabled')}</span>
</label>
<label class="hm-channel-check">
<input id="hm-display-file-mutation-verifier" type="checkbox" ${displayValues.displayFileMutationVerifier ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
<span>${t('engine.hermesDisplayConfigFileMutationVerifier')}</span>
</label>
</div>
<div class="hm-channel-footnote">${t('engine.hermesDisplayConfigFootnote')}</div>
</div>
</div>
`
}
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()}
<div class="hm-panel">
@@ -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',

View File

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

View File

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

View File

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

View File

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