mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(keys): 新增密钥自动获取模型功能
- 添加 auto_fetch_models 字段支持定时从上游 API 获取可用模型 - 添加 locked_models 字段支持锁定模型,刷新时不会被删除 - 新增 ModelFetchScheduler 调度器定期执行模型获取任务 - 前端 KeyFormDialog 添加自动获取模型开关 - 前端 KeyAllowedModelsEditDialog 支持模型锁定操作 - ProviderDetailDrawer 显示密钥同步状态 - 数据库迁移添加新字段
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
<Dialog
|
||||
:model-value="isOpen"
|
||||
:title="props.apiKey?.name ? `模型权限 - ${props.apiKey.name}` : '模型权限'"
|
||||
description="选中的模型将被允许访问,不选择则允许全部"
|
||||
:description="isAutoFetchMode ? '自动获取模式:只允许已选择的模型,锁定的模型刷新时不会被删除' : '选中的模型将被允许访问,不选择则允许全部'"
|
||||
:icon="Shield"
|
||||
size="2xl"
|
||||
@update:model-value="handleDialogUpdate"
|
||||
@@ -34,42 +34,25 @@
|
||||
</div>
|
||||
<!-- 已选数量徽章 -->
|
||||
<span
|
||||
v-if="selectedModels.length === 0"
|
||||
v-if="selectedModels.length === 0 && !isAutoFetchMode"
|
||||
class="h-6 px-2 text-xs rounded flex items-center bg-muted text-muted-foreground shrink-0"
|
||||
>
|
||||
全部模型
|
||||
</span>
|
||||
<span
|
||||
v-else-if="selectedModels.length === 0 && isAutoFetchMode"
|
||||
class="h-6 px-2 text-xs rounded flex items-center bg-amber-500/10 text-amber-600 dark:text-amber-400 shrink-0"
|
||||
>
|
||||
未选择模型
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="h-6 px-2 text-xs rounded flex items-center bg-primary/10 text-primary shrink-0"
|
||||
>
|
||||
已选 {{ selectedModels.length }} 个
|
||||
</span>
|
||||
<button
|
||||
v-if="upstreamModelsLoaded"
|
||||
type="button"
|
||||
class="p-1.5 hover:bg-muted rounded-md transition-colors shrink-0"
|
||||
title="刷新上游模型"
|
||||
:disabled="fetchingUpstreamModels"
|
||||
@click="fetchUpstreamModels()"
|
||||
>
|
||||
<RefreshCw
|
||||
class="w-4 h-4"
|
||||
:class="{ 'animate-spin': fetchingUpstreamModels }"
|
||||
/>
|
||||
</button>
|
||||
<Button
|
||||
v-else-if="!fetchingUpstreamModels"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8"
|
||||
title="从提供<E68F90><E4BE9B><EFBFBD>获取模型"
|
||||
@click="fetchUpstreamModels()"
|
||||
>
|
||||
<Zap class="w-4 h-4" />
|
||||
</Button>
|
||||
<Loader2
|
||||
v-else
|
||||
v-if="fetchingUpstreamModels"
|
||||
class="w-4 h-4 animate-spin text-muted-foreground shrink-0"
|
||||
/>
|
||||
</div>
|
||||
@@ -85,10 +68,10 @@
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 添加自定义模型(搜索内容不在列表中时显示,固定在顶部) -->
|
||||
<!-- 添加自定义模型(搜索内容不在列表中时显示) -->
|
||||
<div
|
||||
v-if="searchQuery && canAddAsCustom"
|
||||
class="px-3 py-2 border-b bg-background sticky top-0 z-10"
|
||||
class="px-3 py-2 border-b bg-background sticky top-0 z-30"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between px-3 py-2 rounded-lg border border-dashed hover:border-primary hover:bg-primary/5 cursor-pointer transition-colors"
|
||||
@@ -102,10 +85,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自定义模型(手动添加的,始终显示全部,搜索命中的排前面) -->
|
||||
<!-- 自定义模型 -->
|
||||
<div v-if="customModels.length > 0">
|
||||
<div
|
||||
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-10 cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
class="flex items-center justify-between px-3 h-9 bg-muted sticky top-0 z-20 cursor-pointer hover:bg-muted/80 transition-colors border-b border-border/30"
|
||||
@click="toggleGroupCollapse('custom')"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -136,15 +119,26 @@
|
||||
class="w-3 h-3 text-primary-foreground"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm font-mono truncate">{{ model }}</span>
|
||||
<span class="text-sm font-mono truncate flex-1">{{ model }}</span>
|
||||
<button
|
||||
v-if="selectedModels.includes(model)"
|
||||
type="button"
|
||||
class="p-1 rounded hover:bg-muted-foreground/10 transition-colors shrink-0 text-muted-foreground"
|
||||
:title="isLocked(model) ? '已锁定 - 点击解锁' : '点击锁定(刷新时不会被删除)'"
|
||||
@click="toggleLock(model, $event)"
|
||||
>
|
||||
<Lock v-if="isLocked(model)" class="w-3.5 h-3.5" />
|
||||
<LockOpen v-else class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 全局模型 -->
|
||||
<div v-if="filteredGlobalModels.length > 0">
|
||||
<!-- 提供商模型 -->
|
||||
<template v-if="filteredGlobalModels.length > 0">
|
||||
<!-- 标题 sticky top -->
|
||||
<div
|
||||
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-10 cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
class="flex items-center justify-between px-3 h-9 bg-muted sticky top-0 z-20 cursor-pointer hover:bg-muted/80 transition-colors border-b border-border/30"
|
||||
@click="toggleGroupCollapse('global')"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -152,7 +146,7 @@
|
||||
class="w-4 h-4 transition-transform shrink-0"
|
||||
:class="collapsedGroups.has('global') ? '-rotate-90' : ''"
|
||||
/>
|
||||
<span class="text-xs font-medium">全局模型</span>
|
||||
<span class="text-xs font-medium">提供商模型</span>
|
||||
<span class="text-xs text-muted-foreground">({{ filteredGlobalModels.length }})</span>
|
||||
</div>
|
||||
<button
|
||||
@@ -163,6 +157,7 @@
|
||||
{{ isAllGlobalModelsSelected ? '取消全选' : '全选' }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- 内容 -->
|
||||
<div
|
||||
v-show="!collapsedGroups.has('global')"
|
||||
class="space-y-1 p-2"
|
||||
@@ -190,76 +185,91 @@
|
||||
{{ model.name }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="selectedModels.includes(model.name)"
|
||||
type="button"
|
||||
class="p-1 rounded hover:bg-muted-foreground/10 transition-colors shrink-0 text-muted-foreground"
|
||||
:title="isLocked(model.name) ? '已锁定 - 点击解锁' : '点击锁定(刷新时不会被删除)'"
|
||||
@click="toggleLock(model.name, $event)"
|
||||
>
|
||||
<Lock v-if="isLocked(model.name)" class="w-3.5 h-3.5" />
|
||||
<LockOpen v-else class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 上游模型组 -->
|
||||
<div
|
||||
v-for="group in filteredUpstreamGroups"
|
||||
:key="group.api_format"
|
||||
>
|
||||
<!-- 上游模型 -->
|
||||
<template v-if="filteredUpstreamModels.length > 0">
|
||||
<!-- 标题 sticky(双向粘性:top 和 bottom) -->
|
||||
<div
|
||||
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-10 cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
@click="toggleGroupCollapse(group.api_format)"
|
||||
class="flex items-center justify-between px-3 h-9 bg-muted sticky z-20 cursor-pointer hover:bg-muted/80 transition-colors border-b border-border/30"
|
||||
:style="{
|
||||
top: filteredGlobalModels.length > 0 ? '36px' : '0px',
|
||||
bottom: '0px'
|
||||
}"
|
||||
@click="toggleGroupCollapse('upstream')"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<ChevronDown
|
||||
class="w-4 h-4 transition-transform shrink-0"
|
||||
:class="collapsedGroups.has(group.api_format) ? '-rotate-90' : ''"
|
||||
:class="collapsedGroups.has('upstream') ? '-rotate-90' : ''"
|
||||
/>
|
||||
<span class="text-xs font-medium">
|
||||
{{ API_FORMAT_LABELS[group.api_format] || group.api_format }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">({{ group.models.length }})</span>
|
||||
<span class="text-xs font-medium">上游模型</span>
|
||||
<span class="text-xs text-muted-foreground">({{ upstreamModelNames.length }})</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-primary hover:underline"
|
||||
@click.stop="toggleAllUpstreamGroup(group.api_format)"
|
||||
@click.stop="toggleAllUpstreamModels"
|
||||
>
|
||||
{{ isUpstreamGroupAllSelected(group.api_format) ? '取消全选' : '全选' }}
|
||||
{{ isAllUpstreamModelsSelected ? '取消全选' : '全选' }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- 内容 -->
|
||||
<div
|
||||
v-show="!collapsedGroups.has(group.api_format)"
|
||||
v-show="!collapsedGroups.has('upstream')"
|
||||
class="space-y-1 p-2"
|
||||
>
|
||||
<div
|
||||
v-for="model in group.models"
|
||||
:key="model.id"
|
||||
v-for="model in filteredUpstreamModels"
|
||||
:key="model"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
|
||||
@click="toggleModel(model.id)"
|
||||
@click="toggleModel(model)"
|
||||
>
|
||||
<div
|
||||
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
|
||||
:class="selectedModels.includes(model.id) ? 'bg-primary border-primary' : ''"
|
||||
:class="selectedModels.includes(model) ? 'bg-primary border-primary' : ''"
|
||||
>
|
||||
<Check
|
||||
v-if="selectedModels.includes(model.id)"
|
||||
v-if="selectedModels.includes(model)"
|
||||
class="w-3 h-3 text-primary-foreground"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm font-mono truncate">{{ model.id }}</span>
|
||||
<span class="text-sm font-mono truncate flex-1">{{ model }}</span>
|
||||
<button
|
||||
v-if="selectedModels.includes(model)"
|
||||
type="button"
|
||||
class="p-1 rounded hover:bg-muted-foreground/10 transition-colors shrink-0 text-muted-foreground"
|
||||
:title="isLocked(model) ? '已锁定 - 点击解锁' : '点击锁定(刷新时不会被删除)'"
|
||||
@click="toggleLock(model, $event)"
|
||||
>
|
||||
<Lock v-if="isLocked(model)" class="w-3.5 h-3.5" />
|
||||
<LockOpen v-else class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-if="filteredGlobalModels.length === 0 && filteredUpstreamGroups.length === 0 && customModels.length === 0"
|
||||
v-if="showEmptyState"
|
||||
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
|
||||
>
|
||||
<Shield class="w-10 h-10 mb-2 opacity-30" />
|
||||
<p class="text-sm">
|
||||
{{ searchQuery ? '无匹配结果' : '暂无可选模型' }}
|
||||
</p>
|
||||
<p
|
||||
v-if="!upstreamModelsLoaded"
|
||||
class="text-xs mt-1"
|
||||
>
|
||||
点击闪电按钮从上游获取模型
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -296,25 +306,24 @@ import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import {
|
||||
Shield,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
Zap,
|
||||
Plus,
|
||||
Check,
|
||||
ChevronDown
|
||||
ChevronDown,
|
||||
Lock,
|
||||
LockOpen
|
||||
} from 'lucide-vue-next'
|
||||
import { Dialog, Button, Input } from '@/components/ui'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { parseApiError, parseUpstreamModelError } from '@/utils/errorParser'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import {
|
||||
updateProviderKey,
|
||||
API_FORMAT_LABELS,
|
||||
type EndpointAPIKey,
|
||||
type AllowedModels,
|
||||
} from '@/api/endpoints'
|
||||
import { getGlobalModels, type GlobalModelResponse } from '@/api/global-models'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { useUpstreamModelsCache } from '../composables/useUpstreamModelsCache'
|
||||
import type { UpstreamModel } from '@/api/endpoints/types'
|
||||
|
||||
interface AvailableModel {
|
||||
@@ -335,6 +344,7 @@ const emit = defineEmits<{
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const { confirmWarning } = useConfirm()
|
||||
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
|
||||
|
||||
const isOpen = computed(() => props.open)
|
||||
const saving = ref(false)
|
||||
@@ -350,31 +360,52 @@ const searchQuery = ref('')
|
||||
|
||||
// 可用模型列表(全局模型)
|
||||
const allGlobalModels = ref<AvailableModel[]>([])
|
||||
// 上游模型列表
|
||||
// 上游模型列表(从 API 查询获取)
|
||||
const upstreamModels = ref<UpstreamModel[]>([])
|
||||
|
||||
// 已选中的模型
|
||||
const selectedModels = ref<string[]>([])
|
||||
const initialSelectedModels = ref<string[]>([])
|
||||
|
||||
// 已锁定的模型
|
||||
const lockedModels = ref<string[]>([])
|
||||
const initialLockedModels = ref<string[]>([])
|
||||
|
||||
// 所有添加过的自定义模型(包括已取消勾选的,保存前不消失)
|
||||
const allCustomModels = ref<string[]>([])
|
||||
|
||||
// 是否为字典模式(按 API 格式区分)
|
||||
const isDictMode = ref(false)
|
||||
|
||||
// 是否为自动获取模式
|
||||
const isAutoFetchMode = computed(() => props.apiKey?.auto_fetch_models ?? false)
|
||||
|
||||
// 空状态判断
|
||||
const showEmptyState = computed(() => {
|
||||
return filteredGlobalModels.value.length === 0 &&
|
||||
filteredUpstreamModels.value.length === 0 &&
|
||||
customModels.value.length === 0
|
||||
})
|
||||
|
||||
// 折叠状态
|
||||
const collapsedGroups = ref<Set<string>>(new Set())
|
||||
|
||||
// 是否有更改
|
||||
const hasChanges = computed(() => {
|
||||
// 检查选中模型是否有变化
|
||||
if (selectedModels.value.length !== initialSelectedModels.value.length) return true
|
||||
const sorted1 = [...selectedModels.value].sort()
|
||||
const sorted2 = [...initialSelectedModels.value].sort()
|
||||
return sorted1.some((v, i) => v !== sorted2[i])
|
||||
if (sorted1.some((v, i) => v !== sorted2[i])) return true
|
||||
|
||||
// 检查锁定模型是否有变化
|
||||
if (lockedModels.value.length !== initialLockedModels.value.length) return true
|
||||
const sortedLocked1 = [...lockedModels.value].sort()
|
||||
const sortedLocked2 = [...initialLockedModels.value].sort()
|
||||
return sortedLocked1.some((v, i) => v !== sortedLocked2[i])
|
||||
})
|
||||
|
||||
// 所有已知模型的集合(全局 + 上游)
|
||||
// 所有已知模型的集合(全局 + 上游模型)
|
||||
const allKnownModels = computed(() => {
|
||||
const set = new Set<string>()
|
||||
allGlobalModels.value.forEach(m => set.add(m.name))
|
||||
@@ -382,6 +413,58 @@ const allKnownModels = computed(() => {
|
||||
return set
|
||||
})
|
||||
|
||||
// 全局模型名称集合(用于判断模型是否为全局模型)
|
||||
const globalModelNamesSet = computed(() => {
|
||||
return new Set(allGlobalModels.value.map(m => m.name))
|
||||
})
|
||||
|
||||
// 判断模型是否为全局模型(提供商模型)
|
||||
function isGlobalModel(modelId: string): boolean {
|
||||
return globalModelNamesSet.value.has(modelId)
|
||||
}
|
||||
|
||||
// Key 支持的 API 格式
|
||||
const keyApiFormats = computed(() => props.apiKey?.api_formats ?? [])
|
||||
|
||||
// 上游模型名称列表(去重后)
|
||||
const upstreamModelNames = computed(() => {
|
||||
const names = new Set<string>()
|
||||
upstreamModels.value.forEach(m => {
|
||||
// 只包含 Key 支持的 API 格式的模型
|
||||
if (!m.api_format || keyApiFormats.value.includes(m.api_format)) {
|
||||
names.add(m.id)
|
||||
}
|
||||
})
|
||||
return Array.from(names).sort()
|
||||
})
|
||||
|
||||
// 过滤后的上游模型
|
||||
const filteredUpstreamModels = computed(() => {
|
||||
if (!searchQuery.value.trim()) return upstreamModelNames.value
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return upstreamModelNames.value.filter(m => m.toLowerCase().includes(query))
|
||||
})
|
||||
|
||||
// 上游模型是否全选
|
||||
const isAllUpstreamModelsSelected = computed(() => {
|
||||
if (filteredUpstreamModels.value.length === 0) return false
|
||||
return filteredUpstreamModels.value.every(m => selectedModels.value.includes(m))
|
||||
})
|
||||
|
||||
// 全选/取消全选上游模型
|
||||
function toggleAllUpstreamModels() {
|
||||
const allIds = filteredUpstreamModels.value
|
||||
if (isAllUpstreamModelsSelected.value) {
|
||||
selectedModels.value = selectedModels.value.filter(id => !allIds.includes(id))
|
||||
} else {
|
||||
allIds.forEach(id => {
|
||||
if (!selectedModels.value.includes(id)) {
|
||||
selectedModels.value.push(id)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义模型列表(显示所有添加过的,不因取消勾选而消失)
|
||||
const customModels = computed(() => {
|
||||
return allCustomModels.value
|
||||
@@ -415,7 +498,7 @@ const canAddAsCustom = computed(() => {
|
||||
// 精确匹配全局模型就不显示
|
||||
if (allGlobalModels.value.some(m => m.name === search)) return false
|
||||
// 精确匹配上游模型就不显示
|
||||
if (upstreamModels.value.some(m => m.id === search)) return false
|
||||
if (upstreamModelNames.value.includes(search)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -429,59 +512,53 @@ const filteredGlobalModels = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
// 按 API 格式分组的上游模型(过滤后)
|
||||
const filteredUpstreamGroups = computed(() => {
|
||||
if (!upstreamModelsLoaded.value) return []
|
||||
|
||||
const query = searchQuery.value.toLowerCase().trim()
|
||||
const groups: Record<string, UpstreamModel[]> = {}
|
||||
|
||||
for (const model of upstreamModels.value) {
|
||||
// 搜索过滤
|
||||
if (query && !model.id.toLowerCase().includes(query)) continue
|
||||
|
||||
const format = model.api_format || 'unknown'
|
||||
if (!groups[format]) groups[format] = []
|
||||
groups[format].push(model)
|
||||
}
|
||||
|
||||
const order = Object.keys(API_FORMAT_LABELS)
|
||||
return Object.entries(groups)
|
||||
.map(([api_format, models]) => ({ api_format, models }))
|
||||
.filter(g => g.models.length > 0)
|
||||
.sort((a, b) => {
|
||||
const aIndex = order.indexOf(a.api_format)
|
||||
const bIndex = order.indexOf(b.api_format)
|
||||
if (aIndex === -1 && bIndex === -1) return a.api_format.localeCompare(b.api_format)
|
||||
if (aIndex === -1) return 1
|
||||
if (bIndex === -1) return -1
|
||||
return aIndex - bIndex
|
||||
})
|
||||
})
|
||||
|
||||
// 全局模型是否全选
|
||||
const isAllGlobalModelsSelected = computed(() => {
|
||||
if (filteredGlobalModels.value.length === 0) return false
|
||||
return filteredGlobalModels.value.every(m => selectedModels.value.includes(m.name))
|
||||
})
|
||||
|
||||
// 检查某个上游组是否全选
|
||||
function isUpstreamGroupAllSelected(apiFormat: string): boolean {
|
||||
const group = filteredUpstreamGroups.value.find(g => g.api_format === apiFormat)
|
||||
if (!group || group.models.length === 0) return false
|
||||
return group.models.every(m => selectedModels.value.includes(m.id))
|
||||
}
|
||||
|
||||
// 切换模型选中状态
|
||||
function toggleModel(modelId: string) {
|
||||
const idx = selectedModels.value.indexOf(modelId)
|
||||
if (idx === -1) {
|
||||
selectedModels.value.push(modelId)
|
||||
// 自动获取模式下,勾选全局模型时自动锁定
|
||||
// 防止下次刷新时被覆盖(即使全局模型与上游模型同名)
|
||||
if (isAutoFetchMode.value && isGlobalModel(modelId)) {
|
||||
if (!lockedModels.value.includes(modelId)) {
|
||||
lockedModels.value.push(modelId)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
selectedModels.value.splice(idx, 1)
|
||||
// 取消选中时也取消锁定
|
||||
const lockIdx = lockedModels.value.indexOf(modelId)
|
||||
if (lockIdx !== -1) {
|
||||
lockedModels.value.splice(lockIdx, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 切换模型锁定状态
|
||||
function toggleLock(modelId: string, event: Event) {
|
||||
event.stopPropagation()
|
||||
// 只有已选中的模型才能锁定
|
||||
if (!selectedModels.value.includes(modelId)) return
|
||||
|
||||
const idx = lockedModels.value.indexOf(modelId)
|
||||
if (idx === -1) {
|
||||
lockedModels.value.push(modelId)
|
||||
} else {
|
||||
lockedModels.value.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查模型是否被锁定
|
||||
function isLocked(modelId: string): boolean {
|
||||
return lockedModels.value.includes(modelId)
|
||||
}
|
||||
|
||||
// 添加自定义模型
|
||||
function addCustomModel() {
|
||||
const model = searchQuery.value.trim()
|
||||
@@ -501,30 +578,17 @@ function toggleAllGlobalModels() {
|
||||
if (isAllGlobalModelsSelected.value) {
|
||||
// 取消全选
|
||||
selectedModels.value = selectedModels.value.filter(id => !allNames.includes(id))
|
||||
// 同时取消锁定
|
||||
lockedModels.value = lockedModels.value.filter(id => !allNames.includes(id))
|
||||
} else {
|
||||
// 全选
|
||||
allNames.forEach(name => {
|
||||
if (!selectedModels.value.includes(name)) {
|
||||
selectedModels.value.push(name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 全选/取消全选某个上游组
|
||||
function toggleAllUpstreamGroup(apiFormat: string) {
|
||||
const group = filteredUpstreamGroups.value.find(g => g.api_format === apiFormat)
|
||||
if (!group) return
|
||||
|
||||
const allIds = group.models.map(m => m.id)
|
||||
if (isUpstreamGroupAllSelected(apiFormat)) {
|
||||
// 取消全选
|
||||
selectedModels.value = selectedModels.value.filter(id => !allIds.includes(id))
|
||||
} else {
|
||||
// 全选
|
||||
allIds.forEach(id => {
|
||||
if (!selectedModels.value.includes(id)) {
|
||||
selectedModels.value.push(id)
|
||||
// 自动获取模式下,勾选全局模型时自动锁定
|
||||
if (isAutoFetchMode.value && !lockedModels.value.includes(name)) {
|
||||
lockedModels.value.push(name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -558,29 +622,22 @@ async function loadGlobalModels() {
|
||||
}
|
||||
}
|
||||
|
||||
// 从提供商获取模型
|
||||
// 从提供商获取模型(使用缓存)
|
||||
async function fetchUpstreamModels() {
|
||||
if (!props.providerId || !props.apiKey) return
|
||||
try {
|
||||
fetchingUpstreamModels.value = true
|
||||
const response = await adminApi.queryProviderModels(props.providerId, props.apiKey.id)
|
||||
const result = await fetchCachedModels(props.providerId, props.apiKey.id)
|
||||
if (loadingCancelled) return
|
||||
if (response.success && response.data?.models) {
|
||||
upstreamModels.value = response.data.models
|
||||
if (result.models.length > 0) {
|
||||
upstreamModels.value = result.models
|
||||
upstreamModelsLoaded.value = true
|
||||
// 获取上游模型后,从自定义模型列表中移除已变成已知的模型
|
||||
const upstreamIds = new Set(response.data.models.map((m: UpstreamModel) => m.id))
|
||||
const upstreamIds = new Set(result.models.map((m: UpstreamModel) => m.id))
|
||||
allCustomModels.value = allCustomModels.value.filter(m => !upstreamIds.has(m))
|
||||
} else {
|
||||
const errorMsg = response.data?.error
|
||||
? parseUpstreamModelError(response.data.error)
|
||||
: '获取上游模型失败'
|
||||
showError(errorMsg, '获取上游模型失败')
|
||||
} else if (result.error) {
|
||||
showError(result.error, '获取上游模型失败')
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (loadingCancelled) return
|
||||
const rawError = err.response?.data?.detail || err.message || '获取上游模型失败'
|
||||
showError(parseUpstreamModelError(rawError), '获取上游模型失败')
|
||||
} finally {
|
||||
fetchingUpstreamModels.value = false
|
||||
}
|
||||
@@ -613,16 +670,45 @@ watch(() => props.open, async (open) => {
|
||||
const parsed = parseAllowedModels(props.apiKey.allowed_models ?? null)
|
||||
selectedModels.value = [...parsed]
|
||||
initialSelectedModels.value = [...parsed]
|
||||
|
||||
// 加载锁定的模型
|
||||
const locked = props.apiKey.locked_models ?? []
|
||||
lockedModels.value = [...locked]
|
||||
initialLockedModels.value = [...locked]
|
||||
|
||||
searchQuery.value = ''
|
||||
upstreamModels.value = []
|
||||
upstreamModelsLoaded.value = false
|
||||
allCustomModels.value = []
|
||||
|
||||
// 默认全部收缩,自动获取模式下展开上游模型
|
||||
if (props.apiKey.auto_fetch_models) {
|
||||
collapsedGroups.value = new Set(['global', 'custom'])
|
||||
} else {
|
||||
collapsedGroups.value = new Set(['global', 'upstream', 'custom'])
|
||||
}
|
||||
|
||||
// 加载全局模型
|
||||
await loadGlobalModels()
|
||||
|
||||
// 加载全局模型后,从已选中的模型中提取自定义模型(不在全局模型中的)
|
||||
const globalModelNames = new Set(allGlobalModels.value.map(m => m.name))
|
||||
allCustomModels.value = selectedModels.value.filter(m => !globalModelNames.has(m))
|
||||
// 自动获取上游模型
|
||||
await fetchUpstreamModels()
|
||||
|
||||
// 自动获取模式下,用最新上游模型刷新(保留锁定的模型)
|
||||
if (props.apiKey.auto_fetch_models) {
|
||||
// 锁定的模型 + 最新上游模型(去重)
|
||||
const newSelected = new Set(lockedModels.value)
|
||||
upstreamModelNames.value.forEach(m => newSelected.add(m))
|
||||
selectedModels.value = Array.from(newSelected)
|
||||
initialSelectedModels.value = [...selectedModels.value]
|
||||
}
|
||||
|
||||
// 提取自定义模型(不在全局模型和上游模型中的)
|
||||
const upstreamModelIdsSet = new Set(upstreamModels.value.map(m => m.id))
|
||||
// 自定义模型是用户手动添加的、不在已知模型列表中的
|
||||
allCustomModels.value = selectedModels.value.filter(m =>
|
||||
!globalModelNamesSet.value.has(m) && !upstreamModelIdsSet.has(m)
|
||||
)
|
||||
} else {
|
||||
loadingCancelled = true
|
||||
}
|
||||
@@ -658,7 +744,13 @@ async function handleSave() {
|
||||
? [...selectedModels.value]
|
||||
: null
|
||||
|
||||
await updateProviderKey(props.apiKey.id, { allowed_models: newAllowed })
|
||||
// 只保存已选中且被锁定的模型
|
||||
const newLocked = lockedModels.value.filter(m => selectedModels.value.includes(m))
|
||||
|
||||
await updateProviderKey(props.apiKey.id, {
|
||||
allowed_models: newAllowed,
|
||||
locked_models: newLocked
|
||||
})
|
||||
success('模型权限已更新', '成功')
|
||||
emit('saved')
|
||||
emit('close')
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
@update:model-value="handleDialogUpdate"
|
||||
>
|
||||
<form
|
||||
class="space-y-4"
|
||||
class="space-y-3"
|
||||
autocomplete="off"
|
||||
@submit.prevent="handleSave"
|
||||
>
|
||||
@@ -219,6 +219,23 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自动获取模型 -->
|
||||
<div class="flex items-center justify-between py-2 px-3 rounded-md border border-border/60 bg-muted/30">
|
||||
<div class="space-y-0.5">
|
||||
<Label class="text-sm font-medium">自动获取模型</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
系统将定时从上游获取可用模型, 但无法默认接受提供商模型
|
||||
</p>
|
||||
<p
|
||||
v-if="showAutoFetchWarning"
|
||||
class="text-xs text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
已配置的模型权限将在下次获取时被覆盖
|
||||
</p>
|
||||
</div>
|
||||
<Switch v-model="form.auto_fetch_models" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
@@ -240,7 +257,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { Dialog, Button, Input, Label } from '@/components/ui'
|
||||
import { Dialog, Button, Input, Label, Switch } from '@/components/ui'
|
||||
import { Key, SquarePen } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useFormDialog } from '@/composables/useFormDialog'
|
||||
@@ -277,6 +294,21 @@ const { success, error: showError } = useToast()
|
||||
// 排序后的可用 API 格式列表
|
||||
const sortedApiFormats = computed(() => sortApiFormats(props.availableApiFormats))
|
||||
|
||||
// 显示自动获取模型警告:编辑模式下,原本未启用但现在启用,且已有 allowed_models
|
||||
const showAutoFetchWarning = computed(() => {
|
||||
if (!props.editingKey) return false
|
||||
// 原本已启用,不需要警告
|
||||
if (props.editingKey.auto_fetch_models) return false
|
||||
// 现在未启用,不需要警告
|
||||
if (!form.value.auto_fetch_models) return false
|
||||
// 检查是否有已配置的模型权限
|
||||
const allowedModels = props.editingKey.allowed_models
|
||||
if (!allowedModels) return false
|
||||
if (Array.isArray(allowedModels) && allowedModels.length === 0) return false
|
||||
if (typeof allowedModels === 'object' && Object.keys(allowedModels).length === 0) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const isOpen = computed(() => props.open)
|
||||
const saving = ref(false)
|
||||
const formNonce = ref(createFieldNonce())
|
||||
@@ -303,7 +335,8 @@ const form = ref({
|
||||
max_probe_interval_minutes: 32,
|
||||
note: '',
|
||||
is_active: true,
|
||||
capabilities: {} as Record<string, boolean>
|
||||
capabilities: {} as Record<string, boolean>,
|
||||
auto_fetch_models: false
|
||||
})
|
||||
|
||||
// 加载能力列表
|
||||
@@ -404,7 +437,8 @@ function resetForm() {
|
||||
max_probe_interval_minutes: 32,
|
||||
note: '',
|
||||
is_active: true,
|
||||
capabilities: {}
|
||||
capabilities: {},
|
||||
auto_fetch_models: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,7 +469,8 @@ function loadKeyData() {
|
||||
max_probe_interval_minutes: props.editingKey.max_probe_interval_minutes ?? 32,
|
||||
note: props.editingKey.note || '',
|
||||
is_active: props.editingKey.is_active,
|
||||
capabilities: { ...(props.editingKey.capabilities || {}) }
|
||||
capabilities: { ...(props.editingKey.capabilities || {}) },
|
||||
auto_fetch_models: props.editingKey.auto_fetch_models ?? false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,7 +549,8 @@ async function handleSave() {
|
||||
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
|
||||
note: form.value.note,
|
||||
is_active: form.value.is_active,
|
||||
capabilities: capabilitiesData
|
||||
capabilities: capabilitiesData,
|
||||
auto_fetch_models: form.value.auto_fetch_models
|
||||
}
|
||||
|
||||
if (form.value.api_key.trim()) {
|
||||
@@ -535,7 +571,8 @@ async function handleSave() {
|
||||
cache_ttl_minutes: form.value.cache_ttl_minutes,
|
||||
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
|
||||
note: form.value.note,
|
||||
capabilities: capabilitiesData || undefined
|
||||
capabilities: capabilitiesData || undefined,
|
||||
auto_fetch_models: form.value.auto_fetch_models
|
||||
})
|
||||
success('密钥已添加', '成功')
|
||||
// 添加模式:不关闭对话框,只清除名称和密钥以便继续添加
|
||||
|
||||
@@ -309,6 +309,17 @@
|
||||
@keydown="(e) => handlePriorityKeydown(e, key)"
|
||||
@blur="handlePriorityBlur(key)"
|
||||
>
|
||||
<!-- 自动获取模型状态 -->
|
||||
<template v-if="key.auto_fetch_models">
|
||||
<span class="text-muted-foreground/40">|</span>
|
||||
<span
|
||||
class="cursor-help"
|
||||
:class="key.last_models_fetch_error ? 'text-amber-600 dark:text-amber-400' : ''"
|
||||
:title="getAutoFetchStatusTitle(key)"
|
||||
>
|
||||
{{ key.last_models_fetch_error ? '同步失败' : '自动同步' }}
|
||||
</span>
|
||||
</template>
|
||||
<!-- RPM 限制信息(第二位) -->
|
||||
<template v-if="key.rpm_limit || key.is_adaptive">
|
||||
<span class="text-muted-foreground/40">|</span>
|
||||
@@ -1596,6 +1607,22 @@ function getHealthScoreBarColor(score: number): string {
|
||||
return 'bg-red-500 dark:bg-red-400'
|
||||
}
|
||||
|
||||
// 获取自动获取模型状态的 title 提示
|
||||
function getAutoFetchStatusTitle(key: EndpointAPIKey): string {
|
||||
const parts: string[] = ['自动获取模型已启用']
|
||||
|
||||
if (key.last_models_fetch_at) {
|
||||
const date = new Date(key.last_models_fetch_at)
|
||||
parts.push(`上次同步: ${date.toLocaleString()}`)
|
||||
}
|
||||
|
||||
if (key.last_models_fetch_error) {
|
||||
parts.push(`错误: ${key.last_models_fetch_error}`)
|
||||
}
|
||||
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
// 检查指定格式是否熔断
|
||||
function isFormatCircuitOpen(key: EndpointAPIKey, format: string): boolean {
|
||||
if (!key.circuit_breaker_by_format) return false
|
||||
|
||||
@@ -61,9 +61,11 @@
|
||||
/>
|
||||
<!-- 模型信息 -->
|
||||
<div class="text-left flex-1 min-w-0">
|
||||
<span class="font-semibold text-sm">
|
||||
{{ model.global_model_display_name || model.provider_model_name }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-semibold text-sm">
|
||||
{{ model.global_model_display_name || model.provider_model_name }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground mt-1 flex items-center gap-1">
|
||||
<span class="font-mono truncate">{{ model.provider_model_name }}</span>
|
||||
<button
|
||||
|
||||
@@ -17,6 +17,7 @@ interface CacheEntry {
|
||||
type FetchResult = { models: UpstreamModel[]; error?: string }
|
||||
|
||||
// 全局缓存(模块级别,所有组件共享)
|
||||
// 支持两种 key: providerId 或 providerId:apiKeyId
|
||||
const cache = new Map<string, CacheEntry>()
|
||||
const CACHE_TTL = 5 * 60 * 1000 // 5分钟
|
||||
|
||||
@@ -26,39 +27,50 @@ const pendingRequests = new Map<string, Promise<FetchResult>>()
|
||||
// 请求状态
|
||||
const loadingMap = ref<Map<string, boolean>>(new Map())
|
||||
|
||||
/**
|
||||
* 生成缓存 key
|
||||
*/
|
||||
function getCacheKey(providerId: string, apiKeyId?: string): string {
|
||||
return apiKeyId ? `${providerId}:${apiKeyId}` : providerId
|
||||
}
|
||||
|
||||
export function useUpstreamModelsCache() {
|
||||
/**
|
||||
* 获取上游模型列表
|
||||
* @param providerId 提供商ID
|
||||
* @param apiKeyId 可选的 API Key ID(用于获取特定 Key 支持的模型)
|
||||
* @param forceRefresh 是否强制刷新
|
||||
* @returns 模型列表或 null(如果请求失败)
|
||||
*/
|
||||
async function fetchModels(
|
||||
providerId: string,
|
||||
apiKeyId?: string,
|
||||
forceRefresh = false
|
||||
): Promise<FetchResult> {
|
||||
const cacheKey = getCacheKey(providerId, apiKeyId)
|
||||
|
||||
// 检查缓存
|
||||
if (!forceRefresh) {
|
||||
const cached = cache.get(providerId)
|
||||
const cached = cache.get(cacheKey)
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||
return { models: cached.models }
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有进行中的请求(非强制刷新时复用)
|
||||
if (!forceRefresh && pendingRequests.has(providerId)) {
|
||||
return pendingRequests.get(providerId)!
|
||||
if (!forceRefresh && pendingRequests.has(cacheKey)) {
|
||||
return pendingRequests.get(cacheKey)!
|
||||
}
|
||||
|
||||
// 创建新请求
|
||||
const requestPromise = (async (): Promise<FetchResult> => {
|
||||
try {
|
||||
loadingMap.value.set(providerId, true)
|
||||
const response = await adminApi.queryProviderModels(providerId)
|
||||
loadingMap.value.set(cacheKey, true)
|
||||
const response = await adminApi.queryProviderModels(providerId, apiKeyId)
|
||||
|
||||
if (response.success && response.data?.models) {
|
||||
// 存入缓存
|
||||
cache.set(providerId, {
|
||||
cache.set(cacheKey, {
|
||||
models: response.data.models,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
@@ -73,20 +85,21 @@ export function useUpstreamModelsCache() {
|
||||
const rawError = err.response?.data?.detail || err.message || '获取上游模型失败'
|
||||
return { models: [], error: parseUpstreamModelError(rawError) }
|
||||
} finally {
|
||||
loadingMap.value.set(providerId, false)
|
||||
pendingRequests.delete(providerId)
|
||||
loadingMap.value.set(cacheKey, false)
|
||||
pendingRequests.delete(cacheKey)
|
||||
}
|
||||
})()
|
||||
|
||||
pendingRequests.set(providerId, requestPromise)
|
||||
pendingRequests.set(cacheKey, requestPromise)
|
||||
return requestPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的模型(不发起请求)
|
||||
*/
|
||||
function getCachedModels(providerId: string): UpstreamModel[] | null {
|
||||
const cached = cache.get(providerId)
|
||||
function getCachedModels(providerId: string, apiKeyId?: string): UpstreamModel[] | null {
|
||||
const cacheKey = getCacheKey(providerId, apiKeyId)
|
||||
const cached = cache.get(cacheKey)
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||
return cached.models
|
||||
}
|
||||
@@ -94,17 +107,19 @@ export function useUpstreamModelsCache() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定提供商的缓存
|
||||
* 清除指定提供商/Key的缓存
|
||||
*/
|
||||
function clearCache(providerId: string) {
|
||||
cache.delete(providerId)
|
||||
function clearCache(providerId: string, apiKeyId?: string) {
|
||||
const cacheKey = getCacheKey(providerId, apiKeyId)
|
||||
cache.delete(cacheKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否正在加载
|
||||
*/
|
||||
function isLoading(providerId: string): boolean {
|
||||
return loadingMap.value.get(providerId) || false
|
||||
function isLoading(providerId: string, apiKeyId?: string): boolean {
|
||||
const cacheKey = getCacheKey(providerId, apiKeyId)
|
||||
return loadingMap.value.get(cacheKey) || false
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user