Models:支持在 Web UI 里管理可见模型 (#613)
* feat(models): add WUI model visibility filter Store provider model visibility in Web UI app config and filter the WUI model picker/model page without rewriting Hermes CLI config or canonical model IDs. * fix(models): sync sidebar after visibility changes
This commit is contained in:
@@ -4,12 +4,74 @@ import { getActiveEnvPath, getActiveAuthPath } from '../../services/hermes/herme
|
||||
import { readConfigYaml, writeConfigYaml, fetchProviderModels, buildModelGroups, PROVIDER_ENV_MAP } from '../../services/config-helpers'
|
||||
import { buildProviderModelMap, PROVIDER_PRESETS } from '../../shared/providers'
|
||||
import { getCopilotModelsDetailed, resolveCopilotOAuthToken, type CopilotModelMeta } from '../../services/hermes/copilot-models'
|
||||
import { readAppConfig } from '../../services/app-config'
|
||||
import { readAppConfig, writeAppConfig, type ModelVisibilityRule } from '../../services/app-config'
|
||||
import { getDb } from '../../db'
|
||||
import { MODEL_CONTEXT_TABLE } from '../../db/hermes/schemas'
|
||||
|
||||
const PROVIDER_MODEL_CATALOG = buildProviderModelMap()
|
||||
|
||||
type ModelMeta = { preview?: boolean; disabled?: boolean }
|
||||
type AvailableGroup = { provider: string; label: string; base_url: string; models: string[]; api_key: string; builtin?: boolean; model_meta?: Record<string, ModelMeta>; available_models?: string[] }
|
||||
type ModelVisibility = Record<string, ModelVisibilityRule>
|
||||
|
||||
function uniqueStrings(values: unknown): string[] {
|
||||
if (!Array.isArray(values)) return []
|
||||
return Array.from(new Set(values.map(v => String(v || '').trim()).filter(Boolean)))
|
||||
}
|
||||
|
||||
function normalizeModelVisibility(input: unknown): ModelVisibility {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) return {}
|
||||
const out: ModelVisibility = {}
|
||||
for (const [provider, rawRule] of Object.entries(input as Record<string, unknown>)) {
|
||||
const providerKey = String(provider || '').trim()
|
||||
if (!providerKey || !rawRule || typeof rawRule !== 'object' || Array.isArray(rawRule)) continue
|
||||
const rule = rawRule as { mode?: unknown; models?: unknown }
|
||||
const mode = rule.mode === 'include' ? 'include' : 'all'
|
||||
const models = uniqueStrings(rule.models)
|
||||
if (mode === 'include') {
|
||||
if (models.length > 0) out[providerKey] = { mode, models }
|
||||
} else {
|
||||
out[providerKey] = { mode: 'all', models: [] }
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function filterModelsForProvider(provider: string, models: string[], visibility: ModelVisibility): string[] {
|
||||
const rule = visibility[provider]
|
||||
if (!rule || rule.mode !== 'include') return models
|
||||
const allowed = new Set(rule.models)
|
||||
const visible = models.filter(model => allowed.has(model))
|
||||
// If a stale hand-edited rule references models that are no longer present,
|
||||
// fail open so the provider remains recoverable from the Web UI.
|
||||
return visible.length > 0 ? visible : models
|
||||
}
|
||||
|
||||
function applyModelVisibility(groups: AvailableGroup[], visibility: ModelVisibility): AvailableGroup[] {
|
||||
return groups
|
||||
.map(group => {
|
||||
const availableModels = group.available_models || group.models
|
||||
return {
|
||||
...group,
|
||||
available_models: availableModels,
|
||||
models: filterModelsForProvider(group.provider, availableModels, visibility),
|
||||
}
|
||||
})
|
||||
.filter(group => group.models.length > 0)
|
||||
}
|
||||
|
||||
function resolveVisibleDefault(defaultModel: string, defaultProvider: string, groups: AvailableGroup[]) {
|
||||
if (defaultModel) {
|
||||
const explicit = groups.find(group => group.provider === defaultProvider && group.models.includes(defaultModel))
|
||||
if (explicit) return { defaultModel, defaultProvider }
|
||||
const inferred = groups.find(group => group.models.includes(defaultModel))
|
||||
if (inferred) return { defaultModel, defaultProvider: inferred.provider }
|
||||
}
|
||||
const fallback = groups.find(group => group.models.length > 0)
|
||||
return { defaultModel: fallback?.models[0] || '', defaultProvider: fallback?.provider || '' }
|
||||
}
|
||||
|
||||
|
||||
// Copilot 授权检测:复用同一套 token 解析逻辑(含 ~/.config/github-copilot/apps.json
|
||||
// 与 ghp_ PAT 跳过),与 getCopilotModels 行为一致,避免出现"模型能拉到却被判未授权"。
|
||||
async function isCopilotAuthorized(envContent: string): Promise<boolean> {
|
||||
@@ -41,7 +103,7 @@ export async function getAvailable(ctx: any) {
|
||||
currentDefault = modelSection.trim()
|
||||
}
|
||||
|
||||
const groups: Array<{ provider: string; label: string; base_url: string; models: string[]; api_key: string; builtin?: boolean; model_meta?: Record<string, { preview?: boolean; disabled?: boolean }> }> = []
|
||||
const groups: AvailableGroup[] = []
|
||||
const seenProviders = new Set<string>()
|
||||
|
||||
let envContent = ''
|
||||
@@ -57,10 +119,11 @@ export async function getAvailable(ctx: any) {
|
||||
const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm'))
|
||||
return match?.[1]?.trim() || ''
|
||||
}
|
||||
const addGroup = (provider: string, label: string, base_url: string, models: string[], api_key: string, builtin?: boolean, model_meta?: Record<string, { preview?: boolean; disabled?: boolean }>) => {
|
||||
const addGroup = (provider: string, label: string, base_url: string, models: string[], api_key: string, builtin?: boolean, model_meta?: Record<string, ModelMeta>) => {
|
||||
if (seenProviders.has(provider)) return
|
||||
seenProviders.add(provider)
|
||||
groups.push({ provider, label, base_url, models: [...models], api_key, ...(builtin ? { builtin: true } : {}), ...(model_meta ? { model_meta } : {}) })
|
||||
const availableModels = [...models]
|
||||
groups.push({ provider, label, base_url, models: availableModels, available_models: availableModels, api_key, ...(builtin ? { builtin: true } : {}), ...(model_meta ? { model_meta } : {}) })
|
||||
}
|
||||
|
||||
const isOAuthAuthorized = (providerKey: string): boolean => {
|
||||
@@ -93,6 +156,7 @@ export async function getAvailable(ctx: any) {
|
||||
// 时也不返回。避免误把 VS Code/gh CLI 用户的全局凭证当作 hermes provider。
|
||||
const appConfig = await readAppConfig()
|
||||
const copilotEnabled = appConfig.copilotEnabled === true
|
||||
const modelVisibility = normalizeModelVisibility(appConfig.modelVisibility)
|
||||
|
||||
// 兼容老用户:上一版本会"自动 fallback discovery"出 Copilot;升级后这些用户的
|
||||
// config.yaml 可能仍把 model.default 指向某个 copilot 模型。若此时 copilot 已不
|
||||
@@ -184,6 +248,8 @@ export async function getAvailable(ctx: any) {
|
||||
}
|
||||
|
||||
for (const g of groups) { g.models = Array.from(new Set(g.models)) }
|
||||
const visibleGroups = applyModelVisibility(groups, modelVisibility)
|
||||
const visibleDefault = resolveVisibleDefault(currentDefault, currentDefaultProvider, visibleGroups)
|
||||
|
||||
// 动态拉一次 copilot 模型用于 allProviders 展示(同一请求复用缓存)
|
||||
// 未启用 Copilot 时跳过拉取,避免空跑网络请求。
|
||||
@@ -199,11 +265,36 @@ export async function getAvailable(ctx: any) {
|
||||
|
||||
if (groups.length === 0) {
|
||||
const fallback = buildModelGroups(config)
|
||||
ctx.body = { ...fallback, allProviders: allProvidersBase }
|
||||
const fallbackGroups: AvailableGroup[] = fallback.groups.map(group => {
|
||||
const models = group.models.map(model => model.id)
|
||||
return {
|
||||
provider: group.provider,
|
||||
label: group.provider,
|
||||
base_url: '',
|
||||
models,
|
||||
available_models: models,
|
||||
api_key: '',
|
||||
}
|
||||
})
|
||||
const visibleFallbackGroups = applyModelVisibility(fallbackGroups, modelVisibility)
|
||||
const fallbackDefault = resolveVisibleDefault(fallback.default, currentDefaultProvider, visibleFallbackGroups)
|
||||
ctx.body = {
|
||||
default: fallbackDefault.defaultModel,
|
||||
default_provider: fallbackDefault.defaultProvider,
|
||||
groups: visibleFallbackGroups,
|
||||
allProviders: allProvidersBase,
|
||||
model_visibility: modelVisibility,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.body = { default: currentDefault, default_provider: currentDefaultProvider, groups, allProviders: allProvidersBase }
|
||||
ctx.body = {
|
||||
default: visibleDefault.defaultModel,
|
||||
default_provider: visibleDefault.defaultProvider,
|
||||
groups: visibleGroups,
|
||||
allProviders: allProvidersBase,
|
||||
model_visibility: modelVisibility,
|
||||
}
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
@@ -362,3 +453,40 @@ export async function getModelContext(ctx: any) {
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function setModelVisibility(ctx: any) {
|
||||
const { provider, mode, models } = ctx.request.body as { provider?: string; mode?: string; models?: string[] }
|
||||
const providerKey = String(provider || '').trim()
|
||||
if (!providerKey) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing provider' }
|
||||
return
|
||||
}
|
||||
if (mode !== 'all' && mode !== 'include') {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Invalid visibility mode' }
|
||||
return
|
||||
}
|
||||
const selectedModels = uniqueStrings(models)
|
||||
if (mode === 'include' && selectedModels.length === 0) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Select at least one model' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const appConfig = await readAppConfig()
|
||||
const modelVisibility = normalizeModelVisibility(appConfig.modelVisibility)
|
||||
if (mode === 'all') {
|
||||
delete modelVisibility[providerKey]
|
||||
} else {
|
||||
modelVisibility[providerKey] = { mode: 'include', models: selectedModels }
|
||||
}
|
||||
const saved = await writeAppConfig({ modelVisibility })
|
||||
ctx.body = { success: true, model_visibility: normalizeModelVisibility(saved.modelVisibility) }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user