mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-06-25 17:54:10 +08:00
feat(hermes): dynamically load provider registry in setup & dashboard (Step 2)
Wire the new Rust `hermes_list_providers` command into the frontend and
replace the hardcoded OpenClaw PROVIDER_PRESETS usage with the
authoritative Hermes registry. Closes the G4 gap from the v3 design.
New module `src/engines/hermes/lib/providers.js`:
- Async `loadHermesProviders()` with per-session cache.
- `groupProviders()` buckets by authType + region: apiKeyIntl,
apiKeyCn, aggregators, oauth, externalProc, custom.
- Lookup helpers: `findProviderById`, `inferProviderByBaseUrl`,
`defaultModelFor`, plus cache reset for hot-reload scenarios.
- Exported auth_type / transport string constants mirroring Rust.
Refactored `src/engines/hermes/pages/setup.js`:
- Drops `PROVIDER_PRESETS` import; loads registry before first paint.
- Provider buttons are grouped under labeled sections (International,
China, Aggregators), with an OAuth hint block listing the CLI
commands users must run (e.g. `hermes auth login nous`).
- Selected provider detail panel now shows target env var
(`ANTHROPIC_API_KEY`, `DEEPSEEK_API_KEY`, etc.) and model catalog
size.
- `doSaveConfig` sends the provider id (not preset key) through
`api.configureHermes`; falls back to `custom` when the base URL
doesn't match any registered provider.
- `doFetchModels` maps provider.transport → apiType.
- Graceful fallback: when the registry is empty (Web mode), UI
degrades to manual Base URL + API Key entry.
Refactored `src/engines/hermes/pages/dashboard.js`:
- Loads provider registry in parallel with gateway refresh.
- Preset buttons filter out the `custom` placeholder and source
data from the async-loaded list.
- Uses `inferProviderByBaseUrl` consistently for highlight / fetch /
save flows.
Frontend API wiring:
- `src/lib/tauri-api.js`:
- Added `hermesListProviders` (10-minute cache).
- Extended `hermesFetchModels` and `hermesUpdateModel` with
optional `provider` param.
- `scripts/dev-api.js`:
- `hermes_list_providers`: Web-mode stub returning [] (triggers
frontend fallback UI).
- `hermes_fetch_models` / `hermes_update_model` accept provider
param (no-op in fetch; full YAML rewrite in update_model matching
Rust behavior).
Verified: `npm run build` green (1.04s). Setup chunk 24.34 kB,
dashboard chunk 24.30 kB. No new warnings.
This commit is contained in:
@@ -6836,7 +6836,16 @@ const handlers = {
|
||||
return { model: displayModel, model_raw: modelName, base_url: baseUrl, provider, api_key: apiKey, config_exists: fs.existsSync(configPath) }
|
||||
},
|
||||
|
||||
async hermes_fetch_models({ baseUrl, apiKey, apiType } = {}) {
|
||||
// Web-mode stub: the authoritative 22-provider registry lives in Rust.
|
||||
// Web mode is primarily used for remote admin on headless Linux where
|
||||
// Hermes configuration is a minor flow; returning an empty array makes
|
||||
// the frontend fall back to a "Please use the desktop app to configure
|
||||
// Hermes providers" message in setup.js.
|
||||
hermes_list_providers() {
|
||||
return []
|
||||
},
|
||||
|
||||
async hermes_fetch_models({ baseUrl, apiKey, apiType, provider: _provider } = {}) {
|
||||
const api = apiType || 'openai'
|
||||
let base = baseUrl.replace(/\/+$/, '')
|
||||
for (const suffix of ['/chat/completions', '/completions', '/responses', '/messages', '/models']) {
|
||||
@@ -6867,20 +6876,68 @@ const handlers = {
|
||||
return models.sort()
|
||||
},
|
||||
|
||||
hermes_update_model({ model } = {}) {
|
||||
hermes_update_model({ model, provider } = {}) {
|
||||
const configPath = path.join(hermesHome(), 'config.yaml')
|
||||
const content = fs.readFileSync(configPath, 'utf8')
|
||||
let found = false
|
||||
const newContent = content.split('\n').map(line => {
|
||||
const lines = content.split('\n')
|
||||
const out = []
|
||||
let inModel = false
|
||||
let defaultWritten = false
|
||||
let providerWritten = false
|
||||
let defaultIndent = ' '
|
||||
|
||||
for (const line of lines) {
|
||||
const t = line.trim()
|
||||
if (t.startsWith('default:') && !found) {
|
||||
found = true
|
||||
const indent = line.length - line.trimStart().length
|
||||
return ' '.repeat(indent) + `default: ${model}`
|
||||
if (t.startsWith('model:')) {
|
||||
inModel = true
|
||||
out.push(line)
|
||||
continue
|
||||
}
|
||||
return line
|
||||
}).join('\n')
|
||||
if (!found) throw new Error('config.yaml 中未找到 model.default 字段')
|
||||
if (inModel) {
|
||||
const isIndented = line.startsWith(' ') || line.startsWith('\t')
|
||||
if (!isIndented && t && !t.startsWith('#')) {
|
||||
// leaving model block — flush provider if needed
|
||||
if (provider && provider !== 'custom' && !providerWritten) {
|
||||
out.push(`${defaultIndent}provider: ${provider}`)
|
||||
providerWritten = true
|
||||
}
|
||||
inModel = false
|
||||
out.push(line)
|
||||
continue
|
||||
}
|
||||
if (t.startsWith('default:')) {
|
||||
const indentLen = line.length - line.trimStart().length
|
||||
defaultIndent = ' '.repeat(indentLen)
|
||||
out.push(`${defaultIndent}default: ${model}`)
|
||||
defaultWritten = true
|
||||
continue
|
||||
}
|
||||
if (t.startsWith('provider:')) {
|
||||
if (provider && provider !== 'custom') {
|
||||
const indentLen = line.length - line.trimStart().length
|
||||
out.push(`${' '.repeat(indentLen)}provider: ${provider}`)
|
||||
providerWritten = true
|
||||
continue
|
||||
}
|
||||
if (provider === 'custom') continue // drop
|
||||
// no new provider → keep old
|
||||
out.push(line)
|
||||
providerWritten = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
out.push(line)
|
||||
}
|
||||
|
||||
// still in model block at EOF
|
||||
if (inModel && provider && provider !== 'custom' && !providerWritten) {
|
||||
out.push(`${defaultIndent}provider: ${provider}`)
|
||||
}
|
||||
|
||||
if (!defaultWritten) throw new Error('config.yaml 中未找到 model.default 字段')
|
||||
|
||||
let newContent = out.join('\n')
|
||||
if (!newContent.endsWith('\n')) newContent += '\n'
|
||||
fs.writeFileSync(configPath, newContent)
|
||||
return `模型已切换为 ${model}`
|
||||
},
|
||||
|
||||
172
src/engines/hermes/lib/providers.js
Normal file
172
src/engines/hermes/lib/providers.js
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Hermes provider registry (frontend mirror).
|
||||
*
|
||||
* The authoritative data lives in Rust at
|
||||
* `src-tauri/src/commands/hermes_providers.rs::ALL_PROVIDERS`
|
||||
* and is exposed via the Tauri command `hermes_list_providers`.
|
||||
*
|
||||
* This module:
|
||||
* 1. Loads the 22 providers once per session (cached)
|
||||
* 2. Groups them by auth type and region for UI rendering
|
||||
* 3. Provides small lookup helpers (by id, by model, etc.)
|
||||
*
|
||||
* Never hardcode provider data here — always call `loadHermesProviders()`
|
||||
* so we stay in sync with the Rust side.
|
||||
*/
|
||||
|
||||
import { api } from '../../../lib/tauri-api.js'
|
||||
|
||||
// Auth type constants (must match Rust side)
|
||||
export const AUTH_API_KEY = 'api_key'
|
||||
export const AUTH_OAUTH_DEVICE = 'oauth_device_code'
|
||||
export const AUTH_OAUTH_EXTERNAL = 'oauth_external'
|
||||
export const AUTH_EXTERNAL_PROCESS = 'external_process'
|
||||
|
||||
// Transport constants
|
||||
export const TRANSPORT_OPENAI_CHAT = 'openai_chat'
|
||||
export const TRANSPORT_ANTHROPIC = 'anthropic_messages'
|
||||
export const TRANSPORT_GOOGLE = 'google_gemini'
|
||||
export const TRANSPORT_CODEX = 'codex_responses'
|
||||
|
||||
// China-region provider ids (for UI sub-grouping). Everything else is
|
||||
// considered "International" by default.
|
||||
const CN_PROVIDER_IDS = new Set(['zai', 'kimi-coding', 'alibaba', 'minimax-cn', 'xiaomi'])
|
||||
|
||||
// Aggregator ids (also tagged via `isAggregator` on the data).
|
||||
const AGGREGATOR_IDS = new Set([
|
||||
'openrouter',
|
||||
'ai-gateway',
|
||||
'opencode-zen',
|
||||
'opencode-go',
|
||||
'kilocode',
|
||||
'huggingface',
|
||||
'nous',
|
||||
])
|
||||
|
||||
let _cached = null
|
||||
let _loadPromise = null
|
||||
|
||||
/**
|
||||
* Fetch the full provider list from Rust (cached for the session).
|
||||
* Returns [] if the backend is unreachable — callers should degrade gracefully.
|
||||
*/
|
||||
export async function loadHermesProviders() {
|
||||
if (_cached) return _cached
|
||||
if (_loadPromise) return _loadPromise
|
||||
|
||||
_loadPromise = (async () => {
|
||||
try {
|
||||
const list = await api.hermesListProviders()
|
||||
_cached = Array.isArray(list) ? list : []
|
||||
return _cached
|
||||
} catch (err) {
|
||||
console.warn('[hermes/providers] failed to load registry:', err)
|
||||
_cached = []
|
||||
return _cached
|
||||
} finally {
|
||||
_loadPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
return _loadPromise
|
||||
}
|
||||
|
||||
/** Look up a provider by stable id; returns undefined if unknown. */
|
||||
export function findProviderById(list, id) {
|
||||
return list?.find(p => p.id === id)
|
||||
}
|
||||
|
||||
/** Case-insensitive search by display name or id. */
|
||||
export function searchProviders(list, query) {
|
||||
const q = (query || '').trim().toLowerCase()
|
||||
if (!q) return list
|
||||
return list.filter(p =>
|
||||
p.id.toLowerCase().includes(q) || p.name.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Group providers by auth type, with api_key further split into region
|
||||
* buckets for UI rendering. Returns:
|
||||
* {
|
||||
* apiKeyIntl: HermesProvider[],
|
||||
* apiKeyCn: HermesProvider[],
|
||||
* aggregators: HermesProvider[],
|
||||
* oauth: HermesProvider[],
|
||||
* externalProc: HermesProvider[],
|
||||
* custom: HermesProvider[],
|
||||
* }
|
||||
*/
|
||||
export function groupProviders(list) {
|
||||
const groups = {
|
||||
apiKeyIntl: [],
|
||||
apiKeyCn: [],
|
||||
aggregators: [],
|
||||
oauth: [],
|
||||
externalProc: [],
|
||||
custom: [],
|
||||
}
|
||||
|
||||
for (const p of list || []) {
|
||||
if (p.id === 'custom') {
|
||||
groups.custom.push(p)
|
||||
continue
|
||||
}
|
||||
if (p.authType === AUTH_EXTERNAL_PROCESS) {
|
||||
groups.externalProc.push(p)
|
||||
continue
|
||||
}
|
||||
if (p.authType === AUTH_OAUTH_DEVICE || p.authType === AUTH_OAUTH_EXTERNAL) {
|
||||
groups.oauth.push(p)
|
||||
continue
|
||||
}
|
||||
if (p.isAggregator || AGGREGATOR_IDS.has(p.id)) {
|
||||
groups.aggregators.push(p)
|
||||
continue
|
||||
}
|
||||
if (CN_PROVIDER_IDS.has(p.id)) {
|
||||
groups.apiKeyCn.push(p)
|
||||
continue
|
||||
}
|
||||
groups.apiKeyIntl.push(p)
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a freshly entered base URL, guess which provider best matches.
|
||||
* Used by setup/dashboard forms to auto-highlight the preset button.
|
||||
*/
|
||||
export function inferProviderByBaseUrl(list, rawBaseUrl) {
|
||||
const normalize = (u) => (u || '')
|
||||
.trim()
|
||||
.replace(/\/+$/, '')
|
||||
.replace(/\/(chat\/completions|completions|responses|messages|models)$/, '')
|
||||
const target = normalize(rawBaseUrl)
|
||||
if (!target) return null
|
||||
for (const p of list || []) {
|
||||
if (normalize(p.baseUrl) === target) return p
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a sensible default model for a provider.
|
||||
* Aggregators may have empty `models` — callers must handle null.
|
||||
*/
|
||||
export function defaultModelFor(provider) {
|
||||
if (!provider || !provider.models || !provider.models.length) return null
|
||||
return provider.models[0]
|
||||
}
|
||||
|
||||
/** Synchronous accessor for already-loaded registry. */
|
||||
export function getCachedProviders() {
|
||||
return _cached || []
|
||||
}
|
||||
|
||||
/** Force a reload on next call (e.g. after dev hot-reload). */
|
||||
export function clearProviderCache() {
|
||||
_cached = null
|
||||
_loadPromise = null
|
||||
}
|
||||
@@ -3,7 +3,10 @@
|
||||
*/
|
||||
import { t } from '../../../lib/i18n.js'
|
||||
import { api } from '../../../lib/tauri-api.js'
|
||||
import { PROVIDER_PRESETS } from '../../../lib/model-presets.js'
|
||||
import {
|
||||
loadHermesProviders,
|
||||
inferProviderByBaseUrl,
|
||||
} from '../lib/providers.js'
|
||||
|
||||
const ICONS = {
|
||||
running: `<svg viewBox="0 0 24 24" fill="none" stroke="var(--success, #22c55e)" stroke-width="2.5" width="20" height="20"><circle cx="12" cy="12" r="10"/><polyline points="16 12 12 8 8 12"/><line x1="12" y1="16" x2="12" y2="8"/></svg>`,
|
||||
@@ -14,7 +17,8 @@ const ICONS = {
|
||||
refresh: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>`,
|
||||
}
|
||||
|
||||
const HERMES_PROVIDERS = PROVIDER_PRESETS.filter(p => !p.hidden)
|
||||
// Provider registry—异步加载,第一次 render 前填充
|
||||
let hermesProviders = []
|
||||
|
||||
// Lazy Tauri event listen (avoid top-level await for vite build)
|
||||
let _listenFn = null
|
||||
@@ -131,7 +135,7 @@ export function render() {
|
||||
const displayModel = modelName || t('engine.dashNoModel')
|
||||
|
||||
// 服务商高亮匹配
|
||||
const activePreset = HERMES_PROVIDERS.find(p => formBaseUrl === p.baseUrl)
|
||||
const activePreset = inferProviderByBaseUrl(hermesProviders, formBaseUrl)
|
||||
|
||||
// 模型下拉 HTML
|
||||
const dropdownHtml = showDropdown && models.length
|
||||
@@ -195,9 +199,13 @@ export function render() {
|
||||
</div>
|
||||
<div style="${modelConfigCollapsed ? 'display:none' : 'padding:0 20px 20px'}">
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px">
|
||||
${HERMES_PROVIDERS.map(p =>
|
||||
`<button class="btn btn-sm btn-secondary hm-preset-btn" data-key="${p.key}" data-url="${esc(p.baseUrl)}" data-api="${p.api || 'openai-completions'}" style="font-size:11px;padding:2px 8px;${activePreset?.key === p.key ? 'opacity:1;font-weight:600' : 'opacity:0.6'}">${p.label}</button>`
|
||||
).join('')}
|
||||
${hermesProviders.filter(p => p.id !== 'custom').map(p => {
|
||||
const api = p.transport === 'anthropic_messages' ? 'anthropic-messages'
|
||||
: p.transport === 'google_gemini' ? 'google-generative-ai'
|
||||
: 'openai-completions'
|
||||
const active = activePreset?.id === p.id
|
||||
return `<button class="btn btn-sm btn-secondary hm-preset-btn" data-key="${p.id}" data-url="${esc(p.baseUrl)}" data-api="${api}" style="font-size:11px;padding:2px 8px;${active ? 'opacity:1;font-weight:600' : 'opacity:0.6'}">${p.name}</button>`
|
||||
}).join('')}
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
|
||||
<label style="display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--text-secondary)">
|
||||
@@ -419,8 +427,12 @@ export function render() {
|
||||
if (!formBaseUrl) { cfgMsg = `<span style="color:var(--warning)">${t('engine.configFetchNeedUrl')}</span>`; draw(); return }
|
||||
if (!formApiKey) { cfgMsg = `<span style="color:var(--warning)">${t('engine.configFetchNeedKey')}</span>`; draw(); return }
|
||||
|
||||
const matched = HERMES_PROVIDERS.find(p => formBaseUrl === p.baseUrl)
|
||||
const apiType = matched?.api || 'openai-completions'
|
||||
const matched = inferProviderByBaseUrl(hermesProviders, formBaseUrl)
|
||||
const apiType = matched
|
||||
? (matched.transport === 'anthropic_messages' ? 'anthropic-messages'
|
||||
: matched.transport === 'google_gemini' ? 'google-generative-ai'
|
||||
: 'openai-completions')
|
||||
: 'openai-completions'
|
||||
|
||||
fetchBusy = true; cfgMsg = ''; draw()
|
||||
try {
|
||||
@@ -441,8 +453,8 @@ export function render() {
|
||||
if (!formApiKey) { cfgMsg = `<span style="color:var(--warning)">${t('engine.configFetchNeedKey')}</span>`; draw(); return }
|
||||
if (!formModel) { cfgMsg = `<span style="color:var(--warning)">请输入模型名</span>`; draw(); return }
|
||||
|
||||
const matched = HERMES_PROVIDERS.find(p => formBaseUrl && p.baseUrl === formBaseUrl)
|
||||
const provider = matched?.key || 'custom'
|
||||
const matched = inferProviderByBaseUrl(hermesProviders, formBaseUrl)
|
||||
const provider = matched?.id || 'custom'
|
||||
|
||||
modelBusy = true; cfgMsg = ''; draw()
|
||||
try {
|
||||
@@ -534,8 +546,15 @@ export function render() {
|
||||
draw()
|
||||
}
|
||||
|
||||
// 初始加载
|
||||
refresh()
|
||||
// 初始加载:先拉取 provider registry(和 refresh 并行),再渲染
|
||||
;(async () => {
|
||||
try {
|
||||
hermesProviders = await loadHermesProviders()
|
||||
} catch (err) {
|
||||
console.warn('[hermes/dashboard] failed to load providers:', err)
|
||||
}
|
||||
refresh()
|
||||
})()
|
||||
|
||||
// --- Guardian 事件监听:实时响应 Gateway 状态变化 ---
|
||||
let unlisteners = []
|
||||
|
||||
@@ -5,8 +5,13 @@
|
||||
*/
|
||||
import { t } from '../../../lib/i18n.js'
|
||||
import { api, invalidate } from '../../../lib/tauri-api.js'
|
||||
import { PROVIDER_PRESETS } from '../../../lib/model-presets.js'
|
||||
import { getActiveEngine } from '../../../lib/engine-manager.js'
|
||||
import {
|
||||
loadHermesProviders,
|
||||
groupProviders,
|
||||
inferProviderByBaseUrl,
|
||||
findProviderById,
|
||||
} from '../lib/providers.js'
|
||||
|
||||
// SVG 图标
|
||||
const ICONS = {
|
||||
@@ -20,8 +25,10 @@ const ICONS = {
|
||||
|
||||
// 核心安装不带 extras,后续可在管理页面按需安装
|
||||
|
||||
// Hermes 使用 OpenAI 兼容接口,过滤出兼容的服务商
|
||||
const HERMES_PROVIDERS = PROVIDER_PRESETS.filter(p => !p.hidden)
|
||||
// Provider 数据 — 异步从 Rust hermes_providers.rs 加载(首次 render 前)
|
||||
// Web 模式下 dev-api.js 返回空数组,UI 会降级到手填模式
|
||||
let hermesProviders = []
|
||||
let hermesGroups = { apiKeyIntl: [], apiKeyCn: [], aggregators: [], oauth: [], externalProc: [], custom: [] }
|
||||
|
||||
export function render() {
|
||||
const el = document.createElement('div')
|
||||
@@ -228,9 +235,7 @@ export function render() {
|
||||
|
||||
// --- 配置阶段 ---
|
||||
function renderConfigure() {
|
||||
const presetBtns = HERMES_PROVIDERS.map(p =>
|
||||
`<button class="btn btn-sm btn-secondary hermes-preset-btn" data-key="${p.key}" data-url="${p.baseUrl}" data-api="${p.api}" style="font-size:12px;padding:3px 10px;margin:0 6px 6px 0">${p.label}${p.badge ? ` <span style="font-size:9px;background:var(--accent);color:#fff;padding:1px 4px;border-radius:6px;margin-left:3px">${p.badge}</span>` : ''}</button>`
|
||||
).join('')
|
||||
const presetBtns = renderGroupedProviderButtons()
|
||||
|
||||
return `<div class="card" style="margin-bottom:16px">
|
||||
<div class="card-body" style="padding:24px">
|
||||
@@ -240,7 +245,7 @@ export function render() {
|
||||
<div class="hermes-form">
|
||||
<div class="hermes-field">
|
||||
<span>${t('engine.configProvider')}</span>
|
||||
<div style="display:flex;flex-wrap:wrap">${presetBtns}</div>
|
||||
${presetBtns}
|
||||
<div id="hm-preset-detail" style="display:none;margin-top:6px;padding:8px 12px;background:var(--bg-tertiary);border-radius:var(--radius-md,8px);font-size:12px"></div>
|
||||
</div>
|
||||
<label class="hermes-field">
|
||||
@@ -340,14 +345,18 @@ export function render() {
|
||||
// 高亮选中
|
||||
el.querySelectorAll('.hermes-preset-btn').forEach(b => b.style.opacity = '0.5')
|
||||
btn.style.opacity = '1'
|
||||
// 显示服务商详情
|
||||
const preset = HERMES_PROVIDERS.find(p => p.key === btn.dataset.key)
|
||||
// 显示服务商详情(展示 authType + models 预览)
|
||||
const preset = findProviderById(hermesProviders, btn.dataset.key)
|
||||
const detailEl = el.querySelector('#hm-preset-detail')
|
||||
if (detailEl && preset) {
|
||||
let html = preset.desc ? `<div style="color:var(--text-secondary);line-height:1.5">${preset.desc}</div>` : ''
|
||||
if (preset.site) html += `<a href="${preset.site}" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-size:11px;margin-top:3px;display:inline-block">→ ${preset.label} 官网</a>`
|
||||
detailEl.innerHTML = html
|
||||
detailEl.style.display = html ? 'block' : 'none'
|
||||
const envLine = preset.apiKeyEnvVars && preset.apiKeyEnvVars.length
|
||||
? `<div style="color:var(--text-tertiary);font-size:11px;margin-top:2px">写入 <code>${preset.apiKeyEnvVars[0]}</code></div>`
|
||||
: ''
|
||||
const modelsPreview = preset.models && preset.models.length
|
||||
? `<div style="color:var(--text-tertiary);font-size:11px;margin-top:2px">${preset.models.length} 个已知模型</div>`
|
||||
: '<div style="color:var(--text-tertiary);font-size:11px;margin-top:2px">聚合路由:请自行指定模型</div>'
|
||||
detailEl.innerHTML = `<div style="color:var(--text-secondary);line-height:1.5">${preset.name}</div>${envLine}${modelsPreview}`
|
||||
detailEl.style.display = 'block'
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -533,9 +542,13 @@ export function render() {
|
||||
// 移除常见尾部路径
|
||||
base = base.replace(/\/(chat\/completions|completions|responses|messages|models)\/?$/, '')
|
||||
|
||||
// 判断 API 类型(大部分是 OpenAI 兼容)
|
||||
const matched = HERMES_PROVIDERS.find(p => baseUrl === p.baseUrl)
|
||||
const apiType = matched?.api || 'openai-completions'
|
||||
// 判断 API 类型:按 provider transport 推断,fallback 到 openai 兼容
|
||||
const matched = inferProviderByBaseUrl(hermesProviders, baseUrl)
|
||||
let apiType = 'openai-completions'
|
||||
if (matched) {
|
||||
if (matched.transport === 'anthropic_messages') apiType = 'anthropic-messages'
|
||||
else if (matched.transport === 'google_gemini') apiType = 'google-generative-ai'
|
||||
}
|
||||
|
||||
let models = []
|
||||
|
||||
@@ -598,9 +611,9 @@ export function render() {
|
||||
const baseUrl = el.querySelector('#hm-baseurl')?.value?.trim()
|
||||
const apiKey = el.querySelector('#hm-apikey')?.value?.trim()
|
||||
const model = el.querySelector('#hm-model')?.value?.trim()
|
||||
// 从 baseUrl 推断 provider key
|
||||
const matched = HERMES_PROVIDERS.find(p => baseUrl && p.baseUrl === baseUrl)
|
||||
const provider = matched?.key || 'openai'
|
||||
// 从 baseUrl 推断 provider id;推不出来时用 'custom',让后端按通用 OpenAI 兼容处理
|
||||
const matched = inferProviderByBaseUrl(hermesProviders, baseUrl)
|
||||
const provider = matched?.id || 'custom'
|
||||
|
||||
if (!apiKey) {
|
||||
alert('请输入 API Key')
|
||||
@@ -655,8 +668,63 @@ export function render() {
|
||||
draw()
|
||||
}
|
||||
|
||||
// 启动检测
|
||||
detect()
|
||||
// 启动检测前先加载 provider registry,然后启动检测
|
||||
;(async () => {
|
||||
try {
|
||||
hermesProviders = await loadHermesProviders()
|
||||
hermesGroups = groupProviders(hermesProviders)
|
||||
} catch (err) {
|
||||
console.warn('[hermes/setup] failed to load providers:', err)
|
||||
}
|
||||
detect()
|
||||
})()
|
||||
|
||||
return el
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper: render the grouped provider buttons shown in renderConfigure()
|
||||
// ============================================================================
|
||||
|
||||
function renderGroupedProviderButtons() {
|
||||
if (!hermesProviders.length) {
|
||||
return `<div style="padding:10px 12px;background:var(--bg-tertiary);border-radius:var(--radius-sm,6px);color:var(--text-secondary);font-size:12px;line-height:1.6">
|
||||
未能加载 provider 列表。Web 模式下可手动填写下方 Base URL 与 API Key 完成配置。
|
||||
</div>`
|
||||
}
|
||||
|
||||
const sectionStyle = 'margin-top:6px'
|
||||
const titleStyle = 'font-size:11px;color:var(--text-tertiary);margin:4px 0 4px;font-weight:500;letter-spacing:0.3px'
|
||||
const rowStyle = 'display:flex;flex-wrap:wrap'
|
||||
|
||||
const btn = (p) => {
|
||||
const envHint = p.apiKeyEnvVars && p.apiKeyEnvVars.length
|
||||
? ` title="${p.apiKeyEnvVars[0]}"`
|
||||
: ''
|
||||
return `<button class="btn btn-sm btn-secondary hermes-preset-btn"
|
||||
data-key="${p.id}"
|
||||
data-url="${p.baseUrl}"
|
||||
data-api="${p.transport === 'anthropic_messages' ? 'anthropic-messages' : p.transport === 'google_gemini' ? 'google-generative-ai' : 'openai-completions'}"${envHint}
|
||||
style="font-size:12px;padding:3px 10px;margin:0 6px 6px 0">${p.name}</button>`
|
||||
}
|
||||
|
||||
const parts = []
|
||||
|
||||
if (hermesGroups.apiKeyIntl.length) {
|
||||
parts.push(`<div style="${sectionStyle}"><div style="${titleStyle}">国际 · API Key</div><div style="${rowStyle}">${hermesGroups.apiKeyIntl.map(btn).join('')}</div></div>`)
|
||||
}
|
||||
if (hermesGroups.apiKeyCn.length) {
|
||||
parts.push(`<div style="${sectionStyle}"><div style="${titleStyle}">国内 · API Key</div><div style="${rowStyle}">${hermesGroups.apiKeyCn.map(btn).join('')}</div></div>`)
|
||||
}
|
||||
if (hermesGroups.aggregators.length) {
|
||||
parts.push(`<div style="${sectionStyle}"><div style="${titleStyle}">聚合 / 路由</div><div style="${rowStyle}">${hermesGroups.aggregators.map(btn).join('')}</div></div>`)
|
||||
}
|
||||
if (hermesGroups.oauth.length) {
|
||||
const oauthItems = hermesGroups.oauth.map(p =>
|
||||
`<div style="font-size:11px;color:var(--text-tertiary);margin-right:10px"><code>${p.name}</code>:需运行 <code>${p.cliAuthHint}</code></div>`
|
||||
).join('')
|
||||
parts.push(`<div style="${sectionStyle}"><div style="${titleStyle}">OAuth 登录(需终端)</div><div style="display:flex;flex-wrap:wrap;gap:4px 0">${oauthItems}</div></div>`)
|
||||
}
|
||||
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
@@ -398,8 +398,9 @@ export const api = {
|
||||
hermesApiProxy: (method, path, body, headers) => invoke('hermes_api_proxy', { method, path, body: body || null, headers: headers || null }),
|
||||
hermesAgentRun: (input, sessionId, conversationHistory, instructions) => invoke('hermes_agent_run', { input, sessionId: sessionId || null, conversationHistory: conversationHistory || null, instructions: instructions || null }),
|
||||
hermesReadConfig: () => invoke('hermes_read_config'),
|
||||
hermesFetchModels: (baseUrl, apiKey, apiType) => invoke('hermes_fetch_models', { baseUrl, apiKey, apiType: apiType || null }),
|
||||
hermesUpdateModel: (model) => invoke('hermes_update_model', { model }),
|
||||
hermesFetchModels: (baseUrl, apiKey, apiType, provider) => invoke('hermes_fetch_models', { baseUrl, apiKey, apiType: apiType || null, provider: provider || null }),
|
||||
hermesUpdateModel: (model, provider) => invoke('hermes_update_model', { model, provider: provider || null }),
|
||||
hermesListProviders: () => cachedInvoke('hermes_list_providers', {}, 600000),
|
||||
hermesDetectEnvironments: () => invoke('hermes_detect_environments'),
|
||||
hermesSetGatewayUrl: (url) => invoke('hermes_set_gateway_url', { url: url || null }),
|
||||
updateHermes: () => invoke('update_hermes'),
|
||||
|
||||
Reference in New Issue
Block a user