diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 104120e..2901441 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -4222,6 +4222,76 @@ export function mergeHermesQuickCommandsConfig(config = {}, form = {}) { return next } +function isHermesModelAliasName(value) { + return /^[a-zA-Z0-9_.-]+$/.test(String(value || '').trim()) +} + +function normalizeHermesModelAliasString(entry, field, key, required = false) { + if (!Object.hasOwn(entry, field) || entry[field] == null || entry[field] === '') { + if (required) throw new Error(`${key}.${field} 不能为空`) + delete entry[field] + return + } + if (typeof entry[field] !== 'string') throw new Error(`${key}.${field} 必须是字符串`) + const value = entry[field].trim() + if (!value && required) throw new Error(`${key}.${field} 不能为空`) + if (value) entry[field] = value + else delete entry[field] +} + +function validateHermesModelAliases(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('model_aliases 必须是 JSON 对象') + } + const normalized = {} + for (const [rawAlias, rawConfig] of Object.entries(value)) { + const alias = String(rawAlias || '').trim() + if (!alias || !isHermesModelAliasName(alias)) { + throw new Error(`model_aliases.${rawAlias || ''} 别名只能包含字母、数字、下划线、点和短横线`) + } + if (!rawConfig || typeof rawConfig !== 'object' || Array.isArray(rawConfig)) { + throw new Error(`model_aliases.${alias} 必须是 JSON 对象`) + } + const entry = mergeConfigsPreservingFields(rawConfig, {}) + normalizeHermesModelAliasString(entry, 'model', `model_aliases.${alias}`, true) + normalizeHermesModelAliasString(entry, 'provider', `model_aliases.${alias}`) + normalizeHermesModelAliasString(entry, 'base_url', `model_aliases.${alias}`) + normalized[alias] = entry + } + return normalized +} + +function parseHermesModelAliasesJson(raw) { + const text = String(raw ?? '').trim() + if (!text) return {} + let value + try { + value = JSON.parse(text) + } catch (err) { + throw new Error(`model_aliases JSON 格式错误: ${err.message}`) + } + return validateHermesModelAliases(value) +} + +export function buildHermesModelAliasesConfigValues(config = {}) { + const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} + const modelAliases = root.model_aliases && typeof root.model_aliases === 'object' && !Array.isArray(root.model_aliases) + ? validateHermesModelAliases(root.model_aliases) + : {} + return { + modelAliasesJson: JSON.stringify(modelAliases, null, 2), + } +} + +export function mergeHermesModelAliasesConfig(config = {}, form = {}) { + const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {}) + const currentValues = buildHermesModelAliasesConfigValues(next) + const modelAliases = parseHermesModelAliasesJson(Object.hasOwn(form, 'modelAliasesJson') ? form.modelAliasesJson : currentValues.modelAliasesJson) + if (Object.keys(modelAliases).length) next.model_aliases = modelAliases + else delete next.model_aliases + return next +} + function normalizeHermesHookTimeout(entry, key) { if (!Object.hasOwn(entry, 'timeout') || entry.timeout == null || entry.timeout === '') { delete entry.timeout @@ -11435,6 +11505,27 @@ const handlers = { } }, + hermes_model_aliases_config_read() { + const { configPath, exists, config } = readHermesConfigYamlObject() + return { + exists, + configPath, + values: buildHermesModelAliasesConfigValues(config), + } + }, + + hermes_model_aliases_config_save({ form } = {}) { + const { configPath, config } = readHermesConfigYamlObject() + const next = mergeHermesModelAliasesConfig(config, form || {}) + const backup = writeHermesConfigYamlObject(configPath, next) + return { + ok: true, + configPath, + backup, + values: buildHermesModelAliasesConfigValues(next), + } + }, + hermes_hooks_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 9fc1664..75072a7 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -4407,6 +4407,123 @@ fn merge_hermes_quick_commands_config( Ok(()) } +fn is_hermes_model_alias_name(value: &str) -> bool { + let text = value.trim(); + !text.is_empty() + && text + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-')) +} + +fn normalize_hermes_model_alias_string( + entry: &mut serde_json::Map, + field: &str, + key: &str, + required: bool, +) -> Result<(), String> { + let empty = entry.get(field).is_none_or(|value| { + value.is_null() || value.as_str().is_some_and(|text| text.trim().is_empty()) + }); + if empty { + if required { + return Err(format!("{key}.{field} 不能为空")); + } + entry.remove(field); + return Ok(()); + } + let Some(value) = entry.get(field).and_then(|value| value.as_str()) else { + return Err(format!("{key}.{field} 必须是字符串")); + }; + let value = value.trim().to_string(); + if value.is_empty() { + if required { + return Err(format!("{key}.{field} 不能为空")); + } + entry.remove(field); + } else { + entry.insert(field.to_string(), Value::String(value)); + } + Ok(()) +} + +fn validate_hermes_model_aliases(value: &Value) -> Result, String> { + let Some(object) = value.as_object() else { + return Err("model_aliases 必须是 JSON 对象".to_string()); + }; + let mut normalized = serde_json::Map::new(); + for (raw_alias, raw_config) in object { + let alias = raw_alias.trim(); + if !is_hermes_model_alias_name(alias) { + return Err(format!( + "model_aliases.{} 别名只能包含字母、数字、下划线、点和短横线", + if raw_alias.is_empty() { + "" + } else { + raw_alias + } + )); + } + let Some(config) = raw_config.as_object() else { + return Err(format!("model_aliases.{alias} 必须是 JSON 对象")); + }; + let mut entry = config.clone(); + let key = format!("model_aliases.{alias}"); + normalize_hermes_model_alias_string(&mut entry, "model", &key, true)?; + normalize_hermes_model_alias_string(&mut entry, "provider", &key, false)?; + normalize_hermes_model_alias_string(&mut entry, "base_url", &key, false)?; + normalized.insert(alias.to_string(), Value::Object(entry)); + } + Ok(normalized) +} + +fn parse_hermes_model_aliases_json( + raw: Option, +) -> Result, String> { + let text = raw.unwrap_or_default().trim().to_string(); + if text.is_empty() { + return Ok(serde_json::Map::new()); + } + let value: Value = + serde_json::from_str(&text).map_err(|err| format!("model_aliases JSON 格式错误: {err}"))?; + validate_hermes_model_aliases(&value) +} + +fn build_hermes_model_aliases_config_values(config: &serde_yaml::Value) -> Value { + let root = config.as_mapping(); + let model_aliases = root + .and_then(|map| map.get(yaml_key("model_aliases"))) + .and_then(|value| serde_json::to_value(value).ok()) + .and_then(|value| validate_hermes_model_aliases(&value).ok()) + .unwrap_or_default(); + + serde_json::json!({ + "modelAliasesJson": serde_json::to_string_pretty(&Value::Object(model_aliases)).unwrap_or_else(|_| "{}".to_string()), + }) +} + +fn merge_hermes_model_aliases_config( + config: &mut serde_yaml::Value, + form: &Value, +) -> Result<(), String> { + let current = build_hermes_model_aliases_config_values(config); + let model_aliases = + parse_hermes_model_aliases_json(form_string(form, "modelAliasesJson").or_else(|| { + current["modelAliasesJson"] + .as_str() + .map(ToString::to_string) + }))?; + + let root = ensure_yaml_object(config)?; + if model_aliases.is_empty() { + root.remove(yaml_key("model_aliases")); + } else { + let yaml_value = serde_yaml::to_value(Value::Object(model_aliases)) + .map_err(|err| format!("model_aliases 转换 YAML 失败: {err}"))?; + root.insert(yaml_key("model_aliases"), yaml_value); + } + Ok(()) +} + fn is_hermes_hook_event(value: &str) -> bool { matches!( value, @@ -8416,6 +8533,30 @@ pub fn hermes_quick_commands_config_save(form: Value) -> Result { })) } +#[tauri::command] +pub fn hermes_model_aliases_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_model_aliases_config_values(&config), + })) +} + +#[tauri::command] +pub fn hermes_model_aliases_config_save(form: Value) -> Result { + let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?; + merge_hermes_model_aliases_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_model_aliases_config_values(&config), + })) +} + #[tauri::command] pub fn hermes_hooks_config_read() -> Result { let (config_path, exists, config) = read_hermes_channel_yaml_config()?; @@ -16206,6 +16347,163 @@ streaming: } } +#[cfg(test)] +mod hermes_model_aliases_config_tests { + use super::{build_hermes_model_aliases_config_values, merge_hermes_model_aliases_config}; + use serde_json::json; + + #[test] + fn model_aliases_values_have_empty_defaults() { + let config: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let values = build_hermes_model_aliases_config_values(&config); + assert_eq!(values["modelAliasesJson"], "{}"); + } + + #[test] + fn model_aliases_values_read_yaml_mapping() { + let config: serde_yaml::Value = serde_yaml::from_str( + r#" +model_aliases: + opus: + model: claude-opus-4-6 + provider: anthropic + qwen: + model: "qwen3.5:397b" + provider: custom + base_url: https://ollama.com/v1 +"#, + ) + .unwrap(); + + let values = build_hermes_model_aliases_config_values(&config); + let parsed: serde_json::Value = + serde_json::from_str(values["modelAliasesJson"].as_str().unwrap()).unwrap(); + assert_eq!(parsed["opus"]["model"], "claude-opus-4-6"); + assert_eq!(parsed["opus"]["provider"], "anthropic"); + assert_eq!(parsed["qwen"]["model"], "qwen3.5:397b"); + assert_eq!(parsed["qwen"]["base_url"], "https://ollama.com/v1"); + } + + #[test] + fn merge_model_aliases_config_preserves_unknown_fields_and_unrelated_yaml() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +model: + provider: openrouter +model_aliases: + opus: + model: old-opus + provider: anthropic + custom_flag: drop-with-replace +memory: + memory_enabled: true +"#, + ) + .unwrap(); + + merge_hermes_model_aliases_config( + &mut config, + &json!({ + "modelAliasesJson": r#"{ + "opus": { + "model": "claude-opus-4-6", + "provider": "anthropic", + "custom_flag": "keep-alias" + }, + "qwen": { + "model": "qwen3.5:397b", + "provider": "custom", + "base_url": "https://ollama.com/v1" + } + }"#, + }), + ) + .unwrap(); + + assert_eq!(config["model"]["provider"].as_str(), Some("openrouter")); + assert_eq!(config["memory"]["memory_enabled"].as_bool(), Some(true)); + assert_eq!( + config["model_aliases"]["opus"]["model"].as_str(), + Some("claude-opus-4-6") + ); + assert_eq!( + config["model_aliases"]["opus"]["custom_flag"].as_str(), + Some("keep-alias") + ); + assert_eq!( + config["model_aliases"]["qwen"]["provider"].as_str(), + Some("custom") + ); + assert_eq!( + config["model_aliases"]["qwen"]["base_url"].as_str(), + Some("https://ollama.com/v1") + ); + } + + #[test] + fn merge_model_aliases_config_removes_empty_mapping() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +model_aliases: + opus: + model: claude-opus-4-6 +streaming: + enabled: true +"#, + ) + .unwrap(); + + merge_hermes_model_aliases_config(&mut config, &json!({ "modelAliasesJson": "{}" })) + .unwrap(); + + assert!(config["model_aliases"].is_null()); + assert_eq!(config["streaming"]["enabled"].as_bool(), Some(true)); + } + + #[test] + fn merge_model_aliases_config_rejects_invalid_values() { + let mut config = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + let err = + merge_hermes_model_aliases_config(&mut config, &json!({ "modelAliasesJson": "[" })) + .unwrap_err(); + assert!(err.contains("model_aliases JSON")); + let err = + merge_hermes_model_aliases_config(&mut config, &json!({ "modelAliasesJson": "[]" })) + .unwrap_err(); + assert!(err.contains("model_aliases")); + let err = merge_hermes_model_aliases_config( + &mut config, + &json!({ "modelAliasesJson": r#"{ "bad alias": { "model": "m", "provider": "p" } }"# }), + ) + .unwrap_err(); + assert!(err.contains("model_aliases.bad alias")); + let err = merge_hermes_model_aliases_config( + &mut config, + &json!({ "modelAliasesJson": r#"{ "opus": "claude-opus-4-6" }"# }), + ) + .unwrap_err(); + assert!(err.contains("model_aliases.opus")); + let err = merge_hermes_model_aliases_config( + &mut config, + &json!({ "modelAliasesJson": r#"{ "opus": { "provider": "anthropic" } }"# }), + ) + .unwrap_err(); + assert!(err.contains("model_aliases.opus.model")); + let err = merge_hermes_model_aliases_config( + &mut config, + &json!({ "modelAliasesJson": r#"{ "opus": { "model": "claude-opus-4-6", "provider": 123 } }"# }), + ) + .unwrap_err(); + assert!(err.contains("model_aliases.opus.provider")); + let err = merge_hermes_model_aliases_config( + &mut config, + &json!({ "modelAliasesJson": r#"{ "qwen": { "model": "qwen3.5:397b", "base_url": 123 } }"# }), + ) + .unwrap_err(); + assert!(err.contains("model_aliases.qwen.base_url")); + } +} + #[cfg(test)] mod hermes_hooks_config_tests { use super::{build_hermes_hooks_config_values, merge_hermes_hooks_config}; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7431501..9f292d3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -277,6 +277,8 @@ pub fn run() { hermes::hermes_skills_config_save, hermes::hermes_quick_commands_config_read, hermes::hermes_quick_commands_config_save, + hermes::hermes_model_aliases_config_read, + hermes::hermes_model_aliases_config_save, hermes::hermes_hooks_config_read, hermes::hermes_hooks_config_save, hermes::hermes_provider_overrides_config_read, diff --git a/src/engines/hermes/pages/config.js b/src/engines/hermes/pages/config.js index 34b7199..fab0567 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -83,6 +83,10 @@ const QUICK_COMMANDS_DEFAULTS = { quickCommandsJson: '{}', } +const MODEL_ALIASES_DEFAULTS = { + modelAliasesJson: '{}', +} + const HOOKS_DEFAULTS = { hooksAutoAccept: false, hooksJson: '{}', @@ -286,6 +290,7 @@ export function render() { let memoryValues = { ...MEMORY_DEFAULTS } let skillsValues = { ...SKILLS_DEFAULTS } let quickCommandsValues = { ...QUICK_COMMANDS_DEFAULTS } + let modelAliasesValues = { ...MODEL_ALIASES_DEFAULTS } let hooksValues = { ...HOOKS_DEFAULTS } let providerOverridesValues = { ...PROVIDER_OVERRIDES_DEFAULTS } let mcpServersValues = { ...MCP_SERVERS_DEFAULTS } @@ -318,6 +323,7 @@ export function render() { let memoryLoading = true let skillsLoading = true let quickCommandsLoading = true + let modelAliasesLoading = true let hooksLoading = true let providerOverridesLoading = true let mcpServersLoading = true @@ -350,6 +356,7 @@ export function render() { let memorySaving = false let skillsSaving = false let quickCommandsSaving = false + let modelAliasesSaving = false let hooksSaving = false let providerOverridesSaving = false let mcpServersSaving = false @@ -382,6 +389,7 @@ export function render() { let memoryError = null let skillsError = null let quickCommandsError = null + let modelAliasesError = null let hooksError = null let providerOverridesError = null let mcpServersError = null @@ -413,7 +421,7 @@ export function render() { } function isBusy() { - return loading || runtimeLoading || compressionLoading || promptCachingLoading || openrouterCacheLoading || providerRoutingLoading || auxiliaryLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || quickCommandsLoading || hooksLoading || providerOverridesLoading || mcpServersLoading || agentToolsetsLoading || platformToolsetsLoading || agentRuntimeLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || streamingLoading || executionLimitsLoading || ioSafetyLoading || checkpointsLoading || cronLoading || loggingLoading || approvalsLoading || privacyLoading || browserLoading || sttLoading || terminalLoading || saving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || hooksSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || platformToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || privacySaving || browserSaving || sttSaving || terminalSaving + return loading || runtimeLoading || compressionLoading || promptCachingLoading || openrouterCacheLoading || providerRoutingLoading || auxiliaryLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || quickCommandsLoading || modelAliasesLoading || hooksLoading || providerOverridesLoading || mcpServersLoading || agentToolsetsLoading || platformToolsetsLoading || agentRuntimeLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || streamingLoading || executionLimitsLoading || ioSafetyLoading || checkpointsLoading || cronLoading || loggingLoading || approvalsLoading || privacyLoading || browserLoading || sttLoading || terminalLoading || saving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || modelAliasesSaving || hooksSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || platformToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || privacySaving || browserSaving || sttSaving || terminalSaving } function option(labelKey, value, selected) { @@ -875,7 +883,7 @@ export function render() { } function renderQuickCommandsConfigPanel() { - const disabled = loading || saving || quickCommandsLoading || quickCommandsSaving || hooksSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || quickCommandsLoading || quickCommandsSaving || modelAliasesSaving || hooksSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -900,8 +908,34 @@ export function render() { ` } + function renderModelAliasesConfigPanel() { + const disabled = loading || saving || modelAliasesLoading || modelAliasesSaving || quickCommandsSaving || hooksSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + return ` +
+
+
+
${t('engine.hermesModelAliasesConfigTitle')}
+
${t('engine.hermesModelAliasesConfigDesc')}
+
+
+ ${modelAliasesSaving ? t('engine.hermesConfigStatusSaving') : modelAliasesLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesModelAliasesConfigStatusReady')} + +
+
+
+ ${renderError(modelAliasesError)} + +
${t('engine.hermesModelAliasesConfigFootnote')}
+
+
+ ` + } + function renderHooksConfigPanel() { - const disabled = loading || saving || hooksLoading || hooksSaving || quickCommandsSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + const disabled = loading || saving || hooksLoading || hooksSaving || quickCommandsSaving || modelAliasesSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -1894,6 +1928,7 @@ export function render() { ${renderMemoryPanel()} ${renderSkillsConfigPanel()} ${renderQuickCommandsConfigPanel()} + ${renderModelAliasesConfigPanel()} ${renderHooksConfigPanel()} ${renderProviderOverridesConfigPanel()} ${renderMcpServersConfigPanel()} @@ -1933,6 +1968,7 @@ export function render() { 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-model-aliases-save')?.addEventListener('click', saveModelAliasesConfig) el.querySelector('#hm-hooks-save')?.addEventListener('click', saveHooksConfig) el.querySelector('#hm-provider-overrides-save')?.addEventListener('click', saveProviderOverridesConfig) el.querySelector('#hm-mcp-servers-save')?.addEventListener('click', saveMcpServersConfig) @@ -2011,6 +2047,11 @@ export function render() { quickCommandsValues = { ...QUICK_COMMANDS_DEFAULTS, ...(data?.values || {}) } } + async function loadModelAliasesConfig() { + const data = await api.hermesModelAliasesConfigRead() + modelAliasesValues = { ...MODEL_ALIASES_DEFAULTS, ...(data?.values || {}) } + } + async function loadHooksConfig() { const data = await api.hermesHooksConfigRead() hooksValues = { ...HOOKS_DEFAULTS, ...(data?.values || {}) } @@ -2128,6 +2169,7 @@ export function render() { memoryLoading = true skillsLoading = true quickCommandsLoading = true + modelAliasesLoading = true hooksLoading = true providerOverridesLoading = true mcpServersLoading = true @@ -2160,6 +2202,7 @@ export function render() { memoryError = null skillsError = null quickCommandsError = null + modelAliasesError = null hooksError = null providerOverridesError = null mcpServersError = null @@ -2357,6 +2400,14 @@ export function render() { quickCommandsLoading = false draw() } + try { + await loadModelAliasesConfig() + } catch (err) { + modelAliasesError = humanizeError(err, t('engine.hermesModelAliasesConfigLoadFailed') || 'Load model aliases config failed') + } finally { + modelAliasesLoading = false + draw() + } try { await loadHooksConfig() } catch (err) { @@ -2488,6 +2539,9 @@ export function render() { try { await loadQuickCommandsConfig() } catch {} + try { + await loadModelAliasesConfig() + } catch {} try { await loadHooksConfig() } catch {} @@ -2844,6 +2898,31 @@ export function render() { } } + async function saveModelAliasesConfig() { + const form = { + modelAliasesJson: el.querySelector('#hm-model-aliases-json')?.value || '{}', + } + modelAliasesSaving = true + modelAliasesError = null + draw() + try { + const result = await api.hermesModelAliasesConfigSave(form) + modelAliasesValues = { ...MODEL_ALIASES_DEFAULTS, ...(result?.values || form) } + await refreshRawAfterStructuredSave() + const backup = result?.backup || '' + toast({ + message: t('engine.hermesModelAliasesConfigSaveSuccess'), + hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '', + }, 'success') + } catch (err) { + modelAliasesError = humanizeError(err, t('engine.hermesModelAliasesConfigSaveFailed') || 'Save model aliases config failed') + toast(modelAliasesError, 'error') + } finally { + modelAliasesSaving = false + draw() + } + } + async function saveHooksConfig() { const form = { hooksAutoAccept: !!el.querySelector('#hm-hooks-auto-accept')?.checked, diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 725d4b5..70efe30 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -529,6 +529,8 @@ export const api = { hermesSkillsConfigSave: (form) => invoke('hermes_skills_config_save', { form }), hermesQuickCommandsConfigRead: () => invoke('hermes_quick_commands_config_read'), hermesQuickCommandsConfigSave: (form) => invoke('hermes_quick_commands_config_save', { form }), + hermesModelAliasesConfigRead: () => invoke('hermes_model_aliases_config_read'), + hermesModelAliasesConfigSave: (form) => invoke('hermes_model_aliases_config_save', { form }), hermesHooksConfigRead: () => invoke('hermes_hooks_config_read'), hermesHooksConfigSave: (form) => invoke('hermes_hooks_config_save', { form }), hermesProviderOverridesConfigRead: () => invoke('hermes_provider_overrides_config_read'), diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index 2f33c7d..64bbab9 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -823,6 +823,15 @@ export default { 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 必須以 / 開頭。'), + hermesModelAliasesConfigTitle: _('模型别名', 'Model aliases', '模型別名'), + hermesModelAliasesConfigDesc: _('配置 /model 命令可用的短别名,把常用模型、provider 和自定义 base_url 固定下来,减少手输错误。', 'Configure short aliases for the /model command, pinning common models, providers, and custom base_url values to reduce manual input errors.', '設定 /model 命令可用的短別名,把常用模型、provider 和自訂 base_url 固定下來,減少手動輸入錯誤。'), + hermesModelAliasesConfigStatusReady: _('结构化 JSON', 'structured JSON', '結構化 JSON'), + hermesModelAliasesConfigSave: _('保存模型别名', 'Save model aliases', '儲存模型別名'), + hermesModelAliasesConfigSaveSuccess: _('模型别名已保存,建议重启 Hermes Gateway 生效', 'Model aliases saved. Restart Hermes Gateway to take effect.', '模型別名已儲存,建議重啟 Hermes Gateway 生效'), + hermesModelAliasesConfigLoadFailed: _('加载模型别名失败', 'Load model aliases failed', '載入模型別名失敗'), + hermesModelAliasesConfigSaveFailed: _('保存模型别名失败', 'Save model aliases failed', '儲存模型別名失敗'), + hermesModelAliasesConfigJson: _('model_aliases JSON 映射', 'model_aliases JSON map', 'model_aliases JSON 映射'), + hermesModelAliasesConfigFootnote: _('键名是 /model 使用的短别名。每项至少需要 model,可选 provider 和 base_url;未知字段会保留在 raw YAML 中。', 'Keys are short aliases used by /model. Each entry needs at least model, with optional provider and base_url. Unknown fields stay in raw YAML.', '鍵名是 /model 使用的短別名。每項至少需要 model,可選 provider 和 base_url;未知欄位會保留在 raw YAML 中。'), hermesHooksConfigTitle: _('Shell Hooks', 'Shell hooks', 'Shell Hooks'), hermesHooksConfigDesc: _('配置 Hermes 在工具调用、模型调用和会话生命周期中执行的本地脚本。请只添加可信脚本,自动接受会跳过首次确认。', 'Configure local scripts Hermes runs during tool calls, model calls, and session lifecycle events. Only add trusted scripts; auto accept skips first-use confirmation.', '設定 Hermes 在工具呼叫、模型呼叫和工作階段生命週期中執行的本機腳本。請只加入可信腳本,自動接受會略過首次確認。'), hermesHooksConfigStatusReady: _('结构化 JSON', 'structured JSON', '結構化 JSON'), diff --git a/tests/hermes-config-page-ui.test.js b/tests/hermes-config-page-ui.test.js index e4c5877..302dadd 100644 --- a/tests/hermes-config-page-ui.test.js +++ b/tests/hermes-config-page-ui.test.js @@ -86,6 +86,15 @@ test('Hermes 配置页会暴露 Hooks 结构化配置字段', () => { } }) +test('Hermes 配置页会暴露模型别名结构化配置字段', () => { + for (const id of [ + 'hm-model-aliases-save', + 'hm-model-aliases-json', + ]) { + assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`) + } +}) + test('Hermes 配置页会暴露全局禁用工具集结构化配置字段', () => { for (const id of [ 'hm-agent-toolsets-save', @@ -382,6 +391,7 @@ test('Hermes 配置页新增结构化配置不会暴露翻译 key', () => { key.includes('ProviderOverridesConfig') || key.includes('McpServersConfig') || key.includes('HooksConfig') || + key.includes('ModelAliasesConfig') || key.includes('AgentToolsetsConfig') || key.includes('AgentRuntimeConfig') || key.includes('UnauthorizedDmConfig') || diff --git a/tests/hermes-model-aliases-config.test.js b/tests/hermes-model-aliases-config.test.js new file mode 100644 index 0000000..18a7771 --- /dev/null +++ b/tests/hermes-model-aliases-config.test.js @@ -0,0 +1,127 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildHermesModelAliasesConfigValues, + mergeHermesModelAliasesConfig, +} from '../scripts/dev-api.js' + +test('Hermes 模型别名配置读取会提供空对象默认值', () => { + const values = buildHermesModelAliasesConfigValues({}) + + assert.deepEqual(values, { + modelAliasesJson: '{}', + }) +}) + +test('Hermes 模型别名配置读取会格式化已有映射', () => { + const values = buildHermesModelAliasesConfigValues({ + model_aliases: { + opus: { + model: 'claude-opus-4-6', + provider: 'anthropic', + }, + qwen: { + model: 'qwen3.5:397b', + provider: 'custom', + base_url: 'https://ollama.com/v1', + }, + }, + }) + const mapping = JSON.parse(values.modelAliasesJson) + + assert.deepEqual(mapping.opus, { + model: 'claude-opus-4-6', + provider: 'anthropic', + }) + assert.deepEqual(mapping.qwen, { + model: 'qwen3.5:397b', + provider: 'custom', + base_url: 'https://ollama.com/v1', + }) +}) + +test('Hermes 模型别名配置保存会保留未知字段并写入 model_aliases', () => { + const next = mergeHermesModelAliasesConfig({ + model: { provider: 'openrouter' }, + model_aliases: { + opus: { + model: 'old-opus', + provider: 'anthropic', + custom_flag: 'drop-with-replace', + }, + }, + memory: { memory_enabled: true }, + }, { + modelAliasesJson: JSON.stringify({ + opus: { + model: 'claude-opus-4-6', + provider: 'anthropic', + custom_flag: 'keep-alias', + }, + qwen: { + model: 'qwen3.5:397b', + provider: 'custom', + base_url: 'https://ollama.com/v1', + }, + }), + }) + + assert.deepEqual(next.model, { provider: 'openrouter' }) + assert.deepEqual(next.memory, { memory_enabled: true }) + assert.deepEqual(next.model_aliases.opus, { + model: 'claude-opus-4-6', + provider: 'anthropic', + custom_flag: 'keep-alias', + }) + assert.deepEqual(next.model_aliases.qwen, { + model: 'qwen3.5:397b', + provider: 'custom', + base_url: 'https://ollama.com/v1', + }) +}) + +test('Hermes 模型别名配置保存空对象会移除 model_aliases', () => { + const next = mergeHermesModelAliasesConfig({ + model_aliases: { + opus: { model: 'claude-opus-4-6', provider: 'anthropic' }, + }, + streaming: { enabled: true }, + }, { + modelAliasesJson: '{}', + }) + + assert.equal(next.model_aliases, undefined) + assert.deepEqual(next.streaming, { enabled: true }) +}) + +test('Hermes 模型别名配置保存会拒绝非法 JSON、名称和字段类型', () => { + assert.throws( + () => mergeHermesModelAliasesConfig({}, { modelAliasesJson: '[' }), + /model_aliases JSON/, + ) + assert.throws( + () => mergeHermesModelAliasesConfig({}, { modelAliasesJson: '[]' }), + /model_aliases/, + ) + assert.throws( + () => mergeHermesModelAliasesConfig({}, { modelAliasesJson: JSON.stringify({ 'bad alias': { model: 'm', provider: 'p' } }) }), + /model_aliases\.bad alias/, + ) + assert.throws( + () => mergeHermesModelAliasesConfig({}, { modelAliasesJson: JSON.stringify({ opus: 'claude-opus-4-6' }) }), + /model_aliases\.opus/, + ) + assert.throws( + () => mergeHermesModelAliasesConfig({}, { modelAliasesJson: JSON.stringify({ opus: { provider: 'anthropic' } }) }), + /model_aliases\.opus\.model/, + ) + assert.throws( + () => mergeHermesModelAliasesConfig({}, { modelAliasesJson: JSON.stringify({ opus: { model: 'claude-opus-4-6', provider: 123 } }) }), + /model_aliases\.opus\.provider/, + ) + assert.throws( + () => mergeHermesModelAliasesConfig({}, { modelAliasesJson: JSON.stringify({ qwen: { model: 'qwen3.5:397b', base_url: 123 } }) }), + /model_aliases\.qwen\.base_url/, + ) +})