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:
@@ -25,11 +25,20 @@ export interface ConfigModelsResponse {
|
||||
groups: ModelGroup[]
|
||||
}
|
||||
|
||||
export interface ModelVisibilityRule {
|
||||
mode: 'all' | 'include'
|
||||
models: string[]
|
||||
}
|
||||
|
||||
export type ModelVisibility = Record<string, ModelVisibilityRule>
|
||||
|
||||
export interface AvailableModelGroup {
|
||||
provider: string // credential pool key (e.g. "zai", "custom:subrouter.ai")
|
||||
label: string // display name (e.g. "zai", "subrouter.ai")
|
||||
base_url: string
|
||||
models: string[]
|
||||
/** Full unfiltered model catalog for this provider, used to restore hidden WUI models. */
|
||||
available_models?: string[]
|
||||
api_key: string
|
||||
builtin?: boolean
|
||||
/** 可选:模型 ID -> 元数据(preview/disabled)。目前仅 Copilot 提供。 */
|
||||
@@ -41,6 +50,7 @@ export interface AvailableModelsResponse {
|
||||
default_provider: string
|
||||
groups: AvailableModelGroup[]
|
||||
allProviders: AvailableModelGroup[]
|
||||
model_visibility?: ModelVisibility
|
||||
}
|
||||
|
||||
export interface CustomProvider {
|
||||
@@ -104,3 +114,14 @@ export async function updateProvider(poolKey: string, data: {
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateModelVisibility(data: {
|
||||
provider: string
|
||||
mode: 'all' | 'include'
|
||||
models: string[]
|
||||
}): Promise<{ success: boolean; model_visibility: ModelVisibility }> {
|
||||
return request<{ success: boolean; model_visibility: ModelVisibility }>('/api/hermes/model-visibility', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { NButton, useMessage, useDialog } from 'naive-ui'
|
||||
import { NButton, NCheckbox, NCheckboxGroup, NModal, useMessage, useDialog } from 'naive-ui'
|
||||
import type { AvailableModelGroup } from '@/api/hermes/system'
|
||||
import { useModelsStore } from '@/stores/hermes/models'
|
||||
import { useAppStore } from '@/stores/hermes/app'
|
||||
@@ -21,6 +21,45 @@ const isCustom = computed(() => !props.provider.builtin && props.provider.provid
|
||||
const isCopilot = computed(() => props.provider.provider === 'copilot')
|
||||
const displayName = computed(() => props.provider.label)
|
||||
const deleting = ref(false)
|
||||
const showVisibilityModal = ref(false)
|
||||
const visibilitySaving = ref(false)
|
||||
const selectedVisibleModels = ref<string[]>([])
|
||||
|
||||
const sourceProvider = computed(() => modelsStore.allProviders.find(g => g.provider === props.provider.provider))
|
||||
const allModels = computed(() => props.provider.available_models?.length ? props.provider.available_models : (sourceProvider.value?.models?.length ? sourceProvider.value.models : props.provider.models))
|
||||
const visibilityRule = computed(() => appStore.getProviderVisibility(props.provider.provider))
|
||||
const isFiltered = computed(() => visibilityRule.value.mode === 'include')
|
||||
const visibleCountLabel = computed(() => `${props.provider.models.length}/${allModels.value.length}`)
|
||||
|
||||
function openVisibilityModal() {
|
||||
const rule = appStore.getProviderVisibility(props.provider.provider)
|
||||
selectedVisibleModels.value = rule.mode === 'include' ? allModels.value.filter(m => rule.models.includes(m)) : [...allModels.value]
|
||||
showVisibilityModal.value = true
|
||||
}
|
||||
|
||||
async function handleVisibilitySave() {
|
||||
if (selectedVisibleModels.value.length === 0) {
|
||||
message.error(t('models.visibilitySelectOne'))
|
||||
return
|
||||
}
|
||||
visibilitySaving.value = true
|
||||
try {
|
||||
const selected = selectedVisibleModels.value.filter(m => allModels.value.includes(m))
|
||||
const mode = selected.length === allModels.value.length ? 'all' : 'include'
|
||||
await appStore.setModelVisibility(props.provider.provider, { mode, models: selected })
|
||||
await modelsStore.fetchProviders()
|
||||
showVisibilityModal.value = false
|
||||
message.success(t('models.visibilitySaved'))
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('models.visibilitySaveFailed'))
|
||||
} finally {
|
||||
visibilitySaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetVisibility() {
|
||||
selectedVisibleModels.value = [...allModels.value]
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
let copilotMsg = ''
|
||||
@@ -93,7 +132,9 @@ async function handleDelete() {
|
||||
</div>
|
||||
<div class="info-row models-row">
|
||||
<span class="info-label">{{ t('models.models') }}</span>
|
||||
<span class="info-value models-count">{{ provider.models.length }} {{ t('models.count') }}</span>
|
||||
<span class="info-value models-count">
|
||||
{{ isFiltered ? visibleCountLabel : provider.models.length }} {{ t('models.count') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="models-list">
|
||||
<span
|
||||
@@ -108,8 +149,46 @@ async function handleDelete() {
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<NButton size="tiny" quaternary @click="openVisibilityModal">{{ t('models.manageVisibleModels') }}</NButton>
|
||||
<NButton size="tiny" quaternary type="error" :loading="deleting" @click="handleDelete">{{ t('common.delete') }}</NButton>
|
||||
</div>
|
||||
|
||||
<NModal
|
||||
v-model:show="showVisibilityModal"
|
||||
preset="card"
|
||||
:title="t('models.manageVisibleModelsFor', { name: displayName })"
|
||||
:style="{ width: 'min(560px, calc(100vw - 32px))' }"
|
||||
:mask-closable="!visibilitySaving"
|
||||
>
|
||||
<p class="visibility-hint">{{ t('models.visibilityHint') }}</p>
|
||||
<div class="visibility-count">
|
||||
{{ selectedVisibleModels.length }}/{{ allModels.length }} {{ t('models.count') }}
|
||||
</div>
|
||||
<div class="visibility-list">
|
||||
<NCheckboxGroup v-model:value="selectedVisibleModels">
|
||||
<NCheckbox
|
||||
v-for="model in allModels"
|
||||
:key="model"
|
||||
:value="model"
|
||||
class="visibility-model"
|
||||
>
|
||||
<code>{{ model }}</code>
|
||||
</NCheckbox>
|
||||
</NCheckboxGroup>
|
||||
</div>
|
||||
<div class="visibility-actions">
|
||||
<NButton size="small" quaternary :disabled="visibilitySaving" @click="resetVisibility">
|
||||
{{ t('models.showAllModels') }}
|
||||
</NButton>
|
||||
<div class="visibility-action-spacer" />
|
||||
<NButton size="small" :disabled="visibilitySaving" @click="showVisibilityModal = false">
|
||||
{{ t('common.cancel') }}
|
||||
</NButton>
|
||||
<NButton size="small" type="primary" :loading="visibilitySaving" @click="handleVisibilitySave">
|
||||
{{ t('common.save') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -237,4 +316,48 @@ async function handleDelete() {
|
||||
border-top: 1px solid $border-light;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.visibility-hint {
|
||||
margin: 0 0 10px;
|
||||
color: $text-secondary;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.visibility-count {
|
||||
color: $text-muted;
|
||||
font-size: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.visibility-list {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid $border-light;
|
||||
border-radius: $radius-sm;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.visibility-model {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding: 4px 2px;
|
||||
|
||||
code {
|
||||
font-family: $font-code;
|
||||
font-size: 12px;
|
||||
color: $text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
.visibility-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.visibility-action-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -553,6 +553,13 @@ export default {
|
||||
models: 'Models',
|
||||
count: 'models',
|
||||
more: 'more',
|
||||
manageVisibleModels: 'Manage visible models',
|
||||
manageVisibleModelsFor: 'Manage visible models for {name}',
|
||||
visibilityHint: 'Only affects the Web UI model picker and Models page. Hermes CLI provider/model config is not rewritten; calls still use canonical model IDs.',
|
||||
visibilitySelectOne: 'Keep at least one visible model',
|
||||
visibilitySaved: 'Visible models saved',
|
||||
visibilitySaveFailed: 'Failed to save visible models',
|
||||
showAllModels: 'Show all models',
|
||||
builtIn: 'Built-in',
|
||||
customType: 'Custom',
|
||||
provider: 'Provider',
|
||||
|
||||
@@ -553,6 +553,13 @@ export default {
|
||||
models: '模型列表',
|
||||
count: '个模型',
|
||||
more: '个更多',
|
||||
manageVisibleModels: '管理可见模型',
|
||||
manageVisibleModelsFor: '管理 {name} 可见模型',
|
||||
visibilityHint: '仅影响 Web UI 的模型选择器和模型页展示,不会改写 Hermes CLI 的 provider/model 配置。实际调用仍使用原始模型 ID。',
|
||||
visibilitySelectOne: '至少保留一个可见模型',
|
||||
visibilitySaved: '可见模型已保存',
|
||||
visibilitySaveFailed: '保存可见模型失败',
|
||||
showAllModels: '显示全部模型',
|
||||
builtIn: '内置',
|
||||
customType: '自定义',
|
||||
provider: 'Provider',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { checkHealth, fetchAvailableModels, updateDefaultModel, triggerUpdate, type AvailableModelGroup } from '@/api/hermes/system'
|
||||
import { checkHealth, fetchAvailableModels, updateDefaultModel, updateModelVisibility, triggerUpdate, type AvailableModelGroup, type AvailableModelsResponse, type ModelVisibility, type ModelVisibilityRule } from '@/api/hermes/system'
|
||||
|
||||
const WEB_UI_VERSION = __APP_VERSION__
|
||||
|
||||
@@ -20,6 +20,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
const selectedModel = ref('')
|
||||
const selectedProvider = ref('')
|
||||
const customModels = ref<Record<string, string[]>>({})
|
||||
const modelVisibility = ref<ModelVisibility>({})
|
||||
const healthPollTimer = ref<ReturnType<typeof setInterval>>()
|
||||
const nodeVersion = ref('')
|
||||
|
||||
@@ -58,12 +59,21 @@ export const useAppStore = defineStore('app', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function applyAvailableModelsResponse(res: AvailableModelsResponse) {
|
||||
modelGroups.value = res.groups
|
||||
modelVisibility.value = res.model_visibility || {}
|
||||
const defaultGroup = res.groups.find(g => g.provider === (res.default_provider || '') && g.models.includes(res.default))
|
||||
const inferredGroup = res.groups.find(g => g.models.includes(res.default))
|
||||
const fallbackGroup = res.groups.find(g => g.models.length > 0)
|
||||
const selectedGroup = defaultGroup || inferredGroup || fallbackGroup
|
||||
selectedModel.value = selectedGroup ? (defaultGroup || inferredGroup ? res.default : selectedGroup.models[0]) : ''
|
||||
selectedProvider.value = selectedGroup?.provider || ''
|
||||
}
|
||||
|
||||
async function loadModels() {
|
||||
try {
|
||||
const res = await fetchAvailableModels()
|
||||
modelGroups.value = res.groups
|
||||
selectedModel.value = res.default
|
||||
selectedProvider.value = res.default_provider || ''
|
||||
applyAvailableModelsResponse(res)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -89,6 +99,22 @@ export const useAppStore = defineStore('app', () => {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function getProviderVisibility(provider: string): ModelVisibilityRule {
|
||||
return modelVisibility.value[provider] || { mode: 'all', models: [] }
|
||||
}
|
||||
|
||||
function isModelVisible(provider: string, model: string): boolean {
|
||||
const rule = getProviderVisibility(provider)
|
||||
return rule.mode !== 'include' || rule.models.includes(model)
|
||||
}
|
||||
|
||||
async function setModelVisibility(provider: string, rule: ModelVisibilityRule) {
|
||||
const res = await updateModelVisibility({ provider, mode: rule.mode, models: rule.models })
|
||||
modelVisibility.value = res.model_visibility || {}
|
||||
await loadModels()
|
||||
}
|
||||
|
||||
function startHealthPolling(interval = 30000) {
|
||||
stopHealthPolling()
|
||||
checkConnection()
|
||||
@@ -134,6 +160,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
doUpdate,
|
||||
modelGroups,
|
||||
customModels,
|
||||
modelVisibility,
|
||||
selectedModel,
|
||||
selectedProvider,
|
||||
streamEnabled,
|
||||
@@ -141,7 +168,11 @@ export const useAppStore = defineStore('app', () => {
|
||||
maxTokens,
|
||||
checkConnection,
|
||||
loadModels,
|
||||
applyAvailableModelsResponse,
|
||||
switchModel,
|
||||
getProviderVisibility,
|
||||
isModelVisible,
|
||||
setModelVisibility,
|
||||
startHealthPolling,
|
||||
stopHealthPolling,
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ export const useModelsStore = defineStore('models', () => {
|
||||
providers.value = res.groups
|
||||
allProviders.value = res.allProviders
|
||||
defaultModel.value = res.default
|
||||
const appStore = useAppStore()
|
||||
appStore.applyAvailableModelsResponse(res)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch providers:', err)
|
||||
} finally {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export const modelRoutes = new Router()
|
||||
modelRoutes.get('/api/hermes/available-models', ctrl.getAvailable)
|
||||
modelRoutes.get('/api/hermes/config/models', ctrl.getConfigModels)
|
||||
modelRoutes.put('/api/hermes/config/model', ctrl.setConfigModel)
|
||||
modelRoutes.put('/api/hermes/model-visibility', ctrl.setModelVisibility)
|
||||
|
||||
// Model context routes
|
||||
modelRoutes.get('/api/hermes/model-context', ctrl.getModelContext)
|
||||
|
||||
@@ -5,6 +5,11 @@ import { homedir } from 'os'
|
||||
const APP_HOME = join(homedir(), '.hermes-web-ui')
|
||||
const APP_CONFIG_FILE = join(APP_HOME, 'config.json')
|
||||
|
||||
export interface ModelVisibilityRule {
|
||||
mode: 'all' | 'include'
|
||||
models: string[]
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
// Whether GitHub Copilot has been explicitly added by the user in web-ui.
|
||||
// Default false: even when COPILOT_GITHUB_TOKEN / gh-cli / apps.json can
|
||||
@@ -12,6 +17,11 @@ export interface AppConfig {
|
||||
// via "Add Provider". Mirrors how the user manages Codex/Nous: the web-ui
|
||||
// owns the provider list, system credentials are merely a fallback source.
|
||||
copilotEnabled?: boolean
|
||||
|
||||
// Web UI-only model picker visibility. This filters what the WUI exposes in
|
||||
// its sidebar/model pages and never renames or rewrites Hermes canonical
|
||||
// provider/model IDs. Hermes CLI config remains the upstream source of truth.
|
||||
modelVisibility?: Record<string, ModelVisibilityRule>
|
||||
}
|
||||
|
||||
let cache: AppConfig | null = null
|
||||
|
||||
@@ -6,6 +6,7 @@ const mockSystemApi = vi.hoisted(() => ({
|
||||
checkHealth: vi.fn(),
|
||||
fetchAvailableModels: vi.fn(),
|
||||
updateDefaultModel: vi.fn(),
|
||||
updateModelVisibility: vi.fn(),
|
||||
triggerUpdate: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -34,6 +35,77 @@ describe('App Store', () => {
|
||||
expect(window.localStorage.getItem('hermes_sidebar_collapsed')).toBe('0')
|
||||
})
|
||||
|
||||
|
||||
|
||||
it('loads model visibility and falls back when the configured default is hidden', async () => {
|
||||
mockSystemApi.fetchAvailableModels.mockResolvedValue({
|
||||
default: 'deepseek-chat',
|
||||
default_provider: 'deepseek',
|
||||
groups: [
|
||||
{
|
||||
provider: 'deepseek',
|
||||
label: 'DeepSeek',
|
||||
base_url: 'https://api.deepseek.com/v1',
|
||||
api_key: 'sk-test',
|
||||
models: ['deepseek-reasoner'],
|
||||
},
|
||||
],
|
||||
allProviders: [],
|
||||
model_visibility: {
|
||||
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
|
||||
},
|
||||
})
|
||||
const store = useAppStore()
|
||||
|
||||
await store.loadModels()
|
||||
|
||||
expect(store.modelVisibility).toEqual({
|
||||
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
|
||||
})
|
||||
expect(store.selectedModel).toBe('deepseek-reasoner')
|
||||
expect(store.selectedProvider).toBe('deepseek')
|
||||
expect(store.isModelVisible('deepseek', 'deepseek-reasoner')).toBe(true)
|
||||
expect(store.isModelVisible('deepseek', 'deepseek-chat')).toBe(false)
|
||||
})
|
||||
|
||||
it('persists model visibility without changing the canonical selected model id', async () => {
|
||||
mockSystemApi.fetchAvailableModels.mockResolvedValue({
|
||||
default: 'deepseek-reasoner',
|
||||
default_provider: 'deepseek',
|
||||
groups: [
|
||||
{
|
||||
provider: 'deepseek',
|
||||
label: 'DeepSeek',
|
||||
base_url: 'https://api.deepseek.com/v1',
|
||||
api_key: 'sk-test',
|
||||
models: ['deepseek-reasoner'],
|
||||
},
|
||||
],
|
||||
allProviders: [],
|
||||
model_visibility: {
|
||||
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
|
||||
},
|
||||
})
|
||||
mockSystemApi.updateModelVisibility.mockResolvedValue({
|
||||
success: true,
|
||||
model_visibility: {
|
||||
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
|
||||
},
|
||||
})
|
||||
const store = useAppStore()
|
||||
|
||||
await store.setModelVisibility('deepseek', { mode: 'include', models: ['deepseek-reasoner'] })
|
||||
|
||||
expect(mockSystemApi.updateModelVisibility).toHaveBeenCalledWith({
|
||||
provider: 'deepseek',
|
||||
mode: 'include',
|
||||
models: ['deepseek-reasoner'],
|
||||
})
|
||||
expect(store.selectedModel).toBe('deepseek-reasoner')
|
||||
expect(store.selectedProvider).toBe('deepseek')
|
||||
expect(mockSystemApi.updateDefaultModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears the updating state and reports failure when self-update request fails', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockSystemApi.triggerUpdate.mockRejectedValue(new Error('install failed'))
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
const mockSystemApi = vi.hoisted(() => ({
|
||||
fetchAvailableModels: vi.fn(),
|
||||
updateDefaultModel: vi.fn(),
|
||||
addCustomProvider: vi.fn(),
|
||||
removeCustomProvider: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/system', () => mockSystemApi)
|
||||
|
||||
import { useAppStore } from '@/stores/hermes/app'
|
||||
import { useModelsStore } from '@/stores/hermes/models'
|
||||
|
||||
describe('Models Store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('keeps the sidebar model picker in sync after provider model visibility changes', async () => {
|
||||
const visibleGroups = [
|
||||
{
|
||||
provider: 'deepseek',
|
||||
label: 'DeepSeek',
|
||||
base_url: 'https://api.deepseek.com/v1',
|
||||
api_key: 'sk-test',
|
||||
models: ['deepseek-v4-flash', 'deepseek-v4-pro'],
|
||||
available_models: ['deepseek-v4-flash', 'deepseek-v4-pro'],
|
||||
model_meta: {
|
||||
'deepseek-v4-pro': { preview: true },
|
||||
},
|
||||
},
|
||||
]
|
||||
mockSystemApi.fetchAvailableModels.mockResolvedValue({
|
||||
default: 'deepseek-v4-flash',
|
||||
default_provider: 'deepseek',
|
||||
groups: visibleGroups,
|
||||
allProviders: visibleGroups,
|
||||
model_visibility: {
|
||||
deepseek: { mode: 'include', models: ['deepseek-v4-flash', 'deepseek-v4-pro'] },
|
||||
},
|
||||
})
|
||||
|
||||
const appStore = useAppStore()
|
||||
appStore.modelGroups = [
|
||||
{
|
||||
provider: 'deepseek',
|
||||
label: 'DeepSeek',
|
||||
base_url: 'https://api.deepseek.com/v1',
|
||||
api_key: 'sk-test',
|
||||
models: ['deepseek-v4-flash'],
|
||||
available_models: ['deepseek-v4-flash', 'deepseek-v4-pro'],
|
||||
},
|
||||
]
|
||||
|
||||
const modelsStore = useModelsStore()
|
||||
await modelsStore.fetchProviders()
|
||||
|
||||
expect(modelsStore.providers[0].models).toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
|
||||
expect(appStore.modelGroups[0].models).toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
|
||||
expect(appStore.modelGroups[0].available_models).toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
|
||||
expect(appStore.modelGroups[0].model_meta).toEqual({
|
||||
'deepseek-v4-pro': { preview: true },
|
||||
})
|
||||
expect(appStore.modelVisibility).toEqual({
|
||||
deepseek: { mode: 'include', models: ['deepseek-v4-flash', 'deepseek-v4-pro'] },
|
||||
})
|
||||
expect(appStore.selectedModel).toBe('deepseek-v4-flash')
|
||||
expect(appStore.selectedProvider).toBe('deepseek')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockReadFile, mockReadConfigYaml, mockFetchProviderModels, mockBuildModelGroups, mockReadAppConfig, mockWriteAppConfig } = vi.hoisted(() => ({
|
||||
mockReadFile: vi.fn(),
|
||||
mockReadConfigYaml: vi.fn(),
|
||||
mockFetchProviderModels: vi.fn(),
|
||||
mockBuildModelGroups: vi.fn(() => ({ default: '', groups: [] })),
|
||||
mockReadAppConfig: vi.fn(),
|
||||
mockWriteAppConfig: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: mockReadFile,
|
||||
}))
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
||||
getActiveEnvPath: () => '/fake/home/.hermes/.env',
|
||||
getActiveAuthPath: () => '/fake/home/.hermes/auth.json',
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/config-helpers', () => ({
|
||||
readConfigYaml: mockReadConfigYaml,
|
||||
writeConfigYaml: vi.fn(),
|
||||
fetchProviderModels: mockFetchProviderModels,
|
||||
buildModelGroups: mockBuildModelGroups,
|
||||
PROVIDER_ENV_MAP: {
|
||||
deepseek: { api_key_env: 'DEEPSEEK_API_KEY' },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/shared/providers', () => ({
|
||||
buildProviderModelMap: () => ({
|
||||
deepseek: ['deepseek-chat', 'deepseek-reasoner'],
|
||||
}),
|
||||
PROVIDER_PRESETS: [
|
||||
{
|
||||
value: 'deepseek',
|
||||
label: 'DeepSeek',
|
||||
base_url: 'https://api.deepseek.com/v1',
|
||||
models: ['deepseek-chat', 'deepseek-reasoner'],
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/copilot-models', () => ({
|
||||
getCopilotModelsDetailed: vi.fn(async () => []),
|
||||
resolveCopilotOAuthToken: vi.fn(async () => ''),
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/app-config', () => ({
|
||||
readAppConfig: mockReadAppConfig,
|
||||
writeAppConfig: mockWriteAppConfig,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/db', () => ({
|
||||
getDb: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/db/hermes/schemas', () => ({
|
||||
MODEL_CONTEXT_TABLE: 'model_context',
|
||||
}))
|
||||
|
||||
import * as ctrl from '../../packages/server/src/controllers/hermes/models'
|
||||
|
||||
function makeCtx(body: Record<string, unknown> = {}): any {
|
||||
return { params: {}, query: {}, request: { body }, body: undefined, status: 200 }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockReadFile.mockResolvedValue('DEEPSEEK_API_KEY=sk-test\n')
|
||||
mockReadConfigYaml.mockResolvedValue({ model: { default: 'deepseek-chat', provider: 'deepseek' } })
|
||||
mockBuildModelGroups.mockReturnValue({ default: '', groups: [] })
|
||||
mockReadAppConfig.mockResolvedValue({})
|
||||
mockWriteAppConfig.mockImplementation(async patch => patch)
|
||||
})
|
||||
|
||||
describe('models controller — model visibility', () => {
|
||||
it('filters available models per provider without changing canonical IDs', async () => {
|
||||
mockReadAppConfig.mockResolvedValue({
|
||||
modelVisibility: {
|
||||
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
|
||||
},
|
||||
})
|
||||
|
||||
const ctx = makeCtx()
|
||||
await ctrl.getAvailable(ctx)
|
||||
|
||||
expect(ctx.status).toBe(200)
|
||||
expect(ctx.body.groups).toHaveLength(1)
|
||||
expect(ctx.body.groups[0]).toMatchObject({
|
||||
provider: 'deepseek',
|
||||
models: ['deepseek-reasoner'],
|
||||
available_models: ['deepseek-chat', 'deepseek-reasoner'],
|
||||
})
|
||||
expect(ctx.body.default).toBe('deepseek-reasoner')
|
||||
expect(ctx.body.default_provider).toBe('deepseek')
|
||||
expect(ctx.body.model_visibility).toEqual({
|
||||
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
it('fails open for stale include rules so a provider can be recovered in the UI', async () => {
|
||||
mockReadAppConfig.mockResolvedValue({
|
||||
modelVisibility: {
|
||||
deepseek: { mode: 'include', models: ['missing-model'] },
|
||||
},
|
||||
})
|
||||
|
||||
const ctx = makeCtx()
|
||||
await ctrl.getAvailable(ctx)
|
||||
|
||||
expect(ctx.body.groups[0]).toMatchObject({
|
||||
provider: 'deepseek',
|
||||
models: ['deepseek-chat', 'deepseek-reasoner'],
|
||||
available_models: ['deepseek-chat', 'deepseek-reasoner'],
|
||||
})
|
||||
})
|
||||
|
||||
it('applies visibility to the config fallback path when no credentialed providers are active', async () => {
|
||||
mockReadFile.mockResolvedValue('')
|
||||
mockReadConfigYaml.mockResolvedValue({
|
||||
model: { default: 'custom-a' },
|
||||
custom_providers: [
|
||||
{ name: 'local', model: 'custom-a' },
|
||||
{ name: 'local', model: 'custom-b' },
|
||||
],
|
||||
})
|
||||
mockReadAppConfig.mockResolvedValue({
|
||||
modelVisibility: {
|
||||
Custom: { mode: 'include', models: ['custom-b'] },
|
||||
},
|
||||
})
|
||||
mockBuildModelGroups.mockReturnValue({
|
||||
default: 'custom-a',
|
||||
groups: [
|
||||
{
|
||||
provider: 'Custom',
|
||||
models: [
|
||||
{ id: 'custom-a', label: 'local: custom-a' },
|
||||
{ id: 'custom-b', label: 'local: custom-b' },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const ctx = makeCtx()
|
||||
await ctrl.getAvailable(ctx)
|
||||
|
||||
expect(ctx.body.groups).toEqual([
|
||||
expect.objectContaining({
|
||||
provider: 'Custom',
|
||||
models: ['custom-b'],
|
||||
available_models: ['custom-a', 'custom-b'],
|
||||
}),
|
||||
])
|
||||
expect(ctx.body.default).toBe('custom-b')
|
||||
expect(ctx.body.default_provider).toBe('Custom')
|
||||
})
|
||||
|
||||
it('saves include visibility in web-ui app config only', async () => {
|
||||
mockReadAppConfig.mockResolvedValue({ copilotEnabled: true })
|
||||
mockWriteAppConfig.mockResolvedValue({
|
||||
copilotEnabled: true,
|
||||
modelVisibility: { deepseek: { mode: 'include', models: ['deepseek-chat'] } },
|
||||
})
|
||||
|
||||
const ctx = makeCtx({ provider: 'deepseek', mode: 'include', models: ['deepseek-chat', 'deepseek-chat', ''] })
|
||||
await ctrl.setModelVisibility(ctx)
|
||||
|
||||
expect(mockWriteAppConfig).toHaveBeenCalledWith({
|
||||
modelVisibility: { deepseek: { mode: 'include', models: ['deepseek-chat'] } },
|
||||
})
|
||||
expect(ctx.body).toEqual({
|
||||
success: true,
|
||||
model_visibility: { deepseek: { mode: 'include', models: ['deepseek-chat'] } },
|
||||
})
|
||||
})
|
||||
|
||||
it('resets a provider to all models by deleting its web-ui visibility rule', async () => {
|
||||
mockReadAppConfig.mockResolvedValue({
|
||||
modelVisibility: {
|
||||
deepseek: { mode: 'include', models: ['deepseek-chat'] },
|
||||
openrouter: { mode: 'include', models: ['x'] },
|
||||
},
|
||||
})
|
||||
mockWriteAppConfig.mockResolvedValue({
|
||||
modelVisibility: {
|
||||
openrouter: { mode: 'include', models: ['x'] },
|
||||
},
|
||||
})
|
||||
|
||||
const ctx = makeCtx({ provider: 'deepseek', mode: 'all', models: [] })
|
||||
await ctrl.setModelVisibility(ctx)
|
||||
|
||||
expect(mockWriteAppConfig).toHaveBeenCalledWith({
|
||||
modelVisibility: {
|
||||
openrouter: { mode: 'include', models: ['x'] },
|
||||
},
|
||||
})
|
||||
expect(ctx.body.model_visibility).toEqual({
|
||||
openrouter: { mode: 'include', models: ['x'] },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects empty include lists', async () => {
|
||||
const ctx = makeCtx({ provider: 'deepseek', mode: 'include', models: [] })
|
||||
await ctrl.setModelVisibility(ctx)
|
||||
|
||||
expect(ctx.status).toBe(400)
|
||||
expect(ctx.body).toEqual({ error: 'Select at least one model' })
|
||||
expect(mockWriteAppConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user