mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat: 缓存计费细分、能力匹配优化、用户模型调用计数
1. 缓存创建 tokens 区分 5min/1h TTL,支持按缓存时长差异化计费 - Usage 表新增 cache_creation_input_tokens_5m/1h 字段 - Claude handler 解析新格式 (ephemeral_5m/1h, claude_cache_creation_5/1h) - 计费规则支持 cache_ttl_pricing 覆盖 cache_creation 价格 2. 能力匹配机制优化 - COMPATIBLE 能力不再硬过滤,改为排序阶段通过 capability_miss_count 优先级处理 - cache_1h 改为 COMPATIBLE + REQUEST_PARAM(自动检测请求体中的 ttl=1h) - gemini_files 改为 EXCLUSIVE + REQUEST_PARAM(自动检测 fileData.fileUri) - 移除前端模型偏好/能力配置 UI(不再需要用户手动配置) 3. 新增用户-模型维度调用次数计数器 (UserModelUsageCount) - 原子递增,避免从 Usage 表聚合查询 - 前端模型目录和用户可用模型列表展示调用次数 4. 其他改进 - global_model_id 改为必填(NOT NULL),清理孤立模型 - 模型映射对话框支持从上游获取模型列表并分组折叠 - 端点测试不再依赖端点启用状态 - 异步任务页面对普通用户隐藏用户信息列 - Dashboard 响应式布局断点调整 (sm -> lg) - 号池管理仅展示已启用号池的提供商
This commit is contained in:
@@ -26,7 +26,7 @@ export interface TieredPricingConfig {
|
||||
export interface Model {
|
||||
id: string
|
||||
provider_id: string
|
||||
global_model_id?: string // 关联的 GlobalModel ID
|
||||
global_model_id: string // 关联的 GlobalModel ID
|
||||
provider_model_name: string // Provider 侧的主模型名称
|
||||
provider_model_mappings?: ProviderModelMapping[] | null // 模型名称映射列表(带优先级)
|
||||
config?: Record<string, unknown> | null // 额外配置(如 billing/video 等)
|
||||
@@ -251,8 +251,8 @@ export interface UpstreamModel {
|
||||
export interface ImportFromUpstreamSuccessItem {
|
||||
model_id: string
|
||||
provider_model_id: string
|
||||
global_model_id?: string // 可选,未关联时为空字符串
|
||||
global_model_name?: string // 可选,未关联时为空字符串
|
||||
global_model_id: string
|
||||
global_model_name: string
|
||||
created_global_model: boolean // 始终为 false(不再自动创建 GlobalModel)
|
||||
}
|
||||
|
||||
|
||||
@@ -257,6 +257,7 @@ export const meApi = {
|
||||
default_tiered_pricing: TieredPricingConfig | null
|
||||
supported_capabilities: string[] | null
|
||||
config: Record<string, unknown> | null
|
||||
usage_count: number
|
||||
}>
|
||||
total: number
|
||||
}> {
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface PublicGlobalModel {
|
||||
supported_capabilities: string[] | null
|
||||
// 模型配置(JSON)
|
||||
config: Record<string, unknown> | null
|
||||
// 调用次数
|
||||
usage_count: number
|
||||
}
|
||||
|
||||
export interface PublicGlobalModelListResponse {
|
||||
|
||||
@@ -139,26 +139,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型偏好 -->
|
||||
<div
|
||||
v-if="model.supported_capabilities && model.supported_capabilities.length > 0"
|
||||
class="space-y-3"
|
||||
>
|
||||
<h4 class="font-semibold text-sm">
|
||||
模型偏好
|
||||
</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
v-for="cap in model.supported_capabilities"
|
||||
:key="cap"
|
||||
variant="outline"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ getCapabilityDisplayName(cap) }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 默认定价 -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-semibold text-sm">
|
||||
@@ -475,12 +455,10 @@ import { getGlobalModelRoutingPreview } from '@/api/global-models'
|
||||
// 使用外部类型定义
|
||||
import type { GlobalModelResponse } from '@/api/global-models'
|
||||
import type { TieredPricingConfig, PricingTier, ModelRoutingPreviewResponse } from '@/api/endpoints/types'
|
||||
import type { CapabilityDefinition } from '@/api/endpoints'
|
||||
import type { RoutingProviderInfo } from '@/api/global-models'
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
hasBlockingDialogOpen: false,
|
||||
capabilities: () => [],
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
@@ -500,7 +478,6 @@ interface Props {
|
||||
model: GlobalModelResponse | null
|
||||
open: boolean
|
||||
hasBlockingDialogOpen?: boolean
|
||||
capabilities?: CapabilityDefinition[]
|
||||
}
|
||||
|
||||
// RoutingTab 引用
|
||||
@@ -571,12 +548,6 @@ defineExpose({
|
||||
refreshRoutingData
|
||||
})
|
||||
|
||||
// 根据能力名称获取显示名称
|
||||
function getCapabilityDisplayName(capName: string): string {
|
||||
const cap = props.capabilities?.find(c => c.name === capName)
|
||||
return cap?.display_name || capName
|
||||
}
|
||||
|
||||
// 检测是否有视频分辨率计费配置
|
||||
const hasVideoPricing = computed(() => {
|
||||
const priceByResolution = props.model?.config?.billing?.video?.price_per_second_by_resolution
|
||||
|
||||
@@ -179,7 +179,6 @@ const searchQuery = ref('')
|
||||
const existingGlobalModelIds = computed(() => {
|
||||
return new Set(
|
||||
existingModels.value
|
||||
.filter(m => m.global_model_id)
|
||||
.map(m => m.global_model_id)
|
||||
)
|
||||
})
|
||||
|
||||
@@ -63,6 +63,33 @@
|
||||
>
|
||||
已选 {{ selectedNames.length }} 个
|
||||
</span>
|
||||
<!-- 刷新上游模型按钮 -->
|
||||
<button
|
||||
v-if="upstreamModelsLoaded"
|
||||
type="button"
|
||||
class="p-2 hover:bg-muted rounded-md transition-colors shrink-0"
|
||||
:disabled="fetchingUpstreamModels"
|
||||
title="刷新上游模型"
|
||||
@click="fetchUpstreamModels()"
|
||||
>
|
||||
<RefreshCw
|
||||
class="w-4 h-4"
|
||||
:class="{ 'animate-spin': fetchingUpstreamModels }"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="!fetchingUpstreamModels"
|
||||
type="button"
|
||||
class="p-2 hover:bg-muted rounded-md transition-colors shrink-0"
|
||||
title="从提供商获取模型"
|
||||
@click="fetchUpstreamModels()"
|
||||
>
|
||||
<Zap class="w-4 h-4" />
|
||||
</button>
|
||||
<Loader2
|
||||
v-else
|
||||
class="w-4 h-4 animate-spin text-muted-foreground shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 模型列表 -->
|
||||
@@ -97,14 +124,22 @@
|
||||
<!-- 自定义映射名称 -->
|
||||
<div v-if="customNames.length > 0">
|
||||
<div
|
||||
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-20"
|
||||
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-20 cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
@click="toggleGroupCollapse('custom')"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<ChevronDown
|
||||
class="w-4 h-4 transition-transform shrink-0"
|
||||
:class="collapsedGroups.has('custom') ? '-rotate-90' : ''"
|
||||
/>
|
||||
<span class="text-xs font-medium">自定义模型</span>
|
||||
<span class="text-xs text-muted-foreground">({{ customNames.length }})</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1 p-2">
|
||||
<div
|
||||
v-show="!collapsedGroups.has('custom')"
|
||||
class="space-y-1 p-2"
|
||||
>
|
||||
<div
|
||||
v-for="name in sortedCustomNames"
|
||||
:key="name"
|
||||
@@ -125,6 +160,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上游模型 -->
|
||||
<template v-if="filteredUpstreamModels.length > 0">
|
||||
<div
|
||||
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-20 cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
@click="toggleGroupCollapse('upstream')"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<ChevronDown
|
||||
class="w-4 h-4 transition-transform shrink-0"
|
||||
:class="collapsedGroups.has('upstream') ? '-rotate-90' : ''"
|
||||
/>
|
||||
<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="toggleAllUpstreamModels"
|
||||
>
|
||||
{{ isAllUpstreamModelsSelected ? '取消全选' : '全选' }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-show="!collapsedGroups.has('upstream')"
|
||||
class="space-y-1 p-2"
|
||||
>
|
||||
<div
|
||||
v-for="name in filteredUpstreamModels"
|
||||
:key="name"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
|
||||
@click="toggleName(name)"
|
||||
>
|
||||
<div
|
||||
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
|
||||
:class="selectedNames.includes(name) ? 'bg-primary border-primary' : ''"
|
||||
>
|
||||
<Check
|
||||
v-if="selectedNames.includes(name)"
|
||||
class="w-3 h-3 text-primary-foreground"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm font-mono truncate flex-1">{{ name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-if="showEmptyState"
|
||||
@@ -167,7 +248,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Tag, Loader2, Plus, Search, Check } from 'lucide-vue-next'
|
||||
import { Tag, Loader2, Plus, Search, Check, ChevronDown, RefreshCw, Zap } from 'lucide-vue-next'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
@@ -184,8 +265,10 @@ import { parseApiError } from '@/utils/errorParser'
|
||||
import {
|
||||
type Model,
|
||||
type ProviderModelAlias,
|
||||
type UpstreamModel,
|
||||
} from '@/api/endpoints'
|
||||
import { updateModel } from '@/api/endpoints/models'
|
||||
import { useUpstreamModelsCache } from '../composables/useUpstreamModelsCache'
|
||||
|
||||
export interface AliasGroup {
|
||||
model: Model
|
||||
@@ -204,6 +287,7 @@ const props = defineProps<{
|
||||
models: Model[]
|
||||
editingGroup?: AliasGroup | null
|
||||
preselectedModelId?: string | null
|
||||
hasAutoFetchKey?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -212,14 +296,23 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
|
||||
|
||||
// 状态
|
||||
const submitting = ref(false)
|
||||
const loadingModels = ref(false)
|
||||
const fetchingUpstreamModels = ref(false)
|
||||
const upstreamModelsLoaded = ref(false)
|
||||
|
||||
// 搜索
|
||||
const searchQuery = ref('')
|
||||
|
||||
// 折叠状态
|
||||
const collapsedGroups = ref<Set<string>>(new Set())
|
||||
|
||||
// 上游模型
|
||||
const upstreamModels = ref<UpstreamModel[]>([])
|
||||
|
||||
// 表单数据
|
||||
const formData = ref<{
|
||||
modelId: string
|
||||
@@ -233,9 +326,26 @@ const selectedNames = ref<string[]>([])
|
||||
// 自定义名称列表(手动添加的)
|
||||
const allCustomNames = ref<string[]>([])
|
||||
|
||||
// 自定义名称列表
|
||||
// 所有已知名称集合
|
||||
const allKnownNames = computed(() => {
|
||||
const set = new Set<string>()
|
||||
upstreamModels.value.forEach(m => set.add(m.id))
|
||||
return set
|
||||
})
|
||||
|
||||
// 上游模型名称列表(去重后)
|
||||
const upstreamModelNames = computed(() => {
|
||||
const names = new Set<string>()
|
||||
upstreamModels.value.forEach(m => {
|
||||
names.add(m.id)
|
||||
})
|
||||
return Array.from(names).sort()
|
||||
})
|
||||
|
||||
// 自定义名称列表(排除上游模型中已有的)
|
||||
const customNames = computed(() => {
|
||||
return allCustomNames.value
|
||||
const upstreamSet = new Set(upstreamModelNames.value)
|
||||
return allCustomNames.value.filter(name => !upstreamSet.has(name))
|
||||
})
|
||||
|
||||
// 排序后的自定义名称
|
||||
@@ -261,12 +371,26 @@ const canAddAsCustom = computed(() => {
|
||||
if (!search) return false
|
||||
if (selectedNames.value.includes(search)) return false
|
||||
if (allCustomNames.value.includes(search)) return false
|
||||
if (allKnownNames.value.has(search)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
// 过滤后的上游模型
|
||||
const filteredUpstreamModels = computed(() => {
|
||||
if (!searchQuery.value.trim()) return upstreamModelNames.value
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return upstreamModelNames.value.filter(name => name.toLowerCase().includes(query))
|
||||
})
|
||||
|
||||
// 空状态判断
|
||||
const showEmptyState = computed(() => {
|
||||
return customNames.value.length === 0
|
||||
return filteredUpstreamModels.value.length === 0 && customNames.value.length === 0
|
||||
})
|
||||
|
||||
// 上游模型是否全选
|
||||
const isAllUpstreamModelsSelected = computed(() => {
|
||||
if (filteredUpstreamModels.value.length === 0) return false
|
||||
return filteredUpstreamModels.value.every(name => selectedNames.value.includes(name))
|
||||
})
|
||||
|
||||
// 切换名称选中状态
|
||||
@@ -284,17 +408,71 @@ function addCustomName() {
|
||||
const name = searchQuery.value.trim()
|
||||
if (name && !selectedNames.value.includes(name)) {
|
||||
selectedNames.value.push(name)
|
||||
if (!allCustomNames.value.includes(name)) {
|
||||
if (!allKnownNames.value.has(name) && !allCustomNames.value.includes(name)) {
|
||||
allCustomNames.value.push(name)
|
||||
}
|
||||
searchQuery.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 全选/取消全选上游模型
|
||||
function toggleAllUpstreamModels() {
|
||||
const allNames = filteredUpstreamModels.value
|
||||
if (isAllUpstreamModelsSelected.value) {
|
||||
selectedNames.value = selectedNames.value.filter(name => !allNames.includes(name))
|
||||
} else {
|
||||
allNames.forEach(name => {
|
||||
if (!selectedNames.value.includes(name)) {
|
||||
selectedNames.value.push(name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 切换折叠状态
|
||||
function toggleGroupCollapse(group: string) {
|
||||
if (collapsedGroups.value.has(group)) {
|
||||
collapsedGroups.value.delete(group)
|
||||
} else {
|
||||
collapsedGroups.value.add(group)
|
||||
}
|
||||
collapsedGroups.value = new Set(collapsedGroups.value)
|
||||
}
|
||||
|
||||
// 从提供商获取模型(使用缓存)
|
||||
async function fetchUpstreamModels() {
|
||||
if (!props.providerId) return
|
||||
try {
|
||||
loadingModels.value = true
|
||||
fetchingUpstreamModels.value = true
|
||||
const result = await fetchCachedModels(props.providerId)
|
||||
if (result.models.length > 0) {
|
||||
upstreamModels.value = result.models
|
||||
upstreamModelsLoaded.value = true
|
||||
// 获取上游模型后,将不在上游列表中的已选名称添加到自定义列表
|
||||
const upstreamIds = new Set(result.models.map(m => m.id))
|
||||
const customFromSelected = selectedNames.value.filter(name => !upstreamIds.has(name))
|
||||
const mergedCustom = new Set([...allCustomNames.value, ...customFromSelected])
|
||||
allCustomNames.value = Array.from(mergedCustom).filter(name => !upstreamIds.has(name))
|
||||
}
|
||||
if (result.error) {
|
||||
showError(result.error, '获取上游模型失败')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
showError(parseApiError(err, '获取上游模型列表失败'), '错误')
|
||||
} finally {
|
||||
loadingModels.value = false
|
||||
fetchingUpstreamModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听打开状态
|
||||
watch(() => props.open, async (isOpen) => {
|
||||
if (isOpen) {
|
||||
initForm()
|
||||
if (props.hasAutoFetchKey) {
|
||||
await fetchUpstreamModels()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -315,6 +493,9 @@ function initForm() {
|
||||
allCustomNames.value = []
|
||||
}
|
||||
searchQuery.value = ''
|
||||
upstreamModels.value = []
|
||||
upstreamModelsLoaded.value = false
|
||||
collapsedGroups.value = new Set()
|
||||
}
|
||||
|
||||
// 处理模型选择变更
|
||||
|
||||
@@ -541,7 +541,7 @@ async function loadAvailableGlobalModels() {
|
||||
|
||||
// 获取当前 provider 已添加的模型的 global_model_id 列表
|
||||
const existingGlobalModelIds = new Set(
|
||||
existingModels.map((m: Model) => m.global_model_id).filter(Boolean)
|
||||
existingModels.map((m: Model) => m.global_model_id)
|
||||
)
|
||||
|
||||
// 过滤掉已添加的模型
|
||||
|
||||
@@ -325,6 +325,7 @@
|
||||
:models="models"
|
||||
:editing-group="editingGroup"
|
||||
:preselected-model-id="preselectedModelId"
|
||||
:has-auto-fetch-key="hasAutoFetchKey"
|
||||
@saved="onDialogSaved"
|
||||
/>
|
||||
|
||||
@@ -420,6 +421,11 @@ const aliasMappingPreview = computed(() => props.mappingPreview ?? null)
|
||||
const providerEndpoints = computed(() => props.endpoints ?? [])
|
||||
const providerKeysState = computed(() => props.providerKeys ?? [])
|
||||
|
||||
// 是否有 key 配置了自动获取上游模型
|
||||
const hasAutoFetchKey = computed(() => {
|
||||
return providerKeysState.value.some(k => k.auto_fetch_models)
|
||||
})
|
||||
|
||||
// 展开状态
|
||||
const expandedItems = ref<Set<string>>(new Set())
|
||||
|
||||
@@ -641,18 +647,17 @@ async function onDialogSaved() {
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
// 获取可用的 API 格式(有活跃端点,去重)
|
||||
// 获取可用的 API 格式(所有端点,去重;测试只关注 Key 是否支持,不依赖端点启用状态)
|
||||
const availableApiFormats = computed(() => {
|
||||
const formats = new Set(
|
||||
providerEndpoints.value
|
||||
.filter(ep => ep.is_active)
|
||||
.map(ep => ep.api_format)
|
||||
)
|
||||
return [...formats]
|
||||
})
|
||||
|
||||
// 获取映射项支持的 API 格式
|
||||
// 逻辑:找到支持该映射格式的所有活跃 Key,获取这些 Key 支持的所有格式,与活跃端点格式取交集
|
||||
// 逻辑:找到支持该映射格式的所有活跃 Key,获取这些 Key 支持的所有格式,与端点格式取交集
|
||||
function getItemAvailableFormats(item: CombinedMapping): string[] {
|
||||
// 精确映射:基于 group.apiFormats 筛选
|
||||
if (item.type === 'exact' && item.group?.apiFormats && item.group.apiFormats.length > 0) {
|
||||
@@ -677,7 +682,7 @@ function getItemAvailableFormats(item: CombinedMapping): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
// 与活跃端点格式取交集
|
||||
// 与端点格式取交集
|
||||
return availableApiFormats.value.filter(fmt => keyFormats.has(fmt))
|
||||
}
|
||||
|
||||
|
||||
@@ -281,6 +281,14 @@
|
||||
<span class="text-xs font-mono">${{ (detail.cache_read_cost || 0).toFixed(6) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 缓存创建 5m/1h 细分 -->
|
||||
<div
|
||||
v-if="(detail.cache_creation_input_tokens_5m || 0) > 0 || (detail.cache_creation_input_tokens_1h || 0) > 0"
|
||||
class="flex items-center pl-[56px]"
|
||||
>
|
||||
<span class="text-xs text-muted-foreground/50">5min: {{ detail.cache_creation_input_tokens_5m || 0 }}</span>
|
||||
<span class="text-xs text-muted-foreground/50 ml-4">1h: {{ detail.cache_creation_input_tokens_1h || 0 }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -502,6 +502,7 @@ const navigation = computed(() => {
|
||||
title: '账户',
|
||||
items: [
|
||||
{ name: '使用统计', href: '/dashboard/usage', icon: BarChart3 },
|
||||
{ name: '异步任务', href: '/dashboard/async-tasks', icon: Zap },
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -126,6 +126,11 @@ const routes: RouteRecordRaw[] = [
|
||||
path: 'models',
|
||||
name: 'ModelCatalog',
|
||||
component: () => importWithRetry(() => import('@/views/user/ModelCatalog.vue'))
|
||||
},
|
||||
{
|
||||
path: 'async-tasks',
|
||||
name: 'UserAsyncTasks',
|
||||
component: () => importWithRetry(() => import('@/views/admin/AsyncTasks.vue'))
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -133,6 +133,16 @@ export function formatCost(cost: number | null | undefined): string {
|
||||
return `$${cost.toFixed(4)}`
|
||||
}
|
||||
|
||||
// Usage count formatting (compact display for large numbers)
|
||||
export function formatUsageCount(count: number): string {
|
||||
if (count >= 1000000) {
|
||||
return `${(count / 1000000).toFixed(1)}M`
|
||||
} else if (count >= 1000) {
|
||||
return `${(count / 1000).toFixed(1)}K`
|
||||
}
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
// Format remaining time from unix timestamp
|
||||
export function formatRemainingTime(expireAt: number | undefined, currentTime: number): string {
|
||||
if (!expireAt) return '未知'
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
任务
|
||||
</TableHead>
|
||||
<TableHead class="w-[15%]">
|
||||
用户/Provider
|
||||
{{ isAdmin ? '用户/Provider' : 'Provider' }}
|
||||
</TableHead>
|
||||
<TableHead class="w-[12%]">
|
||||
状态
|
||||
@@ -220,7 +220,10 @@
|
||||
<!-- 用户/Provider -->
|
||||
<TableCell>
|
||||
<div class="space-y-0.5 text-sm">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div
|
||||
v-if="isAdmin"
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<User class="w-3 h-3 text-muted-foreground" />
|
||||
<span class="truncate max-w-[100px]">{{ task.username }}</span>
|
||||
</div>
|
||||
@@ -367,7 +370,10 @@
|
||||
|
||||
<!-- 信息网格 -->
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
<div class="flex items-center gap-1.5 text-muted-foreground">
|
||||
<div
|
||||
v-if="isAdmin"
|
||||
class="flex items-center gap-1.5 text-muted-foreground"
|
||||
>
|
||||
<User class="w-3 h-3" />
|
||||
<span class="truncate">{{ task.username }}</span>
|
||||
</div>
|
||||
@@ -492,8 +498,10 @@
|
||||
</span>
|
||||
<span class="opacity-40">|</span>
|
||||
<span>{{ formatDateFull(selectedTask.created_at) }}</span>
|
||||
<span class="opacity-40">|</span>
|
||||
<span>用户: {{ selectedTask.username }}</span>
|
||||
<template v-if="isAdmin">
|
||||
<span class="opacity-40">|</span>
|
||||
<span>用户: {{ selectedTask.username }}</span>
|
||||
</template>
|
||||
<span class="opacity-40">|</span>
|
||||
<span>Provider: {{ selectedTask.provider_name }}</span>
|
||||
</div>
|
||||
@@ -882,8 +890,11 @@ import {
|
||||
ExternalLink,
|
||||
Copy,
|
||||
} from 'lucide-vue-next'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const isAdmin = computed(() => authStore.user?.role === 'admin')
|
||||
const { toast } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
|
||||
@@ -59,9 +59,6 @@
|
||||
<TableHead class="w-[240px]">
|
||||
模型名称
|
||||
</TableHead>
|
||||
<TableHead class="w-[140px]">
|
||||
模型偏好
|
||||
</TableHead>
|
||||
<TableHead class="w-[160px] text-center">
|
||||
价格 ($/M)
|
||||
</TableHead>
|
||||
@@ -82,7 +79,7 @@
|
||||
<TableBody>
|
||||
<TableRow v-if="loading">
|
||||
<TableCell
|
||||
colspan="7"
|
||||
colspan="6"
|
||||
class="text-center py-8"
|
||||
>
|
||||
<Loader2 class="w-6 h-6 animate-spin mx-auto" />
|
||||
@@ -90,7 +87,7 @@
|
||||
</TableRow>
|
||||
<TableRow v-else-if="filteredGlobalModels.length === 0">
|
||||
<TableCell
|
||||
colspan="7"
|
||||
colspan="6"
|
||||
class="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
没有找到匹配的模型
|
||||
@@ -121,22 +118,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="flex flex-wrap gap-0.5">
|
||||
<template v-if="model.supported_capabilities?.length">
|
||||
<span
|
||||
v-for="capName in model.supported_capabilities"
|
||||
:key="capName"
|
||||
class="text-[11px] px-1 py-0.5 rounded bg-muted/60 text-muted-foreground"
|
||||
:title="getCapabilityDisplayName(capName)"
|
||||
>{{ getCapabilityShortName(capName) }}</span>
|
||||
</template>
|
||||
<span
|
||||
v-else
|
||||
class="text-muted-foreground text-xs"
|
||||
>-</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<div class="text-xs space-y-0.5">
|
||||
<!-- 按 Token 计费 -->
|
||||
@@ -297,19 +278,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二行:模型偏好 -->
|
||||
<div
|
||||
v-if="model.supported_capabilities?.length"
|
||||
class="flex flex-wrap gap-0.5"
|
||||
>
|
||||
<span
|
||||
v-for="capName in model.supported_capabilities"
|
||||
:key="capName"
|
||||
class="text-[11px] px-1 py-0.5 rounded bg-muted/60 text-muted-foreground"
|
||||
>{{ getCapabilityShortName(capName) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 第三行:统计信息 -->
|
||||
<!-- 第二行:统计信息 -->
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>提供商 {{ model.active_provider_count || 0 }}/{{ model.provider_count || 0 }}</span>
|
||||
<span>调用 {{ formatUsageCount(model.usage_count || 0) }}</span>
|
||||
@@ -350,7 +319,6 @@
|
||||
:model="selectedModel"
|
||||
:open="!!selectedModel"
|
||||
:has-blocking-dialog-open="hasBlockingDialogOpen"
|
||||
:capabilities="capabilities"
|
||||
@update:open="handleDrawerOpenChange"
|
||||
@edit-model="editModel"
|
||||
@toggle-model-status="toggleModelStatus"
|
||||
@@ -693,8 +661,8 @@ import {
|
||||
type GlobalModelResponse,
|
||||
} from '@/api/global-models'
|
||||
import { log } from '@/utils/logger'
|
||||
import { formatUsageCount } from '@/utils/format'
|
||||
import { getProvidersSummary, type ProviderWithEndpointsSummary } from '@/api/endpoints/providers'
|
||||
import { getAllCapabilities, type CapabilityDefinition } from '@/api/endpoints'
|
||||
|
||||
|
||||
interface ModelProviderDisplay {
|
||||
@@ -733,7 +701,6 @@ const editingModel = ref<GlobalModelResponse | null>(null)
|
||||
// 数据
|
||||
const globalModels = ref<GlobalModelResponse[]>([])
|
||||
const providers = ref<ProviderWithEndpointsSummary[]>([])
|
||||
const capabilities = ref<CapabilityDefinition[]>([])
|
||||
|
||||
// 模型目录分页
|
||||
const catalogCurrentPage = ref(1)
|
||||
@@ -790,16 +757,6 @@ const editingProviderModel = computed<Model | null>(() => {
|
||||
// 使用全局确认对话框
|
||||
const { confirmDanger } = useConfirm()
|
||||
|
||||
// 格式化调用次数(大数字简化显示)
|
||||
function formatUsageCount(count: number): string {
|
||||
if (count >= 1000000) {
|
||||
return `${(count / 1000000).toFixed(1) }M`
|
||||
} else if (count >= 1000) {
|
||||
return `${(count / 1000).toFixed(1) }K`
|
||||
}
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
// 从 GlobalModel 的 default_tiered_pricing 获取第一阶梯价格
|
||||
function getFirstTierPrice(model: GlobalModelResponse, type: 'input' | 'output'): number | null {
|
||||
const tiered = model.default_tiered_pricing
|
||||
@@ -1489,31 +1446,10 @@ async function loadProviders() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCapabilities() {
|
||||
try {
|
||||
capabilities.value = await getAllCapabilities()
|
||||
} catch (err) {
|
||||
log.error('Failed to load capabilities:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 capability 的显示名称
|
||||
function getCapabilityDisplayName(capName: string): string {
|
||||
const cap = capabilities.value.find(c => c.name === capName)
|
||||
return cap?.display_name || capName
|
||||
}
|
||||
|
||||
// 获取 capability 的短名称(用于表格展示)
|
||||
function getCapabilityShortName(capName: string): string {
|
||||
const cap = capabilities.value.find(c => c.name === capName)
|
||||
return cap?.short_name || cap?.display_name || capName
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
refreshData(),
|
||||
loadProviders(),
|
||||
loadCapabilities(),
|
||||
])
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -652,7 +652,7 @@ async function loadOverview() {
|
||||
overviewLoading.value = true
|
||||
try {
|
||||
const res = await getPoolOverview()
|
||||
poolProviders.value = res.items
|
||||
poolProviders.value = res.items.filter(item => item.pool_enabled)
|
||||
// Auto-select first provider if none selected
|
||||
if (!selectedProviderId.value && res.items.length > 0) {
|
||||
await selectProvider(res.items[0].provider_id)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
|
||||
<!-- 页面头部:统计卡片 + 公告 -->
|
||||
<div class="flex flex-col sm:flex-row gap-6 sm:items-start">
|
||||
<div class="flex flex-col lg:flex-row gap-6 lg:items-start">
|
||||
<!-- 左侧统计区域 -->
|
||||
<div
|
||||
ref="statsPanelRef"
|
||||
@@ -215,12 +215,7 @@
|
||||
Monthly
|
||||
</Badge>
|
||||
</div>
|
||||
<div
|
||||
class="grid gap-2 sm:gap-3"
|
||||
:class="[
|
||||
hasCacheData ? 'grid-cols-2 xl:grid-cols-4' : 'grid-cols-1 max-w-xs'
|
||||
]"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
|
||||
<Card
|
||||
v-if="cacheStats"
|
||||
class="relative p-3 sm:p-4 border-book-cloth/30"
|
||||
@@ -284,7 +279,7 @@
|
||||
<!-- 右侧系统公告 -->
|
||||
<div
|
||||
id="announcements-section"
|
||||
class="w-full sm:w-[260px] md:w-[300px] lg:w-[320px] flex-shrink-0 flex flex-col min-h-0"
|
||||
class="w-full lg:w-[300px] xl:w-[320px] flex-shrink-0 flex flex-col min-h-0"
|
||||
:style="announcementsContainerStyle"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between flex-shrink-0">
|
||||
@@ -299,7 +294,7 @@
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Card class="overflow-hidden p-4 flex flex-col flex-1 min-h-0 h-full max-h-[280px] sm:max-h-none">
|
||||
<Card class="overflow-hidden p-4 flex flex-col flex-1 min-h-0 h-full max-h-[280px] lg:max-h-none">
|
||||
<div
|
||||
v-if="loadingAnnouncements"
|
||||
class="flex-1 flex items-center justify-center"
|
||||
@@ -856,7 +851,7 @@ const announcementsContainerStyle = computed(() => {
|
||||
|
||||
function checkScreenSize() {
|
||||
if (typeof window !== 'undefined') {
|
||||
isLargeScreen.value = window.innerWidth >= 640 // sm breakpoint
|
||||
isLargeScreen.value = window.innerWidth >= 1024 // lg breakpoint
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,12 +40,12 @@
|
||||
<TableHead class="w-[140px] h-12 font-semibold">
|
||||
模型名称
|
||||
</TableHead>
|
||||
<TableHead class="w-[120px] h-12 font-semibold">
|
||||
模型偏好
|
||||
</TableHead>
|
||||
<TableHead class="w-[140px] h-12 font-semibold text-center">
|
||||
价格 ($/M)
|
||||
</TableHead>
|
||||
<TableHead class="w-[80px] h-12 font-semibold text-center">
|
||||
调用次数
|
||||
</TableHead>
|
||||
<TableHead class="w-[70px] h-12 font-semibold text-center">
|
||||
状态
|
||||
</TableHead>
|
||||
@@ -93,38 +93,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<div class="flex gap-1.5 flex-wrap items-center">
|
||||
<template v-if="getModelSupportedCapabilities(model).length > 0">
|
||||
<button
|
||||
v-for="cap in getModelSupportedCapabilitiesDetails(model)"
|
||||
:key="cap.name"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-all"
|
||||
:class="[
|
||||
isCapabilityEnabled(model.name, cap.name)
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-transparent text-muted-foreground border border-dashed border-muted-foreground/50 hover:border-primary/50 hover:text-foreground'
|
||||
]"
|
||||
:title="cap.description"
|
||||
@click.stop="toggleCapability(model.name, cap.name)"
|
||||
>
|
||||
<Check
|
||||
v-if="isCapabilityEnabled(model.name, cap.name)"
|
||||
class="w-3 h-3"
|
||||
/>
|
||||
<Plus
|
||||
v-else
|
||||
class="w-3 h-3"
|
||||
/>
|
||||
{{ cap.short_name || cap.display_name }}
|
||||
</button>
|
||||
</template>
|
||||
<span
|
||||
v-else
|
||||
class="text-muted-foreground text-xs"
|
||||
>-</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<div class="text-xs space-y-0.5">
|
||||
<!-- 按 Token 计费 -->
|
||||
@@ -154,6 +122,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<span class="text-sm font-mono">{{ formatUsageCount(model.usage_count || 0) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<Badge :variant="model.is_active ? 'success' : 'secondary'">
|
||||
{{ model.is_active ? '可用' : '停用' }}
|
||||
@@ -194,41 +165,15 @@
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<!-- 第二行:价格 -->
|
||||
<div
|
||||
v-if="getFirstTierPrice(model, 'input') || getFirstTierPrice(model, 'output')"
|
||||
class="text-xs text-muted-foreground font-mono"
|
||||
>
|
||||
In: ${{ getFirstTierPrice(model, 'input')?.toFixed(2) || '-' }} / Out: ${{ getFirstTierPrice(model, 'output')?.toFixed(2) || '-' }}
|
||||
</div>
|
||||
|
||||
<!-- 第四行:模型偏好按钮 -->
|
||||
<div
|
||||
v-if="getModelSupportedCapabilities(model).length > 0"
|
||||
class="flex gap-1.5 flex-wrap"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
v-for="cap in getModelSupportedCapabilitiesDetails(model)"
|
||||
:key="cap.name"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-all"
|
||||
:class="[
|
||||
isCapabilityEnabled(model.name, cap.name)
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-transparent text-muted-foreground border border-dashed border-muted-foreground/50'
|
||||
]"
|
||||
@click="toggleCapability(model.name, cap.name)"
|
||||
<!-- 第二行:价格 + 调用次数 -->
|
||||
<div class="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span
|
||||
v-if="getFirstTierPrice(model, 'input') || getFirstTierPrice(model, 'output')"
|
||||
class="font-mono"
|
||||
>
|
||||
<Check
|
||||
v-if="isCapabilityEnabled(model.name, cap.name)"
|
||||
class="w-3 h-3"
|
||||
/>
|
||||
<Plus
|
||||
v-else
|
||||
class="w-3 h-3"
|
||||
/>
|
||||
{{ cap.short_name || cap.display_name }}
|
||||
</button>
|
||||
In: ${{ getFirstTierPrice(model, 'input')?.toFixed(2) || '-' }} / Out: ${{ getFirstTierPrice(model, 'output')?.toFixed(2) || '-' }}
|
||||
</span>
|
||||
<span class="font-mono">{{ formatUsageCount(model.usage_count || 0) }} 次</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -250,10 +195,6 @@
|
||||
<UserModelDetailDrawer
|
||||
v-model:open="drawerOpen"
|
||||
:model="selectedModel"
|
||||
:capabilities="allCapabilities"
|
||||
:user-configurable-capabilities="userConfigurableCapabilities"
|
||||
:model-capability-settings="modelCapabilitySettings"
|
||||
@toggle-capability="toggleCapability"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -264,8 +205,6 @@ import {
|
||||
Loader2,
|
||||
Search,
|
||||
Copy,
|
||||
Check,
|
||||
Plus,
|
||||
} from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
@@ -283,12 +222,8 @@ import {
|
||||
RefreshButton,
|
||||
} from '@/components/ui'
|
||||
import { type PublicGlobalModel } from '@/api/public-models'
|
||||
import { formatUsageCount } from '@/utils/format'
|
||||
import { meApi } from '@/api/me'
|
||||
import {
|
||||
getUserConfigurableCapabilities,
|
||||
getAllCapabilities,
|
||||
type CapabilityDefinition
|
||||
} from '@/api/endpoints'
|
||||
import UserModelDetailDrawer from './components/UserModelDetailDrawer.vue'
|
||||
import { useRowClick } from '@/composables/useRowClick'
|
||||
import { log } from '@/utils/logger'
|
||||
@@ -319,83 +254,6 @@ function openModelDetail(model: PublicGlobalModel, event: MouseEvent) {
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
// 能力筛选
|
||||
const capabilityFilters = ref({
|
||||
vision: false,
|
||||
toolUse: false,
|
||||
extendedThinking: false,
|
||||
})
|
||||
|
||||
// 能力配置相关
|
||||
const availableCapabilities = ref<CapabilityDefinition[]>([])
|
||||
const allCapabilities = ref<CapabilityDefinition[]>([])
|
||||
const userConfigurableCapabilities = computed(() =>
|
||||
availableCapabilities.value.filter(cap => cap.config_mode === 'user_configurable')
|
||||
)
|
||||
const modelCapabilitySettings = ref<Record<string, Record<string, boolean>>>({})
|
||||
const savingCapability = ref<string | null>(null) // 正在保存的能力标识 "modelName:capName"
|
||||
|
||||
// 获取模型支持的可配置能力名称列表(从 supported_capabilities 字段读取)
|
||||
function getModelSupportedCapabilities(model: PublicGlobalModel): string[] {
|
||||
if (!model.supported_capabilities) return []
|
||||
// 只返回用户可配置的能力
|
||||
return model.supported_capabilities.filter(capName =>
|
||||
userConfigurableCapabilities.value.some(cap => cap.name === capName)
|
||||
)
|
||||
}
|
||||
|
||||
// 获取模型支持的可配置能力详情列表
|
||||
function getModelSupportedCapabilitiesDetails(model: PublicGlobalModel): CapabilityDefinition[] {
|
||||
const supportedNames = getModelSupportedCapabilities(model)
|
||||
return userConfigurableCapabilities.value.filter(cap => supportedNames.includes(cap.name))
|
||||
}
|
||||
|
||||
// 检查某个能力是否已启用
|
||||
function isCapabilityEnabled(modelName: string, capName: string): boolean {
|
||||
return modelCapabilitySettings.value[modelName]?.[capName] || false
|
||||
}
|
||||
|
||||
// 切换能力配置
|
||||
async function toggleCapability(modelName: string, capName: string) {
|
||||
const capKey = `${modelName}:${capName}`
|
||||
if (savingCapability.value === capKey) return // 防止重复点击
|
||||
|
||||
savingCapability.value = capKey
|
||||
try {
|
||||
const currentEnabled = isCapabilityEnabled(modelName, capName)
|
||||
const newEnabled = !currentEnabled
|
||||
|
||||
// 更新本地状态
|
||||
const newSettings = { ...modelCapabilitySettings.value }
|
||||
if (!newSettings[modelName]) {
|
||||
newSettings[modelName] = {}
|
||||
}
|
||||
|
||||
if (newEnabled) {
|
||||
newSettings[modelName][capName] = true
|
||||
} else {
|
||||
delete newSettings[modelName][capName]
|
||||
// 如果该模型没有任何能力配置了,删除整个模型条目
|
||||
if (Object.keys(newSettings[modelName]).length === 0) {
|
||||
delete newSettings[modelName]
|
||||
}
|
||||
}
|
||||
|
||||
// 调用 API 保存
|
||||
await meApi.updateModelCapabilitySettings({
|
||||
model_capability_settings: Object.keys(newSettings).length > 0 ? newSettings : null
|
||||
})
|
||||
|
||||
// 更新本地状态
|
||||
modelCapabilitySettings.value = newSettings
|
||||
} catch (err) {
|
||||
log.error('保存能力配置失败:', err)
|
||||
showError('保存失败,请重试')
|
||||
} finally {
|
||||
savingCapability.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 筛选后的模型列表
|
||||
const filteredModels = computed(() => {
|
||||
let result = models.value
|
||||
@@ -409,17 +267,6 @@ const filteredModels = computed(() => {
|
||||
})
|
||||
}
|
||||
|
||||
// 能力筛选
|
||||
if (capabilityFilters.value.vision) {
|
||||
result = result.filter(m => m.config?.vision === true)
|
||||
}
|
||||
if (capabilityFilters.value.toolUse) {
|
||||
result = result.filter(m => m.config?.function_calling === true)
|
||||
}
|
||||
if (capabilityFilters.value.extendedThinking) {
|
||||
result = result.filter(m => m.config?.extended_thinking === true)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -430,10 +277,10 @@ const paginatedModels = computed(() => {
|
||||
return filteredModels.value.slice(start, end)
|
||||
})
|
||||
|
||||
// 搜索或筛选变化时重置到第一页
|
||||
watch([searchQuery, capabilityFilters], () => {
|
||||
// 搜索变化时重置到第一页
|
||||
watch(searchQuery, () => {
|
||||
currentPage.value = 1
|
||||
}, { deep: true })
|
||||
})
|
||||
|
||||
async function loadModels() {
|
||||
loading.value = true
|
||||
@@ -449,30 +296,8 @@ async function loadModels() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCapabilities() {
|
||||
try {
|
||||
const [userCaps, allCaps] = await Promise.all([
|
||||
getUserConfigurableCapabilities(),
|
||||
getAllCapabilities()
|
||||
])
|
||||
availableCapabilities.value = userCaps
|
||||
allCapabilities.value = allCaps
|
||||
} catch (err) {
|
||||
log.error('Failed to load capabilities:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModelCapabilitySettings() {
|
||||
try {
|
||||
const response = await meApi.getModelCapabilitySettings()
|
||||
modelCapabilitySettings.value = response.model_capability_settings || {}
|
||||
} catch (err) {
|
||||
log.error('Failed to load model capability settings:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshData() {
|
||||
await Promise.all([loadModels(), loadCapabilities(), loadModelCapabilitySettings()])
|
||||
await loadModels()
|
||||
}
|
||||
|
||||
// 从 PublicGlobalModel 的 default_tiered_pricing 获取第一阶梯价格
|
||||
|
||||
@@ -76,9 +76,6 @@
|
||||
<TableHead class="min-w-[200px] h-12 font-semibold">
|
||||
密钥名称
|
||||
</TableHead>
|
||||
<TableHead class="min-w-[80px] h-12 font-semibold">
|
||||
能力
|
||||
</TableHead>
|
||||
<TableHead class="min-w-[160px] h-12 font-semibold">
|
||||
密钥
|
||||
</TableHead>
|
||||
@@ -120,42 +117,6 @@
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<!-- 能力 -->
|
||||
<TableCell class="py-4">
|
||||
<div class="flex gap-1.5 flex-wrap items-center">
|
||||
<template v-if="userConfigurableCapabilities.length > 0">
|
||||
<button
|
||||
v-for="cap in userConfigurableCapabilities"
|
||||
:key="cap.name"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-all"
|
||||
:class="[
|
||||
apiKey.is_locked ? 'opacity-50 cursor-not-allowed' : '',
|
||||
isCapabilityEnabled(apiKey, cap.name)
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-transparent text-muted-foreground border border-dashed border-muted-foreground/50 hover:border-primary/50 hover:text-foreground'
|
||||
]"
|
||||
:title="apiKey.is_locked ? '已锁定' : getCapabilityTooltip(cap, isCapabilityEnabled(apiKey, cap.name))"
|
||||
:disabled="apiKey.is_locked"
|
||||
@click.stop="!apiKey.is_locked && toggleCapability(apiKey, cap.name)"
|
||||
>
|
||||
<Check
|
||||
v-if="isCapabilityEnabled(apiKey, cap.name)"
|
||||
class="w-3 h-3"
|
||||
/>
|
||||
<Plus
|
||||
v-else
|
||||
class="w-3 h-3"
|
||||
/>
|
||||
{{ cap.short_name || cap.display_name }}
|
||||
</button>
|
||||
</template>
|
||||
<span
|
||||
v-else
|
||||
class="text-muted-foreground text-xs"
|
||||
>-</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<!-- 密钥显示 -->
|
||||
<TableCell class="py-4">
|
||||
<div class="flex items-center gap-1.5">
|
||||
@@ -478,7 +439,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { meApi, type ApiKey } from '@/api/me'
|
||||
import { getAllCapabilities, type CapabilityDefinition } from '@/api/endpoints'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
@@ -495,7 +455,7 @@ import {
|
||||
TableRow
|
||||
} from '@/components/ui'
|
||||
import RefreshButton from '@/components/ui/refresh-button.vue'
|
||||
import { Plus, Key, Copy, Trash2, Loader2, Activity, CheckCircle, Power, Check } from 'lucide-vue-next'
|
||||
import { Plus, Key, Copy, Trash2, Loader2, Activity, CheckCircle, Power } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { log } from '@/utils/logger'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
@@ -526,26 +486,10 @@ const newKeyName = ref('')
|
||||
const newKeyValue = ref('')
|
||||
const keyToDelete = ref<ApiKey | null>(null)
|
||||
|
||||
// 能力配置相关
|
||||
const availableCapabilities = ref<CapabilityDefinition[]>([])
|
||||
const userConfigurableCapabilities = computed(() =>
|
||||
availableCapabilities.value.filter(cap => cap.config_mode === 'user_configurable')
|
||||
)
|
||||
const savingCapability = ref<string | null>(null) // 正在保存的能力标识 "keyId:capName"
|
||||
|
||||
onMounted(() => {
|
||||
loadApiKeys()
|
||||
loadCapabilities()
|
||||
})
|
||||
|
||||
async function loadCapabilities() {
|
||||
try {
|
||||
availableCapabilities.value = await getAllCapabilities()
|
||||
} catch (error) {
|
||||
log.error('Failed to load capabilities:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApiKeys() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -625,50 +569,6 @@ async function toggleApiKey(apiKey: ApiKey) {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查某个能力是否已启用
|
||||
function isCapabilityEnabled(apiKey: ApiKey, capName: string): boolean {
|
||||
return apiKey.force_capabilities?.[capName] || false
|
||||
}
|
||||
|
||||
// 切换能力配置
|
||||
async function toggleCapability(apiKey: ApiKey, capName: string) {
|
||||
const capKey = `${apiKey.id}:${capName}`
|
||||
if (savingCapability.value === capKey) return // 防止重复点击
|
||||
|
||||
savingCapability.value = capKey
|
||||
try {
|
||||
const currentEnabled = isCapabilityEnabled(apiKey, capName)
|
||||
const newEnabled = !currentEnabled
|
||||
|
||||
// 构建新的能力配置
|
||||
const newCapabilities: Record<string, boolean> = { ...(apiKey.force_capabilities || {}) }
|
||||
|
||||
if (newEnabled) {
|
||||
newCapabilities[capName] = true
|
||||
} else {
|
||||
delete newCapabilities[capName]
|
||||
}
|
||||
|
||||
const capabilitiesData = Object.keys(newCapabilities).length > 0 ? newCapabilities : null
|
||||
|
||||
// 调用 API 保存
|
||||
await meApi.updateApiKeyCapabilities(apiKey.id, {
|
||||
force_capabilities: capabilitiesData
|
||||
})
|
||||
|
||||
// 更新本地数据
|
||||
const index = apiKeys.value.findIndex(k => k.id === apiKey.id)
|
||||
if (index !== -1) {
|
||||
apiKeys.value[index].force_capabilities = capabilitiesData
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('保存能力配置失败:', err)
|
||||
showError('保存失败,请重试')
|
||||
} finally {
|
||||
savingCapability.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function copyApiKey(apiKey: ApiKey) {
|
||||
try {
|
||||
// 调用后端 API 获取完整密钥
|
||||
@@ -745,12 +645,4 @@ function formatRelativeTime(dateString: string): string {
|
||||
return formatDate(dateString)
|
||||
}
|
||||
|
||||
// 获取能力按钮的提示文字
|
||||
function getCapabilityTooltip(cap: CapabilityDefinition, isEnabled: boolean): string {
|
||||
if (isEnabled) {
|
||||
return `[已启用] 此密钥只能访问支持${cap.display_name}的模型`
|
||||
}
|
||||
return `${cap.description}`
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -150,51 +150,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型偏好 -->
|
||||
<div
|
||||
v-if="getModelUserConfigurableCapabilities().length > 0"
|
||||
class="space-y-3"
|
||||
>
|
||||
<h4 class="font-semibold text-sm">
|
||||
模型偏好
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="cap in getModelUserConfigurableCapabilities()"
|
||||
:key="cap.name"
|
||||
class="flex items-center justify-between p-3 rounded-lg border"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium">
|
||||
{{ cap.display_name }}
|
||||
</p>
|
||||
<p
|
||||
v-if="cap.description"
|
||||
class="text-xs text-muted-foreground truncate"
|
||||
>
|
||||
{{ cap.description }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
:class="[
|
||||
isCapabilityEnabled(cap.name) ? 'bg-primary' : 'bg-muted'
|
||||
]"
|
||||
role="switch"
|
||||
:aria-checked="isCapabilityEnabled(cap.name)"
|
||||
@click="handleToggleCapability(cap.name)"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none inline-block h-4 w-4 transform rounded-full bg-background shadow-lg ring-0 transition duration-200 ease-in-out"
|
||||
:class="[
|
||||
isCapabilityEnabled(cap.name) ? 'translate-x-4' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 定价信息 -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-semibold text-sm">
|
||||
@@ -365,13 +320,11 @@ import TableCell from '@/components/ui/table-cell.vue'
|
||||
|
||||
import type { PublicGlobalModel } from '@/api/public-models'
|
||||
import type { TieredPricingConfig, PricingTier } from '@/api/endpoints/types'
|
||||
import type { CapabilityDefinition } from '@/api/endpoints'
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'toggleCapability': [modelName: string, capName: string]
|
||||
}>()
|
||||
|
||||
const { copyToClipboard } = useClipboard()
|
||||
@@ -379,29 +332,6 @@ const { copyToClipboard } = useClipboard()
|
||||
interface Props {
|
||||
model: PublicGlobalModel | null
|
||||
open: boolean
|
||||
capabilities?: CapabilityDefinition[]
|
||||
userConfigurableCapabilities?: CapabilityDefinition[]
|
||||
modelCapabilitySettings?: Record<string, Record<string, boolean>>
|
||||
}
|
||||
|
||||
// 获取模型支持的用户可配置能力
|
||||
function getModelUserConfigurableCapabilities(): CapabilityDefinition[] {
|
||||
if (!props.model?.supported_capabilities || !props.userConfigurableCapabilities) return []
|
||||
return props.userConfigurableCapabilities.filter(cap =>
|
||||
props.model?.supported_capabilities?.includes(cap.name)
|
||||
)
|
||||
}
|
||||
|
||||
// 检查能力是否已启用
|
||||
function isCapabilityEnabled(capName: string): boolean {
|
||||
if (!props.model) return false
|
||||
return props.modelCapabilitySettings?.[props.model.name]?.[capName] || false
|
||||
}
|
||||
|
||||
// 切换能力
|
||||
function handleToggleCapability(capName: string) {
|
||||
if (!props.model) return
|
||||
emit('toggleCapability', props.model.name, capName)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
|
||||
Reference in New Issue
Block a user