Files
Aether/frontend/src/features/providers/components/provider-tabs/ModelsTab.vue

530 lines
18 KiB
Vue
Raw Normal View History

2025-12-10 20:52:44 +08:00
<template>
<Card class="overflow-hidden">
<!-- 标题头部 -->
<div class="p-4 border-b border-border/60">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold flex items-center gap-2">
模型列表
</h3>
<Button
variant="outline"
size="sm"
class="h-8"
@click="openBatchAssignDialog"
>
2025-12-10 20:52:44 +08:00
<Layers class="w-3.5 h-3.5 mr-1.5" />
关联模型
</Button>
</div>
</div>
<!-- 加载状态 -->
<div
v-if="loading"
class="flex items-center justify-center py-12"
>
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
2025-12-10 20:52:44 +08:00
</div>
<!-- 模型列表 -->
<div
v-else-if="models.length > 0"
class="overflow-hidden"
>
<table
ref="modelsListRef"
class="w-full text-sm table-fixed"
>
<colgroup>
<col class="w-[45%]">
<col class="w-[30%]">
<col class="w-[25%]">
</colgroup>
2025-12-10 20:52:44 +08:00
<tbody>
<tr
v-for="model in paginatedModels"
2025-12-10 20:52:44 +08:00
:key="model.id"
class="border-b border-border/40 last:border-b-0 hover:bg-muted/30 transition-colors"
>
<td class="align-top px-4 py-3">
<div class="flex items-center gap-2.5">
<!-- 状态指示灯 -->
<div
class="w-2 h-2 rounded-full shrink-0"
:class="getStatusIndicatorClass(model)"
:title="getStatusTitle(model)"
/>
2025-12-10 20:52:44 +08:00
<!-- 模型信息 -->
<div class="text-left flex-1 min-w-0">
<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>
2025-12-10 20:52:44 +08:00
<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
class="p-0.5 hover:bg-muted rounded transition-colors shrink-0"
title="复制模型 ID"
@click.stop="copyModelId(model.provider_model_name)"
2025-12-10 20:52:44 +08:00
>
<Copy class="w-3 h-3" />
</button>
</div>
</div>
</div>
</td>
<td class="align-top px-4 py-3 text-xs whitespace-nowrap">
<div
class="grid gap-1"
style="grid-template-columns: auto 1fr;"
>
2025-12-10 20:52:44 +08:00
<!-- Token 计费 -->
<template v-if="hasTokenPricing(model)">
<span class="text-muted-foreground text-right">/:</span>
2025-12-10 20:52:44 +08:00
<span class="font-mono font-semibold">
${{ formatPrice(model.effective_input_price) }}/${{ formatPrice(model.effective_output_price) }}
</span>
</template>
<template v-if="getEffectiveCachePrice(model, 'creation') > 0 || getEffectiveCachePrice(model, 'read') > 0">
<span class="text-muted-foreground text-right">{{ get1hCachePrice(model) > 0 ? '5min 缓存:' : '缓存:' }}</span>
2025-12-10 20:52:44 +08:00
<span class="font-mono font-semibold">
${{ formatPrice(getEffectiveCachePrice(model, 'creation')) }}/${{ formatPrice(getEffectiveCachePrice(model, 'read')) }}
</span>
</template>
<!-- 1h 缓存价格 -->
<template v-if="get1hCachePrice(model) > 0">
<span class="text-muted-foreground text-right">1h 缓存创建:</span>
2025-12-10 20:52:44 +08:00
<span class="font-mono font-semibold">
${{ formatPrice(get1hCachePrice(model)) }}
</span>
</template>
<!-- 按次计费 -->
<template v-if="hasRequestPricing(model)">
<span class="text-muted-foreground text-right">按次:</span>
<span class="font-mono font-semibold">
${{ formatPrice(model.effective_price_per_request ?? model.price_per_request) }}/
</span>
</template>
<!-- 视频费用计费 -->
<template v-if="hasVideoPricing(model)">
<span class="text-muted-foreground text-right">视频:</span>
<span
class="font-mono font-semibold"
:title="getVideoPricingTooltip(model)"
>
{{ getVideoPricingDisplay(model) }}
</span>
</template>
<!-- 无计费配置 -->
<template v-if="!hasTokenPricing(model) && !hasRequestPricing(model) && !hasVideoPricing(model)">
2025-12-10 20:52:44 +08:00
<span class="text-muted-foreground"></span>
</template>
</div>
</td>
<td class="align-top px-4 py-3">
<div class="flex justify-end gap-1">
<!-- 测试按钮模拟外部请求 -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="测试模型"
:disabled="testingModelId === model.id"
@click="testModelConnection(model)"
>
<Loader2
v-if="testingModelId === model.id"
class="w-3.5 h-3.5 animate-spin"
/>
<Play
v-else
class="w-3.5 h-3.5"
/>
</Button>
2025-12-10 20:52:44 +08:00
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="编辑"
@click="editModel(model)"
2025-12-10 20:52:44 +08:00
>
<Edit class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:disabled="togglingModelId === model.id"
:title="model.is_active ? '点击停用' : '点击启用'"
@click="toggleModelActive(model)"
2025-12-10 20:52:44 +08:00
>
<Power class="w-3.5 h-3.5" />
</Button>
</div>
</td>
</tr>
</tbody>
</table>
<!-- 分页控制 -->
<div
v-if="shouldPaginateModels"
class="px-4 py-2 border-t border-border/40 flex items-center justify-between text-xs text-muted-foreground"
>
<span> {{ sortedModels.length }} 个模型</span>
<div class="flex items-center gap-1.5">
<Button
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
:disabled="currentModelPage <= 1"
@click="currentModelPage--"
>
</Button>
<span class="tabular-nums">{{ currentModelPage }} / {{ totalModelPages }}</span>
<Button
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
:disabled="currentModelPage >= totalModelPages"
@click="currentModelPage++"
>
</Button>
</div>
</div>
2025-12-10 20:52:44 +08:00
</div>
<!-- 空状态 -->
<div
v-else
class="p-8 text-center text-muted-foreground"
>
2025-12-10 20:52:44 +08:00
<Box class="w-12 h-12 mx-auto mb-3 opacity-50" />
<p class="text-sm">
暂无模型
</p>
<p class="text-xs mt-1">
请前往"模型目录"页面添加模型
</p>
2025-12-10 20:52:44 +08:00
</div>
</Card>
<ModelTestDialog
:open="testDialogOpen"
:result="testResult"
:mode="testResultMode"
:selecting-model-name="pendingTestModel ? (pendingTestModel.global_model_display_name || pendingTestModel.provider_model_name) : null"
:endpoints="activeEndpoints"
:selected-endpoint="selectedTestEndpoint"
:testing="!!pendingTestModel && testingModelId === pendingTestModel.id"
:show-endpoint-selector="activeEndpoints.length > 1"
@close="handleTestDialogClose"
@back="handleTestDialogBack"
@select-endpoint="handleSelectTestEndpoint"
/>
2025-12-10 20:52:44 +08:00
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useSmartPagination } from '@/composables/useSmartPagination'
import { Box, Edit, Layers, Power, Copy, Loader2, Play } from 'lucide-vue-next'
2025-12-10 20:52:44 +08:00
import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue'
import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import { sortResolutionEntries } from '@/utils/form'
import {
testModelFailover,
type Model,
type ProviderEndpoint,
type TestModelFailoverResponse,
} from '@/api/endpoints'
2025-12-10 20:52:44 +08:00
import { updateModel } from '@/api/endpoints/models'
import { parseApiError } from '@/utils/errorParser'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
import ModelTestDialog from './ModelTestDialog.vue'
2025-12-10 20:52:44 +08:00
const props = defineProps<{
provider: ProviderWithEndpointsSummary
models?: Model[]
endpoints?: ProviderEndpoint[]
2025-12-10 20:52:44 +08:00
}>()
const emit = defineEmits<{
'editModel': [model: Model]
'batchAssign': []
'refresh': []
2025-12-10 20:52:44 +08:00
}>()
const { error: showError, success: showSuccess } = useToast()
const { copyToClipboard } = useClipboard()
2025-12-10 20:52:44 +08:00
// 状态
const loading = ref(false)
const localModels = ref<Model[]>([])
2025-12-10 20:52:44 +08:00
const togglingModelId = ref<string | null>(null)
const testingModelId = ref<string | null>(null)
const testResult = ref<TestModelFailoverResponse | null>(null)
const testResultMode = ref<'global' | 'direct'>('global')
const testDialogOpen = ref(false)
const pendingTestModel = ref<Model | null>(null)
const selectedTestEndpoint = ref<ProviderEndpoint | null>(null)
// 使用 props 传入的数据,或使用本地数据
const activeEndpoints = computed(() => (props.endpoints ?? []).filter(endpoint => endpoint.is_active))
// 使用 props 传入的数据,或使用本地数据
const models = computed(() => props.models ?? localModels.value)
2025-12-10 20:52:44 +08:00
// 按名称排序的模型列表
const sortedModels = computed(() => {
return [...models.value].sort((a, b) => {
const nameA = (a.global_model_display_name || a.provider_model_name || '').toLowerCase()
const nameB = (b.global_model_display_name || b.provider_model_name || '').toLowerCase()
return nameA.localeCompare(nameB)
})
})
// ===== 模型列表智能分页 =====
const modelsListRef = ref<HTMLElement | null>(null)
const {
currentPage: currentModelPage,
totalPages: totalModelPages,
shouldPaginate: shouldPaginateModels,
paginatedItems: paginatedModels,
} = useSmartPagination(sortedModels, modelsListRef)
2025-12-10 20:52:44 +08:00
// 复制模型 ID 到剪贴板
async function copyModelId(modelId: string) {
await copyToClipboard(modelId)
2025-12-10 20:52:44 +08:00
}
// 刷新数据(通知父组件刷新)
function refresh() {
emit('refresh')
2025-12-10 20:52:44 +08:00
}
// 格式化价格显示
function formatPrice(price: number | null | undefined): string {
if (price === null || price === undefined) return '-'
// 如果是整数或小数点后只有1-2位直接显示
if (price >= 0.01 || price === 0) {
return price.toFixed(2)
}
// 对于非常小的数字,使用科学计数法
if (price < 0.0001) {
return price.toExponential(2)
}
// 其他情况保留4位小数
return price.toFixed(4)
}
// 检查是否有按 Token 计费
function hasTokenPricing(model: Model): boolean {
const inputPrice = model.effective_input_price
const outputPrice = model.effective_output_price
return (inputPrice != null && inputPrice > 0) || (outputPrice != null && outputPrice > 0)
}
// 获取有效的缓存价格(从 effective_tiered_pricing 或 tiered_pricing 中提取)
function getEffectiveCachePrice(model: Model, type: 'creation' | 'read'): number {
const tiered = model.effective_tiered_pricing || model.tiered_pricing
if (!tiered?.tiers?.length) return 0
const firstTier = tiered.tiers[0]
if (type === 'creation') {
return firstTier.cache_creation_price_per_1m || 0
}
return firstTier.cache_read_price_per_1m || 0
}
// 获取 1h 缓存价格
function get1hCachePrice(model: Model): number {
const tiered = model.effective_tiered_pricing || model.tiered_pricing
if (!tiered?.tiers?.length) return 0
const firstTier = tiered.tiers[0]
const ttl1h = firstTier.cache_ttl_pricing?.find(t => t.ttl_minutes === 60)
return ttl1h?.cache_creation_price_per_1m || 0
}
// 检查是否有按次计费
function hasRequestPricing(model: Model): boolean {
const requestPrice = model.effective_price_per_request ?? model.price_per_request
return requestPrice != null && requestPrice > 0
}
// 检查是否有视频分辨率计费配置
function hasVideoPricing(model: Model): boolean {
const priceByResolution = model.effective_config?.billing?.video?.price_per_second_by_resolution
|| model.config?.billing?.video?.price_per_second_by_resolution
return priceByResolution && typeof priceByResolution === 'object' && Object.keys(priceByResolution).length > 0
}
// 获取视频计费的显示文本
function getVideoPricingDisplay(model: Model): string {
const priceByResolution = model.effective_config?.billing?.video?.price_per_second_by_resolution
|| model.config?.billing?.video?.price_per_second_by_resolution
if (!priceByResolution || typeof priceByResolution !== 'object') return ''
const entries = sortResolutionEntries(Object.entries(priceByResolution))
if (entries.length === 0) return ''
// 获取最低分辨率和价格
const [firstRes, firstPrice] = entries[0]
const priceStr = `${firstRes} $${(firstPrice as number).toFixed(2)}/s`
if (entries.length > 1) {
return `${priceStr} [${entries.length}种]`
}
return priceStr
}
// 获取视频计费详情的 tooltip
function getVideoPricingTooltip(model: Model): string {
const priceByResolution = model.effective_config?.billing?.video?.price_per_second_by_resolution
|| model.config?.billing?.video?.price_per_second_by_resolution
if (!priceByResolution || typeof priceByResolution !== 'object') return ''
const entries = sortResolutionEntries(Object.entries(priceByResolution))
return entries.map(([res, price]) => `${res}: $${(price as number).toFixed(4)}/s`).join('\n')
}
2025-12-10 20:52:44 +08:00
// 获取状态指示灯样式
function getStatusIndicatorClass(model: Model): string {
if (!model.is_active) {
// 停用 - 灰色
2025-12-10 20:52:44 +08:00
return 'bg-gray-400 dark:bg-gray-600'
}
if (model.is_available) {
// 活跃且可用 - 绿色
return 'bg-green-500 dark:bg-green-400'
}
// 活跃但不可用 - 红色
return 'bg-red-500 dark:bg-red-400'
}
// 获取状态提示文本
function getStatusTitle(model: Model): string {
if (!model.is_active) {
return '停用'
2025-12-10 20:52:44 +08:00
}
if (model.is_available) {
return '活跃且可用'
}
return '活跃但不可用'
}
// 编辑模型
2025-12-10 20:52:44 +08:00
function editModel(model: Model) {
emit('editModel', model)
2025-12-10 20:52:44 +08:00
}
// 打开批量关联对话框
function openBatchAssignDialog() {
emit('batchAssign')
2025-12-10 20:52:44 +08:00
}
// 切换模型启用状态
async function toggleModelActive(model: Model) {
if (togglingModelId.value) return
togglingModelId.value = model.id
try {
const newStatus = !model.is_active
await updateModel(props.provider.id, model.id, { is_active: newStatus })
model.is_active = newStatus
showSuccess(newStatus ? '模型已启用' : '模型已停用')
} catch (err: unknown) {
showError(parseApiError(err, '操作失败'), '错误')
2025-12-10 20:52:44 +08:00
} finally {
togglingModelId.value = null
}
}
function resetTestDialogState() {
testDialogOpen.value = false
pendingTestModel.value = null
selectedTestEndpoint.value = null
testResult.value = null
}
function handleTestDialogClose() {
resetTestDialogState()
}
function handleTestDialogBack() {
if (testingModelId.value) return
testResult.value = null
selectedTestEndpoint.value = null
}
async function handleSelectTestEndpoint(endpointId: string) {
if (!pendingTestModel.value) return
const endpoint = activeEndpoints.value.find(item => item.id === endpointId)
if (!endpoint) return
await runModelTest(pendingTestModel.value, endpoint)
}
async function runModelTest(model: Model, endpoint?: ProviderEndpoint) {
if (testingModelId.value) return
testingModelId.value = model.id
testDialogOpen.value = true
selectedTestEndpoint.value = endpoint ?? null
try {
const modelName = model.global_model_name || model.provider_model_name
const result = await testModelFailover({
provider_id: props.provider.id,
mode: 'global',
model_name: modelName,
api_format: endpoint?.api_format,
endpoint_id: endpoint?.id,
message: 'hello',
})
if (result.success) {
const successAttempt = result.attempts.find(a => a.status === 'success')
const latency = successAttempt?.latency_ms != null ? ` (${successAttempt.latency_ms}ms)` : ''
const mapped = successAttempt?.effective_model && successAttempt.effective_model !== modelName
? ` -> ${successAttempt.effective_model}`
: ''
const endpointPrefix = endpoint ? `[${formatApiFormat(endpoint.api_format)}] ` : ''
showSuccess(`${endpointPrefix}${modelName}${mapped} 测试成功${latency}`)
resetTestDialogState()
return
}
testResultMode.value = 'global'
testResult.value = result
} catch (err: unknown) {
showError(`模型测试失败: ${parseApiError(err, '测试请求失败')}`)
if (activeEndpoints.value.length <= 1) {
resetTestDialogState()
return
}
selectedTestEndpoint.value = null
} finally {
testingModelId.value = null
}
}
// 测试模型连接性(模拟外部请求,带故障转移)
async function testModelConnection(model: Model) {
if (testingModelId.value) return
if (activeEndpoints.value.length === 0) {
showError('暂无可用于测试的活跃端点')
return
}
pendingTestModel.value = model
selectedTestEndpoint.value = null
testResult.value = null
testDialogOpen.value = true
if (activeEndpoints.value.length === 1) {
await runModelTest(model, activeEndpoints.value[0])
}
}
// 暴露给父组件
defineExpose({
reload: refresh
})
2025-12-10 20:52:44 +08:00
</script>