优化页面交互并增强 Provider 配置能力
Build / build (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s

- 表单下拉框不再用 label 包裹,改为 div + aria-label,避免点击后
  下拉面板瞬间关闭
- Provider 卡片新增「从上游获取模型」和「修改」入口,支持编辑
  base URL、API Key 与 api_mode,凭据写入对应的环境变量
- 服务端抽出 provider-model-discovery 统一不同 api_mode 的模型发现,
  旧的 custom_providers 与新的 providers 字典结构都能正确更新
- Agent Bridge 改为后台启动,HTTP 接口在 Python 运行时就绪前即可用;
  worker 连接被拒时按类型重试,不重放可能已产生副作用的请求
- 断线重连后恢复 profiles、models 与会话列表,健康检查改为自调度
  的 setTimeout,离线时 2 秒后重试
- 批量删除确认文案改用 Popconfirm content,避免变成侧栏触发器文本
This commit is contained in:
2026-10-22 14:29:48 +08:00
committed by trae-solo-agent
parent 90c759c760
commit 3ca960f388
35 changed files with 876 additions and 78 deletions
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'fs'
import { join } from 'path'
import { getActiveEnvPath, getActiveAuthPath, getActiveProfileName, getProfileDir, listProfileNamesFromDisk } from '../../services/hermes/hermes-profile'
import { readConfigYaml, readConfigYamlForProfile, updateConfigYaml, updateConfigYamlForProfile, fetchProviderModels, buildModelGroups, PROVIDER_ENV_MAP } from '../../services/config-helpers'
import { discoverProviderModels } from '../../services/hermes/provider-model-discovery'
import { getCompatibleCustomProviders } from '../../services/hermes/custom-providers-compat'
import { buildProviderModelMap, PROVIDER_PRESETS } from '../../shared/providers'
import { getCopilotModelsDetailed, resolveCopilotOAuthToken, type CopilotModelMeta } from '../../services/hermes/copilot-models'
@@ -22,7 +23,7 @@ const PROVIDER_MODEL_CATALOG = buildProviderModelMap()
type ModelMeta = { preview?: boolean; disabled?: boolean; alias?: string }
type ProviderApiMode = 'chat_completions' | 'codex_responses' | 'anthropic_messages' | 'bedrock_converse' | 'codex_app_server'
type AvailableGroup = { provider: string; label: string; base_url: string; models: string[]; api_key: string; api_mode?: ProviderApiMode; builtin?: boolean; model_meta?: Record<string, ModelMeta>; available_models?: string[]; base_url_env?: string; provider_source?: 'custom_providers' | 'providers'; provider_key?: string }
type AvailableGroup = { provider: string; label: string; base_url: string; models: string[]; api_key: string; api_mode?: ProviderApiMode; builtin?: boolean; credentials_editable?: boolean; model_meta?: Record<string, ModelMeta>; available_models?: string[]; base_url_env?: string; provider_source?: 'custom_providers' | 'providers'; provider_key?: string }
type ModelVisibility = Record<string, ModelVisibilityRule>
type CustomModels = Record<string, string[]>
@@ -366,7 +367,9 @@ async function buildAvailableForProfile(
seenProviders.add(provider)
const availableModels = [...new Set(models)]
const apiMode = providerApiMode(provider, extra?.api_mode)
groups.push({ provider, label, base_url, models: availableModels, available_models: availableModels, api_key, ...(apiMode ? { api_mode: apiMode } : {}), ...(builtin ? { builtin: true } : {}), ...(model_meta ? { model_meta } : {}), ...(extra?.provider_source ? { provider_source: extra.provider_source } : {}), ...(extra?.provider_key ? { provider_key: extra.provider_key } : {}) })
groups.push({ provider, label, base_url, models: availableModels, available_models: availableModels, api_key,
...(PROVIDER_ENV_MAP[provider]?.base_url_env ? { base_url_env: PROVIDER_ENV_MAP[provider].base_url_env } : {}),
...(PROVIDER_ENV_MAP[provider]?.api_key_env ? { credentials_editable: true } : {}), ...(apiMode ? { api_mode: apiMode } : {}), ...(builtin ? { builtin: true } : {}), ...(model_meta ? { model_meta } : {}), ...(extra?.provider_source ? { provider_source: extra.provider_source } : {}), ...(extra?.provider_key ? { provider_key: extra.provider_key } : {}) })
}
const copilotEnabled = appConfig.copilotEnabled === true
@@ -421,7 +424,7 @@ async function buildAvailableForProfile(
let models = [...new Set([cp.model, ...configuredModels, ...builtinCatalogModels].filter(Boolean) as string[])]
const cachedModels = getCachedProviderModels(modelCatalogCache, providerKey, baseUrl)
if (cachedModels) models = [...new Set([...models, ...cachedModels])]
return { providerKey, label: cp.name, base_url: baseUrl, models, api_key: cp.api_key || '', api_mode: cp.api_mode, builtin: isBuiltinProviderKey(providerKey), provider_source: cp.source, provider_key: cp.provider_key }
return { providerKey, label: cp.name, base_url: baseUrl, models, api_key: cp.api_key || (cp.key_env ? envGetValue(cp.key_env) : ''), api_mode: cp.api_mode, builtin: isBuiltinProviderKey(providerKey), provider_source: cp.source, provider_key: cp.provider_key }
}),
)
for (const result of customFetches) {
@@ -805,7 +808,7 @@ export async function removeCustomModel(ctx: any) {
export async function fetchProviderModelList(ctx: any) {
try {
const body = ctx.request.body as { base_url?: string; api_key?: string; freeOnly?: boolean; provider?: string; label?: string; update_cache?: boolean }
const body = ctx.request.body as { base_url?: string; api_key?: string; freeOnly?: boolean; provider?: string; label?: string; update_cache?: boolean; api_mode?: ProviderApiMode }
const baseUrl = String(body?.base_url || '').trim()
const apiKey = String(body?.api_key || '').trim()
const freeOnly = body?.freeOnly === true
@@ -832,33 +835,15 @@ export async function fetchProviderModelList(ctx: any) {
return
}
const base = baseUrl.replace(/\/+$/, '')
const modelsUrl = /\/v\d+\/?$/.test(base) ? `${base}/models` : `${base}/v1/models`
const headers: Record<string, string> = {}
if (apiKey) headers.Authorization = `Bearer ${apiKey}`
const res = await fetch(modelsUrl, {
headers,
signal: AbortSignal.timeout(8000),
})
if (!res.ok) {
const apiMode = providerApiMode(provider, body?.api_mode)
let models = await discoverProviderModels(baseUrl, apiKey, apiMode === 'anthropic_messages')
if (freeOnly) models = models.filter(model => model.endsWith(':free'))
const uniqueModels = models
if (body?.update_cache === true && provider && uniqueModels.length === 0) {
ctx.status = 502
ctx.body = { error: `Provider returned HTTP ${res.status}` }
ctx.body = { error: 'Provider returned no models; existing catalog was kept' }
return
}
const data = await res.json() as { data?: Array<{ id?: unknown }> }
if (!Array.isArray(data.data)) {
ctx.status = 502
ctx.body = { error: 'Provider returned unexpected format' }
return
}
let models = data.data
.map(m => String(m?.id || '').trim())
.filter(Boolean)
if (freeOnly) models = models.filter(m => m.endsWith(':free'))
const uniqueModels = Array.from(new Set(models)).sort()
if (body?.update_cache === true && provider) {
await writeProviderModelCatalogEntry({
provider,
@@ -194,23 +194,35 @@ export async function create(ctx: any) {
export async function update(ctx: any) {
const poolKey = decodeURIComponent(ctx.params.poolKey)
const { name, base_url, api_key, model, api_mode } = ctx.request.body as {
name?: string; base_url?: string; api_key?: string; model?: string; api_mode?: ProviderApiMode
const { name, base_url, api_key, model, api_mode, source, providerKey } = ctx.request.body as {
name?: string; base_url?: string; api_key?: string; model?: string; api_mode?: ProviderApiMode; source?: string; providerKey?: string
}
const customApiMode = normalizeApiMode(api_mode)
if (base_url !== undefined) {
try {
const parsed = new URL(base_url)
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('Invalid protocol')
} catch {
ctx.status = 400; ctx.body = { error: 'base_url must be a valid HTTP or HTTPS URL' }; return
}
}
try {
const profile = requestedProfile(ctx)
const isCustom = poolKey.startsWith('custom:')
if (isCustom) {
const found = await updateConfigYamlForProfile(profile, (config) => {
if (!Array.isArray(config.custom_providers)) return { data: config, result: false, write: false }
const entry = (config.custom_providers as any[]).find((e: any) => {
return `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}` === poolKey
})
if (!entry) return { data: config, result: false, write: false }
const found = await updateConfigYamlForProfile(profile, async (config) => {
const legacyIndex = source === 'providers' ? -1 : findLegacyCustomProviderIndex(config, poolKey)
const dictKey = source === 'custom_providers' || legacyIndex !== -1
? '' : findProviderDictKey(config, poolKey, providerKey)
const entry = legacyIndex !== -1 ? config.custom_providers[legacyIndex] : dictKey ? config.providers[dictKey] : null
const normalized = normalizeCustomProviderEntry(entry, dictKey, dictKey ? 'providers' : 'custom_providers')
if (!entry || !normalized || providerKeyForCustomName(normalized.name) !== poolKey) return { data: config, result: false, write: false }
if (name !== undefined) entry.name = name
if (base_url !== undefined) entry.base_url = base_url
if (api_key !== undefined) entry.api_key = api_key
if (base_url !== undefined) entry.base_url = base_url.trim()
if (api_key !== undefined) {
if (normalized.key_env) await saveEnvValueForProfile(profile, normalized.key_env, api_key)
entry.api_key = api_key
}
if (model !== undefined) entry.model = model
if (customApiMode !== undefined) entry.api_mode = customApiMode
return { data: config, result: true }
@@ -220,10 +232,17 @@ export async function update(ctx: any) {
}
} else {
const envMapping = PROVIDER_ENV_MAP[poolKey]
if (!envMapping?.api_key_env) {
if (!envMapping || (!envMapping.api_key_env && !envMapping.base_url_env)) {
ctx.status = 400; ctx.body = { error: `Cannot update credentials for "${poolKey}"` }; return
}
if (api_key !== undefined) { await saveEnvValueForProfile(profile, envMapping.api_key_env, api_key) }
if (base_url !== undefined && !envMapping.base_url_env) {
ctx.status = 400; ctx.body = { error: `Cannot update base URL for "${poolKey}"` }; return
}
if (api_key !== undefined && !envMapping.api_key_env) {
ctx.status = 400; ctx.body = { error: `Cannot update API key for "${poolKey}"` }; return
}
if (api_key !== undefined) await saveEnvValueForProfile(profile, envMapping.api_key_env, api_key)
if (base_url !== undefined) await saveEnvValueForProfile(profile, envMapping.base_url_env, base_url.trim())
}
// TODO: Test if provider works without gateway restart
// try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') }
+6 -5
View File
@@ -18,7 +18,7 @@ import { setGroupChatServer } from './routes/hermes/group-chat'
import { setChatRunServer } from './routes/hermes/chat-run'
import { GroupChatServer } from './services/hermes/group-chat'
import { ChatRunSocket } from './services/hermes/run-chat'
import { startAgentBridgeManager } from './services/hermes/agent-bridge'
import { getAgentBridgeManager } from './services/hermes/agent-bridge'
import { HermesSkillInjector } from './services/hermes/skill-injector'
import { injectBundledMcpServer } from './services/hermes/studio-mcp-autoinject'
import { ensureProfileGatewaysRunning } from './services/hermes/gateway-autostart'
@@ -109,7 +109,7 @@ function skillInjectionDisabled(): boolean {
return envFlagEnabled('HERMES_WEB_UI_DISABLE_SKILL_INJECTION')
}
async function startRuntimeServicesBeforeListen(): Promise<void> {
async function startRuntimeServices(): Promise<void> {
if (gatewayAutostartDisabled()) {
console.log('[bootstrap] profile gateway check disabled by HERMES_WEB_UI_DISABLE_GATEWAY_AUTOSTART')
} else {
@@ -122,7 +122,7 @@ async function startRuntimeServicesBeforeListen(): Promise<void> {
}
try {
agentBridgeManager = await startAgentBridgeManager()
await agentBridgeManager.start()
console.log('[bootstrap] agent bridge started')
} catch (err) {
logger.warn(err, '[bootstrap] agent bridge failed to start')
@@ -187,8 +187,6 @@ export async function bootstrap() {
console.warn('[bootstrap] failed to inject bundled MCP server:', err instanceof Error ? err.message : err)
}
await startRuntimeServicesBeforeListen()
const app = new Koa()
// Initialize all web-ui SQLite tables
const { initAllStores } = await import('./db/hermes/init')
@@ -291,7 +289,10 @@ export async function bootstrap() {
})
})
agentBridgeManager = getAgentBridgeManager()
bindShutdown(servers, groupChatServer, chatRunServer, agentBridgeManager)
// Session/history APIs must remain available while the Python runtime starts.
void startRuntimeServices()
startVersionCheck()
}
@@ -373,10 +373,23 @@ export class AgentBridgeClient {
}, '[agent-bridge-client] request')
}
try {
const socket = await this.connectSocket()
socket.write(`${JSON.stringify(payload)}\n`)
const raw = await this.readResponse(socket, timeoutMs)
const response = JSON.parse(raw) as { ok?: boolean; error?: string }
const workerConnectDeadline = Date.now() + Math.max(0, this.connectRetryMs)
let response: { ok?: boolean; error?: string; error_type?: string }
for (;;) {
const socket = await this.connectSocket()
socket.write(`${JSON.stringify(payload)}\n`)
const raw = await this.readResponse(socket, timeoutMs)
response = JSON.parse(raw) as typeof response
// The broker reports worker connect failures before any request bytes
// reach the worker. Only these typed failures are safe to replay;
// timeouts, lost responses and agent errors may have side effects.
const workerNotListening = response.ok === false && (
(response.error_type === 'ConnectionRefusedError' && /^\[Errno (111|61|10061)\]/.test(response.error || '')) ||
(response.error_type === 'FileNotFoundError' && /^\[Errno 2\]/.test(response.error || ''))
)
if (!workerNotListening || Date.now() >= workerConnectDeadline) break
await delay(100)
}
if (!response.ok) {
const error = new AgentBridgeError(response.error || 'Agent bridge request failed')
error.response = response
@@ -11,8 +11,8 @@
* Studio historically only read the legacy `custom_providers:` list, which makes
* v12+ dict-shaped providers invisible in the model picker, catalog, and context
* lookups (Issue #1570). This helper mirrors Agent's behavior so Studio can read
* both shapes; write paths intentionally still target the legacy list (that's
* the canonical write surface Studio's UI manipulates today).
* both shapes. Creation targets the legacy list; updates and deletion preserve
* the original source and dictionary key returned by this helper.
*/
const KNOWN_KEYS = new Set([
@@ -0,0 +1,30 @@
/** Strict live discovery: never replace a cached catalog with a partial/error response. */
export async function discoverProviderModels(baseUrl: string, apiKey: string, anthropic = false): Promise<string[]> {
const base = baseUrl.replace(/\/+$/, '')
const modelsUrl = new URL(/\/v\d+\/?$/.test(base) ? `${base}/models` : `${base}/v1/models`)
const headers: Record<string, string> = {}
if (anthropic) {
if (apiKey) headers['x-api-key'] = apiKey
headers['anthropic-version'] = '2023-06-01'
} else if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`
}
const models: string[] = []
const cursors = new Set<string>()
const signal = AbortSignal.timeout(8000)
// Anthropic-compatible /models uses has_more + last_id cursor pagination.
// https://platform.claude.com/docs/en/api/models/list
for (let page = 0; page < 100; page++) {
const res = await fetch(modelsUrl.toString(), { headers, signal })
if (!res.ok) throw new Error(`Provider returned HTTP ${res.status}`)
const data = await res.json() as { data?: Array<{ id?: unknown }>; has_more?: boolean; last_id?: string }
if (!Array.isArray(data.data)) throw new Error('Provider returned unexpected format')
models.push(...data.data.map(model => String(model?.id || '').trim()).filter(Boolean))
if (!data.has_more) return [...new Set(models)].sort()
const cursor = data.last_id
if (typeof cursor !== 'string' || !cursor || cursors.has(cursor)) throw new Error('Provider returned invalid model pagination')
cursors.add(cursor)
modelsUrl.searchParams.set('after_id', cursor)
}
throw new Error('Provider model pagination limit exceeded')
}