mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 04:10:00 +08:00
feat(hermes): add base model config
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
¤t["modelDefault"],
|
||||
)?,
|
||||
"model.default",
|
||||
true,
|
||||
)?;
|
||||
let provider = normalize_hermes_model_config_string(
|
||||
hermes_model_form_string(
|
||||
form,
|
||||
"modelProvider",
|
||||
"model.provider",
|
||||
¤t["modelProvider"],
|
||||
)?,
|
||||
"model.provider",
|
||||
true,
|
||||
)?;
|
||||
let base_url = normalize_hermes_model_config_string(
|
||||
hermes_model_form_string(
|
||||
form,
|
||||
"modelBaseUrl",
|
||||
"model.base_url",
|
||||
¤t["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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || '{}',
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 或 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 必須以 / 開頭。'),
|
||||
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'),
|
||||
|
||||
@@ -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',
|
||||
|
||||
90
tests/hermes-model-config.test.js
Normal file
90
tests/hermes-model-config.test.js
Normal 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/,
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user