mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 04:10:00 +08:00
feat(hermes): add quick commands config form
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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<serde_json::Map<String, Value>, 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<String>,
|
||||
) -> Result<serde_json::Map<String, Value>, 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<String>,
|
||||
strict: bool,
|
||||
@@ -5245,6 +5350,30 @@ pub fn hermes_skills_config_save(form: Value) -> Result<Value, String> {
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn hermes_quick_commands_config_read() -> Result<Value, String> {
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
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::{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 `
|
||||
<div class="hm-panel hm-config-runtime-panel">
|
||||
<div class="hm-panel-header">
|
||||
@@ -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 `
|
||||
<div class="hm-panel hm-config-runtime-panel hm-config-compression-panel">
|
||||
<div class="hm-panel-header">
|
||||
@@ -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 `
|
||||
<div class="hm-panel hm-config-runtime-panel hm-config-guardrails-panel">
|
||||
<div class="hm-panel-header">
|
||||
@@ -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 `
|
||||
<div class="hm-panel hm-config-runtime-panel hm-config-memory-panel">
|
||||
<div class="hm-panel-header">
|
||||
@@ -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 `
|
||||
<div class="hm-panel hm-config-runtime-panel hm-config-skills-panel">
|
||||
<div class="hm-panel-header">
|
||||
@@ -396,8 +404,34 @@ export function render() {
|
||||
`
|
||||
}
|
||||
|
||||
function renderQuickCommandsConfigPanel() {
|
||||
const disabled = loading || saving || quickCommandsLoading || quickCommandsSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || terminalSaving
|
||||
return `
|
||||
<div class="hm-panel hm-config-runtime-panel hm-config-quick-commands-panel">
|
||||
<div class="hm-panel-header">
|
||||
<div>
|
||||
<div class="hm-panel-title">${t('engine.hermesQuickCommandsConfigTitle')}</div>
|
||||
<div class="hm-channel-panel-desc">${t('engine.hermesQuickCommandsConfigDesc')}</div>
|
||||
</div>
|
||||
<div class="hm-panel-actions">
|
||||
<span class="hm-muted">${quickCommandsSaving ? t('engine.hermesConfigStatusSaving') : quickCommandsLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesQuickCommandsConfigStatusReady')}</span>
|
||||
<button class="hm-btn hm-btn--cta hm-btn--sm" id="hm-quick-commands-save" ${disabled ? 'disabled' : ''}>${t('engine.hermesQuickCommandsConfigSave')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hm-panel-body">
|
||||
${renderError(quickCommandsError)}
|
||||
<label class="hm-field hm-field--wide">
|
||||
<span class="hm-field-label">${t('engine.hermesQuickCommandsConfigJson')}</span>
|
||||
<textarea id="hm-quick-commands-json" class="hm-input" spellcheck="false" rows="8" ${disabled ? 'disabled' : ''} style="font-family:var(--hm-font-mono);line-height:1.65;min-height:220px">${esc(quickCommandsValues.quickCommandsJson)}</textarea>
|
||||
</label>
|
||||
<div class="hm-channel-footnote">${t('engine.hermesQuickCommandsConfigFootnote')}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
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 `
|
||||
<div class="hm-panel hm-config-runtime-panel hm-config-streaming-panel">
|
||||
<div class="hm-panel-header">
|
||||
@@ -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 `
|
||||
<div class="hm-panel hm-config-runtime-panel hm-config-execution-limits-panel">
|
||||
<div class="hm-panel-header">
|
||||
@@ -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 `
|
||||
<div class="hm-panel hm-config-runtime-panel hm-config-terminal-panel">
|
||||
<div class="hm-panel-header">
|
||||
@@ -613,6 +647,7 @@ export function render() {
|
||||
${renderToolGuardrailsPanel()}
|
||||
${renderMemoryPanel()}
|
||||
${renderSkillsConfigPanel()}
|
||||
${renderQuickCommandsConfigPanel()}
|
||||
|
||||
<div class="hm-panel">
|
||||
<div class="hm-panel-header">
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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', '已匯出'),
|
||||
|
||||
@@ -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')
|
||||
|
||||
86
tests/hermes-quick-commands-config.test.js
Normal file
86
tests/hermes-quick-commands-config.test.js
Normal file
@@ -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/,
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user