Files
hermes-ui/packages/client/src/components/hermes/models/ProviderCard.vue
T
toor 3ca960f388
Build / build (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
优化页面交互并增强 Provider 配置能力
- 表单下拉框不再用 label 包裹,改为 div + aria-label,避免点击后
  下拉面板瞬间关闭
- Provider 卡片新增「从上游获取模型」和「修改」入口,支持编辑
  base URL、API Key 与 api_mode,凭据写入对应的环境变量
- 服务端抽出 provider-model-discovery 统一不同 api_mode 的模型发现,
  旧的 custom_providers 与新的 providers 字典结构都能正确更新
- Agent Bridge 改为后台启动,HTTP 接口在 Python 运行时就绪前即可用;
  worker 连接被拒时按类型重试,不重放可能已产生副作用的请求
- 断线重连后恢复 profiles、models 与会话列表,健康检查改为自调度
  的 setTimeout,离线时 2 秒后重试
- 批量删除确认文案改用 Popconfirm content,避免变成侧栏触发器文本
2026-10-22 14:29:48 +08:00

635 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useMessage } from '@/composables/useMessage'
import { useDialog } from '@/composables/useDialog'
import type { AvailableModelGroup } from '@/api/hermes/system'
import { useModelsStore } from '@/stores/hermes/models'
import { useAppStore } from '@/stores/hermes/app'
import { useChatStore } from '@/stores/hermes/chat'
import ProviderEditModal from './ProviderEditModal.vue'
import { fetchProviderModels } from '@/api/hermes/system'
import { checkCopilotToken, disableCopilot } from '@/api/hermes/copilot-auth'
import { useI18n } from 'vue-i18n'
const props = defineProps<{ provider: AvailableModelGroup }>()
const { t } = useI18n()
const modelsStore = useModelsStore()
const appStore = useAppStore()
const chatStore = useChatStore()
const message = useMessage()
const dialog = useDialog()
const isCustomProviderKey = computed(() => props.provider.provider.startsWith('custom:'))
const isCustom = computed(() => !props.provider.builtin && isCustomProviderKey.value)
const isConfigBackedProvider = computed(() => isCustomProviderKey.value || (!props.provider.builtin && !!props.provider.provider_source))
const isCopilot = computed(() => props.provider.provider === 'copilot')
const displayName = computed(() => props.provider.label)
const deleting = ref(false)
const showEditModal = ref(false)
const fetchingUpstream = ref(false)
const canEditProvider = computed(() => isConfigBackedProvider.value || props.provider.credentials_editable || !!props.provider.base_url_env)
async function handleFetchUpstream() {
if (fetchingUpstream.value) return
fetchingUpstream.value = true
try {
const result = await fetchProviderModels({
provider: props.provider.provider,
label: props.provider.label,
base_url: props.provider.base_url,
api_key: props.provider.api_key,
update_cache: true,
api_mode: props.provider.api_mode,
freeOnly: props.provider.provider === 'openrouter',
})
await modelsStore.fetchProviders()
await appStore.reloadModels()
message.success(t('models.upstreamModelsFetched', { count: result.models.length }))
} catch (e: any) {
message.error(e?.message || t('models.upstreamModelsFailed'))
} finally {
fetchingUpstream.value = false
}
}
const destructiveActionLabel = computed(() => {
if (isConfigBackedProvider.value) return t('common.delete')
if (isCopilot.value) return t('models.disableProvider')
return t('models.clearProviderCredentials')
})
const destructiveActionTitle = computed(() => {
if (isConfigBackedProvider.value) return t('models.deleteProvider')
if (isCopilot.value) return t('models.disableProvider')
return t('models.clearProviderCredentials')
})
function destructiveConfirmContent(copilotMsg: string) {
if (isConfigBackedProvider.value) return t('models.deleteConfirm', { name: displayName.value })
if (isCopilot.value) {
const base = t('models.disableProviderConfirm', { name: displayName.value })
return copilotMsg ? `${base}\n\n${copilotMsg}` : base
}
return t('models.clearCredentialsConfirm', { name: displayName.value })
}
const showAliasListModal = ref(false)
const showAliasModal = ref(false)
const aliasProvider = ref('')
const aliasModel = ref('')
const aliasInput = ref('')
const showVisibilityModal = ref(false)
const visibilitySaving = ref(false)
const selectedVisibleModels = ref<string[]>([])
const defaultingProvider = ref(false)
const defaultingModel = ref<string | null>(null)
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}`)
const isDefaultProvider = computed(() => modelsStore.defaultProvider === props.provider.provider)
const defaultModelOptions = computed(() => props.provider.models.map(model => ({
label: modelDisplayName(model),
value: model,
})))
const defaultModelSelectValue = computed(() => isDefaultProvider.value ? modelsStore.defaultModel || null : null)
const previewModels = computed(() => {
const firstModels = props.provider.models.slice(0, 20)
const currentDefault = modelsStore.defaultModel
if (isDefaultProvider.value && currentDefault && props.provider.models.includes(currentDefault) && !firstModels.includes(currentDefault)) {
return [currentDefault, ...firstModels.slice(0, 19)]
}
return firstModels
})
const hiddenModelCount = computed(() => Math.max(props.provider.models.length - previewModels.value.length, 0))
function isDefaultModel(model: string) {
return isDefaultProvider.value && modelsStore.defaultModel === model
}
function modelAlias(model: string) {
return appStore.getModelAlias(model, props.provider.provider)
}
function modelDisplayName(model: string) {
return appStore.displayModelName(model, props.provider.provider)
}
function openAliasEditor(model: string) {
aliasProvider.value = props.provider.provider
aliasModel.value = model
aliasInput.value = appStore.getModelAlias(model, props.provider.provider)
showAliasModal.value = true
}
async function handleSetDefaultProvider() {
if (isDefaultProvider.value) return
defaultingProvider.value = true
try {
await modelsStore.setDefaultProvider(props.provider.provider)
message.success(t('models.defaultProviderUpdated'))
} catch (e: any) {
message.error(e?.message || t('models.defaultProviderUpdateFailed'))
} finally {
defaultingProvider.value = false
}
}
async function handleSetDefaultModel(model: string | null) {
if (!model) return
if (isDefaultModel(model)) return
defaultingModel.value = model
try {
await modelsStore.setDefaultModel(model, props.provider.provider)
message.success(t('models.defaultModelUpdated'))
} catch (e: any) {
message.error(e?.message || t('models.defaultModelUpdateFailed'))
} finally {
if (defaultingModel.value === model) defaultingModel.value = null
}
}
async function saveAlias() {
if (!aliasModel.value || !aliasProvider.value) return
try {
await appStore.setModelAlias(aliasModel.value, aliasProvider.value, aliasInput.value)
showAliasModal.value = false
} catch (e: any) {
message.error(e.message || t('models.aliasSaveFailed'))
}
}
async function clearAlias() {
aliasInput.value = ''
await saveAlias()
}
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]
}
function clearVisibility() {
selectedVisibleModels.value = []
}
async function handleDelete() {
let copilotMsg = ''
if (isCopilot.value) {
// 提前查 source,让用户清楚移除会不会影响 VS Code/gh CLI 等其他工具的登录态
try {
const status = await checkCopilotToken()
if (status.source === 'env') copilotMsg = t('models.copilotDeleteHintEnv')
else if (status.source === 'gh-cli') copilotMsg = t('models.copilotDeleteHintGhCli')
else if (status.source === 'apps-json') copilotMsg = t('models.copilotDeleteHintAppsJson')
} catch { /* ignore — fall back to generic confirm copy */ }
}
dialog.warning({
title: destructiveActionTitle.value,
content: destructiveConfirmContent(copilotMsg),
positiveText: destructiveActionLabel.value,
negativeText: t('common.cancel'),
onPositiveClick: async () => {
deleting.value = true
try {
if (isCopilot.value) {
// Copilot 走显式 opt-in 模型:disable 把 enabled 置 false,
// 仅当 token 来自 ~/.hermes/.env 时才清掉,gh-cli / apps.json 不动。
await disableCopilot()
// 服务端会在默认模型属于 copilot 时清掉 model.default,这里再清理本地
// 会话级 model/provider,避免 Chat 页继续显示已下架的 copilot 模型。
chatStore.clearProviderFromSessions('copilot')
await modelsStore.fetchProviders()
} else {
await modelsStore.removeProvider(props.provider.provider, {
source: props.provider.provider_source,
providerKey: props.provider.provider_key,
})
}
// 删完之后若已没有默认模型,自动从剩余 provider 里挑一个,避免 chat 页
// "无默认模型"的尴尬态。与 hermes CLI `model` 子命令的隐含行为对齐。
if (!appStore.selectedModel && appStore.modelGroups.length > 0) {
const first = appStore.modelGroups.find(g => g.models.length > 0)
if (first) {
await appStore.switchModel(first.models[0], first.provider)
}
}
message.success(isConfigBackedProvider.value
? t('models.providerDeleted')
: isCopilot.value
? t('models.providerDisabled')
: t('models.providerCredentialsCleared'))
} catch (e: any) {
message.error(e.message)
} finally {
deleting.value = false
}
},
})
}
</script>
<template>
<div class="cy-panel provider-card">
<div class="cy-panel-h">
<h3 class="provider-name">{{ displayName }}</h3>
<span class="cy-tag">{{ isCustom ? t('models.customType') : t('models.builtIn') }}</span>
<div class="cy-panel-actions card-header-actions">
<span v-if="isDefaultProvider" class="cy-tag acc">{{ t('models.currentDefault') }}</span>
<a-button
v-else
size="mini"
type="text"
:loading="defaultingProvider"
@click="handleSetDefaultProvider"
>
{{ t('models.setDefaultProvider') }}
</a-button>
</div>
</div>
<div class="cy-panel-b">
<div class="cy-kv">
<div class="cy-kr">
<span class="cy-kk">{{ t('models.provider') }}</span>
<span class="cy-kv-v"><code class="cy-code">{{ provider.provider }}</code></span>
</div>
<div class="cy-kr">
<span class="cy-kk">{{ t('models.baseUrl') }}</span>
<span class="cy-kv-v"><code class="cy-code">{{ provider.base_url }}</code></span>
</div>
<div class="cy-kr">
<span class="cy-kk">{{ t('models.models') }}</span>
<span class="cy-kv-v">{{ isFiltered ? visibleCountLabel : provider.models.length }} {{ t('models.count') }}</span>
</div>
<div class="cy-kr">
<span class="cy-kk">{{ t('models.defaultModel') }}</span>
<span class="cy-kv-v">
<a-select
class="default-model-select"
size="mini"
allow-search
:model-value="defaultModelSelectValue ?? undefined"
:options="defaultModelOptions"
:placeholder="t('models.selectModel')"
:disabled="provider.models.length === 0"
:loading="defaultingModel !== null"
@change="handleSetDefaultModel($event as string | null)"
/>
</span>
</div>
</div>
<div class="models-list">
<button
v-for="model in previewModels"
:key="model"
class="cy-tag model-tag"
:class="{ acc: isDefaultModel(model) }"
type="button"
:title="t('models.aliasTitleFor', { model })"
@click="openAliasEditor(model)"
>
<span class="model-tag-name">{{ modelDisplayName(model) }}</span>
<span v-if="isDefaultModel(model)" class="model-tag-default">{{ t('models.defaultShort') }}</span>
<span v-if="modelAlias(model)" class="model-tag-id">{{ model }}</span>
</button>
<span v-if="hiddenModelCount > 0" class="cy-tag model-tag-more">
+{{ hiddenModelCount }} {{ t('models.more') }}
</span>
</div>
</div>
<div class="cy-panel-b card-actions">
<a-button size="mini" type="text" :loading="fetchingUpstream" :disabled="!provider.base_url || showEditModal" @click="handleFetchUpstream">{{ t('models.fetchUpstreamModels') }}</a-button>
<a-button v-if="canEditProvider" size="mini" type="text" :disabled="fetchingUpstream || deleting" @click="showEditModal = true">{{ t('common.edit') }}</a-button>
<a-button size="mini" type="text" @click="showAliasListModal = true">{{ t('models.aliasManage') }}</a-button>
<a-button size="mini" type="text" @click="openVisibilityModal">{{ t('models.manageVisibleModels') }}</a-button>
<a-button size="mini" type="text" status="danger" :loading="deleting" @click="handleDelete">{{ destructiveActionLabel }}</a-button>
</div>
<ProviderEditModal v-if="showEditModal" :provider="provider" @close="showEditModal = false" />
<a-modal
v-model:visible="showAliasListModal"
:footer="false"
:title="t('models.aliasManageFor', { provider: displayName })"
width="min(560px, calc(100vw - 32px))"
:mask-closable="true"
>
<div class="alias-list-hint">{{ t('models.aliasHint') }}</div>
<div class="alias-list">
<div v-for="model in provider.models" :key="model" class="alias-row">
<div class="alias-row-text">
<span class="alias-row-name">{{ modelDisplayName(model) }}</span>
<span v-if="isDefaultModel(model)" class="alias-row-default">{{ t('models.defaultShort') }}</span>
<code class="alias-row-id">{{ model }}</code>
</div>
<a-button size="mini" type="text" @click="openAliasEditor(model)">{{ t('models.aliasEdit') }}</a-button>
</div>
</div>
</a-modal>
<a-modal
v-model:visible="showAliasModal"
:title="aliasModel ? t('models.aliasTitleFor', { model: aliasModel }) : t('models.aliasTitle')"
width="min(420px, calc(100vw - 32px))"
:mask-closable="true"
>
<a-input
v-model="aliasInput"
:placeholder="t('models.aliasPlaceholder')"
allow-clear
@keydown.enter="saveAlias"
/>
<div v-if="aliasModel" class="model-alias-canonical">
{{ t('models.aliasCanonical', { model: aliasModel }) }}
</div>
<div class="model-alias-hint">{{ t('models.aliasHint') }}</div>
<template #footer>
<div class="model-alias-actions">
<a-button type="text" :disabled="!appStore.getModelAlias(aliasModel, aliasProvider)" @click="clearAlias">
{{ t('models.aliasUseOriginal') }}
</a-button>
<div class="model-alias-spacer" />
<a-button @click="showAliasModal = false">{{ t('common.cancel') }}</a-button>
<a-button type="primary" @click="saveAlias">{{ t('common.save') }}</a-button>
</div>
</template>
</a-modal>
<a-modal
v-model:visible="showVisibilityModal"
:footer="false"
:title="t('models.manageVisibleModelsFor', { name: displayName })"
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">
<a-checkbox-group v-model="selectedVisibleModels">
<a-checkbox
v-for="model in allModels"
:key="model"
:value="model"
class="visibility-model"
>
<code>{{ modelDisplayName(model) }}</code>
<code v-if="modelAlias(model)" class="visibility-model-id">{{ model }}</code>
</a-checkbox>
</a-checkbox-group>
</div>
<div class="visibility-actions">
<a-button size="small" type="text" :disabled="visibilitySaving" @click="resetVisibility">
{{ t('models.showAllModels') }}
</a-button>
<a-button size="small" type="text" :disabled="visibilitySaving" @click="clearVisibility">
{{ t('models.clearVisibleModels') }}
</a-button>
<div class="visibility-action-spacer" />
<a-button size="small" :disabled="visibilitySaving" @click="showVisibilityModal = false">
{{ t('common.cancel') }}
</a-button>
<a-button size="small" type="primary" :loading="visibilitySaving" @click="handleVisibilitySave">
{{ t('common.save') }}
</a-button>
</div>
</a-modal>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.provider-card {
transition: border-color $transition-fast;
&:hover {
border-color: rgba(var(--accent-primary-rgb), 0.3);
}
}
.provider-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 52%;
}
.default-model-select {
flex: 1;
min-width: 0;
}
.models-list {
display: flex;
flex-wrap: wrap;
gap: 4px 6px;
margin-top: 12px;
max-height: 100px;
overflow-y: auto;
align-content: flex-start;
}
.model-tag {
max-width: 260px;
height: auto;
min-height: 20px;
padding: 2px 6px;
cursor: pointer;
text-align: left;
&:hover {
color: $text-primary;
border-color: rgba(var(--accent-primary-rgb), 0.45);
}
}
.model-tag-more {
color: $accent-primary;
border-color: rgba(var(--accent-primary-rgb), 0.35);
background: var(--accent-soft);
}
.model-tag-name,
.model-tag-id {
overflow: hidden;
text-overflow: ellipsis;
}
.model-tag-id {
color: $text-faint;
font-size: 9.5px;
}
.model-tag-default,
.alias-row-default {
color: $warning;
font-family: $font-ui;
font-size: 10px;
font-weight: 600;
}
.card-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 9px 12px;
border-top: 1px solid $border-light;
}
.alias-list-hint,
.model-alias-hint {
color: $text-muted;
font-size: 12px;
}
.alias-list-hint {
margin-bottom: 12px;
}
.alias-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 45vh;
overflow-y: auto;
}
.alias-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
border: 1px solid $border-light;
border-radius: $radius-sm;
}
.alias-row-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.alias-row-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: $text-primary;
font-family: $font-code;
font-size: 12px;
}
.alias-row-id,
.model-alias-canonical {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: $text-muted;
font-family: $font-code;
font-size: 11px;
}
.model-alias-canonical {
margin-top: 8px;
}
.model-alias-hint {
margin-top: 6px;
}
.model-alias-actions {
display: flex;
align-items: center;
gap: 8px;
}
.model-alias-spacer {
flex: 1;
}
.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-model-id {
margin-left: 6px;
color: $text-muted !important;
font-size: 11px !important;
}
.visibility-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 14px;
}
.visibility-action-spacer {
flex: 1;
}
</style>