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:
Zhicheng Han
2026-05-11 15:24:45 +02:00
committed by GitHub
parent c6fb449a19
commit 3a1893d401
12 changed files with 710 additions and 12 deletions
+21
View File
@@ -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>
+7
View File
@@ -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',
+7
View File
@@ -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',
+35 -4
View File
@@ -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 {