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 {
|
||||
|
||||
Reference in New Issue
Block a user