feat(hermes): add model catalog controls

This commit is contained in:
晴天
2026-05-27 05:06:32 +08:00
parent 18c0164bdb
commit c9e4a380a9
8 changed files with 723 additions and 3 deletions

View File

@@ -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 {