feat(hermes): add base model config

This commit is contained in:
晴天
2026-05-26 05:25:30 +08:00
parent 1e56a54aeb
commit 66375d2807
8 changed files with 558 additions and 3 deletions

View File

@@ -4292,6 +4292,44 @@ export function mergeHermesQuickCommandsConfig(config = {}, form = {}) {
return next
}
function normalizeHermesModelConfigString(value, key, required = false) {
if (value == null || value === '') {
if (required) throw new Error(`${key} 不能为空`)
return ''
}
if (typeof value !== 'string') throw new Error(`${key} 必须是字符串`)
const text = value.trim()
if (!text && required) throw new Error(`${key} 不能为空`)
return text
}
export function buildHermesModelConfigValues(config = {}) {
const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {}
const model = root.model && typeof root.model === 'object' && !Array.isArray(root.model) ? root.model : {}
const defaultModel = typeof model.default === 'string' ? model.default : model.model
return {
modelDefault: typeof defaultModel === 'string' ? defaultModel.trim() : '',
modelProvider: typeof model.provider === 'string' && model.provider.trim() ? model.provider.trim() : 'auto',
modelBaseUrl: typeof model.base_url === 'string' ? model.base_url.trim() : '',
}
}
export function mergeHermesModelConfig(config = {}, form = {}) {
const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {})
const currentValues = buildHermesModelConfigValues(next)
const model = next.model && typeof next.model === 'object' && !Array.isArray(next.model)
? mergeConfigsPreservingFields(next.model, {})
: {}
model.default = normalizeHermesModelConfigString(Object.hasOwn(form, 'modelDefault') ? form.modelDefault : currentValues.modelDefault, 'model.default', true)
model.provider = normalizeHermesModelConfigString(Object.hasOwn(form, 'modelProvider') ? form.modelProvider : currentValues.modelProvider, 'model.provider', true) || 'auto'
const baseUrl = normalizeHermesModelConfigString(Object.hasOwn(form, 'modelBaseUrl') ? form.modelBaseUrl : currentValues.modelBaseUrl, 'model.base_url')
if (baseUrl) model.base_url = baseUrl
else delete model.base_url
delete model.model
next.model = model
return next
}
function isHermesModelAliasName(value) {
return /^[a-zA-Z0-9_.-]+$/.test(String(value || '').trim())
}
@@ -11575,6 +11613,27 @@ const handlers = {
}
},
hermes_model_config_read() {
const { configPath, exists, config } = readHermesConfigYamlObject()
return {
exists,
configPath,
values: buildHermesModelConfigValues(config),
}
},
hermes_model_config_save({ form } = {}) {
const { configPath, config } = readHermesConfigYamlObject()
const next = mergeHermesModelConfig(config, form || {})
const backup = writeHermesConfigYamlObject(configPath, next)
return {
ok: true,
configPath,
backup,
values: buildHermesModelConfigValues(next),
}
},
hermes_model_aliases_config_read() {
const { configPath, exists, config } = readHermesConfigYamlObject()
return {

View File

@@ -4407,6 +4407,121 @@ fn merge_hermes_quick_commands_config(
Ok(())
}
fn normalize_hermes_model_config_string(
value: Option<String>,
key: &str,
required: bool,
) -> Result<String, String> {
let text = value.unwrap_or_default().trim().to_string();
if text.is_empty() && required {
return Err(format!("{key} 不能为空"));
}
Ok(text)
}
fn hermes_model_form_string(
form: &Value,
form_key: &str,
yaml_key: &str,
current: &Value,
) -> Result<Option<String>, String> {
if let Some(value) = form.get(form_key) {
if let Some(text) = value.as_str() {
return Ok(Some(text.to_string()));
}
return Err(format!("{yaml_key} 必须是字符串"));
}
Ok(current.as_str().map(ToString::to_string))
}
fn build_hermes_model_config_values(config: &serde_yaml::Value) -> Value {
let root = config.as_mapping();
let model = root
.and_then(|map| map.get(yaml_key("model")))
.and_then(|value| value.as_mapping());
let model_default = model
.and_then(|map| {
map.get(yaml_key("default"))
.or_else(|| map.get(yaml_key("model")))
})
.and_then(|value| value.as_str())
.unwrap_or_default()
.trim()
.to_string();
let provider = model
.and_then(|map| map.get(yaml_key("provider")))
.and_then(|value| value.as_str())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "auto".to_string());
let base_url = model
.and_then(|map| map.get(yaml_key("base_url")))
.and_then(|value| value.as_str())
.unwrap_or_default()
.trim()
.to_string();
serde_json::json!({
"modelDefault": model_default,
"modelProvider": provider,
"modelBaseUrl": base_url,
})
}
fn merge_hermes_model_config(config: &mut serde_yaml::Value, form: &Value) -> Result<(), String> {
let current = build_hermes_model_config_values(config);
let model_default = normalize_hermes_model_config_string(
hermes_model_form_string(
form,
"modelDefault",
"model.default",
&current["modelDefault"],
)?,
"model.default",
true,
)?;
let provider = normalize_hermes_model_config_string(
hermes_model_form_string(
form,
"modelProvider",
"model.provider",
&current["modelProvider"],
)?,
"model.provider",
true,
)?;
let base_url = normalize_hermes_model_config_string(
hermes_model_form_string(
form,
"modelBaseUrl",
"model.base_url",
&current["modelBaseUrl"],
)?,
"model.base_url",
false,
)?;
let root = ensure_yaml_object(config)?;
let mut model = root
.get(yaml_key("model"))
.and_then(|value| value.as_mapping())
.cloned()
.unwrap_or_default();
model.insert(
yaml_key("default"),
serde_yaml::Value::String(model_default),
);
model.insert(yaml_key("provider"), serde_yaml::Value::String(provider));
if base_url.is_empty() {
model.remove(yaml_key("base_url"));
} else {
model.insert(yaml_key("base_url"), serde_yaml::Value::String(base_url));
}
model.remove(yaml_key("model"));
root.insert(yaml_key("model"), serde_yaml::Value::Mapping(model));
Ok(())
}
fn is_hermes_model_alias_name(value: &str) -> bool {
let text = value.trim();
!text.is_empty()
@@ -8717,6 +8832,30 @@ pub fn hermes_quick_commands_config_save(form: Value) -> Result<Value, String> {
}))
}
#[tauri::command]
pub fn hermes_model_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_model_config_values(&config),
}))
}
#[tauri::command]
pub fn hermes_model_config_save(form: Value) -> Result<Value, String> {
let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?;
merge_hermes_model_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_config_values(&config),
}))
}
#[tauri::command]
pub fn hermes_model_aliases_config_read() -> Result<Value, String> {
let (config_path, exists, config) = read_hermes_channel_yaml_config()?;
@@ -16531,6 +16670,157 @@ streaming:
}
}
#[cfg(test)]
mod hermes_model_config_tests {
use super::{build_hermes_model_config_values, merge_hermes_model_config};
use serde_json::json;
#[test]
fn model_values_have_defaults_and_read_legacy_model_key() {
let config: serde_yaml::Value = serde_yaml::from_str("{}").unwrap();
let values = build_hermes_model_config_values(&config);
assert_eq!(values["modelDefault"], "");
assert_eq!(values["modelProvider"], "auto");
assert_eq!(values["modelBaseUrl"], "");
let config: serde_yaml::Value = serde_yaml::from_str(
r#"
model:
model: anthropic/claude-sonnet-4-6
provider: openrouter
base_url: https://openrouter.ai/api/v1
"#,
)
.unwrap();
let values = build_hermes_model_config_values(&config);
assert_eq!(values["modelDefault"], "anthropic/claude-sonnet-4-6");
assert_eq!(values["modelProvider"], "openrouter");
assert_eq!(values["modelBaseUrl"], "https://openrouter.ai/api/v1");
}
#[test]
fn merge_model_preserves_unknown_fields_and_writes_base_url() {
let mut config: serde_yaml::Value = serde_yaml::from_str(
r#"
model:
default: old-model
provider: auto
base_url: https://old.example/v1
auth_mode: env
context_length: 200000
memory:
memory_enabled: true
"#,
)
.unwrap();
merge_hermes_model_config(
&mut config,
&json!({
"modelDefault": "anthropic/claude-opus-4.6",
"modelProvider": "openrouter",
"modelBaseUrl": "https://openrouter.ai/api/v1",
}),
)
.unwrap();
assert_eq!(
config["model"]["default"].as_str(),
Some("anthropic/claude-opus-4.6")
);
assert_eq!(config["model"]["provider"].as_str(), Some("openrouter"));
assert_eq!(
config["model"]["base_url"].as_str(),
Some("https://openrouter.ai/api/v1")
);
assert_eq!(config["model"]["auth_mode"].as_str(), Some("env"));
assert_eq!(config["model"]["context_length"].as_i64(), Some(200000));
assert_eq!(config["memory"]["memory_enabled"].as_bool(), Some(true));
}
#[test]
fn merge_model_empty_base_url_removes_field_and_legacy_model_key() {
let mut config: serde_yaml::Value = serde_yaml::from_str(
r#"
model:
model: old-model
provider: custom
base_url: https://old.example/v1
max_tokens: 8192
display:
language: zh
"#,
)
.unwrap();
merge_hermes_model_config(
&mut config,
&json!({
"modelDefault": "google/gemini-3-flash-preview",
"modelProvider": "auto",
"modelBaseUrl": " ",
}),
)
.unwrap();
assert_eq!(
config["model"]["default"].as_str(),
Some("google/gemini-3-flash-preview")
);
assert_eq!(config["model"]["provider"].as_str(), Some("auto"));
assert!(config["model"]["base_url"].is_null());
assert!(config["model"]["model"].is_null());
assert_eq!(config["model"]["max_tokens"].as_i64(), Some(8192));
assert_eq!(config["display"]["language"].as_str(), Some("zh"));
}
#[test]
fn merge_model_rejects_empty_model() {
let mut config = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
let err = merge_hermes_model_config(
&mut config,
&json!({
"modelDefault": " ",
"modelProvider": "auto",
}),
)
.unwrap_err();
assert!(err.contains("model.default"));
}
#[test]
fn merge_model_rejects_non_string_form_values() {
let mut config: serde_yaml::Value = serde_yaml::from_str(
r#"
model:
default: gpt-5
provider: auto
"#,
)
.unwrap();
let err = merge_hermes_model_config(
&mut config,
&json!({
"modelDefault": "gpt-5",
"modelProvider": 123,
}),
)
.unwrap_err();
assert!(err.contains("model.provider"));
let err = merge_hermes_model_config(
&mut config,
&json!({
"modelDefault": "gpt-5",
"modelProvider": "auto",
"modelBaseUrl": 123,
}),
)
.unwrap_err();
assert!(err.contains("model.base_url"));
}
}
#[cfg(test)]
mod hermes_model_aliases_config_tests {
use super::{build_hermes_model_aliases_config_values, merge_hermes_model_aliases_config};

View File

@@ -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_config_read,
hermes::hermes_model_config_save,
hermes::hermes_model_aliases_config_read,
hermes::hermes_model_aliases_config_save,
hermes::hermes_hooks_config_read,

View File

@@ -83,6 +83,12 @@ const QUICK_COMMANDS_DEFAULTS = {
quickCommandsJson: '{}',
}
const MODEL_DEFAULTS = {
modelDefault: '',
modelProvider: 'auto',
modelBaseUrl: '',
}
const MODEL_ALIASES_DEFAULTS = {
modelAliasesJson: '{}',
}
@@ -300,6 +306,7 @@ export function render() {
let memoryValues = { ...MEMORY_DEFAULTS }
let skillsValues = { ...SKILLS_DEFAULTS }
let quickCommandsValues = { ...QUICK_COMMANDS_DEFAULTS }
let modelValues = { ...MODEL_DEFAULTS }
let modelAliasesValues = { ...MODEL_ALIASES_DEFAULTS }
let hooksValues = { ...HOOKS_DEFAULTS }
let providerOverridesValues = { ...PROVIDER_OVERRIDES_DEFAULTS }
@@ -333,6 +340,7 @@ export function render() {
let memoryLoading = true
let skillsLoading = true
let quickCommandsLoading = true
let modelLoading = true
let modelAliasesLoading = true
let hooksLoading = true
let providerOverridesLoading = true
@@ -366,6 +374,7 @@ export function render() {
let memorySaving = false
let skillsSaving = false
let quickCommandsSaving = false
let modelSaving = false
let modelAliasesSaving = false
let hooksSaving = false
let providerOverridesSaving = false
@@ -399,6 +408,7 @@ export function render() {
let memoryError = null
let skillsError = null
let quickCommandsError = null
let modelError = null
let modelAliasesError = null
let hooksError = null
let providerOverridesError = null
@@ -431,7 +441,7 @@ export function render() {
}
function isBusy() {
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
return loading || runtimeLoading || compressionLoading || promptCachingLoading || openrouterCacheLoading || providerRoutingLoading || auxiliaryLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || quickCommandsLoading || modelLoading || 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 || modelSaving || 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) {
@@ -893,7 +903,7 @@ export function render() {
}
function renderQuickCommandsConfigPanel() {
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
const disabled = loading || saving || quickCommandsLoading || quickCommandsSaving || modelSaving || modelAliasesSaving || hooksSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving
return `
<div class="hm-panel hm-config-runtime-panel hm-config-quick-commands-panel">
<div class="hm-panel-header">
@@ -918,8 +928,44 @@ export function render() {
`
}
function renderModelConfigPanel() {
const disabled = loading || saving || modelLoading || modelSaving || quickCommandsSaving || modelAliasesSaving || hooksSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving
return `
<div class="hm-panel hm-config-runtime-panel hm-config-model-panel">
<div class="hm-panel-header">
<div>
<div class="hm-panel-title">${t('engine.hermesModelConfigTitle')}</div>
<div class="hm-channel-panel-desc">${t('engine.hermesModelConfigDesc')}</div>
</div>
<div class="hm-panel-actions">
<span class="hm-muted">${modelSaving ? t('engine.hermesConfigStatusSaving') : modelLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesModelConfigStatusReady')}</span>
<button class="hm-btn hm-btn--cta hm-btn--sm" id="hm-model-config-save" ${disabled ? 'disabled' : ''}>${t('engine.hermesModelConfigSave')}</button>
</div>
</div>
<div class="hm-panel-body">
${renderError(modelError)}
<div class="hm-config-runtime-grid">
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesModelConfigDefault')}</span>
<input id="hm-model-default" class="hm-input" value="${esc(modelValues.modelDefault)}" placeholder="anthropic/claude-opus-4.6" ${disabled ? 'disabled' : ''}>
</label>
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesModelConfigProvider')}</span>
<input id="hm-model-provider" class="hm-input" value="${esc(modelValues.modelProvider)}" placeholder="auto" ${disabled ? 'disabled' : ''}>
</label>
<label class="hm-field hm-field--wide">
<span class="hm-field-label">${t('engine.hermesModelConfigBaseUrl')}</span>
<input id="hm-model-base-url" class="hm-input" value="${esc(modelValues.modelBaseUrl)}" placeholder="https://openrouter.ai/api/v1" ${disabled ? 'disabled' : ''}>
</label>
</div>
<div class="hm-channel-footnote">${t('engine.hermesModelConfigFootnote')}</div>
</div>
</div>
`
}
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
const disabled = loading || saving || modelAliasesLoading || modelAliasesSaving || quickCommandsSaving || modelSaving || hooksSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving
return `
<div class="hm-panel hm-config-runtime-panel hm-config-model-aliases-panel">
<div class="hm-panel-header">
@@ -1974,6 +2020,7 @@ export function render() {
${renderMemoryPanel()}
${renderSkillsConfigPanel()}
${renderQuickCommandsConfigPanel()}
${renderModelConfigPanel()}
${renderModelAliasesConfigPanel()}
${renderHooksConfigPanel()}
${renderProviderOverridesConfigPanel()}
@@ -2014,6 +2061,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-config-save')?.addEventListener('click', saveModelConfig)
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)
@@ -2093,6 +2141,11 @@ export function render() {
quickCommandsValues = { ...QUICK_COMMANDS_DEFAULTS, ...(data?.values || {}) }
}
async function loadModelConfig() {
const data = await api.hermesModelConfigRead()
modelValues = { ...MODEL_DEFAULTS, ...(data?.values || {}) }
}
async function loadModelAliasesConfig() {
const data = await api.hermesModelAliasesConfigRead()
modelAliasesValues = { ...MODEL_ALIASES_DEFAULTS, ...(data?.values || {}) }
@@ -2215,6 +2268,7 @@ export function render() {
memoryLoading = true
skillsLoading = true
quickCommandsLoading = true
modelLoading = true
modelAliasesLoading = true
hooksLoading = true
providerOverridesLoading = true
@@ -2248,6 +2302,7 @@ export function render() {
memoryError = null
skillsError = null
quickCommandsError = null
modelError = null
modelAliasesError = null
hooksError = null
providerOverridesError = null
@@ -2446,6 +2501,14 @@ export function render() {
quickCommandsLoading = false
draw()
}
try {
await loadModelConfig()
} catch (err) {
modelError = humanizeError(err, t('engine.hermesModelConfigLoadFailed') || 'Load model config failed')
} finally {
modelLoading = false
draw()
}
try {
await loadModelAliasesConfig()
} catch (err) {
@@ -2944,6 +3007,33 @@ export function render() {
}
}
async function saveModelConfig() {
const form = {
modelDefault: el.querySelector('#hm-model-default')?.value || '',
modelProvider: el.querySelector('#hm-model-provider')?.value || 'auto',
modelBaseUrl: el.querySelector('#hm-model-base-url')?.value || '',
}
modelSaving = true
modelError = null
draw()
try {
const result = await api.hermesModelConfigSave(form)
modelValues = { ...MODEL_DEFAULTS, ...(result?.values || form) }
await refreshRawAfterStructuredSave()
const backup = result?.backup || ''
toast({
message: t('engine.hermesModelConfigSaveSuccess'),
hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '',
}, 'success')
} catch (err) {
modelError = humanizeError(err, t('engine.hermesModelConfigSaveFailed') || 'Save model config failed')
toast(modelError, 'error')
} finally {
modelSaving = false
draw()
}
}
async function saveModelAliasesConfig() {
const form = {
modelAliasesJson: el.querySelector('#hm-model-aliases-json')?.value || '{}',

View File

@@ -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 }),
hermesModelConfigRead: () => invoke('hermes_model_config_read'),
hermesModelConfigSave: (form) => invoke('hermes_model_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'),

View File

@@ -823,6 +823,17 @@ export default {
hermesQuickCommandsConfigSaveFailed: _('保存快捷命令失败', 'Save quick commands failed', '儲存快捷命令失敗'),
hermesQuickCommandsConfigJson: _('quick_commands JSON 映射', 'quick_commands JSON map', 'quick_commands JSON 映射'),
hermesQuickCommandsConfigFootnote: _('键名会变成斜杠命令,例如 status 对应 /status。每个命令必须是对象type 只能为 exec 或 aliasexec 需要 commandalias 的 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 或 aliasexec 需要 commandalias 的 target 必須以 / 開頭。'),
hermesModelConfigTitle: _('基础模型', 'Base model', '基礎模型'),
hermesModelConfigDesc: _('配置 Hermes 默认使用的模型、provider 和兼容接口地址。API Key 仍在安装向导或环境变量中管理,这里不会展示密钥。', 'Configure the default model, provider, and compatible API base URL for Hermes. API keys still live in setup or environment variables and are not shown here.', '設定 Hermes 預設使用的模型、provider 和相容介面位址。API Key 仍在安裝精靈或環境變數中管理,這裡不會顯示金鑰。'),
hermesModelConfigStatusReady: _('结构化表单', 'structured form', '結構化表單'),
hermesModelConfigSave: _('保存基础模型', 'Save base model', '儲存基礎模型'),
hermesModelConfigSaveSuccess: _('基础模型已保存,建议重启 Hermes Gateway 生效', 'Base model saved. Restart Hermes Gateway to take effect.', '基礎模型已儲存,建議重啟 Hermes Gateway 生效'),
hermesModelConfigLoadFailed: _('加载基础模型失败', 'Load base model failed', '載入基礎模型失敗'),
hermesModelConfigSaveFailed: _('保存基础模型失败', 'Save base model failed', '儲存基礎模型失敗'),
hermesModelConfigDefault: _('默认模型', 'Default model', '預設模型'),
hermesModelConfigProvider: _('Provider', 'Provider', 'Provider'),
hermesModelConfigBaseUrl: _('兼容接口地址(可选)', 'Compatible API base URL (optional)', '相容介面位址(可選)'),
hermesModelConfigFootnote: _('默认模型不能为空provider 为空时建议填 auto。兼容接口地址留空会移除 model.base_url并保留 model 下其它高级字段。', 'Default model is required; use auto when no provider is pinned. Leaving the base URL blank removes model.base_url while preserving other advanced model fields.', '預設模型不可為空;未固定 provider 時建議填 auto。相容介面位址留空會移除 model.base_url並保留 model 下其他進階欄位。'),
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'),

View File

@@ -58,6 +58,17 @@ test('Hermes 配置页会暴露快捷命令结构化配置字段', () => {
}
})
test('Hermes 配置页会暴露基础模型结构化配置字段', () => {
for (const id of [
'hm-model-config-save',
'hm-model-default',
'hm-model-provider',
'hm-model-base-url',
]) {
assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`)
}
})
test('Hermes 配置页会暴露 provider 超时覆盖结构化配置字段', () => {
for (const id of [
'hm-provider-overrides-save',

View File

@@ -0,0 +1,90 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
buildHermesModelConfigValues,
mergeHermesModelConfig,
} from '../scripts/dev-api.js'
test('Hermes 基础模型配置读取会提供默认值并兼容 model.model', () => {
assert.deepEqual(buildHermesModelConfigValues({}), {
modelDefault: '',
modelProvider: 'auto',
modelBaseUrl: '',
})
const values = buildHermesModelConfigValues({
model: {
model: 'anthropic/claude-sonnet-4-6',
provider: 'openrouter',
base_url: 'https://openrouter.ai/api/v1',
},
})
assert.deepEqual(values, {
modelDefault: 'anthropic/claude-sonnet-4-6',
modelProvider: 'openrouter',
modelBaseUrl: 'https://openrouter.ai/api/v1',
})
})
test('Hermes 基础模型配置保存会保留未知字段并写入 model.default/provider/base_url', () => {
const next = mergeHermesModelConfig({
model: {
default: 'old-model',
provider: 'auto',
base_url: 'https://old.example/v1',
auth_mode: 'env',
context_length: 200000,
},
memory: { memory_enabled: true },
}, {
modelDefault: 'anthropic/claude-opus-4.6',
modelProvider: 'openrouter',
modelBaseUrl: 'https://openrouter.ai/api/v1',
})
assert.deepEqual(next.memory, { memory_enabled: true })
assert.equal(next.model.default, 'anthropic/claude-opus-4.6')
assert.equal(next.model.provider, 'openrouter')
assert.equal(next.model.base_url, 'https://openrouter.ai/api/v1')
assert.equal(next.model.auth_mode, 'env')
assert.equal(next.model.context_length, 200000)
})
test('Hermes 基础模型配置保存空 base_url 会删除该字段但保留 model 其它字段', () => {
const next = mergeHermesModelConfig({
model: {
default: 'old-model',
provider: 'custom',
base_url: 'https://old.example/v1',
max_tokens: 8192,
},
display: { language: 'zh' },
}, {
modelDefault: 'google/gemini-3-flash-preview',
modelProvider: 'auto',
modelBaseUrl: ' ',
})
assert.equal(next.model.default, 'google/gemini-3-flash-preview')
assert.equal(next.model.provider, 'auto')
assert.equal(Object.hasOwn(next.model, 'base_url'), false)
assert.equal(next.model.max_tokens, 8192)
assert.deepEqual(next.display, { language: 'zh' })
})
test('Hermes 基础模型配置保存会拒绝空模型和字段类型错误', () => {
assert.throws(
() => mergeHermesModelConfig({}, { modelDefault: ' ', modelProvider: 'auto' }),
/model\.default/,
)
assert.throws(
() => mergeHermesModelConfig({}, { modelDefault: 'gpt-5', modelProvider: 123 }),
/model\.provider/,
)
assert.throws(
() => mergeHermesModelConfig({}, { modelDefault: 'gpt-5', modelProvider: 'auto', modelBaseUrl: 123 }),
/model\.base_url/,
)
})