diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 6cb7408..e6291a2 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -3331,6 +3331,7 @@ const HERMES_BROWSER_DIALOG_POLICIES = new Set(['must_respond', 'auto_dismiss', const HERMES_WEB_BACKENDS = new Set(['tavily', 'firecrawl', 'parallel', 'exa', 'searxng', 'brave', 'brave_free', 'ddgs', 'xai', 'native']) const HERMES_LSP_WAIT_MODES = new Set(['document', 'full']) const HERMES_LSP_INSTALL_STRATEGIES = new Set(['auto', 'manual', 'off']) +const HERMES_MODEL_CATALOG_DEFAULT_URL = 'https://hermes-agent.nousresearch.com/docs/api/model-catalog.json' const HERMES_STT_PROVIDERS = new Set(['auto', 'local', 'groq', 'openai', 'mistral']) const HERMES_STT_LOCAL_MODELS = new Set(['tiny', 'base', 'small', 'medium', 'large-v3', 'turbo']) const HERMES_STT_OPENAI_MODELS = new Set(['whisper-1', 'gpt-4o-mini-transcribe', 'gpt-4o-transcribe']) @@ -3499,6 +3500,22 @@ function normalizeHermesWebBackend(value, key, strict = false) { return '' } +function normalizeHermesHttpUrl(value, key, fallback = '', strict = false) { + const raw = String(value ?? '').trim() + if (!raw) { + if (strict && !fallback) throw new Error(`${key} 不能为空`) + return fallback + } + try { + const parsed = new URL(raw) + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return raw + } catch (_) { + // 统一在下面抛出可读错误 + } + if (strict) throw new Error(`${key} 必须是 http:// 或 https:// URL`) + return fallback +} + function normalizeHermesLspWaitMode(value, strict = false) { const mode = String(value ?? '').trim().toLowerCase() || 'document' if (HERMES_LSP_WAIT_MODES.has(mode)) return mode @@ -5551,6 +5568,74 @@ export function mergeHermesWebConfig(config = {}, form = {}) { return next } +function validateHermesModelCatalogProviders(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('model_catalog.providers 必须是 JSON object') + } + const normalized = {} + for (const [provider, rawEntry] of Object.entries(value)) { + const name = String(provider || '').trim() + if (!/^[a-zA-Z0-9_.-]+$/.test(name)) { + throw new Error(`model_catalog.providers.${provider} 名称只能包含字母、数字、下划线、点和短横线`) + } + if (!rawEntry || typeof rawEntry !== 'object' || Array.isArray(rawEntry)) { + throw new Error(`model_catalog.providers.${name} 必须是 object`) + } + const entry = mergeConfigsPreservingFields({}, rawEntry) + if (Object.hasOwn(entry, 'url')) { + const url = normalizeHermesHttpUrl(entry.url, `model_catalog.providers.${name}.url`, '', true) + if (url) entry.url = url + else delete entry.url + } + normalized[name] = entry + } + return normalized +} + +function parseHermesModelCatalogProvidersJson(raw) { + const text = String(raw ?? '').trim() + if (!text) return {} + let value + try { + value = JSON.parse(text) + } catch (err) { + throw new Error(`model_catalog.providers JSON 格式错误: ${err.message}`) + } + return validateHermesModelCatalogProviders(value) +} + +export function buildHermesModelCatalogConfigValues(config = {}) { + const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} + const modelCatalog = root.model_catalog && typeof root.model_catalog === 'object' && !Array.isArray(root.model_catalog) + ? root.model_catalog + : {} + const providers = modelCatalog.providers && typeof modelCatalog.providers === 'object' && !Array.isArray(modelCatalog.providers) + ? validateHermesModelCatalogProviders(modelCatalog.providers) + : {} + return { + modelCatalogEnabled: readHermesBool(modelCatalog.enabled, true), + modelCatalogUrl: normalizeHermesHttpUrl(modelCatalog.url, 'model_catalog.url', HERMES_MODEL_CATALOG_DEFAULT_URL, false), + modelCatalogTtlHours: parseHermesInteger(modelCatalog.ttl_hours, 'model_catalog.ttl_hours', 24, 1, 8760, false), + modelCatalogProvidersJson: JSON.stringify(providers, null, 2), + } +} + +export function mergeHermesModelCatalogConfig(config = {}, form = {}) { + const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {}) + const currentValues = buildHermesModelCatalogConfigValues(next) + const modelCatalog = next.model_catalog && typeof next.model_catalog === 'object' && !Array.isArray(next.model_catalog) + ? mergeConfigsPreservingFields(next.model_catalog, {}) + : {} + modelCatalog.enabled = formHermesBool(form, 'modelCatalogEnabled', currentValues.modelCatalogEnabled) + modelCatalog.url = normalizeHermesHttpUrl(Object.hasOwn(form, 'modelCatalogUrl') ? form.modelCatalogUrl : currentValues.modelCatalogUrl, 'model_catalog.url', HERMES_MODEL_CATALOG_DEFAULT_URL, true) + modelCatalog.ttl_hours = parseHermesInteger(Object.hasOwn(form, 'modelCatalogTtlHours') ? form.modelCatalogTtlHours : currentValues.modelCatalogTtlHours, 'model_catalog.ttl_hours', 24, 1, 8760, true) + const providers = parseHermesModelCatalogProvidersJson(Object.hasOwn(form, 'modelCatalogProvidersJson') ? form.modelCatalogProvidersJson : currentValues.modelCatalogProvidersJson) + if (Object.keys(providers).length) modelCatalog.providers = providers + else delete modelCatalog.providers + next.model_catalog = modelCatalog + return next +} + export function buildHermesLspConfigValues(config = {}) { const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} const lsp = root.lsp && typeof root.lsp === 'object' && !Array.isArray(root.lsp) @@ -12902,6 +12987,27 @@ const handlers = { } }, + hermes_model_catalog_config_read() { + const { configPath, exists, config } = readHermesConfigYamlObject() + return { + exists, + configPath, + values: buildHermesModelCatalogConfigValues(config), + } + }, + + hermes_model_catalog_config_save({ form } = {}) { + const { configPath, config } = readHermesConfigYamlObject() + const next = mergeHermesModelCatalogConfig(config, form || {}) + const backup = writeHermesConfigYamlObject(configPath, next) + return { + ok: true, + configPath, + backup, + values: buildHermesModelCatalogConfigValues(next), + } + }, + hermes_stt_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 8547945..8f5ce04 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -3568,6 +3568,85 @@ fn validate_hermes_f64( Ok((value * 10_000.0).round() / 10_000.0) } +const HERMES_MODEL_CATALOG_DEFAULT_URL: &str = + "https://hermes-agent.nousresearch.com/docs/api/model-catalog.json"; + +fn normalize_hermes_http_url( + value: Option, + key: &str, + fallback: &str, + strict: bool, +) -> Result { + let raw = value.unwrap_or_default().trim().to_string(); + if raw.is_empty() { + if strict && fallback.is_empty() { + return Err(format!("{key} 不能为空")); + } + return Ok(fallback.to_string()); + } + if raw.starts_with("http://") || raw.starts_with("https://") { + return Ok(raw); + } + if strict { + return Err(format!("{key} 必须是 http:// 或 https:// URL")); + } + Ok(fallback.to_string()) +} + +fn validate_hermes_model_catalog_providers( + value: &Value, +) -> Result, String> { + let object = value + .as_object() + .ok_or_else(|| "model_catalog.providers 必须是 JSON object".to_string())?; + let mut normalized = serde_json::Map::new(); + for (provider, raw_entry) in object { + if provider.is_empty() + || !provider + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-')) + { + return Err(format!( + "model_catalog.providers.{provider} 名称只能包含字母、数字、下划线、点和短横线" + )); + } + let mut entry = raw_entry + .as_object() + .cloned() + .ok_or_else(|| format!("model_catalog.providers.{provider} 必须是 object"))?; + if entry.contains_key("url") { + let url = normalize_hermes_http_url( + entry + .get("url") + .and_then(|value| value.as_str()) + .map(ToString::to_string), + &format!("model_catalog.providers.{provider}.url"), + "", + true, + )?; + if url.is_empty() { + entry.remove("url"); + } else { + entry.insert("url".to_string(), Value::String(url)); + } + } + normalized.insert(provider.to_string(), Value::Object(entry)); + } + Ok(normalized) +} + +fn parse_hermes_model_catalog_providers_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_catalog.providers JSON 格式错误: {err}"))?; + validate_hermes_model_catalog_providers(&value) +} + fn build_hermes_compression_config_values(config: &serde_yaml::Value) -> Value { let root = config.as_mapping(); let compression = root.and_then(|map| yaml_get_mapping(map, "compression")); @@ -8560,6 +8639,91 @@ fn merge_hermes_web_config(config: &mut serde_yaml::Value, form: &Value) -> Resu Ok(()) } +fn build_hermes_model_catalog_config_values(config: &serde_yaml::Value) -> Value { + let root = config.as_mapping(); + let model_catalog = root.and_then(|map| yaml_get_mapping(map, "model_catalog")); + let enabled = model_catalog + .and_then(|map| yaml_bool_field(map, "enabled")) + .unwrap_or(true); + let url = normalize_hermes_http_url( + model_catalog.and_then(|map| yaml_string_field(map, "url")), + "model_catalog.url", + HERMES_MODEL_CATALOG_DEFAULT_URL, + false, + ) + .unwrap_or_else(|_| HERMES_MODEL_CATALOG_DEFAULT_URL.to_string()); + let ttl_hours = model_catalog + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "ttl_hours"), 24, 1, 8760)) + .unwrap_or(24); + let providers = model_catalog + .and_then(|map| yaml_get(map, "providers")) + .and_then(|value| serde_json::to_value(value).ok()) + .and_then(|value| validate_hermes_model_catalog_providers(&value).ok()) + .unwrap_or_default(); + serde_json::json!({ + "modelCatalogEnabled": enabled, + "modelCatalogUrl": url, + "modelCatalogTtlHours": ttl_hours, + "modelCatalogProvidersJson": serde_json::to_string_pretty(&Value::Object(providers)).unwrap_or_else(|_| "{}".to_string()), + }) +} + +fn merge_hermes_model_catalog_config( + config: &mut serde_yaml::Value, + form: &Value, +) -> Result<(), String> { + let current = build_hermes_model_catalog_config_values(config); + let enabled = form_bool(form, "modelCatalogEnabled") + .unwrap_or_else(|| current["modelCatalogEnabled"].as_bool().unwrap_or(true)); + let url = normalize_hermes_http_url( + if form.get("modelCatalogUrl").is_some() { + form_string(form, "modelCatalogUrl") + } else { + current["modelCatalogUrl"].as_str().map(ToString::to_string) + }, + "model_catalog.url", + HERMES_MODEL_CATALOG_DEFAULT_URL, + true, + )?; + let ttl_hours = validate_hermes_i64( + if form.get("modelCatalogTtlHours").is_some() { + form_i64(form, "modelCatalogTtlHours") + } else { + current["modelCatalogTtlHours"].as_i64() + }, + "model_catalog.ttl_hours", + 24, + 1, + 8760, + )?; + let providers = parse_hermes_model_catalog_providers_json( + if form.get("modelCatalogProvidersJson").is_some() { + form_string(form, "modelCatalogProvidersJson") + } else { + current["modelCatalogProvidersJson"] + .as_str() + .map(ToString::to_string) + }, + )?; + + let root = ensure_yaml_object(config)?; + let model_catalog = yaml_child_object(root, "model_catalog")?; + model_catalog.insert(yaml_key("enabled"), serde_yaml::Value::Bool(enabled)); + model_catalog.insert(yaml_key("url"), serde_yaml::Value::String(url)); + model_catalog.insert( + yaml_key("ttl_hours"), + serde_yaml::Value::Number(serde_yaml::Number::from(ttl_hours)), + ); + if providers.is_empty() { + model_catalog.remove(yaml_key("providers")); + } else { + let yaml_value = serde_yaml::to_value(Value::Object(providers)) + .map_err(|err| format!("model_catalog.providers 序列化失败: {err}"))?; + model_catalog.insert(yaml_key("providers"), yaml_value); + } + Ok(()) +} + fn build_hermes_lsp_config_values(config: &serde_yaml::Value) -> Value { let root = config.as_mapping(); let lsp = root.and_then(|map| yaml_get_mapping(map, "lsp")); @@ -11317,6 +11481,30 @@ pub fn hermes_lsp_config_save(form: Value) -> Result { })) } +#[tauri::command] +pub fn hermes_model_catalog_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_catalog_config_values(&config), + })) +} + +#[tauri::command] +pub fn hermes_model_catalog_config_save(form: Value) -> Result { + let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?; + merge_hermes_model_catalog_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_catalog_config_values(&config), + })) +} + #[tauri::command] pub fn hermes_stt_config_read() -> Result { let (config_path, exists, config) = read_hermes_channel_yaml_config()?; @@ -17910,6 +18098,184 @@ web: } } +#[cfg(test)] +mod hermes_model_catalog_config_tests { + use super::{ + build_hermes_model_catalog_config_values, merge_hermes_model_catalog_config, + HERMES_MODEL_CATALOG_DEFAULT_URL, + }; + use serde_json::json; + + #[test] + fn model_catalog_values_have_upstream_defaults() { + let config: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let values = build_hermes_model_catalog_config_values(&config); + assert_eq!(values["modelCatalogEnabled"], true); + assert_eq!(values["modelCatalogUrl"], HERMES_MODEL_CATALOG_DEFAULT_URL); + assert_eq!(values["modelCatalogTtlHours"], 24); + assert_eq!(values["modelCatalogProvidersJson"], "{}"); + } + + #[test] + fn model_catalog_values_read_yaml_fields() { + let config: serde_yaml::Value = serde_yaml::from_str( + r#" +model_catalog: + enabled: false + url: https://example.com/catalog.json + ttl_hours: 6 + providers: + openrouter: + url: https://mirror.example.com/openrouter.json + nous: + url: https://mirror.example.com/nous.json +"#, + ) + .unwrap(); + let values = build_hermes_model_catalog_config_values(&config); + assert_eq!(values["modelCatalogEnabled"], false); + assert_eq!( + values["modelCatalogUrl"], + "https://example.com/catalog.json" + ); + assert_eq!(values["modelCatalogTtlHours"], 6); + let providers: serde_json::Value = + serde_json::from_str(values["modelCatalogProvidersJson"].as_str().unwrap()).unwrap(); + assert_eq!( + providers["openrouter"]["url"], + "https://mirror.example.com/openrouter.json" + ); + assert_eq!( + providers["nous"]["url"], + "https://mirror.example.com/nous.json" + ); + } + + #[test] + fn merge_model_catalog_config_preserves_unknown_fields() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +model: + provider: openrouter +model_catalog: + enabled: false + url: https://old.example.com/catalog.json + ttl_hours: 12 + providers: + openrouter: + url: https://old.example.com/openrouter.json + custom_flag: keep-catalog +streaming: + enabled: true +"#, + ) + .unwrap(); + + merge_hermes_model_catalog_config( + &mut config, + &json!({ + "modelCatalogEnabled": true, + "modelCatalogUrl": "https://catalog.example.com/model-catalog.json", + "modelCatalogTtlHours": 48, + "modelCatalogProvidersJson": serde_json::to_string(&json!({ + "openrouter": { "url": "https://catalog.example.com/openrouter.json" }, + "nous": { "url": "https://catalog.example.com/nous.json" }, + })).unwrap(), + }), + ) + .unwrap(); + + assert_eq!(config["model"]["provider"].as_str(), Some("openrouter")); + assert_eq!(config["streaming"]["enabled"].as_bool(), Some(true)); + assert_eq!(config["model_catalog"]["enabled"].as_bool(), Some(true)); + assert_eq!( + config["model_catalog"]["url"].as_str(), + Some("https://catalog.example.com/model-catalog.json") + ); + assert_eq!(config["model_catalog"]["ttl_hours"].as_i64(), Some(48)); + assert_eq!( + config["model_catalog"]["providers"]["openrouter"]["url"].as_str(), + Some("https://catalog.example.com/openrouter.json") + ); + assert_eq!( + config["model_catalog"]["providers"]["nous"]["url"].as_str(), + Some("https://catalog.example.com/nous.json") + ); + assert_eq!( + config["model_catalog"]["custom_flag"].as_str(), + Some("keep-catalog") + ); + } + + #[test] + fn merge_model_catalog_config_removes_empty_providers() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +model_catalog: + providers: + openrouter: + url: https://old.example.com/openrouter.json + custom_flag: keep-catalog +streaming: + enabled: true +"#, + ) + .unwrap(); + + merge_hermes_model_catalog_config( + &mut config, + &json!({ + "modelCatalogProvidersJson": "{}", + }), + ) + .unwrap(); + + assert_eq!( + config["model_catalog"]["custom_flag"].as_str(), + Some("keep-catalog") + ); + assert!(config["model_catalog"].get("providers").is_none()); + assert_eq!(config["streaming"]["enabled"].as_bool(), Some(true)); + } + + #[test] + fn merge_model_catalog_config_rejects_invalid_values() { + let mut config = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + let err = merge_hermes_model_catalog_config( + &mut config, + &json!({ "modelCatalogUrl": "ftp://example.com/catalog.json" }), + ) + .unwrap_err(); + assert!(err.contains("model_catalog.url")); + let err = + merge_hermes_model_catalog_config(&mut config, &json!({ "modelCatalogTtlHours": 0 })) + .unwrap_err(); + assert!(err.contains("model_catalog.ttl_hours")); + let err = merge_hermes_model_catalog_config( + &mut config, + &json!({ "modelCatalogProvidersJson": "[" }), + ) + .unwrap_err(); + assert!(err.contains("model_catalog.providers")); + let err = merge_hermes_model_catalog_config( + &mut config, + &json!({ "modelCatalogProvidersJson": serde_json::to_string(&json!({ + "bad provider": { "url": "https://example.com/catalog.json" } + })).unwrap() }), + ) + .unwrap_err(); + assert!(err.contains("model_catalog.providers.bad provider")); + let err = merge_hermes_model_catalog_config( + &mut config, + &json!({ "modelCatalogProvidersJson": serde_json::to_string(&json!({ + "openrouter": { "url": "file:///tmp/catalog.json" } + })).unwrap() }), + ) + .unwrap_err(); + assert!(err.contains("model_catalog.providers.openrouter.url")); + } +} + #[cfg(test)] mod hermes_lsp_config_tests { use super::{build_hermes_lsp_config_values, merge_hermes_lsp_config}; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9e5847f..10cde10 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -331,6 +331,8 @@ pub fn run() { hermes::hermes_web_config_save, hermes::hermes_lsp_config_read, hermes::hermes_lsp_config_save, + hermes::hermes_model_catalog_config_read, + hermes::hermes_model_catalog_config_save, hermes::hermes_stt_config_read, hermes::hermes_stt_config_save, hermes::hermes_tts_voice_config_read, diff --git a/src/engines/hermes/pages/config.js b/src/engines/hermes/pages/config.js index a5c6ef6..7d73d10 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -119,6 +119,13 @@ const MODEL_DEFAULTS = { modelMaxTokens: '', } +const MODEL_CATALOG_DEFAULTS = { + modelCatalogEnabled: true, + modelCatalogUrl: 'https://hermes-agent.nousresearch.com/docs/api/model-catalog.json', + modelCatalogTtlHours: 24, + modelCatalogProvidersJson: '{}', +} + const MODEL_ALIASES_DEFAULTS = { modelAliasesJson: '{}', } @@ -421,6 +428,7 @@ export function render() { let curatorValues = { ...CURATOR_DEFAULTS } let quickCommandsValues = { ...QUICK_COMMANDS_DEFAULTS } let modelValues = { ...MODEL_DEFAULTS } + let modelCatalogValues = { ...MODEL_CATALOG_DEFAULTS } let modelAliasesValues = { ...MODEL_ALIASES_DEFAULTS } let hooksValues = { ...HOOKS_DEFAULTS } let providerOverridesValues = { ...PROVIDER_OVERRIDES_DEFAULTS } @@ -462,6 +470,7 @@ export function render() { let curatorLoading = true let quickCommandsLoading = true let modelLoading = true + let modelCatalogLoading = true let modelAliasesLoading = true let hooksLoading = true let providerOverridesLoading = true @@ -503,6 +512,7 @@ export function render() { let curatorSaving = false let quickCommandsSaving = false let modelSaving = false + let modelCatalogSaving = false let modelAliasesSaving = false let hooksSaving = false let providerOverridesSaving = false @@ -544,6 +554,7 @@ export function render() { let curatorError = null let quickCommandsError = null let modelError = null + let modelCatalogError = null let modelAliasesError = null let hooksError = null let providerOverridesError = null @@ -580,7 +591,7 @@ export function render() { } function isBusy() { - return loading || runtimeLoading || sessionsMaintenanceLoading || updatesLoading || compressionLoading || promptCachingLoading || openrouterCacheLoading || providerRoutingLoading || auxiliaryLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || curatorLoading || quickCommandsLoading || modelLoading || modelAliasesLoading || hooksLoading || providerOverridesLoading || mcpServersLoading || agentToolsetsLoading || platformToolsetsLoading || agentRuntimeLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || kanbanLoading || streamingLoading || executionLimitsLoading || ioSafetyLoading || checkpointsLoading || cronLoading || loggingLoading || approvalsLoading || privacyLoading || browserLoading || webLoading || lspLoading || sttLoading || ttsVoiceLoading || terminalLoading || saving || runtimeSaving || sessionsMaintenanceSaving || updatesSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || curatorSaving || quickCommandsSaving || modelSaving || modelAliasesSaving || hooksSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || platformToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || kanbanSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || privacySaving || browserSaving || webSaving || lspSaving || sttSaving || ttsVoiceSaving || terminalSaving + return loading || runtimeLoading || sessionsMaintenanceLoading || updatesLoading || compressionLoading || promptCachingLoading || openrouterCacheLoading || providerRoutingLoading || auxiliaryLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || curatorLoading || quickCommandsLoading || modelLoading || modelCatalogLoading || modelAliasesLoading || hooksLoading || providerOverridesLoading || mcpServersLoading || agentToolsetsLoading || platformToolsetsLoading || agentRuntimeLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || kanbanLoading || streamingLoading || executionLimitsLoading || ioSafetyLoading || checkpointsLoading || cronLoading || loggingLoading || approvalsLoading || privacyLoading || browserLoading || webLoading || lspLoading || sttLoading || ttsVoiceLoading || terminalLoading || saving || runtimeSaving || sessionsMaintenanceSaving || updatesSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || curatorSaving || quickCommandsSaving || modelSaving || modelCatalogSaving || modelAliasesSaving || hooksSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || platformToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || kanbanSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || privacySaving || browserSaving || webSaving || lspSaving || sttSaving || ttsVoiceSaving || terminalSaving } function option(labelKey, value, selected) { @@ -1224,7 +1235,7 @@ 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 + const disabled = loading || saving || modelLoading || modelSaving || modelCatalogSaving || quickCommandsSaving || modelAliasesSaving || hooksSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -1267,8 +1278,50 @@ export function render() { ` } + function renderModelCatalogConfigPanel() { + const disabled = loading || saving || modelCatalogLoading || modelCatalogSaving || 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 ` +
+
+
+
${t('engine.hermesModelCatalogConfigTitle')}
+
${t('engine.hermesModelCatalogConfigDesc')}
+
+
+ ${modelCatalogSaving ? t('engine.hermesConfigStatusSaving') : modelCatalogLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesModelCatalogConfigStatusReady')} + +
+
+
+ ${renderError(modelCatalogError)} +
+ +
+
+ + + +
+
${t('engine.hermesModelCatalogConfigFootnote')}
+
+
+ ` + } + function renderModelAliasesConfigPanel() { - 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 + const disabled = loading || saving || modelAliasesLoading || modelAliasesSaving || quickCommandsSaving || modelSaving || modelCatalogSaving || hooksSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return `
@@ -2701,6 +2754,7 @@ export function render() { ${renderCuratorConfigPanel()} ${renderQuickCommandsConfigPanel()} ${renderModelConfigPanel()} + ${renderModelCatalogConfigPanel()} ${renderModelAliasesConfigPanel()} ${renderHooksConfigPanel()} ${renderProviderOverridesConfigPanel()} @@ -2746,6 +2800,7 @@ export function render() { el.querySelector('#hm-curator-config-save')?.addEventListener('click', saveCuratorConfig) el.querySelector('#hm-quick-commands-save')?.addEventListener('click', saveQuickCommandsConfig) el.querySelector('#hm-model-config-save')?.addEventListener('click', saveModelConfig) + el.querySelector('#hm-model-catalog-save')?.addEventListener('click', saveModelCatalogConfig) 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) @@ -2849,6 +2904,11 @@ export function render() { modelValues = { ...MODEL_DEFAULTS, ...(data?.values || {}) } } + async function loadModelCatalogConfig() { + const data = await api.hermesModelCatalogConfigRead() + modelCatalogValues = { ...MODEL_CATALOG_DEFAULTS, ...(data?.values || {}) } + } + async function loadModelAliasesConfig() { const data = await api.hermesModelAliasesConfigRead() modelAliasesValues = { ...MODEL_ALIASES_DEFAULTS, ...(data?.values || {}) } @@ -2995,6 +3055,7 @@ export function render() { curatorLoading = true quickCommandsLoading = true modelLoading = true + modelCatalogLoading = true modelAliasesLoading = true hooksLoading = true providerOverridesLoading = true @@ -3036,6 +3097,7 @@ export function render() { curatorError = null quickCommandsError = null modelError = null + modelCatalogError = null modelAliasesError = null hooksError = null providerOverridesError = null @@ -3293,6 +3355,14 @@ export function render() { modelLoading = false draw() } + try { + await loadModelCatalogConfig() + } catch (err) { + modelCatalogError = humanizeError(err, t('engine.hermesModelCatalogConfigLoadFailed') || 'Load model catalog config failed') + } finally { + modelCatalogLoading = false + draw() + } try { await loadModelAliasesConfig() } catch (err) { @@ -3931,6 +4001,34 @@ export function render() { } } + async function saveModelCatalogConfig() { + const form = { + modelCatalogEnabled: !!el.querySelector('#hm-model-catalog-enabled')?.checked, + modelCatalogUrl: el.querySelector('#hm-model-catalog-url')?.value || MODEL_CATALOG_DEFAULTS.modelCatalogUrl, + modelCatalogTtlHours: el.querySelector('#hm-model-catalog-ttl-hours')?.value || '24', + modelCatalogProvidersJson: el.querySelector('#hm-model-catalog-providers-json')?.value || '{}', + } + modelCatalogSaving = true + modelCatalogError = null + draw() + try { + const result = await api.hermesModelCatalogConfigSave(form) + modelCatalogValues = { ...MODEL_CATALOG_DEFAULTS, ...(result?.values || form) } + await refreshRawAfterStructuredSave() + const backup = result?.backup || '' + toast({ + message: t('engine.hermesModelCatalogConfigSaveSuccess'), + hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '', + }, 'success') + } catch (err) { + modelCatalogError = humanizeError(err, t('engine.hermesModelCatalogConfigSaveFailed') || 'Save model catalog config failed') + toast(modelCatalogError, 'error') + } finally { + modelCatalogSaving = false + draw() + } + } + async function saveModelAliasesConfig() { const form = { modelAliasesJson: el.querySelector('#hm-model-aliases-json')?.value || '{}', diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 0f556d3..9f8b378 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -583,6 +583,8 @@ export const api = { hermesWebConfigSave: (form) => invoke('hermes_web_config_save', { form }), hermesLspConfigRead: () => invoke('hermes_lsp_config_read'), hermesLspConfigSave: (form) => invoke('hermes_lsp_config_save', { form }), + hermesModelCatalogConfigRead: () => invoke('hermes_model_catalog_config_read'), + hermesModelCatalogConfigSave: (form) => invoke('hermes_model_catalog_config_save', { form }), hermesSttConfigRead: () => invoke('hermes_stt_config_read'), hermesSttConfigSave: (form) => invoke('hermes_stt_config_save', { form }), hermesTtsVoiceConfigRead: () => invoke('hermes_tts_voice_config_read'), diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index 392cb20..3296a89 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -739,6 +739,18 @@ export default { hermesWebConfigBackend_xai: _('xAI Search', 'xAI Search', 'xAI Search'), hermesWebConfigBackend_native: _('原生提取', 'Native extraction', '原生提取'), hermesWebConfigFootnote: _('这里写入 web.backend、web.search_backend 和 web.extract_backend。留空会删除覆盖,让 Hermes 按密钥、可用 provider 和上游默认策略自动选择;未知 web 字段会保留在 raw YAML 中。', 'This writes web.backend, web.search_backend, and web.extract_backend. Leaving a value empty removes the override so Hermes can auto-select based on keys, available providers, and upstream defaults. Unknown web fields stay in raw YAML.', '這裡寫入 web.backend、web.search_backend 和 web.extract_backend。留空會刪除覆蓋,讓 Hermes 按密鑰、可用 provider 和上游預設策略自動選擇;未知 web 欄位會保留在 raw YAML 中。'), + hermesModelCatalogConfigTitle: _('模型目录', 'Model catalog', '模型目錄'), + hermesModelCatalogConfigDesc: _('控制 Hermes 模型目录拉取、缓存时间和 provider 专属目录覆盖,用于让模型选择列表在网络波动或私有镜像下保持可用。', 'Control Hermes model catalog fetching, cache TTL, and provider-specific catalog overrides so model selection stays usable with network issues or private mirrors.', '控制 Hermes 模型目錄拉取、快取時間和 provider 專屬目錄覆蓋,用於讓模型選擇列表在網路波動或私有鏡像下保持可用。'), + hermesModelCatalogConfigStatusReady: _('结构化配置', 'structured settings', '結構化設定'), + hermesModelCatalogConfigSave: _('保存模型目录配置', 'Save model catalog settings', '儲存模型目錄設定'), + hermesModelCatalogConfigSaveSuccess: _('模型目录配置已保存,建议重启 Hermes Gateway 生效', 'Model catalog settings saved. Restart Hermes Gateway to take effect.', '模型目錄設定已儲存,建議重啟 Hermes Gateway 生效'), + hermesModelCatalogConfigLoadFailed: _('加载模型目录配置失败', 'Load model catalog settings failed', '載入模型目錄設定失敗'), + hermesModelCatalogConfigSaveFailed: _('保存模型目录配置失败', 'Save model catalog settings failed', '儲存模型目錄設定失敗'), + hermesModelCatalogConfigEnabled: _('启用模型目录', 'Enable model catalog', '啟用模型目錄'), + hermesModelCatalogConfigUrl: _('默认目录 URL', 'Default catalog URL', '預設目錄 URL'), + hermesModelCatalogConfigTtlHours: _('缓存小时数', 'Cache TTL hours', '快取小時數'), + hermesModelCatalogConfigProvidersJson: _('Provider 目录覆盖 JSON', 'Provider catalog override JSON', 'Provider 目錄覆蓋 JSON'), + hermesModelCatalogConfigFootnote: _('这里写入 model_catalog.enabled、model_catalog.url、model_catalog.ttl_hours 和 model_catalog.providers。providers 必须是 JSON object,provider 名称只能包含字母、数字、下划线、点和短横线;每个 provider 的 url 必须是 http 或 https。未知 model_catalog 子字段会保留在 raw YAML 中。', 'This writes model_catalog.enabled, model_catalog.url, model_catalog.ttl_hours, and model_catalog.providers. providers must be a JSON object, provider names may contain letters, numbers, underscores, dots, and hyphens only, and each provider url must use http or https. Unknown model_catalog fields stay in raw YAML.', '這裡寫入 model_catalog.enabled、model_catalog.url、model_catalog.ttl_hours 和 model_catalog.providers。providers 必須是 JSON object,provider 名稱只能包含字母、數字、底線、點和短橫線;每個 provider 的 url 必須是 http 或 https。未知 model_catalog 子欄位會保留在 raw YAML 中。'), hermesLspConfigTitle: _('LSP 语义诊断', 'LSP semantic diagnostics', 'LSP 語意診斷'), hermesLspConfigDesc: _('控制写文件和补丁后的语言服务器诊断等待、工作区扫描和缺失服务器自动安装策略。', 'Control language-server diagnostic waits, workspace scans, and missing-server install strategy after file writes and patches.', '控制寫檔和補丁後的語言伺服器診斷等待、工作區掃描和缺失伺服器自動安裝策略。'), hermesLspConfigStatusReady: _('结构化配置', 'structured settings', '結構化設定'), diff --git a/tests/hermes-config-page-ui.test.js b/tests/hermes-config-page-ui.test.js index f6c1437..a0ceff5 100644 --- a/tests/hermes-config-page-ui.test.js +++ b/tests/hermes-config-page-ui.test.js @@ -127,6 +127,18 @@ test('Hermes 配置页会暴露基础模型结构化配置字段', () => { } }) +test('Hermes 配置页会暴露模型目录结构化配置字段', () => { + for (const id of [ + 'hm-model-catalog-save', + 'hm-model-catalog-enabled', + 'hm-model-catalog-url', + 'hm-model-catalog-ttl-hours', + 'hm-model-catalog-providers-json', + ]) { + assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`) + } +}) + test('Hermes 配置页会暴露 provider 超时覆盖结构化配置字段', () => { for (const id of [ 'hm-provider-overrides-save', diff --git a/tests/hermes-model-catalog-config.test.js b/tests/hermes-model-catalog-config.test.js new file mode 100644 index 0000000..eac2a30 --- /dev/null +++ b/tests/hermes-model-catalog-config.test.js @@ -0,0 +1,122 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildHermesModelCatalogConfigValues, + mergeHermesModelCatalogConfig, +} from '../scripts/dev-api.js' + +const DEFAULT_CATALOG_URL = 'https://hermes-agent.nousresearch.com/docs/api/model-catalog.json' + +test('Hermes 模型目录配置读取会提供上游默认值', () => { + const values = buildHermesModelCatalogConfigValues({}) + + assert.deepEqual(values, { + modelCatalogEnabled: true, + modelCatalogUrl: DEFAULT_CATALOG_URL, + modelCatalogTtlHours: 24, + modelCatalogProvidersJson: '{}', + }) +}) + +test('Hermes 模型目录配置读取会回显 YAML 字段', () => { + const values = buildHermesModelCatalogConfigValues({ + model_catalog: { + enabled: false, + url: 'https://example.com/catalog.json', + ttl_hours: 6, + providers: { + openrouter: { + url: 'https://mirror.example.com/openrouter.json', + }, + nous: { + url: 'https://mirror.example.com/nous.json', + }, + }, + }, + }) + + assert.equal(values.modelCatalogEnabled, false) + assert.equal(values.modelCatalogUrl, 'https://example.com/catalog.json') + assert.equal(values.modelCatalogTtlHours, 6) + assert.deepEqual(JSON.parse(values.modelCatalogProvidersJson), { + openrouter: { url: 'https://mirror.example.com/openrouter.json' }, + nous: { url: 'https://mirror.example.com/nous.json' }, + }) +}) + +test('Hermes 模型目录配置保存会保留未知字段并写入上游结构', () => { + const next = mergeHermesModelCatalogConfig({ + model: { provider: 'openrouter' }, + model_catalog: { + enabled: false, + url: 'https://old.example.com/catalog.json', + ttl_hours: 12, + providers: { + openrouter: { + url: 'https://old.example.com/openrouter.json', + }, + }, + custom_flag: 'keep-catalog', + }, + streaming: { enabled: true }, + }, { + modelCatalogEnabled: true, + modelCatalogUrl: 'https://catalog.example.com/model-catalog.json', + modelCatalogTtlHours: 48, + modelCatalogProvidersJson: JSON.stringify({ + openrouter: { url: 'https://catalog.example.com/openrouter.json' }, + nous: { url: 'https://catalog.example.com/nous.json' }, + }), + }) + + assert.deepEqual(next.model, { provider: 'openrouter' }) + assert.deepEqual(next.streaming, { enabled: true }) + assert.equal(next.model_catalog.enabled, true) + assert.equal(next.model_catalog.url, 'https://catalog.example.com/model-catalog.json') + assert.equal(next.model_catalog.ttl_hours, 48) + assert.equal(next.model_catalog.providers.openrouter.url, 'https://catalog.example.com/openrouter.json') + assert.equal(next.model_catalog.providers.nous.url, 'https://catalog.example.com/nous.json') + assert.equal(next.model_catalog.custom_flag, 'keep-catalog') +}) + +test('Hermes 模型目录配置保存空 provider 覆盖会移除 providers', () => { + const next = mergeHermesModelCatalogConfig({ + model_catalog: { + providers: { + openrouter: { url: 'https://old.example.com/openrouter.json' }, + }, + custom_flag: 'keep-catalog', + }, + streaming: { enabled: true }, + }, { + modelCatalogProvidersJson: '{}', + }) + + assert.equal(Object.hasOwn(next.model_catalog, 'providers'), false) + assert.equal(next.model_catalog.custom_flag, 'keep-catalog') + assert.deepEqual(next.streaming, { enabled: true }) +}) + +test('Hermes 模型目录配置保存会拒绝非法 URL、TTL 和 provider JSON', () => { + assert.throws( + () => mergeHermesModelCatalogConfig({}, { modelCatalogUrl: 'ftp://example.com/catalog.json' }), + /model_catalog\.url/, + ) + assert.throws( + () => mergeHermesModelCatalogConfig({}, { modelCatalogTtlHours: 0 }), + /model_catalog\.ttl_hours/, + ) + assert.throws( + () => mergeHermesModelCatalogConfig({}, { modelCatalogProvidersJson: '[' }), + /model_catalog\.providers/, + ) + assert.throws( + () => mergeHermesModelCatalogConfig({}, { modelCatalogProvidersJson: JSON.stringify({ 'bad provider': { url: 'https://example.com/catalog.json' } }) }), + /model_catalog\.providers\.bad provider/, + ) + assert.throws( + () => mergeHermesModelCatalogConfig({}, { modelCatalogProvidersJson: JSON.stringify({ openrouter: { url: 'file:///tmp/catalog.json' } }) }), + /model_catalog\.providers\.openrouter\.url/, + ) +})