From 2de5d1e38a192648594a1f18f160f8d1ba05ba2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Sun, 24 May 2026 21:21:16 +0800 Subject: [PATCH] feat(hermes): add quick commands config form --- scripts/dev-api.js | 84 +++++++ src-tauri/src/commands/hermes.rs | 260 +++++++++++++++++++++ src-tauri/src/lib.rs | 2 + src/engines/hermes/pages/config.js | 97 +++++++- src/lib/tauri-api.js | 2 + src/locales/modules/engine.js | 9 + tests/hermes-config-page-ui.test.js | 10 + tests/hermes-quick-commands-config.test.js | 86 +++++++ 8 files changed, 541 insertions(+), 9 deletions(-) create mode 100644 tests/hermes-quick-commands-config.test.js diff --git a/scripts/dev-api.js b/scripts/dev-api.js index b8e8475..d84fad6 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -3612,6 +3612,69 @@ export function mergeHermesSkillsConfig(config = {}, form = {}) { return next } +function validateHermesQuickCommands(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('quick_commands 必须是 JSON 对象') + } + const normalized = {} + for (const [rawName, rawCommand] of Object.entries(value)) { + const name = String(rawName || '').trim().replace(/^\/+/, '') + if (!name) throw new Error('quick_commands 命令名不能为空') + if (!rawCommand || typeof rawCommand !== 'object' || Array.isArray(rawCommand)) { + throw new Error(`quick_commands.${name} 必须是对象`) + } + const command = mergeConfigsPreservingFields(rawCommand, {}) + const type = String(command.type || '').trim().toLowerCase() + if (!['exec', 'alias'].includes(type)) { + throw new Error(`quick_commands.${name}.type 必须是 exec 或 alias`) + } + command.type = type + if (type === 'exec') { + const shellCommand = String(command.command || '').trim() + if (!shellCommand) throw new Error(`quick_commands.${name}.command 不能为空`) + command.command = shellCommand + } + if (type === 'alias') { + const target = String(command.target || '').trim() + if (!target.startsWith('/')) throw new Error(`quick_commands.${name}.target 必须以 / 开头`) + command.target = target + } + normalized[name] = command + } + return normalized +} + +function parseHermesQuickCommandsJson(raw) { + const text = String(raw ?? '').trim() + if (!text) return {} + let value + try { + value = JSON.parse(text) + } catch (err) { + throw new Error(`quick_commands JSON 格式错误: ${err.message}`) + } + return validateHermesQuickCommands(value) +} + +export function buildHermesQuickCommandsConfigValues(config = {}) { + const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} + const quickCommands = root.quick_commands && typeof root.quick_commands === 'object' && !Array.isArray(root.quick_commands) + ? validateHermesQuickCommands(root.quick_commands) + : {} + return { + quickCommandsJson: JSON.stringify(quickCommands, null, 2), + } +} + +export function mergeHermesQuickCommandsConfig(config = {}, form = {}) { + const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {}) + const currentValues = buildHermesQuickCommandsConfigValues(next) + const quickCommands = parseHermesQuickCommandsJson(Object.hasOwn(form, 'quickCommandsJson') ? form.quickCommandsJson : currentValues.quickCommandsJson) + if (Object.keys(quickCommands).length) next.quick_commands = quickCommands + else delete next.quick_commands + return next +} + export function buildHermesStreamingConfigValues(config = {}) { const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} const streaming = hermesStreamingConfigSource(root) @@ -10097,6 +10160,27 @@ const handlers = { } }, + hermes_quick_commands_config_read() { + const { configPath, exists, config } = readHermesConfigYamlObject() + return { + exists, + configPath, + values: buildHermesQuickCommandsConfigValues(config), + } + }, + + hermes_quick_commands_config_save({ form } = {}) { + const { configPath, config } = readHermesConfigYamlObject() + const next = mergeHermesQuickCommandsConfig(config, form || {}) + const backup = writeHermesConfigYamlObject(configPath, next) + return { + ok: true, + configPath, + backup, + values: buildHermesQuickCommandsConfigValues(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 33f77ee..56f9c47 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -3751,6 +3751,111 @@ fn merge_hermes_skills_config(config: &mut serde_yaml::Value, form: &Value) -> R Ok(()) } +fn build_hermes_quick_commands_config_values(config: &serde_yaml::Value) -> Value { + let root = config.as_mapping(); + let quick_commands = root + .and_then(|map| yaml_get(map, "quick_commands")) + .and_then(|value| value.as_mapping()) + .and_then(|mapping| serde_json::to_value(mapping).ok()) + .unwrap_or_else(|| serde_json::json!({})); + let quick_commands_json = + serde_json::to_string_pretty(&quick_commands).unwrap_or_else(|_| "{}".to_string()); + + serde_json::json!({ + "quickCommandsJson": quick_commands_json, + }) +} + +fn validate_hermes_quick_commands(value: Value) -> Result, String> { + let object = value + .as_object() + .ok_or_else(|| "quick_commands 必须是 JSON 对象".to_string())?; + let mut normalized = serde_json::Map::new(); + for (raw_name, raw_command) in object { + let name = raw_name.trim().trim_start_matches('/').to_string(); + if name.is_empty() { + return Err("quick_commands 命令名不能为空".to_string()); + } + let command_object = raw_command + .as_object() + .ok_or_else(|| format!("quick_commands.{name} 必须是对象"))?; + let mut command = command_object.clone(); + let command_type = command + .get("type") + .and_then(|value| value.as_str()) + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + if !matches!(command_type.as_str(), "exec" | "alias") { + return Err(format!("quick_commands.{name}.type 必须是 exec 或 alias")); + } + command.insert("type".to_string(), Value::String(command_type.clone())); + if command_type == "exec" { + let shell_command = command + .get("command") + .and_then(|value| value.as_str()) + .unwrap_or_default() + .trim() + .to_string(); + if shell_command.is_empty() { + return Err(format!("quick_commands.{name}.command 不能为空")); + } + command.insert("command".to_string(), Value::String(shell_command)); + } + if command_type == "alias" { + let target = command + .get("target") + .and_then(|value| value.as_str()) + .unwrap_or_default() + .trim() + .to_string(); + if !target.starts_with('/') { + return Err(format!("quick_commands.{name}.target 必须以 / 开头")); + } + command.insert("target".to_string(), Value::String(target)); + } + normalized.insert(name, Value::Object(command)); + } + Ok(normalized) +} + +fn parse_hermes_quick_commands_json( + raw: Option, +) -> Result, String> { + let text = raw.unwrap_or_default(); + let text = text.trim(); + if text.is_empty() { + return Ok(serde_json::Map::new()); + } + let value: Value = + serde_json::from_str(text).map_err(|err| format!("quick_commands JSON 格式错误: {err}"))?; + validate_hermes_quick_commands(value) +} + +fn merge_hermes_quick_commands_config( + config: &mut serde_yaml::Value, + form: &Value, +) -> Result<(), String> { + let current = build_hermes_quick_commands_config_values(config); + let quick_commands = + parse_hermes_quick_commands_json(form_string(form, "quickCommandsJson").or_else(|| { + current["quickCommandsJson"] + .as_str() + .map(ToString::to_string) + }))?; + + let root = ensure_yaml_object(config)?; + if quick_commands.is_empty() { + root.remove(yaml_key("quick_commands")); + } else { + let json_value = Value::Object(quick_commands); + let yaml_value = serde_yaml::to_value(json_value) + .map_err(|err| format!("quick_commands 转换 YAML 失败: {err}"))?; + root.insert(yaml_key("quick_commands"), yaml_value); + } + Ok(()) +} + fn normalize_hermes_streaming_transport( value: Option, strict: bool, @@ -5245,6 +5350,30 @@ pub fn hermes_skills_config_save(form: Value) -> Result { })) } +#[tauri::command] +pub fn hermes_quick_commands_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_quick_commands_config_values(&config), + })) +} + +#[tauri::command] +pub fn hermes_quick_commands_config_save(form: Value) -> Result { + let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?; + merge_hermes_quick_commands_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_quick_commands_config_values(&config), + })) +} + #[tauri::command] pub fn hermes_streaming_config_read() -> Result { let (config_path, exists, config) = read_hermes_channel_yaml_config()?; @@ -11138,6 +11267,137 @@ memory: } } +#[cfg(test)] +mod hermes_quick_commands_config_tests { + use super::{build_hermes_quick_commands_config_values, merge_hermes_quick_commands_config}; + use serde_json::json; + + #[test] + fn quick_commands_values_have_empty_defaults() { + let config: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let values = build_hermes_quick_commands_config_values(&config); + assert_eq!(values["quickCommandsJson"], "{}"); + } + + #[test] + fn quick_commands_values_read_yaml_mapping() { + let config: serde_yaml::Value = serde_yaml::from_str( + r#" +quick_commands: + status: + type: exec + command: systemctl status hermes-agent + restart: + type: alias + target: /gateway restart +"#, + ) + .unwrap(); + + let values = build_hermes_quick_commands_config_values(&config); + let parsed: serde_json::Value = + serde_json::from_str(values["quickCommandsJson"].as_str().unwrap()).unwrap(); + assert_eq!(parsed["status"]["command"], "systemctl status hermes-agent"); + assert_eq!(parsed["restart"]["target"], "/gateway restart"); + } + + #[test] + fn merge_quick_commands_config_preserves_unrelated_yaml() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +model: + provider: anthropic +quick_commands: + old: + type: exec + command: uptime +memory: + memory_enabled: true +"#, + ) + .unwrap(); + + merge_hermes_quick_commands_config( + &mut config, + &json!({ + "quickCommandsJson": r#"{ + "status": { "type": "exec", "command": "systemctl status hermes-agent", "timeout": 10 }, + "restart": { "type": "alias", "target": "/gateway restart" } + }"#, + }), + ) + .unwrap(); + + assert_eq!(config["model"]["provider"].as_str(), Some("anthropic")); + assert_eq!(config["memory"]["memory_enabled"].as_bool(), Some(true)); + assert_eq!( + config["quick_commands"]["status"]["command"].as_str(), + Some("systemctl status hermes-agent") + ); + assert_eq!( + config["quick_commands"]["status"]["timeout"].as_i64(), + Some(10) + ); + assert_eq!( + config["quick_commands"]["restart"]["target"].as_str(), + Some("/gateway restart") + ); + assert!(config["quick_commands"]["old"].is_null()); + } + + #[test] + fn merge_quick_commands_config_removes_empty_mapping() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +quick_commands: + status: + type: exec + command: uptime +streaming: + enabled: true +"#, + ) + .unwrap(); + + merge_hermes_quick_commands_config(&mut config, &json!({ "quickCommandsJson": "{}" })) + .unwrap(); + + assert!(config["quick_commands"].is_null()); + assert_eq!(config["streaming"]["enabled"].as_bool(), Some(true)); + } + + #[test] + fn merge_quick_commands_config_rejects_invalid_values() { + let mut config = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + let err = + merge_hermes_quick_commands_config(&mut config, &json!({ "quickCommandsJson": "[" })) + .unwrap_err(); + assert!(err.contains("quick_commands")); + let err = + merge_hermes_quick_commands_config(&mut config, &json!({ "quickCommandsJson": "[]" })) + .unwrap_err(); + assert!(err.contains("quick_commands")); + let err = merge_hermes_quick_commands_config( + &mut config, + &json!({ "quickCommandsJson": r#"{ "bad": "uptime" }"# }), + ) + .unwrap_err(); + assert!(err.contains("quick_commands.bad")); + let err = merge_hermes_quick_commands_config( + &mut config, + &json!({ "quickCommandsJson": r#"{ "status": { "type": "exec", "command": "" } }"# }), + ) + .unwrap_err(); + assert!(err.contains("quick_commands.status.command")); + let err = merge_hermes_quick_commands_config( + &mut config, + &json!({ "quickCommandsJson": r#"{ "restart": { "type": "alias", "target": "gateway restart" } }"# }), + ) + .unwrap_err(); + assert!(err.contains("quick_commands.restart.target")); + } +} + #[cfg(test)] mod hermes_channel_tests { use super::{ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e88cea6..6f26636 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -267,6 +267,8 @@ pub fn run() { hermes::hermes_memory_config_save, hermes::hermes_skills_config_read, hermes::hermes_skills_config_save, + hermes::hermes_quick_commands_config_read, + hermes::hermes_quick_commands_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 83c0227..ffbd715 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -48,6 +48,10 @@ const SKILLS_DEFAULTS = { externalDirs: '', } +const QUICK_COMMANDS_DEFAULTS = { + quickCommandsJson: '{}', +} + const STREAMING_DEFAULTS = { enabled: false, transport: 'edit', @@ -98,6 +102,7 @@ export function render() { let toolGuardrailsValues = { ...TOOL_GUARDRAILS_DEFAULTS } let memoryValues = { ...MEMORY_DEFAULTS } let skillsValues = { ...SKILLS_DEFAULTS } + let quickCommandsValues = { ...QUICK_COMMANDS_DEFAULTS } let streamingValues = { ...STREAMING_DEFAULTS } let executionLimitsValues = { ...EXECUTION_LIMITS_DEFAULTS } let terminalValues = { ...TERMINAL_DEFAULTS } @@ -107,6 +112,7 @@ export function render() { let toolGuardrailsLoading = true let memoryLoading = true let skillsLoading = true + let quickCommandsLoading = true let streamingLoading = true let executionLimitsLoading = true let terminalLoading = true @@ -116,6 +122,7 @@ export function render() { let toolGuardrailsSaving = false let memorySaving = false let skillsSaving = false + let quickCommandsSaving = false let streamingSaving = false let executionLimitsSaving = false let terminalSaving = false @@ -125,6 +132,7 @@ export function render() { let toolGuardrailsError = null let memoryError = null let skillsError = null + let quickCommandsError = null let streamingError = null let executionLimitsError = null let terminalError = null @@ -138,7 +146,7 @@ export function render() { } function isBusy() { - return loading || runtimeLoading || compressionLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || streamingLoading || executionLimitsLoading || terminalLoading || saving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || terminalSaving + return loading || runtimeLoading || compressionLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || quickCommandsLoading || streamingLoading || executionLimitsLoading || terminalLoading || saving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || streamingSaving || executionLimitsSaving || terminalSaving } function option(labelKey, value, selected) { @@ -155,7 +163,7 @@ export function render() { } function renderRuntimePanel() { - const disabled = loading || saving || runtimeLoading || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || terminalSaving + const disabled = loading || saving || runtimeLoading || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || streamingSaving || executionLimitsSaving || terminalSaving return `
@@ -203,7 +211,7 @@ export function render() { } function renderCompressionPanel() { - const disabled = loading || saving || compressionLoading || compressionSaving || runtimeSaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || terminalSaving + const disabled = loading || saving || compressionLoading || compressionSaving || runtimeSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || streamingSaving || executionLimitsSaving || terminalSaving return `
@@ -253,7 +261,7 @@ export function render() { } function renderToolGuardrailsPanel() { - const disabled = loading || saving || toolGuardrailsLoading || toolGuardrailsSaving || runtimeSaving || compressionSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || terminalSaving + const disabled = loading || saving || toolGuardrailsLoading || toolGuardrailsSaving || runtimeSaving || compressionSaving || memorySaving || skillsSaving || quickCommandsSaving || streamingSaving || executionLimitsSaving || terminalSaving return `
@@ -315,7 +323,7 @@ export function render() { } function renderMemoryPanel() { - const disabled = loading || saving || memoryLoading || memorySaving || skillsSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || streamingSaving || executionLimitsSaving || terminalSaving + const disabled = loading || saving || memoryLoading || memorySaving || skillsSaving || quickCommandsSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || streamingSaving || executionLimitsSaving || terminalSaving return `
@@ -365,7 +373,7 @@ export function render() { } function renderSkillsConfigPanel() { - const disabled = loading || saving || skillsLoading || skillsSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || streamingSaving || executionLimitsSaving || terminalSaving + const disabled = loading || saving || skillsLoading || skillsSaving || quickCommandsSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || streamingSaving || executionLimitsSaving || terminalSaving return `
@@ -396,8 +404,34 @@ export function render() { ` } + function renderQuickCommandsConfigPanel() { + const disabled = loading || saving || quickCommandsLoading || quickCommandsSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || terminalSaving + return ` +
+
+
+
${t('engine.hermesQuickCommandsConfigTitle')}
+
${t('engine.hermesQuickCommandsConfigDesc')}
+
+
+ ${quickCommandsSaving ? t('engine.hermesConfigStatusSaving') : quickCommandsLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesQuickCommandsConfigStatusReady')} + +
+
+
+ ${renderError(quickCommandsError)} + +
${t('engine.hermesQuickCommandsConfigFootnote')}
+
+
+ ` + } + function renderStreamingPanel() { - const disabled = loading || saving || streamingLoading || streamingSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || executionLimitsSaving || terminalSaving + const disabled = loading || saving || streamingLoading || streamingSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || executionLimitsSaving || terminalSaving return `
@@ -449,7 +483,7 @@ export function render() { } function renderExecutionLimitsPanel() { - const disabled = loading || saving || executionLimitsLoading || executionLimitsSaving || terminalSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving + const disabled = loading || saving || executionLimitsLoading || executionLimitsSaving || terminalSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || streamingSaving return `
@@ -521,7 +555,7 @@ export function render() { } function renderTerminalPanel() { - const disabled = loading || saving || terminalLoading || terminalSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving + const disabled = loading || saving || terminalLoading || terminalSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || streamingSaving || executionLimitsSaving return `
@@ -613,6 +647,7 @@ export function render() { ${renderToolGuardrailsPanel()} ${renderMemoryPanel()} ${renderSkillsConfigPanel()} + ${renderQuickCommandsConfigPanel()}
@@ -637,6 +672,7 @@ export function render() { el.querySelector('#hm-tool-guardrails-save')?.addEventListener('click', saveToolGuardrails) el.querySelector('#hm-memory-save')?.addEventListener('click', saveMemory) el.querySelector('#hm-skills-config-save')?.addEventListener('click', saveSkillsConfig) + el.querySelector('#hm-quick-commands-save')?.addEventListener('click', saveQuickCommandsConfig) 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) @@ -672,6 +708,11 @@ export function render() { skillsValues = { ...SKILLS_DEFAULTS, ...(data?.values || {}) } } + async function loadQuickCommandsConfig() { + const data = await api.hermesQuickCommandsConfigRead() + quickCommandsValues = { ...QUICK_COMMANDS_DEFAULTS, ...(data?.values || {}) } + } + async function loadStreaming() { const data = await api.hermesStreamingConfigRead() streamingValues = { ...STREAMING_DEFAULTS, ...(data?.values || {}) } @@ -694,6 +735,7 @@ export function render() { toolGuardrailsLoading = true memoryLoading = true skillsLoading = true + quickCommandsLoading = true streamingLoading = true executionLimitsLoading = true terminalLoading = true @@ -703,6 +745,7 @@ export function render() { toolGuardrailsError = null memoryError = null skillsError = null + quickCommandsError = null streamingError = null executionLimitsError = null terminalError = null @@ -778,6 +821,14 @@ export function render() { skillsLoading = false draw() } + try { + await loadQuickCommandsConfig() + } catch (err) { + quickCommandsError = humanizeError(err, t('engine.hermesQuickCommandsConfigLoadFailed') || 'Load quick commands config failed') + } finally { + quickCommandsLoading = false + draw() + } } async function refreshRawAfterStructuredSave() { @@ -814,6 +865,9 @@ export function render() { try { await loadSkillsConfig() } catch {} + try { + await loadQuickCommandsConfig() + } catch {} try { await loadStreaming() } catch {} @@ -979,6 +1033,31 @@ export function render() { } } + async function saveQuickCommandsConfig() { + const form = { + quickCommandsJson: el.querySelector('#hm-quick-commands-json')?.value || '{}', + } + quickCommandsSaving = true + quickCommandsError = null + draw() + try { + const result = await api.hermesQuickCommandsConfigSave(form) + quickCommandsValues = { ...QUICK_COMMANDS_DEFAULTS, ...(result?.values || form) } + await refreshRawAfterStructuredSave() + const backup = result?.backup || '' + toast({ + message: t('engine.hermesQuickCommandsConfigSaveSuccess'), + hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '', + }, 'success') + } catch (err) { + quickCommandsError = humanizeError(err, t('engine.hermesQuickCommandsConfigSaveFailed') || 'Save quick commands config failed') + toast(quickCommandsError, 'error') + } finally { + quickCommandsSaving = 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 382f2ac..7d7432a 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -519,6 +519,8 @@ export const api = { hermesMemoryConfigSave: (form) => invoke('hermes_memory_config_save', { form }), hermesSkillsConfigRead: () => invoke('hermes_skills_config_read'), hermesSkillsConfigSave: (form) => invoke('hermes_skills_config_save', { form }), + hermesQuickCommandsConfigRead: () => invoke('hermes_quick_commands_config_read'), + hermesQuickCommandsConfigSave: (form) => invoke('hermes_quick_commands_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 46dd430..5c994c5 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -620,6 +620,15 @@ export default { hermesSkillsConfigCreationNudgeInterval: _('创建提醒间隔', 'Creation nudge interval', '建立提醒間隔'), hermesSkillsConfigExternalDirs: _('外部技能目录(每行一个)', 'External skill directories, one per line', '外部技能目錄(每行一個)'), hermesSkillsConfigFootnote: _('提醒间隔按用户消息轮数计算,设为 0 可关闭创建提醒。disabled、custom flag 等高级字段会保留在 raw YAML 中。', 'The nudge interval is counted in user turns. Set it to 0 to disable creation nudges. Advanced fields such as disabled skills and custom flags are preserved in raw YAML.', '提醒間隔依使用者訊息輪數計算,設為 0 可關閉建立提醒。disabled、custom flag 等進階欄位會保留在 raw YAML 中。'), + hermesQuickCommandsConfigTitle: _('快捷命令', 'Quick commands', '快捷命令'), + hermesQuickCommandsConfigDesc: _('配置消息平台和 CLI 可直接触发的零 token 运维命令,例如状态检查、磁盘空间和 Gateway 重启别名。', 'Configure zero-token operations commands that messaging platforms and the CLI can trigger directly, such as status checks, disk usage, and Gateway restart aliases.', '設定訊息平台和 CLI 可直接觸發的零 token 維運命令,例如狀態檢查、磁碟空間和 Gateway 重啟別名。'), + hermesQuickCommandsConfigStatusReady: _('结构化 JSON', 'structured JSON', '結構化 JSON'), + hermesQuickCommandsConfigSave: _('保存快捷命令', 'Save quick commands', '儲存快捷命令'), + hermesQuickCommandsConfigSaveSuccess: _('快捷命令已保存,建议重启 Hermes Gateway 生效', 'Quick commands saved. Restart Hermes Gateway to take effect.', '快捷命令已儲存,建議重啟 Hermes Gateway 生效'), + hermesQuickCommandsConfigLoadFailed: _('加载快捷命令失败', 'Load quick commands failed', '載入快捷命令失敗'), + hermesQuickCommandsConfigSaveFailed: _('保存快捷命令失败', 'Save quick commands failed', '儲存快捷命令失敗'), + hermesQuickCommandsConfigJson: _('quick_commands JSON 映射', 'quick_commands JSON map', 'quick_commands JSON 映射'), + hermesQuickCommandsConfigFootnote: _('键名会变成斜杠命令,例如 status 对应 /status。每个命令必须是对象,type 只能为 exec 或 alias;exec 需要 command,alias 的 target 必须以 / 开头。', 'Keys become slash commands, for example status maps to /status. Each command must be an object with type exec or alias; exec needs command, and alias target must start with /.', '鍵名會變成斜線命令,例如 status 對應 /status。每個命令必須是物件,type 只能是 exec 或 alias;exec 需要 command,alias 的 target 必須以 / 開頭。'), // Batch 1 §E: 会话导出 sessionsExport: _('导出', 'Export', '匯出'), sessionsExportSuccess: _('已导出', 'Exported', '已匯出'), diff --git a/tests/hermes-config-page-ui.test.js b/tests/hermes-config-page-ui.test.js index 0f010a5..aa046eb 100644 --- a/tests/hermes-config-page-ui.test.js +++ b/tests/hermes-config-page-ui.test.js @@ -49,6 +49,15 @@ test('Hermes 配置页会暴露 Skills 结构化配置字段', () => { } }) +test('Hermes 配置页会暴露快捷命令结构化配置字段', () => { + for (const id of [ + 'hm-quick-commands-save', + 'hm-quick-commands-json', + ]) { + assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`) + } +}) + test('Hermes 配置页会暴露网关流式结构化配置字段', () => { for (const id of [ 'hm-streaming-save', @@ -108,6 +117,7 @@ test('Hermes 配置页新增结构化配置不会暴露翻译 key', () => { key.includes('ToolGuardrails') || key.includes('MemoryConfig') || key.includes('SkillsConfig') || + key.includes('QuickCommandsConfig') || key.includes('StreamingConfig') || key.includes('ExecutionLimits') || key.includes('TerminalConfig') diff --git a/tests/hermes-quick-commands-config.test.js b/tests/hermes-quick-commands-config.test.js new file mode 100644 index 0000000..4514b85 --- /dev/null +++ b/tests/hermes-quick-commands-config.test.js @@ -0,0 +1,86 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildHermesQuickCommandsConfigValues, + mergeHermesQuickCommandsConfig, +} from '../scripts/dev-api.js' + +test('Hermes 快捷命令配置读取会提供空对象默认值', () => { + const values = buildHermesQuickCommandsConfigValues({}) + + assert.equal(values.quickCommandsJson, '{}') +}) + +test('Hermes 快捷命令配置读取会格式化已有映射', () => { + const values = buildHermesQuickCommandsConfigValues({ + quick_commands: { + status: { type: 'exec', command: 'systemctl status hermes-agent' }, + restart: { type: 'alias', target: '/gateway restart' }, + }, + }) + + assert.deepEqual(JSON.parse(values.quickCommandsJson), { + status: { type: 'exec', command: 'systemctl status hermes-agent' }, + restart: { type: 'alias', target: '/gateway restart' }, + }) +}) + +test('Hermes 快捷命令配置保存会保留无关 YAML 并写入顶层映射', () => { + const next = mergeHermesQuickCommandsConfig({ + model: { provider: 'anthropic' }, + quick_commands: { + old: { type: 'exec', command: 'uptime', custom_flag: 'drop-with-replace' }, + }, + memory: { memory_enabled: true }, + }, { + quickCommandsJson: JSON.stringify({ + status: { type: 'exec', command: 'systemctl status hermes-agent', timeout: 10 }, + restart: { type: 'alias', target: '/gateway restart' }, + }), + }) + + assert.deepEqual(next.model, { provider: 'anthropic' }) + assert.deepEqual(next.memory, { memory_enabled: true }) + assert.deepEqual(next.quick_commands, { + status: { type: 'exec', command: 'systemctl status hermes-agent', timeout: 10 }, + restart: { type: 'alias', target: '/gateway restart' }, + }) +}) + +test('Hermes 快捷命令配置保存空对象会移除 quick_commands', () => { + const next = mergeHermesQuickCommandsConfig({ + quick_commands: { + status: { type: 'exec', command: 'uptime' }, + }, + streaming: { enabled: true }, + }, { + quickCommandsJson: '{}', + }) + + assert.equal(next.quick_commands, undefined) + assert.deepEqual(next.streaming, { enabled: true }) +}) + +test('Hermes 快捷命令配置保存会拒绝非法 JSON 和非法命令结构', () => { + assert.throws( + () => mergeHermesQuickCommandsConfig({}, { quickCommandsJson: '[' }), + /quick_commands/, + ) + assert.throws( + () => mergeHermesQuickCommandsConfig({}, { quickCommandsJson: '[]' }), + /quick_commands/, + ) + assert.throws( + () => mergeHermesQuickCommandsConfig({}, { quickCommandsJson: JSON.stringify({ bad: 'uptime' }) }), + /quick_commands\.bad/, + ) + assert.throws( + () => mergeHermesQuickCommandsConfig({}, { quickCommandsJson: JSON.stringify({ status: { type: 'exec', command: '' } }) }), + /quick_commands\.status\.command/, + ) + assert.throws( + () => mergeHermesQuickCommandsConfig({}, { quickCommandsJson: JSON.stringify({ restart: { type: 'alias', target: 'gateway restart' } }) }), + /quick_commands\.restart\.target/, + ) +})