Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions packages/client/src/api/hermes/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 提供。 */
Expand All @@ -41,6 +50,7 @@ export interface AvailableModelsResponse {
default_provider: string
groups: AvailableModelGroup[]
allProviders: AvailableModelGroup[]
model_visibility?: ModelVisibility
}

export interface CustomProvider {
Expand Down Expand Up @@ -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),
})
}
127 changes: 125 additions & 2 deletions packages/client/src/components/hermes/models/ProviderCard.vue
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 = ''
Expand Down Expand Up @@ -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
Expand All @@ -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>

Expand Down Expand Up @@ -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 changes: 7 additions & 0 deletions packages/client/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
7 changes: 7 additions & 0 deletions packages/client/src/i18n/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
39 changes: 35 additions & 4 deletions packages/client/src/stores/hermes/app.ts
Original file line number Diff line number Diff line change
@@ -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__

Expand All @@ -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('')

Expand Down Expand Up @@ -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
}
Expand All @@ -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()
Expand Down Expand Up @@ -134,14 +160,19 @@ export const useAppStore = defineStore('app', () => {
doUpdate,
modelGroups,
customModels,
modelVisibility,
selectedModel,
selectedProvider,
streamEnabled,
sessionPersistence,
maxTokens,
checkConnection,
loadModels,
applyAvailableModelsResponse,
switchModel,
getProviderVisibility,
isModelVisible,
setModelVisibility,
startHealthPolling,
stopHealthPolling,
}
Expand Down
2 changes: 2 additions & 0 deletions packages/client/src/stores/hermes/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading