2025-12-10 20:52:44 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<Dialog
|
|
|
|
|
|
:model-value="open"
|
|
|
|
|
|
:title="isEditMode ? '编辑模型' : '创建统一模型'"
|
2025-12-16 12:21:21 +08:00
|
|
|
|
:description="isEditMode ? '修改模型配置和价格信息' : ''"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
:icon="isEditMode ? SquarePen : Layers"
|
2026-07-10 12:18:42 +08:00
|
|
|
|
:size="isEditMode ? '4xl' : '3xl'"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
@update:model-value="handleDialogUpdate"
|
|
|
|
|
|
>
|
2025-12-16 12:21:21 +08:00
|
|
|
|
<div
|
|
|
|
|
|
class="flex gap-4"
|
2026-07-10 12:18:42 +08:00
|
|
|
|
:class="isEditMode ? '' : 'h-[600px] flex-col'"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
>
|
2026-07-10 12:18:42 +08:00
|
|
|
|
<!-- 上方:搜索和加载预设(仅创建模式) -->
|
|
|
|
|
|
<section
|
|
|
|
|
|
v-if="!isEditMode && !presetPanelCollapsed"
|
|
|
|
|
|
class="h-full flex flex-col space-y-3"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
>
|
2025-12-16 12:21:21 +08:00
|
|
|
|
<!-- 搜索框 -->
|
2026-07-10 12:18:42 +08:00
|
|
|
|
<div class="relative">
|
2025-12-16 12:21:21 +08:00
|
|
|
|
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<Input
|
2025-12-16 12:21:21 +08:00
|
|
|
|
v-model="searchQuery"
|
|
|
|
|
|
type="text"
|
2025-12-16 17:28:40 +08:00
|
|
|
|
placeholder="搜索模型、提供商..."
|
2025-12-16 12:21:21 +08:00
|
|
|
|
class="pl-8 h-8 text-sm"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2025-12-16 12:21:21 +08:00
|
|
|
|
|
2026-07-10 12:18:42 +08:00
|
|
|
|
<!-- 横向提供商 Logo 与模型列表 -->
|
|
|
|
|
|
<div class="flex-1 min-h-0 overflow-hidden border rounded-lg flex flex-col">
|
2025-12-16 12:21:21 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="loading"
|
2026-07-10 12:18:42 +08:00
|
|
|
|
class="flex items-center justify-center flex-1"
|
2025-12-16 12:21:21 +08:00
|
|
|
|
>
|
|
|
|
|
|
<Loader2 class="w-5 h-5 animate-spin text-muted-foreground" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<template v-else>
|
2026-07-10 12:18:42 +08:00
|
|
|
|
<!-- 提供商 Logo 横向选择 -->
|
2025-12-16 12:21:21 +08:00
|
|
|
|
<div
|
2026-07-10 12:18:42 +08:00
|
|
|
|
v-if="groupedModels.length > 0"
|
|
|
|
|
|
class="relative shrink-0 border-b"
|
2025-12-16 12:21:21 +08:00
|
|
|
|
>
|
2026-07-10 12:18:42 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="absolute left-1 top-1/2 z-10 h-8 w-8 -translate-y-1/2 bg-background/95 shadow-sm"
|
|
|
|
|
|
title="向左滚动"
|
|
|
|
|
|
aria-label="向左滚动提供商"
|
|
|
|
|
|
@click="scrollProviderLogos(-1)"
|
2025-12-16 12:21:21 +08:00
|
|
|
|
>
|
2026-07-10 12:18:42 +08:00
|
|
|
|
<ChevronLeft class="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref="providerLogoScroller"
|
|
|
|
|
|
class="mx-11 flex gap-2 overflow-x-auto p-2 scrollbar-hide"
|
|
|
|
|
|
>
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-for="group in groupedModels"
|
|
|
|
|
|
:key="group.providerId"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="w-[76px] shrink-0 rounded-md border px-2 py-1.5 flex flex-col items-center gap-1 transition-colors"
|
|
|
|
|
|
:class="expandedProvider === group.providerId
|
|
|
|
|
|
? 'border-primary bg-primary/10 text-primary'
|
|
|
|
|
|
: 'border-transparent hover:border-border hover:bg-muted'"
|
|
|
|
|
|
:title="`${group.providerName}(${group.models.length})`"
|
|
|
|
|
|
@click="toggleProvider(group.providerId)"
|
2025-12-16 12:21:21 +08:00
|
|
|
|
>
|
2026-07-10 12:18:42 +08:00
|
|
|
|
<img
|
|
|
|
|
|
:src="getProviderLogoUrl(group.providerId)"
|
|
|
|
|
|
:alt="group.providerName"
|
|
|
|
|
|
class="w-7 h-7 rounded object-contain dark:invert dark:brightness-90"
|
|
|
|
|
|
@error="handleLogoError"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="w-full truncate text-[10px] font-medium text-center">{{ group.providerName }}</span>
|
|
|
|
|
|
</button>
|
2025-12-16 12:21:21 +08:00
|
|
|
|
</div>
|
2026-07-10 12:18:42 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="absolute right-1 top-1/2 z-10 h-8 w-8 -translate-y-1/2 bg-background/95 shadow-sm"
|
|
|
|
|
|
title="向右滚动"
|
|
|
|
|
|
aria-label="向右滚动提供商"
|
|
|
|
|
|
@click="scrollProviderLogos(1)"
|
2025-12-16 12:21:21 +08:00
|
|
|
|
>
|
2026-07-10 12:18:42 +08:00
|
|
|
|
<ChevronRight class="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 当前提供商模型 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="expandedProviderGroup"
|
|
|
|
|
|
class="flex-1 min-h-0 overflow-y-auto p-2 scrollbar-thin"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
2026-07-24 01:45:38 +08:00
|
|
|
|
<div
|
2026-07-10 12:18:42 +08:00
|
|
|
|
v-for="item in expandedProviderGroup.models"
|
2026-02-22 00:43:41 +08:00
|
|
|
|
:key="item.modelId"
|
2026-07-24 01:45:38 +08:00
|
|
|
|
class="relative min-w-0"
|
2025-12-16 12:21:21 +08:00
|
|
|
|
>
|
2026-07-24 01:45:38 +08:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="group relative flex h-full min-h-[152px] w-full min-w-0 flex-col rounded-lg border bg-card p-4 text-left shadow-sm transition-[border-color,box-shadow,transform,background-color] duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
|
|
|
|
|
:class="getExistingModel(item)
|
|
|
|
|
|
? 'cursor-default border-border/70'
|
|
|
|
|
|
: selectedModel?.modelId === item.modelId && selectedModel?.providerId === item.providerId
|
|
|
|
|
|
? 'cursor-pointer border-primary bg-primary/5 ring-1 ring-primary hover:-translate-y-0.5 hover:border-primary/35 hover:shadow-md active:scale-[0.96]'
|
|
|
|
|
|
: 'cursor-pointer border-border/70 hover:-translate-y-0.5 hover:border-primary/35 hover:shadow-md active:scale-[0.96]'"
|
|
|
|
|
|
:disabled="!!getExistingModel(item)"
|
|
|
|
|
|
@click="selectModel(item)"
|
2026-07-10 12:18:42 +08:00
|
|
|
|
>
|
2026-07-24 01:45:38 +08:00
|
|
|
|
<span
|
|
|
|
|
|
v-if="selectedModel?.modelId === item.modelId && selectedModel?.providerId === item.providerId"
|
|
|
|
|
|
class="absolute right-2.5 top-2.5 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Check class="h-3 w-3" />
|
2026-07-10 12:18:42 +08:00
|
|
|
|
</span>
|
2026-07-24 01:45:38 +08:00
|
|
|
|
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="flex w-full items-start gap-2"
|
|
|
|
|
|
:class="getExistingModel(item)
|
|
|
|
|
|
? 'pr-24'
|
|
|
|
|
|
: selectedModel?.modelId === item.modelId && selectedModel?.providerId === item.providerId
|
|
|
|
|
|
? 'pr-7'
|
|
|
|
|
|
: ''"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="min-w-0 flex-1">
|
|
|
|
|
|
<span class="block truncate text-sm font-semibold leading-5">{{ item.modelName }}</span>
|
|
|
|
|
|
<span class="block truncate font-mono text-[10px] text-muted-foreground">{{ item.modelId }}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
|
|
<span class="mt-2 flex min-h-5 flex-wrap gap-1">
|
2026-07-23 13:29:28 +08:00
|
|
|
|
<span
|
2026-07-24 01:45:38 +08:00
|
|
|
|
v-if="item.supportsReasoning"
|
|
|
|
|
|
class="inline-flex items-center gap-1 rounded-md border border-violet-500/20 bg-violet-500/10 px-1.5 py-0.5 text-[9px] font-medium text-violet-700 dark:text-violet-300"
|
2026-07-23 13:29:28 +08:00
|
|
|
|
>
|
2026-07-24 01:45:38 +08:00
|
|
|
|
<BrainCircuit class="h-2.5 w-2.5" />推理
|
2026-07-23 13:29:28 +08:00
|
|
|
|
</span>
|
2026-07-23 15:18:08 +08:00
|
|
|
|
<span
|
2026-07-24 01:45:38 +08:00
|
|
|
|
v-if="item.supportsVision"
|
|
|
|
|
|
class="inline-flex items-center gap-1 rounded-md border border-sky-500/20 bg-sky-500/10 px-1.5 py-0.5 text-[9px] font-medium text-sky-700 dark:text-sky-300"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Eye class="h-2.5 w-2.5" />视觉
|
|
|
|
|
|
</span>
|
2026-07-23 13:29:28 +08:00
|
|
|
|
<span
|
2026-07-24 01:45:38 +08:00
|
|
|
|
v-if="item.supportsToolCall"
|
|
|
|
|
|
class="inline-flex items-center gap-1 rounded-md border border-amber-500/20 bg-amber-500/10 px-1.5 py-0.5 text-[9px] font-medium text-amber-700 dark:text-amber-300"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Wrench class="h-2.5 w-2.5" />工具
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="item.supportsStructuredOutput"
|
|
|
|
|
|
class="inline-flex items-center gap-1 rounded-md border border-emerald-500/20 bg-emerald-500/10 px-1.5 py-0.5 text-[9px] font-medium text-emerald-700 dark:text-emerald-300"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Braces class="h-2.5 w-2.5" />结构化
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="item.supportsEmbedding"
|
|
|
|
|
|
class="inline-flex items-center gap-1 rounded-md border border-fuchsia-500/20 bg-fuchsia-500/10 px-1.5 py-0.5 text-[9px] font-medium text-fuchsia-700 dark:text-fuchsia-300"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Database class="h-2.5 w-2.5" />Embedding
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="item.openWeights"
|
|
|
|
|
|
class="inline-flex items-center gap-1 rounded-md border border-border bg-muted/70 px-1.5 py-0.5 text-[9px] font-medium text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
<PackageOpen class="h-2.5 w-2.5" />开放权重
|
|
|
|
|
|
</span>
|
2026-07-23 13:29:28 +08:00
|
|
|
|
</span>
|
2026-07-10 12:18:42 +08:00
|
|
|
|
|
2026-07-24 01:45:38 +08:00
|
|
|
|
<span class="mt-auto flex w-full items-end justify-between gap-2 border-t border-border/60 pt-2 text-[9px] text-muted-foreground">
|
|
|
|
|
|
<span class="flex min-w-0 flex-col">
|
|
|
|
|
|
<span v-if="item.contextLimit">上下文 {{ formatTokenLimit(item.contextLimit) }}</span>
|
|
|
|
|
|
<span v-else>上下文未知</span>
|
|
|
|
|
|
<span v-if="item.outputLimit">输出 {{ formatTokenLimit(item.outputLimit) }}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="item.inputPrice !== undefined || item.outputPrice !== undefined"
|
|
|
|
|
|
class="shrink-0 text-right font-medium tabular-nums text-foreground/70"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="block">输入 ${{ formatModelPrice(item.inputPrice) }}/M</span>
|
|
|
|
|
|
<span class="block">输出 ${{ formatModelPrice(item.outputPrice) }}/M</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-else-if="item.releaseDate"
|
|
|
|
|
|
class="shrink-0"
|
|
|
|
|
|
>{{ item.releaseDate }}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="getExistingModel(item)"
|
|
|
|
|
|
class="absolute right-2.5 top-2.5 z-10 flex items-center gap-1.5"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="inline-flex h-6 items-center rounded-md bg-muted px-2 text-[10px] font-medium text-muted-foreground">已添加</span>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-7 w-7 bg-background/90 text-muted-foreground shadow-sm hover:text-foreground"
|
|
|
|
|
|
title="去编辑"
|
|
|
|
|
|
:aria-label="`编辑 ${item.modelName}`"
|
|
|
|
|
|
:data-testid="`edit-existing-model-${item.modelId}`"
|
|
|
|
|
|
@click="editExistingModel(item)"
|
2026-07-10 12:18:42 +08:00
|
|
|
|
>
|
2026-07-24 01:45:38 +08:00
|
|
|
|
<SquarePen class="h-3.5 w-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-12-16 12:21:21 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
2026-07-10 12:18:42 +08:00
|
|
|
|
v-else-if="groupedModels.length > 0"
|
|
|
|
|
|
class="flex flex-1 items-center justify-center text-xs text-muted-foreground"
|
2025-12-16 12:21:21 +08:00
|
|
|
|
>
|
2026-07-10 12:18:42 +08:00
|
|
|
|
点击提供商 Logo 展开模型
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="flex flex-1 items-center justify-center text-sm text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ searchQuery ? '未找到模型' : '暂无可用模型' }}
|
2025-12-16 12:21:21 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
2026-07-10 12:18:42 +08:00
|
|
|
|
</section>
|
2025-12-16 12:21:21 +08:00
|
|
|
|
|
2026-07-10 12:18:42 +08:00
|
|
|
|
<!-- 第二步:详细信息表单 -->
|
2025-12-16 12:21:21 +08:00
|
|
|
|
<div
|
2026-07-10 12:18:42 +08:00
|
|
|
|
v-if="isEditMode || presetPanelCollapsed"
|
|
|
|
|
|
class="flex-1 min-h-0 overflow-y-auto scrollbar-thin"
|
2025-12-16 12:21:21 +08:00
|
|
|
|
:class="isEditMode ? 'max-h-[70vh]' : ''"
|
|
|
|
|
|
>
|
2026-07-10 12:18:42 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="!isEditMode"
|
|
|
|
|
|
class="mb-4 flex items-center gap-3 rounded-lg border bg-muted/20 px-4 py-3"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="selectedModel"
|
|
|
|
|
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border bg-background"
|
|
|
|
|
|
>
|
|
|
|
|
|
<img
|
|
|
|
|
|
:src="getProviderLogoUrl(selectedModel.providerId)"
|
|
|
|
|
|
:alt="selectedModel.providerName"
|
|
|
|
|
|
class="h-7 w-7 rounded object-contain dark:invert dark:brightness-90"
|
|
|
|
|
|
@error="handleLogoError"
|
|
|
|
|
|
>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border bg-background"
|
|
|
|
|
|
>
|
|
|
|
|
|
<SquarePen class="h-5 w-5 text-muted-foreground" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="min-w-0 flex-1">
|
|
|
|
|
|
<div class="text-xs text-muted-foreground">
|
|
|
|
|
|
{{ selectedModel ? `已加载 ${selectedModel.providerName} 预设` : '手动填写模式' }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="truncate text-sm font-medium">
|
|
|
|
|
|
{{ selectedModel ? selectedModel.modelName : '填写模型详细信息' }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="selectedModel"
|
|
|
|
|
|
class="truncate text-xs text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ selectedModel.modelId }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-07-24 01:45:38 +08:00
|
|
|
|
<div class="flex shrink-0 items-center gap-2">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="shrink-0"
|
|
|
|
|
|
@click="reopenPresetPanel"
|
|
|
|
|
|
>
|
|
|
|
|
|
返回选择模型
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2026-07-10 12:18:42 +08:00
|
|
|
|
</div>
|
2025-12-16 12:21:21 +08:00
|
|
|
|
<form
|
|
|
|
|
|
class="space-y-5"
|
|
|
|
|
|
@submit.prevent="handleSubmit"
|
|
|
|
|
|
>
|
|
|
|
|
|
<!-- 基本信息 -->
|
2026-07-10 12:18:42 +08:00
|
|
|
|
<section
|
|
|
|
|
|
ref="basicInfoSection"
|
|
|
|
|
|
class="space-y-3 rounded-lg border bg-card p-4"
|
|
|
|
|
|
>
|
2025-12-16 12:21:21 +08:00
|
|
|
|
<h4 class="font-medium text-sm">
|
|
|
|
|
|
基本信息
|
|
|
|
|
|
</h4>
|
|
|
|
|
|
<div class="grid grid-cols-2 gap-3">
|
|
|
|
|
|
<div class="space-y-1.5">
|
|
|
|
|
|
<Label
|
2026-02-03 18:48:39 +08:00
|
|
|
|
for="model-display-name"
|
2025-12-16 12:21:21 +08:00
|
|
|
|
class="text-xs"
|
2026-02-03 18:48:39 +08:00
|
|
|
|
>名称 *</Label>
|
2025-12-16 12:21:21 +08:00
|
|
|
|
<Input
|
2026-02-03 18:48:39 +08:00
|
|
|
|
id="model-display-name"
|
|
|
|
|
|
v-model="form.display_name"
|
|
|
|
|
|
placeholder="Claude 3.5 Sonnet"
|
2025-12-16 12:21:21 +08:00
|
|
|
|
required
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="space-y-1.5">
|
|
|
|
|
|
<Label
|
2026-02-03 18:48:39 +08:00
|
|
|
|
for="model-name"
|
2025-12-16 12:21:21 +08:00
|
|
|
|
class="text-xs"
|
2026-02-03 18:48:39 +08:00
|
|
|
|
>模型ID *</Label>
|
2025-12-16 12:21:21 +08:00
|
|
|
|
<Input
|
2026-02-03 18:48:39 +08:00
|
|
|
|
id="model-name"
|
|
|
|
|
|
v-model="form.name"
|
|
|
|
|
|
placeholder="claude-3-5-sonnet-20241022"
|
|
|
|
|
|
:disabled="isEditMode"
|
2025-12-16 12:21:21 +08:00
|
|
|
|
required
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="space-y-1.5">
|
|
|
|
|
|
<Label
|
|
|
|
|
|
for="model-description"
|
|
|
|
|
|
class="text-xs"
|
|
|
|
|
|
>描述</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="model-description"
|
2026-05-03 17:32:41 +08:00
|
|
|
|
:model-value="getConfigInputValue('description')"
|
2025-12-16 12:21:21 +08:00
|
|
|
|
placeholder="简短描述此模型的特点"
|
|
|
|
|
|
@update:model-value="(v) => setConfigField('description', v || undefined)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-02-19 10:40:49 +08:00
|
|
|
|
<div class="grid grid-cols-2 gap-3">
|
|
|
|
|
|
<div class="space-y-1.5">
|
|
|
|
|
|
<Label
|
|
|
|
|
|
for="model-output-limit"
|
|
|
|
|
|
class="text-xs"
|
|
|
|
|
|
>最大输出 Token</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="model-output-limit"
|
2026-05-03 17:32:41 +08:00
|
|
|
|
:model-value="getConfigInputValue('output_limit')"
|
2026-02-19 10:40:49 +08:00
|
|
|
|
type="number"
|
|
|
|
|
|
min="1"
|
|
|
|
|
|
placeholder="如 8192"
|
|
|
|
|
|
@update:model-value="(v) => setConfigField('output_limit', parseNumberInput(v, { allowFloat: false }))"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="space-y-1.5">
|
|
|
|
|
|
<Label
|
|
|
|
|
|
for="model-context-limit"
|
|
|
|
|
|
class="text-xs"
|
|
|
|
|
|
>上下文窗口</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="model-context-limit"
|
2026-05-03 17:32:41 +08:00
|
|
|
|
:model-value="getConfigInputValue('context_limit')"
|
2026-02-19 10:40:49 +08:00
|
|
|
|
type="number"
|
|
|
|
|
|
min="1"
|
|
|
|
|
|
placeholder="如 200000"
|
|
|
|
|
|
@update:model-value="(v) => setConfigField('context_limit', parseNumberInput(v, { allowFloat: false }))"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-03 17:32:41 +08:00
|
|
|
|
<div class="rounded-lg border border-border/60 bg-muted/20 p-3 space-y-2">
|
|
|
|
|
|
<div class="flex items-start gap-2">
|
|
|
|
|
|
<Checkbox
|
|
|
|
|
|
:model-value="isEmbeddingEnabled"
|
|
|
|
|
|
class="mt-0.5"
|
|
|
|
|
|
@update:model-value="setEmbeddingEnabled"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div class="space-y-1">
|
|
|
|
|
|
<div class="text-sm font-medium">
|
|
|
|
|
|
Embedding
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p class="text-xs text-muted-foreground">
|
|
|
|
|
|
标记为 Embeddings 模型,并使用独立的 embedding API 格式,不按 Chat 模型处理。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="isEmbeddingEnabled"
|
|
|
|
|
|
class="flex flex-wrap gap-1.5"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-for="format in embeddingApiFormats"
|
|
|
|
|
|
:key="format"
|
|
|
|
|
|
class="rounded-md border border-border/60 bg-background px-2 py-0.5 text-[11px] font-mono text-muted-foreground"
|
|
|
|
|
|
>{{ format }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-12-16 12:21:21 +08:00
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 价格配置 -->
|
2026-07-23 13:29:28 +08:00
|
|
|
|
<section
|
|
|
|
|
|
class="space-y-3 rounded-lg border bg-card p-4"
|
|
|
|
|
|
>
|
2026-07-24 01:45:38 +08:00
|
|
|
|
<div class="flex items-center justify-between gap-2">
|
|
|
|
|
|
<h4 class="font-medium text-sm">
|
|
|
|
|
|
选择计费模式
|
2026-07-23 13:29:28 +08:00
|
|
|
|
</h4>
|
2026-07-24 01:45:38 +08:00
|
|
|
|
<Popover
|
|
|
|
|
|
v-if="isEditMode"
|
|
|
|
|
|
:open="onlinePricingSourcePopoverOpen"
|
|
|
|
|
|
:modal="false"
|
|
|
|
|
|
@update:open="handleOnlinePricingSourcePopoverUpdate"
|
|
|
|
|
|
>
|
|
|
|
|
|
<PopoverTrigger as-child>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8 shrink-0"
|
|
|
|
|
|
:disabled="syncingOnlinePricing || submitting"
|
|
|
|
|
|
:title="syncingOnlinePricing ? '正在同步在线价格' : '同步最新在线价格'"
|
|
|
|
|
|
aria-label="同步最新在线价格"
|
|
|
|
|
|
data-testid="sync-online-pricing"
|
|
|
|
|
|
@click="syncOnlinePricing"
|
|
|
|
|
|
>
|
|
|
|
|
|
<RefreshCw
|
|
|
|
|
|
class="h-4 w-4"
|
|
|
|
|
|
:class="syncingOnlinePricing ? 'animate-spin' : ''"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
|
<PopoverContent
|
|
|
|
|
|
class="z-[130] w-80 max-w-[calc(100vw-2rem)] p-3"
|
|
|
|
|
|
side="bottom"
|
|
|
|
|
|
align="end"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="space-y-3">
|
|
|
|
|
|
<div class="flex items-center justify-between gap-3">
|
|
|
|
|
|
<div class="min-w-0">
|
|
|
|
|
|
<div class="truncate text-xs font-medium">
|
|
|
|
|
|
选择在线价格来源
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="truncate text-[10px] text-muted-foreground">
|
|
|
|
|
|
{{ props.model?.display_name || props.model?.name || '当前模型' }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Loader2
|
|
|
|
|
|
v-if="syncingOnlinePricing"
|
|
|
|
|
|
class="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-07-23 13:29:28 +08:00
|
|
|
|
|
2026-07-24 01:45:38 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="syncingOnlinePricing && onlinePricingCandidates.length === 0"
|
|
|
|
|
|
class="flex items-center justify-center py-5 text-xs text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
正在刷新在线价格...
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="max-h-64 space-y-1.5 overflow-y-auto pr-0.5"
|
|
|
|
|
|
role="radiogroup"
|
|
|
|
|
|
aria-label="在线价格来源"
|
|
|
|
|
|
@keydown="handleOnlinePricingSourcePopoverKeydown"
|
|
|
|
|
|
>
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-for="candidate in onlinePricingCandidates"
|
|
|
|
|
|
:key="candidate.providerId"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="radio"
|
|
|
|
|
|
class="flex w-full items-center gap-2 rounded-md border px-2.5 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:cursor-not-allowed disabled:opacity-55"
|
|
|
|
|
|
:class="selectedOnlinePricingProviderId === candidate.providerId
|
|
|
|
|
|
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
|
|
|
|
|
: 'border-border/70 hover:border-primary/35 hover:bg-muted/40'"
|
|
|
|
|
|
:aria-checked="selectedOnlinePricingProviderId === candidate.providerId"
|
|
|
|
|
|
:disabled="syncingOnlinePricing || !isOnlinePricingCandidateSyncable(candidate)"
|
|
|
|
|
|
:tabindex="selectedOnlinePricingProviderId === candidate.providerId
|
|
|
|
|
|
|| (!selectedOnlinePricingProviderId
|
|
|
|
|
|
&& firstSyncableOnlinePricingProviderId === candidate.providerId)
|
|
|
|
|
|
? 0
|
|
|
|
|
|
: -1"
|
|
|
|
|
|
data-online-pricing-source-control
|
|
|
|
|
|
data-online-pricing-source-option
|
|
|
|
|
|
:data-provider-id="candidate.providerId"
|
|
|
|
|
|
:data-testid="`online-pricing-source-${candidate.providerId}`"
|
|
|
|
|
|
@click="selectedOnlinePricingProviderId = candidate.providerId"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="flex h-7 w-7 shrink-0 items-center justify-center rounded border bg-background">
|
|
|
|
|
|
<img
|
|
|
|
|
|
:src="getProviderLogoUrl(candidate.providerId)"
|
|
|
|
|
|
:alt="candidate.providerName"
|
|
|
|
|
|
class="h-5 w-5 rounded object-contain dark:invert dark:brightness-90"
|
|
|
|
|
|
@error="handleLogoError"
|
|
|
|
|
|
>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span class="min-w-0 flex-1">
|
|
|
|
|
|
<span class="block truncate text-xs font-medium">{{ candidate.providerName }}</span>
|
|
|
|
|
|
<span class="block truncate font-mono text-[9px] text-muted-foreground">{{ candidate.providerId }}</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="getOnlinePricingCandidateUnavailableReason(candidate)"
|
|
|
|
|
|
class="block truncate text-[10px] text-rose-600 dark:text-rose-400"
|
|
|
|
|
|
>{{ getOnlinePricingCandidateUnavailableReason(candidate) }}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="isOnlinePricingCandidateSyncable(candidate)"
|
|
|
|
|
|
class="shrink-0 text-right text-[9px] tabular-nums text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="block">输入 ${{ formatModelPrice(candidate.inputPrice) }}/M</span>
|
|
|
|
|
|
<span class="block">输出 ${{ formatModelPrice(candidate.outputPrice) }}/M</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full border"
|
|
|
|
|
|
:class="selectedOnlinePricingProviderId === candidate.providerId
|
|
|
|
|
|
? 'border-primary bg-primary text-primary-foreground'
|
|
|
|
|
|
: 'border-border'"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Check
|
|
|
|
|
|
v-if="selectedOnlinePricingProviderId === candidate.providerId"
|
|
|
|
|
|
class="h-2.5 w-2.5"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="onlinePricingCandidates.length === 0"
|
|
|
|
|
|
class="py-4 text-center text-xs text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
暂无可用在线价格来源
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="flex justify-end gap-2 border-t border-border/60 pt-2">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
:disabled="syncingOnlinePricing"
|
|
|
|
|
|
data-online-pricing-source-control
|
|
|
|
|
|
data-testid="online-pricing-source-cancel"
|
|
|
|
|
|
@keydown="handleOnlinePricingSourcePopoverKeydown"
|
|
|
|
|
|
@click="closeOnlinePricingSourcePopover"
|
|
|
|
|
|
>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
:disabled="!selectedOnlinePricingCandidate
|
|
|
|
|
|
|| !isOnlinePricingCandidateSyncable(selectedOnlinePricingCandidate)
|
|
|
|
|
|
|| syncingOnlinePricing"
|
|
|
|
|
|
data-online-pricing-source-control
|
|
|
|
|
|
@keydown="handleOnlinePricingSourcePopoverKeydown"
|
|
|
|
|
|
@click="confirmOnlinePricingSource"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Loader2
|
|
|
|
|
|
v-if="syncingOnlinePricing"
|
|
|
|
|
|
class="mr-1.5 h-3.5 w-3.5 animate-spin"
|
|
|
|
|
|
/>
|
|
|
|
|
|
{{ syncingOnlinePricing ? '同步中...' : '同步此来源' }}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
</div>
|
2026-07-10 12:18:42 +08:00
|
|
|
|
<Tabs
|
|
|
|
|
|
v-model="billingMode"
|
|
|
|
|
|
@update:model-value="handleBillingModeChange"
|
|
|
|
|
|
>
|
|
|
|
|
|
<TabsList class="grid w-full grid-cols-4">
|
|
|
|
|
|
<TabsTrigger value="token">
|
|
|
|
|
|
Token
|
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
|
<TabsTrigger value="request">
|
|
|
|
|
|
按次
|
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
|
<TabsTrigger value="image">
|
|
|
|
|
|
图片
|
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
|
<TabsTrigger value="video">
|
|
|
|
|
|
视频
|
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
|
</TabsList>
|
|
|
|
|
|
|
|
|
|
|
|
<TieredPricingEditor
|
|
|
|
|
|
v-show="billingMode === 'token' || billingMode === 'image'"
|
|
|
|
|
|
ref="tieredPricingEditorRef"
|
|
|
|
|
|
v-model="tieredPricing"
|
|
|
|
|
|
class="mt-3"
|
2026-07-16 16:30:51 +08:00
|
|
|
|
:auto-fill-missing-cache-prices="autoFillMissingCachePrices"
|
2026-07-10 12:18:42 +08:00
|
|
|
|
:show-token-pricing="billingMode === 'token'"
|
|
|
|
|
|
:show-image-pricing="isImageGenerationEnabled"
|
|
|
|
|
|
:show-image-editor="billingMode === 'image'"
|
2026-07-19 22:00:12 +08:00
|
|
|
|
:show-processing-tier-controls="false"
|
2026-07-16 23:26:47 +08:00
|
|
|
|
:show-processing-tier-multiplier-controls="true"
|
2025-12-16 12:21:21 +08:00
|
|
|
|
/>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
|
2026-07-10 12:18:42 +08:00
|
|
|
|
<TabsContent
|
|
|
|
|
|
value="request"
|
|
|
|
|
|
class="pt-2"
|
2026-02-03 18:48:39 +08:00
|
|
|
|
>
|
2026-07-10 12:18:42 +08:00
|
|
|
|
<div class="rounded-lg border bg-muted/20 p-4 space-y-2">
|
|
|
|
|
|
<Label class="text-xs">每次请求价格(美元)</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
:model-value="form.default_price_per_request ?? ''"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
step="0.001"
|
|
|
|
|
|
min="0"
|
|
|
|
|
|
class="max-w-48"
|
|
|
|
|
|
placeholder="如 0.01"
|
|
|
|
|
|
@update:model-value="(v) => form.default_price_per_request = parseNumberInput(v, { allowFloat: true })"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<p class="text-xs text-muted-foreground">
|
|
|
|
|
|
按每次 API 请求收取固定费用,可与 Token 计费同时使用。
|
|
|
|
|
|
</p>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</div>
|
2026-07-10 12:18:42 +08:00
|
|
|
|
</TabsContent>
|
|
|
|
|
|
|
|
|
|
|
|
<TabsContent
|
|
|
|
|
|
value="video"
|
|
|
|
|
|
class="pt-2"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="space-y-3 rounded-lg border bg-muted/20 p-4">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div class="text-sm font-medium">
|
|
|
|
|
|
视频计费(分辨率 × 时长)
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p class="mt-1 text-xs text-muted-foreground">
|
|
|
|
|
|
根据输出分辨率配置每秒视频价格。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="flex items-center gap-1.5 flex-wrap">
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
2026-07-10 12:18:42 +08:00
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-7 text-xs"
|
|
|
|
|
|
@click="fillVideoResolutionPricePreset('common')"
|
2026-02-03 18:48:39 +08:00
|
|
|
|
>
|
2026-07-10 12:18:42 +08:00
|
|
|
|
通用
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-7 text-xs"
|
|
|
|
|
|
@click="fillVideoResolutionPricePreset('sora')"
|
|
|
|
|
|
>
|
|
|
|
|
|
Sora
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-7 text-xs"
|
|
|
|
|
|
@click="fillVideoResolutionPricePreset('veo')"
|
|
|
|
|
|
>
|
|
|
|
|
|
Veo
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-7 text-xs"
|
|
|
|
|
|
@click="addVideoResolutionPriceRow"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Plus class="w-3.5 h-3.5 mr-0.5" />
|
|
|
|
|
|
自定义
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2026-07-10 12:18:42 +08:00
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="videoResolutionPrices.length > 0"
|
|
|
|
|
|
class="rounded-lg border border-border overflow-hidden"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="grid grid-cols-[1fr_1fr_32px] gap-0 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 border-b border-border">
|
|
|
|
|
|
<span>分辨率</span>
|
|
|
|
|
|
<span>单价($/秒)</span>
|
|
|
|
|
|
<span />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="divide-y divide-border">
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="(row, idx) in videoResolutionPrices"
|
|
|
|
|
|
:key="idx"
|
|
|
|
|
|
class="grid grid-cols-[1fr_1fr_32px] gap-2 items-center px-3 py-1.5"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
v-model="row.resolution"
|
|
|
|
|
|
class="h-7 text-sm"
|
|
|
|
|
|
placeholder="如 720p"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
:model-value="row.price_per_second ?? ''"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
step="0.0001"
|
|
|
|
|
|
min="0"
|
|
|
|
|
|
class="h-7 text-sm"
|
|
|
|
|
|
placeholder="0"
|
|
|
|
|
|
@update:model-value="(v) => row.price_per_second = parseNumberInput(v, { allowFloat: true })"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-7 w-7"
|
|
|
|
|
|
title="删除"
|
|
|
|
|
|
@click="removeVideoResolutionPriceRow(idx)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="rounded-lg border border-dashed py-8 text-center text-xs text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
选择一个价格预设或添加自定义分辨率
|
|
|
|
|
|
</div>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</div>
|
2026-07-10 12:18:42 +08:00
|
|
|
|
</TabsContent>
|
|
|
|
|
|
</Tabs>
|
2025-12-16 12:21:21 +08:00
|
|
|
|
</section>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
<template #footer>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
@click="handleCancel"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
取消
|
|
|
|
|
|
</Button>
|
2025-12-12 16:15:36 +08:00
|
|
|
|
<Button
|
2026-07-10 12:18:42 +08:00
|
|
|
|
v-if="!isEditMode && !presetPanelCollapsed"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
@click="enterManualEntryMode"
|
|
|
|
|
|
>
|
|
|
|
|
|
手动填写
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
v-if="isEditMode || presetPanelCollapsed"
|
2026-07-24 01:45:38 +08:00
|
|
|
|
:disabled="submitting || syncingOnlinePricing || !form.name || !form.display_name"
|
2025-12-12 16:15:36 +08:00
|
|
|
|
@click="handleSubmit"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Loader2
|
|
|
|
|
|
v-if="submitting"
|
|
|
|
|
|
class="w-4 h-4 mr-2 animate-spin"
|
|
|
|
|
|
/>
|
2026-07-24 01:45:38 +08:00
|
|
|
|
{{ isEditMode ? '保存' : '添加' }}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</Button>
|
2025-12-16 12:21:21 +08:00
|
|
|
|
<Button
|
2026-07-10 12:18:42 +08:00
|
|
|
|
v-if="selectedModel && !isEditMode && presetPanelCollapsed"
|
2025-12-16 12:21:21 +08:00
|
|
|
|
type="button"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
@click="clearSelection"
|
|
|
|
|
|
>
|
|
|
|
|
|
清空
|
|
|
|
|
|
</Button>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-07-10 12:18:42 +08:00
|
|
|
|
import { ref, computed, nextTick, watch } from 'vue'
|
2025-12-16 12:21:21 +08:00
|
|
|
|
import {
|
2026-02-03 18:48:39 +08:00
|
|
|
|
Loader2, Layers, SquarePen,
|
2026-07-10 12:18:42 +08:00
|
|
|
|
Search, ChevronLeft, ChevronRight, Plus, Trash2, Check,
|
2026-07-24 01:45:38 +08:00
|
|
|
|
BrainCircuit, Eye, Wrench, Braces, Database, PackageOpen, RefreshCw
|
2025-12-16 12:21:21 +08:00
|
|
|
|
} from 'lucide-vue-next'
|
2026-07-10 12:18:42 +08:00
|
|
|
|
import {
|
2026-07-23 15:18:08 +08:00
|
|
|
|
Dialog, Button, Input, Label, Checkbox,
|
2026-07-10 12:18:42 +08:00
|
|
|
|
Tabs, TabsContent, TabsList, TabsTrigger,
|
2026-07-24 01:45:38 +08:00
|
|
|
|
Popover, PopoverTrigger, PopoverContent,
|
2026-07-10 12:18:42 +08:00
|
|
|
|
} from '@/components/ui'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import { useToast } from '@/composables/useToast'
|
|
|
|
|
|
import { useFormDialog } from '@/composables/useFormDialog'
|
2026-02-03 18:48:39 +08:00
|
|
|
|
import { parseNumberInput, sortResolutionEntries } from '@/utils/form'
|
2025-12-12 16:15:36 +08:00
|
|
|
|
import { log } from '@/utils/logger'
|
2026-02-22 00:43:41 +08:00
|
|
|
|
import { parseApiError } from '@/utils/errorParser'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import TieredPricingEditor from './TieredPricingEditor.vue'
|
2025-12-16 12:21:21 +08:00
|
|
|
|
import {
|
|
|
|
|
|
getModelsDevList,
|
|
|
|
|
|
getProviderLogoUrl,
|
2026-07-24 01:45:38 +08:00
|
|
|
|
refreshModelsDevList,
|
2025-12-16 12:21:21 +08:00
|
|
|
|
type ModelsDevModelItem,
|
|
|
|
|
|
} from '@/api/models-dev'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import {
|
|
|
|
|
|
createGlobalModel,
|
2026-07-23 13:29:28 +08:00
|
|
|
|
listGlobalModels,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
updateGlobalModel,
|
|
|
|
|
|
type GlobalModelResponse,
|
|
|
|
|
|
} from '@/api/global-models'
|
|
|
|
|
|
import type { TieredPricingConfig } from '@/api/endpoints/types'
|
2026-05-03 17:32:41 +08:00
|
|
|
|
import {
|
|
|
|
|
|
EMBEDDING_API_FORMATS,
|
|
|
|
|
|
buildGlobalModelCreatePayload,
|
|
|
|
|
|
buildGlobalModelUpdatePayload,
|
2026-07-15 23:47:19 +08:00
|
|
|
|
cloneTieredPricingConfig,
|
2026-07-23 13:29:28 +08:00
|
|
|
|
findGlobalModelByName,
|
|
|
|
|
|
tieredPricingConfigsEqual,
|
2026-05-03 17:32:41 +08:00
|
|
|
|
} from './global-model-form-helpers'
|
2026-07-11 12:27:09 +08:00
|
|
|
|
import { tieredPricingHasImageOutputPricing } from '../utils/tiered-pricing'
|
2026-07-23 15:18:08 +08:00
|
|
|
|
import { useModelsDevPricingSources } from '../composables/useModelsDevPricingSources'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
|
|
open: boolean
|
|
|
|
|
|
model?: GlobalModelResponse | null
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
|
'update:open': [value: boolean]
|
|
|
|
|
|
'success': []
|
2026-07-24 01:45:38 +08:00
|
|
|
|
'editModel': [model: GlobalModelResponse]
|
|
|
|
|
|
'pricingSynced': [model: GlobalModelResponse]
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const { success, error: showError } = useToast()
|
2026-07-23 15:18:08 +08:00
|
|
|
|
const { getSource, setSource } = useModelsDevPricingSources()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const submitting = ref(false)
|
2026-07-24 01:45:38 +08:00
|
|
|
|
const syncingOnlinePricing = ref(false)
|
2025-12-12 15:43:00 +08:00
|
|
|
|
const tieredPricingEditorRef = ref<InstanceType<typeof TieredPricingEditor> | null>(null)
|
2026-07-10 12:18:42 +08:00
|
|
|
|
const basicInfoSection = ref<HTMLElement | null>(null)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2025-12-16 12:21:21 +08:00
|
|
|
|
// 模型列表相关
|
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
const searchQuery = ref('')
|
2025-12-16 17:28:40 +08:00
|
|
|
|
const allModelsCache = ref<ModelsDevModelItem[]>([]) // 全部模型(缓存)
|
2026-07-23 13:29:28 +08:00
|
|
|
|
const existingModelsCache = ref<GlobalModelResponse[]>([])
|
2025-12-16 12:21:21 +08:00
|
|
|
|
const selectedModel = ref<ModelsDevModelItem | null>(null)
|
|
|
|
|
|
const expandedProvider = ref<string | null>(null)
|
2026-07-10 12:18:42 +08:00
|
|
|
|
const providerLogoScroller = ref<HTMLElement | null>(null)
|
|
|
|
|
|
const presetPanelCollapsed = ref(false)
|
|
|
|
|
|
const billingMode = ref('token')
|
2026-07-24 01:45:38 +08:00
|
|
|
|
const editingOnlinePricingSource = ref<{
|
|
|
|
|
|
model_id: string
|
|
|
|
|
|
provider_id: string
|
|
|
|
|
|
provider_name: string
|
|
|
|
|
|
} | null>(null)
|
|
|
|
|
|
const onlinePricingSourcePopoverOpen = ref(false)
|
|
|
|
|
|
const onlinePricingCandidates = ref<ModelsDevModelItem[]>([])
|
|
|
|
|
|
const selectedOnlinePricingProviderId = ref('')
|
|
|
|
|
|
|
|
|
|
|
|
const selectedOnlinePricingCandidate = computed(() => (
|
|
|
|
|
|
onlinePricingCandidates.value.find(candidate => (
|
|
|
|
|
|
candidate.providerId === selectedOnlinePricingProviderId.value
|
|
|
|
|
|
)) ?? null
|
|
|
|
|
|
))
|
|
|
|
|
|
const firstSyncableOnlinePricingProviderId = computed(() => (
|
|
|
|
|
|
onlinePricingCandidates.value.find(isOnlinePricingCandidateSyncable)?.providerId ?? ''
|
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
watch([onlinePricingSourcePopoverOpen, syncingOnlinePricing], ([open, syncing]) => {
|
|
|
|
|
|
if (!open || syncing) return
|
|
|
|
|
|
void nextTick().then(() => {
|
|
|
|
|
|
if (onlinePricingSourcePopoverOpen.value && !syncingOnlinePricing.value) {
|
|
|
|
|
|
focusOnlinePricingSourcePopover()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}, { flush: 'post' })
|
2026-07-23 13:29:28 +08:00
|
|
|
|
|
|
|
|
|
|
function getExistingModel(model: ModelsDevModelItem): GlobalModelResponse | undefined {
|
|
|
|
|
|
return findGlobalModelByName(existingModelsCache.value, model.modelId)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 12:18:42 +08:00
|
|
|
|
function formatTokenLimit(value: number): string {
|
|
|
|
|
|
if (value >= 1_000_000) {
|
|
|
|
|
|
return `${Number((value / 1_000_000).toFixed(1))}M`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (value >= 1_000) {
|
|
|
|
|
|
return `${Number((value / 1_000).toFixed(1))}K`
|
|
|
|
|
|
}
|
|
|
|
|
|
return String(value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatModelPrice(value?: number): string {
|
|
|
|
|
|
if (value === undefined) return '-'
|
|
|
|
|
|
if (value === 0) return '0'
|
|
|
|
|
|
const precision = value < 0.01 ? 4 : value < 1 ? 3 : 2
|
|
|
|
|
|
return value.toFixed(precision).replace(/\.?0+$/, '')
|
|
|
|
|
|
}
|
2025-12-16 12:21:21 +08:00
|
|
|
|
|
2025-12-16 17:28:40 +08:00
|
|
|
|
// 当前显示的模型列表:有搜索词时用全部,否则只用官方
|
|
|
|
|
|
const allModels = computed(() => {
|
|
|
|
|
|
if (searchQuery.value) {
|
|
|
|
|
|
return allModelsCache.value
|
|
|
|
|
|
}
|
|
|
|
|
|
return allModelsCache.value.filter(m => m.official)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-12-16 12:21:21 +08:00
|
|
|
|
// 按提供商分组的模型
|
|
|
|
|
|
interface ProviderGroup {
|
|
|
|
|
|
providerId: string
|
|
|
|
|
|
providerName: string
|
|
|
|
|
|
models: ModelsDevModelItem[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 12:18:42 +08:00
|
|
|
|
const PROVIDER_PRIORITY_KEYWORDS = [
|
|
|
|
|
|
['anthropic', 'claude'],
|
|
|
|
|
|
['openai'],
|
|
|
|
|
|
['google', 'gemini'],
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
function getProviderPriority(group: ProviderGroup): number {
|
|
|
|
|
|
const searchableText = `${group.providerId} ${group.providerName}`.toLowerCase()
|
|
|
|
|
|
const priority = PROVIDER_PRIORITY_KEYWORDS.findIndex(keywords => (
|
|
|
|
|
|
keywords.some(keyword => searchableText.includes(keyword))
|
|
|
|
|
|
))
|
|
|
|
|
|
return priority === -1 ? PROVIDER_PRIORITY_KEYWORDS.length : priority
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getDefaultProviderId(groups: ProviderGroup[]): string | null {
|
|
|
|
|
|
const claudeProvider = groups.find(group => getProviderPriority(group) === 0)
|
|
|
|
|
|
return claudeProvider?.providerId ?? groups[0]?.providerId ?? null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-16 12:21:21 +08:00
|
|
|
|
const groupedModels = computed(() => {
|
|
|
|
|
|
let models = allModels.value.filter(m => !m.deprecated)
|
2025-12-17 16:41:10 +08:00
|
|
|
|
// 搜索(支持空格分隔的多关键词 AND 搜索)
|
2025-12-16 12:21:21 +08:00
|
|
|
|
if (searchQuery.value) {
|
2025-12-17 16:41:10 +08:00
|
|
|
|
const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
|
|
|
|
|
|
models = models.filter(model => {
|
|
|
|
|
|
const searchableText = `${model.providerId} ${model.providerName} ${model.modelId} ${model.modelName} ${model.family || ''}`.toLowerCase()
|
|
|
|
|
|
return keywords.every(keyword => searchableText.includes(keyword))
|
|
|
|
|
|
})
|
2025-12-16 12:21:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 按提供商分组
|
|
|
|
|
|
const groups = new Map<string, ProviderGroup>()
|
|
|
|
|
|
for (const model of models) {
|
|
|
|
|
|
if (!groups.has(model.providerId)) {
|
|
|
|
|
|
groups.set(model.providerId, {
|
|
|
|
|
|
providerId: model.providerId,
|
|
|
|
|
|
providerName: model.providerName,
|
|
|
|
|
|
models: []
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-02-22 00:43:41 +08:00
|
|
|
|
groups.get(model.providerId)?.models.push(model)
|
2025-12-16 12:21:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 转换为数组并排序
|
2025-12-16 18:33:14 +08:00
|
|
|
|
const result = Array.from(groups.values())
|
2025-12-16 12:21:21 +08:00
|
|
|
|
|
2026-07-10 12:18:42 +08:00
|
|
|
|
const searchKeywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
|
|
|
|
|
|
result.sort((a, b) => {
|
|
|
|
|
|
// 搜索时,优先展示提供商名称或 ID 直接匹配的结果
|
|
|
|
|
|
if (searchKeywords.length > 0) {
|
2025-12-17 16:41:10 +08:00
|
|
|
|
const aText = `${a.providerId} ${a.providerName}`.toLowerCase()
|
|
|
|
|
|
const bText = `${b.providerId} ${b.providerName}`.toLowerCase()
|
2026-07-10 12:18:42 +08:00
|
|
|
|
const aProviderMatch = searchKeywords.some(keyword => aText.includes(keyword))
|
|
|
|
|
|
const bProviderMatch = searchKeywords.some(keyword => bText.includes(keyword))
|
2025-12-16 12:21:21 +08:00
|
|
|
|
if (aProviderMatch && !bProviderMatch) return -1
|
|
|
|
|
|
if (!aProviderMatch && bProviderMatch) return 1
|
2026-07-10 12:18:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Claude(Anthropic)、OpenAI、Google 固定排在最前
|
|
|
|
|
|
const priorityDifference = getProviderPriority(a) - getProviderPriority(b)
|
|
|
|
|
|
if (priorityDifference !== 0) return priorityDifference
|
|
|
|
|
|
return a.providerName.localeCompare(b.providerName)
|
|
|
|
|
|
})
|
2025-12-16 12:21:21 +08:00
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-07-10 12:18:42 +08:00
|
|
|
|
const expandedProviderGroup = computed(() => (
|
|
|
|
|
|
groupedModels.value.find(group => group.providerId === expandedProvider.value) ?? null
|
|
|
|
|
|
))
|
|
|
|
|
|
|
2025-12-16 12:21:21 +08:00
|
|
|
|
// 搜索时如果只有一个提供商,自动展开
|
|
|
|
|
|
watch(groupedModels, (groups) => {
|
2026-07-10 12:18:42 +08:00
|
|
|
|
if (expandedProvider.value && !groups.some(group => group.providerId === expandedProvider.value)) {
|
|
|
|
|
|
expandedProvider.value = null
|
|
|
|
|
|
}
|
2025-12-16 12:21:21 +08:00
|
|
|
|
if (searchQuery.value && groups.length === 1) {
|
|
|
|
|
|
expandedProvider.value = groups[0].providerId
|
2026-07-10 12:18:42 +08:00
|
|
|
|
} else if (!searchQuery.value && !expandedProvider.value) {
|
|
|
|
|
|
expandedProvider.value = getDefaultProviderId(groups)
|
2025-12-16 12:21:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 切换提供商展开状态
|
|
|
|
|
|
function toggleProvider(providerId: string) {
|
|
|
|
|
|
expandedProvider.value = expandedProvider.value === providerId ? null : providerId
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 12:18:42 +08:00
|
|
|
|
function scrollProviderLogos(direction: -1 | 1) {
|
|
|
|
|
|
providerLogoScroller.value?.scrollBy({
|
|
|
|
|
|
left: direction * 280,
|
|
|
|
|
|
behavior: 'smooth',
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleBillingModeChange(mode: string) {
|
|
|
|
|
|
billingMode.value = mode
|
|
|
|
|
|
if (mode === 'image' && !isImageGenerationEnabled.value) {
|
|
|
|
|
|
setImageGenerationEnabled(true)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function scrollToBasicInformation() {
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
basicInfoSection.value?.scrollIntoView({
|
|
|
|
|
|
behavior: 'smooth',
|
|
|
|
|
|
block: 'nearest',
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function enterManualEntryMode() {
|
|
|
|
|
|
clearSelection()
|
|
|
|
|
|
presetPanelCollapsed.value = true
|
|
|
|
|
|
scrollToBasicInformation()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function reopenPresetPanel() {
|
2026-07-16 16:30:51 +08:00
|
|
|
|
clearSelection()
|
2026-07-10 12:18:42 +08:00
|
|
|
|
presetPanelCollapsed.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-16 12:21:21 +08:00
|
|
|
|
// 阶梯计费配置
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const tieredPricing = ref<TieredPricingConfig | null>(null)
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
type VideoResolutionPriceRow = { resolution: string; price_per_second: number | undefined }
|
|
|
|
|
|
|
|
|
|
|
|
const videoResolutionPrices = ref<VideoResolutionPriceRow[]>([])
|
|
|
|
|
|
|
|
|
|
|
|
const VIDEO_RESOLUTION_PRICE_PRESETS: Record<
|
|
|
|
|
|
'common' | 'sora' | 'veo',
|
|
|
|
|
|
VideoResolutionPriceRow[]
|
|
|
|
|
|
> = {
|
|
|
|
|
|
common: [
|
|
|
|
|
|
{ resolution: '480p', price_per_second: 0 },
|
|
|
|
|
|
{ resolution: '720p', price_per_second: 0 },
|
|
|
|
|
|
{ resolution: '1080p', price_per_second: 0 },
|
|
|
|
|
|
{ resolution: '4k', price_per_second: 0 },
|
|
|
|
|
|
],
|
|
|
|
|
|
sora: [
|
|
|
|
|
|
{ resolution: '720x1080', price_per_second: 0 },
|
|
|
|
|
|
{ resolution: '1024x1792', price_per_second: 0 },
|
|
|
|
|
|
],
|
|
|
|
|
|
veo: [
|
|
|
|
|
|
{ resolution: '720p', price_per_second: 0 },
|
|
|
|
|
|
{ resolution: '1080p', price_per_second: 0 },
|
|
|
|
|
|
{ resolution: '4k', price_per_second: 0 },
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-03 17:32:41 +08:00
|
|
|
|
const embeddingApiFormats = [...EMBEDDING_API_FORMATS]
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
interface FormData {
|
|
|
|
|
|
name: string
|
|
|
|
|
|
display_name: string
|
|
|
|
|
|
default_price_per_request?: number
|
|
|
|
|
|
supported_capabilities?: string[]
|
2026-02-22 00:43:41 +08:00
|
|
|
|
config?: Record<string, unknown>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
is_active?: boolean
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const defaultForm = (): FormData => ({
|
|
|
|
|
|
name: '',
|
|
|
|
|
|
display_name: '',
|
|
|
|
|
|
default_price_per_request: undefined,
|
|
|
|
|
|
supported_capabilities: [],
|
2025-12-16 12:21:21 +08:00
|
|
|
|
config: { streaming: true },
|
2025-12-10 20:52:44 +08:00
|
|
|
|
is_active: true,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const form = ref<FormData>(defaultForm())
|
2026-05-18 03:39:55 +08:00
|
|
|
|
const imageGenerationExplicitOverride = ref<boolean | null>(null)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-05-03 17:32:41 +08:00
|
|
|
|
const isEmbeddingEnabled = computed(() => {
|
|
|
|
|
|
return form.value.supported_capabilities?.includes('embedding') === true
|
|
|
|
|
|
|| form.value.config?.embedding === true
|
|
|
|
|
|
|| form.value.config?.model_type === 'embedding'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-18 02:49:56 +08:00
|
|
|
|
const isImageGenerationEnabled = computed(() => {
|
2026-05-18 03:39:55 +08:00
|
|
|
|
if (imageGenerationExplicitOverride.value !== null) {
|
|
|
|
|
|
return imageGenerationExplicitOverride.value
|
|
|
|
|
|
}
|
2026-05-18 02:49:56 +08:00
|
|
|
|
return form.value.supported_capabilities?.includes('image_generation') === true
|
|
|
|
|
|
|| form.value.config?.image_generation === true
|
|
|
|
|
|
|| form.value.config?.model_type === 'image'
|
|
|
|
|
|
|| (Array.isArray(form.value.config?.api_formats)
|
|
|
|
|
|
&& form.value.config.api_formats.some((format) => String(format).endsWith(':image')))
|
2026-05-18 03:39:55 +08:00
|
|
|
|
|| tieredPricingHasImageOutputPricing(tieredPricing.value)
|
2026-05-18 02:49:56 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2025-12-16 12:21:21 +08:00
|
|
|
|
const KEEP_FALSE_CONFIG_KEYS = new Set(['streaming'])
|
|
|
|
|
|
|
|
|
|
|
|
// 设置 config 字段
|
2026-02-22 00:43:41 +08:00
|
|
|
|
function setConfigField(key: string, value: unknown) {
|
2025-12-16 12:21:21 +08:00
|
|
|
|
if (!form.value.config) {
|
|
|
|
|
|
form.value.config = {}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (value === undefined || value === '' || (value === false && !KEEP_FALSE_CONFIG_KEYS.has(key))) {
|
|
|
|
|
|
delete form.value.config[key]
|
|
|
|
|
|
} else {
|
|
|
|
|
|
form.value.config[key] = value
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-03 17:32:41 +08:00
|
|
|
|
function getConfigInputValue(key: string): string | number {
|
|
|
|
|
|
const value = form.value.config?.[key]
|
|
|
|
|
|
return typeof value === 'string' || typeof value === 'number' ? value : ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setEmbeddingEnabled(enabled: boolean) {
|
|
|
|
|
|
const caps = new Set(form.value.supported_capabilities || [])
|
|
|
|
|
|
if (enabled) {
|
|
|
|
|
|
caps.add('embedding')
|
|
|
|
|
|
setConfigField('embedding', true)
|
|
|
|
|
|
setConfigField('model_type', 'embedding')
|
|
|
|
|
|
setConfigField('streaming', false)
|
|
|
|
|
|
form.value.config = {
|
|
|
|
|
|
...(form.value.config || {}),
|
|
|
|
|
|
api_formats: [...embeddingApiFormats],
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
caps.delete('embedding')
|
|
|
|
|
|
setConfigField('embedding', undefined)
|
|
|
|
|
|
if (form.value.config?.model_type === 'embedding') setConfigField('model_type', undefined)
|
|
|
|
|
|
if (Array.isArray(form.value.config?.api_formats)
|
|
|
|
|
|
&& form.value.config.api_formats.every((format) => embeddingApiFormats.includes(String(format)))) {
|
|
|
|
|
|
setConfigField('api_formats', undefined)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
form.value.supported_capabilities = [...caps]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 02:49:56 +08:00
|
|
|
|
function setImageGenerationEnabled(value: boolean | 'indeterminate') {
|
|
|
|
|
|
const enabled = value === true
|
2026-05-18 03:39:55 +08:00
|
|
|
|
imageGenerationExplicitOverride.value = enabled
|
2026-05-18 02:49:56 +08:00
|
|
|
|
const caps = new Set(form.value.supported_capabilities || [])
|
|
|
|
|
|
if (enabled) {
|
|
|
|
|
|
caps.add('image_generation')
|
|
|
|
|
|
setConfigField('image_generation', true)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
caps.delete('image_generation')
|
|
|
|
|
|
setConfigField('image_generation', undefined)
|
|
|
|
|
|
if (form.value.config?.model_type === 'image') setConfigField('model_type', undefined)
|
|
|
|
|
|
}
|
|
|
|
|
|
form.value.supported_capabilities = [...caps]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 00:43:41 +08:00
|
|
|
|
function getNested(obj: unknown, path: string): unknown {
|
2026-02-03 18:48:39 +08:00
|
|
|
|
if (!obj || typeof obj !== 'object') return undefined
|
|
|
|
|
|
const parts = path.split('.').filter(Boolean)
|
2026-02-22 00:43:41 +08:00
|
|
|
|
let cur = obj as Record<string, unknown>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
for (const p of parts) {
|
|
|
|
|
|
if (!cur || typeof cur !== 'object') return undefined
|
2026-02-22 00:43:41 +08:00
|
|
|
|
cur = cur[p] as Record<string, unknown>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
return cur
|
|
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-02-22 00:43:41 +08:00
|
|
|
|
function setNested(obj: unknown, path: string, value: unknown) {
|
2026-02-03 18:48:39 +08:00
|
|
|
|
if (!obj || typeof obj !== 'object') return
|
|
|
|
|
|
const parts = path.split('.').filter(Boolean)
|
|
|
|
|
|
if (parts.length === 0) return
|
2026-02-22 00:43:41 +08:00
|
|
|
|
let cur = obj as Record<string, unknown>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
|
|
|
|
const p = parts[i]
|
|
|
|
|
|
if (!cur[p] || typeof cur[p] !== 'object') {
|
|
|
|
|
|
cur[p] = {}
|
|
|
|
|
|
}
|
2026-02-22 00:43:41 +08:00
|
|
|
|
cur = cur[p] as Record<string, unknown>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
cur[parts[parts.length - 1]] = value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-22 00:43:41 +08:00
|
|
|
|
function deleteNested(obj: unknown, path: string) {
|
2026-02-03 18:48:39 +08:00
|
|
|
|
if (!obj || typeof obj !== 'object') return
|
|
|
|
|
|
const parts = path.split('.').filter(Boolean)
|
|
|
|
|
|
if (parts.length === 0) return
|
2026-02-22 00:43:41 +08:00
|
|
|
|
let cur = obj as Record<string, unknown>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
|
|
|
|
const p = parts[i]
|
|
|
|
|
|
if (!cur[p] || typeof cur[p] !== 'object') return
|
2026-02-22 00:43:41 +08:00
|
|
|
|
cur = cur[p] as Record<string, unknown>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
delete cur[parts[parts.length - 1]]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function pruneEmptyBillingConfig() {
|
|
|
|
|
|
const cfg = form.value.config
|
|
|
|
|
|
if (!cfg || typeof cfg !== 'object') return
|
2026-02-22 00:43:41 +08:00
|
|
|
|
const billing = cfg.billing as Record<string, unknown> | undefined
|
2026-02-03 18:48:39 +08:00
|
|
|
|
if (!billing || typeof billing !== 'object') return
|
2026-02-22 00:43:41 +08:00
|
|
|
|
const video = billing.video as Record<string, unknown> | undefined
|
2026-02-03 18:48:39 +08:00
|
|
|
|
if (video && typeof video === 'object' && Object.keys(video).length === 0) {
|
|
|
|
|
|
delete billing.video
|
|
|
|
|
|
}
|
|
|
|
|
|
if (Object.keys(billing).length === 0) {
|
|
|
|
|
|
delete cfg.billing
|
2025-12-16 12:21:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* Normalize resolution key:
|
|
|
|
|
|
* - lowercase, remove spaces, × → x
|
|
|
|
|
|
* - For WxH format, sort dimensions so smaller comes first (720x1080 = 1080x720)
|
|
|
|
|
|
*/
|
|
|
|
|
|
function normalizeResolutionKey(raw: string): string {
|
|
|
|
|
|
let k = (raw || '').trim().toLowerCase().replace(/\s+/g, '').replace(/×/g, 'x')
|
|
|
|
|
|
// Check if it's WxH format (e.g., 1080x720)
|
|
|
|
|
|
const match = k.match(/^(\d+)x(\d+)$/)
|
|
|
|
|
|
if (match) {
|
|
|
|
|
|
const a = parseInt(match[1], 10)
|
|
|
|
|
|
const b = parseInt(match[2], 10)
|
|
|
|
|
|
// Sort: smaller dimension first
|
|
|
|
|
|
k = a <= b ? `${a}x${b}` : `${b}x${a}`
|
2025-12-16 12:21:21 +08:00
|
|
|
|
}
|
2026-02-03 18:48:39 +08:00
|
|
|
|
return k
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function loadVideoPricingFromConfig() {
|
|
|
|
|
|
const cfg = form.value.config || {}
|
|
|
|
|
|
const raw = getNested(cfg, 'billing.video.price_per_second_by_resolution')
|
|
|
|
|
|
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
|
|
|
|
// 按分辨率从低到高排序
|
2026-02-22 00:43:41 +08:00
|
|
|
|
const sortedEntries = sortResolutionEntries(Object.entries(raw as Record<string, unknown>))
|
2026-02-03 18:48:39 +08:00
|
|
|
|
videoResolutionPrices.value = sortedEntries.map(([k, v]) => ({
|
|
|
|
|
|
resolution: String(k),
|
|
|
|
|
|
price_per_second: typeof v === 'number' ? v : undefined,
|
|
|
|
|
|
}))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
videoResolutionPrices.value = []
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function applyVideoPricingToConfig() {
|
|
|
|
|
|
if (!form.value.config) {
|
|
|
|
|
|
form.value.config = {}
|
|
|
|
|
|
}
|
|
|
|
|
|
const cfg = form.value.config
|
|
|
|
|
|
|
|
|
|
|
|
// Clean legacy keys
|
|
|
|
|
|
deleteNested(cfg, 'billing.video.price_per_second')
|
|
|
|
|
|
deleteNested(cfg, 'billing.video.resolution_multipliers')
|
|
|
|
|
|
|
|
|
|
|
|
// resolution/size prices (normalized: 1080x720 → 720x1080)
|
|
|
|
|
|
const map: Record<string, number> = {}
|
|
|
|
|
|
for (const row of videoResolutionPrices.value) {
|
|
|
|
|
|
const k = normalizeResolutionKey(row.resolution || '')
|
|
|
|
|
|
const v = row.price_per_second
|
|
|
|
|
|
if (!k) continue
|
|
|
|
|
|
if (typeof v !== 'number' || Number.isNaN(v)) continue
|
|
|
|
|
|
map[k] = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if (Object.keys(map).length > 0) {
|
|
|
|
|
|
setNested(cfg, 'billing.video.price_per_second_by_resolution', map)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
deleteNested(cfg, 'billing.video.price_per_second_by_resolution')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pruneEmptyBillingConfig()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function addVideoResolutionPriceRow() {
|
|
|
|
|
|
videoResolutionPrices.value.push({ resolution: '', price_per_second: undefined })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function removeVideoResolutionPriceRow(idx: number) {
|
|
|
|
|
|
videoResolutionPrices.value.splice(idx, 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function fillVideoResolutionPricePreset(preset: 'common' | 'sora' | 'veo') {
|
|
|
|
|
|
videoResolutionPrices.value = VIDEO_RESOLUTION_PRICE_PRESETS[preset].map(r => ({
|
|
|
|
|
|
resolution: r.resolution,
|
|
|
|
|
|
price_per_second: r.price_per_second,
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
2026-07-23 13:29:28 +08:00
|
|
|
|
async function loadExistingModels() {
|
|
|
|
|
|
const models: GlobalModelResponse[] = []
|
|
|
|
|
|
let total = 0
|
|
|
|
|
|
do {
|
|
|
|
|
|
const response = await listGlobalModels({ skip: models.length, limit: 1000 })
|
|
|
|
|
|
models.push(...response.models)
|
|
|
|
|
|
total = response.total
|
|
|
|
|
|
if (response.models.length === 0) break
|
|
|
|
|
|
} while (models.length < total)
|
|
|
|
|
|
existingModelsCache.value = models
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 加载在线目录和已有模型列表
|
2026-02-03 18:48:39 +08:00
|
|
|
|
async function loadModels() {
|
|
|
|
|
|
loading.value = true
|
2026-07-23 13:29:28 +08:00
|
|
|
|
await Promise.all([
|
|
|
|
|
|
allModelsCache.value.length > 0
|
|
|
|
|
|
? Promise.resolve()
|
|
|
|
|
|
: getModelsDevList(false)
|
|
|
|
|
|
.then(models => { allModelsCache.value = models })
|
|
|
|
|
|
.catch(err => log.error('Failed to load online models:', err)),
|
|
|
|
|
|
loadExistingModels()
|
|
|
|
|
|
.catch(err => log.error('Failed to load existing models:', err)),
|
|
|
|
|
|
])
|
|
|
|
|
|
loading.value = false
|
2026-02-03 18:48:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 打开对话框时加载数据
|
2026-07-10 12:18:42 +08:00
|
|
|
|
watch(() => props.open, async (isOpen) => {
|
2026-07-24 01:45:38 +08:00
|
|
|
|
if (!isOpen) {
|
|
|
|
|
|
editingOnlinePricingSource.value = null
|
|
|
|
|
|
resetOnlinePricingSourceSelection()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-02-03 18:48:39 +08:00
|
|
|
|
if (isOpen && !props.model) {
|
2026-07-10 12:18:42 +08:00
|
|
|
|
await loadModels()
|
|
|
|
|
|
if (!expandedProvider.value) {
|
|
|
|
|
|
expandedProvider.value = getDefaultProviderId(groupedModels.value)
|
|
|
|
|
|
}
|
2026-02-03 18:48:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-12-16 12:21:21 +08:00
|
|
|
|
// 选择模型并填充表单
|
|
|
|
|
|
function selectModel(model: ModelsDevModelItem) {
|
2026-07-24 01:45:38 +08:00
|
|
|
|
if (getExistingModel(model)) return
|
|
|
|
|
|
|
2026-05-18 03:39:55 +08:00
|
|
|
|
imageGenerationExplicitOverride.value = null
|
2025-12-16 12:21:21 +08:00
|
|
|
|
selectedModel.value = model
|
|
|
|
|
|
expandedProvider.value = model.providerId
|
|
|
|
|
|
|
|
|
|
|
|
// 构建 config
|
2026-02-22 00:43:41 +08:00
|
|
|
|
const config: Record<string, unknown> = {
|
2026-05-03 17:32:41 +08:00
|
|
|
|
streaming: model.supportsEmbedding ? false : true,
|
2025-12-16 12:21:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (model.supportsVision) config.vision = true
|
|
|
|
|
|
if (model.supportsToolCall) config.function_calling = true
|
|
|
|
|
|
if (model.supportsReasoning) config.extended_thinking = true
|
2025-12-16 17:28:40 +08:00
|
|
|
|
if (model.supportsStructuredOutput) config.structured_output = true
|
|
|
|
|
|
if (model.supportsTemperature !== false) config.temperature = model.supportsTemperature
|
|
|
|
|
|
if (model.supportsAttachment) config.attachment = true
|
|
|
|
|
|
if (model.openWeights) config.open_weights = true
|
2025-12-16 12:21:21 +08:00
|
|
|
|
if (model.contextLimit) config.context_limit = model.contextLimit
|
|
|
|
|
|
if (model.outputLimit) config.output_limit = model.outputLimit
|
|
|
|
|
|
if (model.knowledgeCutoff) config.knowledge_cutoff = model.knowledgeCutoff
|
|
|
|
|
|
if (model.family) config.family = model.family
|
|
|
|
|
|
if (model.releaseDate) config.release_date = model.releaseDate
|
|
|
|
|
|
if (model.inputModalities?.length) config.input_modalities = model.inputModalities
|
|
|
|
|
|
if (model.outputModalities?.length) config.output_modalities = model.outputModalities
|
2026-05-18 02:49:56 +08:00
|
|
|
|
const supportedCapabilities = new Set<string>()
|
|
|
|
|
|
if (model.supportsEmbedding) supportedCapabilities.add('embedding')
|
|
|
|
|
|
if (model.outputModalities?.includes('image')) supportedCapabilities.add('image_generation')
|
2026-07-16 16:30:51 +08:00
|
|
|
|
form.value = {
|
|
|
|
|
|
...defaultForm(),
|
|
|
|
|
|
name: model.modelId,
|
|
|
|
|
|
display_name: model.modelName,
|
|
|
|
|
|
config,
|
|
|
|
|
|
supported_capabilities: [...supportedCapabilities],
|
|
|
|
|
|
}
|
2026-05-03 17:32:41 +08:00
|
|
|
|
if (model.supportsEmbedding) {
|
|
|
|
|
|
setEmbeddingEnabled(true)
|
|
|
|
|
|
}
|
2026-07-10 12:18:42 +08:00
|
|
|
|
if (model.outputModalities?.includes('image')) {
|
|
|
|
|
|
billingMode.value = 'image'
|
|
|
|
|
|
} else if (model.outputModalities?.includes('video')) {
|
|
|
|
|
|
billingMode.value = 'video'
|
|
|
|
|
|
} else {
|
|
|
|
|
|
billingMode.value = 'token'
|
|
|
|
|
|
}
|
2026-02-03 18:48:39 +08:00
|
|
|
|
loadVideoPricingFromConfig()
|
2025-12-16 12:21:21 +08:00
|
|
|
|
|
2026-07-11 18:09:31 +08:00
|
|
|
|
tieredPricing.value = model.tieredPricing
|
2026-07-15 23:47:19 +08:00
|
|
|
|
? cloneTieredPricingConfig(model.tieredPricing)
|
2026-07-11 18:09:31 +08:00
|
|
|
|
: null
|
2026-07-10 12:18:42 +08:00
|
|
|
|
|
|
|
|
|
|
presetPanelCollapsed.value = true
|
|
|
|
|
|
scrollToBasicInformation()
|
2025-12-16 12:21:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 01:45:38 +08:00
|
|
|
|
function editExistingModel(model: ModelsDevModelItem) {
|
|
|
|
|
|
const existingModel = getExistingModel(model)
|
|
|
|
|
|
if (!existingModel) return
|
|
|
|
|
|
editingOnlinePricingSource.value = {
|
|
|
|
|
|
model_id: model.modelId,
|
|
|
|
|
|
provider_id: model.providerId,
|
|
|
|
|
|
provider_name: model.providerName,
|
|
|
|
|
|
}
|
|
|
|
|
|
emit('editModel', existingModel)
|
2026-07-23 13:29:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 01:45:38 +08:00
|
|
|
|
function normalizeModelId(value: string): string {
|
|
|
|
|
|
return value.trim().toLowerCase()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatUnsupportedPricingFields(
|
|
|
|
|
|
fields: ModelsDevModelItem['pricingUnsupportedFields'],
|
|
|
|
|
|
): string {
|
|
|
|
|
|
const labels = {
|
|
|
|
|
|
reasoning: '推理 Token',
|
|
|
|
|
|
input_audio: '输入音频 Token',
|
|
|
|
|
|
output_audio: '输出音频 Token',
|
|
|
|
|
|
}
|
|
|
|
|
|
return (fields ?? []).map(field => labels[field]).join('、')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getOnlinePricingCandidates(
|
|
|
|
|
|
models: ModelsDevModelItem[],
|
|
|
|
|
|
model: GlobalModelResponse,
|
|
|
|
|
|
): ModelsDevModelItem[] {
|
|
|
|
|
|
const modelId = normalizeModelId(model.name)
|
|
|
|
|
|
return models.filter(item => normalizeModelId(item.modelId) === modelId)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveOnlinePricingModel(
|
|
|
|
|
|
candidates: ModelsDevModelItem[],
|
|
|
|
|
|
model: GlobalModelResponse,
|
|
|
|
|
|
): ModelsDevModelItem | null {
|
|
|
|
|
|
const modelId = normalizeModelId(model.name)
|
|
|
|
|
|
const transientSource = editingOnlinePricingSource.value
|
|
|
|
|
|
const storedSource = getSource(model.id)
|
|
|
|
|
|
const preferredProviderId = transientSource?.model_id && normalizeModelId(transientSource.model_id) === modelId
|
|
|
|
|
|
? transientSource.provider_id
|
|
|
|
|
|
: storedSource?.provider_id
|
|
|
|
|
|
|
|
|
|
|
|
if (preferredProviderId) {
|
|
|
|
|
|
const preferred = candidates.find(item => (
|
|
|
|
|
|
item.providerId.trim().toLowerCase() === preferredProviderId.trim().toLowerCase()
|
|
|
|
|
|
))
|
|
|
|
|
|
if (preferred && isOnlinePricingCandidateSyncable(preferred)) return preferred
|
|
|
|
|
|
}
|
|
|
|
|
|
if (candidates.length === 1) return candidates[0]
|
|
|
|
|
|
if (candidates.length === 0) {
|
|
|
|
|
|
throw new Error('models.dev 未找到该模型的在线价格')
|
|
|
|
|
|
}
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isOnlinePricingCandidateSyncable(model: ModelsDevModelItem): boolean {
|
|
|
|
|
|
return !model.pricingUnsupportedFields?.length && !!model.tieredPricing?.tiers?.length
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getOnlinePricingCandidateUnavailableReason(model: ModelsDevModelItem): string {
|
|
|
|
|
|
if (model.pricingUnsupportedFields?.length) {
|
|
|
|
|
|
return `不支持:${formatUnsupportedPricingFields(model.pricingUnsupportedFields)}`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!model.tieredPricing?.tiers?.length) return '在线目录未提供 Token 价格'
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resetOnlinePricingSourceSelection() {
|
|
|
|
|
|
onlinePricingSourcePopoverOpen.value = false
|
|
|
|
|
|
onlinePricingCandidates.value = []
|
|
|
|
|
|
selectedOnlinePricingProviderId.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getOnlinePricingSourceControls(): HTMLElement[] {
|
|
|
|
|
|
return [...document.querySelectorAll<HTMLElement>(
|
|
|
|
|
|
'[data-online-pricing-source-control]:not([disabled]):not([tabindex="-1"])',
|
|
|
|
|
|
)]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function focusOnlinePricingSourcePopover() {
|
|
|
|
|
|
getOnlinePricingSourceControls()[0]?.focus()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleOnlinePricingSourcePopoverKeydown(event: KeyboardEvent) {
|
|
|
|
|
|
const target = event.target instanceof HTMLElement
|
|
|
|
|
|
? event.target.closest<HTMLElement>('[data-online-pricing-source-control]')
|
|
|
|
|
|
: null
|
|
|
|
|
|
if (!target) return
|
|
|
|
|
|
|
|
|
|
|
|
if (event.key === 'Tab') {
|
|
|
|
|
|
const controls = getOnlinePricingSourceControls()
|
|
|
|
|
|
const currentIndex = controls.indexOf(target)
|
|
|
|
|
|
if (currentIndex === -1 || controls.length === 0) return
|
|
|
|
|
|
if (event.shiftKey && currentIndex === 0) {
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
|
controls[controls.length - 1]?.focus()
|
|
|
|
|
|
} else if (!event.shiftKey && currentIndex === controls.length - 1) {
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
|
controls[0]?.focus()
|
|
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft', 'Home', 'End'].includes(event.key)) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const options = [...document.querySelectorAll<HTMLButtonElement>(
|
|
|
|
|
|
'[data-online-pricing-source-option]:not([disabled])',
|
|
|
|
|
|
)]
|
|
|
|
|
|
const currentIndex = options.indexOf(target as HTMLButtonElement)
|
|
|
|
|
|
if (currentIndex === -1 || options.length === 0) return
|
|
|
|
|
|
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
|
const nextIndex = event.key === 'Home'
|
|
|
|
|
|
? 0
|
|
|
|
|
|
: event.key === 'End'
|
|
|
|
|
|
? options.length - 1
|
|
|
|
|
|
: ['ArrowDown', 'ArrowRight'].includes(event.key)
|
|
|
|
|
|
? (currentIndex + 1) % options.length
|
|
|
|
|
|
: (currentIndex - 1 + options.length) % options.length
|
|
|
|
|
|
const nextOption = options[nextIndex]
|
|
|
|
|
|
const providerId = nextOption?.dataset.providerId
|
|
|
|
|
|
if (!nextOption || !providerId) return
|
|
|
|
|
|
selectedOnlinePricingProviderId.value = providerId
|
|
|
|
|
|
nextTick(() => nextOption.focus())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleOnlinePricingSourcePopoverUpdate(open: boolean) {
|
|
|
|
|
|
if (open) {
|
|
|
|
|
|
// The trigger starts the async refresh. Only show the popover once the
|
|
|
|
|
|
// refresh is in progress or source candidates are available.
|
|
|
|
|
|
if (syncingOnlinePricing.value || onlinePricingCandidates.value.length > 0) {
|
|
|
|
|
|
onlinePricingSourcePopoverOpen.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!syncingOnlinePricing.value) resetOnlinePricingSourceSelection()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeOnlinePricingSourcePopover() {
|
|
|
|
|
|
if (syncingOnlinePricing.value) return
|
|
|
|
|
|
resetOnlinePricingSourceSelection()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function applyOnlinePricingModel(onlineModel: ModelsDevModelItem) {
|
|
|
|
|
|
if (!props.model) return
|
|
|
|
|
|
if (onlineModel.pricingUnsupportedFields?.length) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`在线价格包含当前计费引擎无法独立结算的${formatUnsupportedPricingFields(onlineModel.pricingUnsupportedFields)}`,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!onlineModel.tieredPricing?.tiers?.length) {
|
|
|
|
|
|
throw new Error('在线目录未提供该模型的价格配置')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const pricing = cloneTieredPricingConfig(onlineModel.tieredPricing)
|
|
|
|
|
|
const pricingChanged = !tieredPricingConfigsEqual(
|
|
|
|
|
|
props.model.default_tiered_pricing,
|
|
|
|
|
|
pricing,
|
|
|
|
|
|
)
|
|
|
|
|
|
let syncedModel: GlobalModelResponse
|
|
|
|
|
|
if (pricingChanged) {
|
|
|
|
|
|
syncedModel = await updateGlobalModel(props.model.id, {
|
|
|
|
|
|
default_tiered_pricing: pricing,
|
|
|
|
|
|
})
|
|
|
|
|
|
} else {
|
|
|
|
|
|
syncedModel = {
|
|
|
|
|
|
...props.model,
|
|
|
|
|
|
default_tiered_pricing: pricing,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
tieredPricing.value = cloneTieredPricingConfig(pricing)
|
|
|
|
|
|
billingMode.value = 'token'
|
|
|
|
|
|
setSource(props.model.id, {
|
|
|
|
|
|
provider_id: onlineModel.providerId,
|
|
|
|
|
|
provider_name: onlineModel.providerName,
|
|
|
|
|
|
})
|
|
|
|
|
|
emit('pricingSynced', syncedModel)
|
|
|
|
|
|
success(
|
|
|
|
|
|
pricingChanged
|
|
|
|
|
|
? `已同步 ${onlineModel.providerName} 的最新价格`
|
|
|
|
|
|
: `当前价格已是 ${onlineModel.providerName} 的最新价格`,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function confirmOnlinePricingSource() {
|
|
|
|
|
|
const onlineModel = selectedOnlinePricingCandidate.value
|
|
|
|
|
|
if (!onlineModel || syncingOnlinePricing.value || submitting.value) return
|
|
|
|
|
|
|
|
|
|
|
|
syncingOnlinePricing.value = true
|
|
|
|
|
|
let synced = false
|
|
|
|
|
|
try {
|
|
|
|
|
|
await applyOnlinePricingModel(onlineModel)
|
|
|
|
|
|
synced = true
|
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
log.error('同步在线模型价格失败:', err)
|
|
|
|
|
|
showError(parseApiError(err, '同步在线价格失败'), '同步失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
syncingOnlinePricing.value = false
|
|
|
|
|
|
if (synced) resetOnlinePricingSourceSelection()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function syncOnlinePricing() {
|
|
|
|
|
|
if (!isEditMode.value || !props.model || syncingOnlinePricing.value || submitting.value) return
|
|
|
|
|
|
|
|
|
|
|
|
syncingOnlinePricing.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const onlineModels = await refreshModelsDevList(false)
|
|
|
|
|
|
allModelsCache.value = onlineModels
|
|
|
|
|
|
const candidates = getOnlinePricingCandidates(onlineModels, props.model)
|
|
|
|
|
|
const onlineModel = resolveOnlinePricingModel(candidates, props.model)
|
|
|
|
|
|
if (onlineModel) {
|
|
|
|
|
|
await applyOnlinePricingModel(onlineModel)
|
|
|
|
|
|
resetOnlinePricingSourceSelection()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onlinePricingCandidates.value = candidates
|
|
|
|
|
|
selectedOnlinePricingProviderId.value = ''
|
|
|
|
|
|
onlinePricingSourcePopoverOpen.value = true
|
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
resetOnlinePricingSourceSelection()
|
|
|
|
|
|
log.error('同步在线模型价格失败:', err)
|
|
|
|
|
|
showError(parseApiError(err, '同步在线价格失败'), '同步失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
syncingOnlinePricing.value = false
|
|
|
|
|
|
}
|
2026-07-23 13:29:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-16 12:21:21 +08:00
|
|
|
|
// 清除选择(手动填写)
|
|
|
|
|
|
function clearSelection() {
|
2026-05-18 03:39:55 +08:00
|
|
|
|
imageGenerationExplicitOverride.value = null
|
2025-12-16 12:21:21 +08:00
|
|
|
|
selectedModel.value = null
|
|
|
|
|
|
form.value = defaultForm()
|
|
|
|
|
|
tieredPricing.value = null
|
2026-07-16 16:30:51 +08:00
|
|
|
|
videoResolutionPrices.value = []
|
2026-07-10 12:18:42 +08:00
|
|
|
|
billingMode.value = 'token'
|
2025-12-16 12:21:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Logo 加载失败处理
|
|
|
|
|
|
function handleLogoError(event: Event) {
|
|
|
|
|
|
const img = event.target as HTMLImageElement
|
|
|
|
|
|
img.style.display = 'none'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 重置表单
|
|
|
|
|
|
function resetForm() {
|
2026-07-24 01:45:38 +08:00
|
|
|
|
resetOnlinePricingSourceSelection()
|
2026-05-18 03:39:55 +08:00
|
|
|
|
imageGenerationExplicitOverride.value = null
|
2026-07-24 01:45:38 +08:00
|
|
|
|
editingOnlinePricingSource.value = null
|
2025-12-10 20:52:44 +08:00
|
|
|
|
form.value = defaultForm()
|
|
|
|
|
|
tieredPricing.value = null
|
2026-02-03 18:48:39 +08:00
|
|
|
|
videoResolutionPrices.value = []
|
2025-12-16 12:21:21 +08:00
|
|
|
|
searchQuery.value = ''
|
|
|
|
|
|
selectedModel.value = null
|
|
|
|
|
|
expandedProvider.value = null
|
2026-07-10 12:18:42 +08:00
|
|
|
|
presetPanelCollapsed.value = false
|
|
|
|
|
|
billingMode.value = 'token'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-23 13:29:28 +08:00
|
|
|
|
function populateFormFromGlobalModel(model: GlobalModelResponse) {
|
2026-05-18 03:39:55 +08:00
|
|
|
|
imageGenerationExplicitOverride.value = null
|
2026-07-23 13:29:28 +08:00
|
|
|
|
const modelTieredPricing = model.default_tiered_pricing
|
|
|
|
|
|
? cloneTieredPricingConfig(model.default_tiered_pricing)
|
2026-05-18 03:19:58 +08:00
|
|
|
|
: null
|
2026-07-23 13:29:28 +08:00
|
|
|
|
const supportedCapabilities = new Set(model.supported_capabilities || [])
|
2026-05-18 03:19:58 +08:00
|
|
|
|
if (tieredPricingHasImageOutputPricing(modelTieredPricing)) {
|
|
|
|
|
|
supportedCapabilities.add('image_generation')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
form.value = {
|
2026-07-23 13:29:28 +08:00
|
|
|
|
name: model.name,
|
|
|
|
|
|
display_name: model.display_name,
|
|
|
|
|
|
default_price_per_request: model.default_price_per_request,
|
2026-05-18 03:19:58 +08:00
|
|
|
|
supported_capabilities: [...supportedCapabilities],
|
2026-07-23 13:29:28 +08:00
|
|
|
|
config: model.config ? { ...model.config } : { streaming: true },
|
|
|
|
|
|
is_active: model.is_active,
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
2026-05-18 03:19:58 +08:00
|
|
|
|
tieredPricing.value = modelTieredPricing
|
2026-02-03 18:48:39 +08:00
|
|
|
|
loadVideoPricingFromConfig()
|
2026-07-24 01:45:38 +08:00
|
|
|
|
billingMode.value = 'token'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-23 13:29:28 +08:00
|
|
|
|
// 加载模型数据(编辑模式)
|
|
|
|
|
|
function loadModelData() {
|
|
|
|
|
|
if (!props.model) return
|
2026-07-24 01:45:38 +08:00
|
|
|
|
resetOnlinePricingSourceSelection()
|
|
|
|
|
|
if (
|
|
|
|
|
|
editingOnlinePricingSource.value
|
|
|
|
|
|
&& normalizeModelId(editingOnlinePricingSource.value.model_id) !== normalizeModelId(props.model.name)
|
|
|
|
|
|
) {
|
|
|
|
|
|
editingOnlinePricingSource.value = null
|
|
|
|
|
|
}
|
2026-07-23 13:29:28 +08:00
|
|
|
|
// 先重置创建模式的残留状态
|
|
|
|
|
|
selectedModel.value = null
|
|
|
|
|
|
searchQuery.value = ''
|
|
|
|
|
|
expandedProvider.value = null
|
|
|
|
|
|
presetPanelCollapsed.value = false
|
|
|
|
|
|
populateFormFromGlobalModel(props.model)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
// 使用 useFormDialog 统一处理对话框逻辑
|
|
|
|
|
|
const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
|
|
|
|
|
|
isOpen: () => props.open,
|
|
|
|
|
|
entity: () => props.model,
|
|
|
|
|
|
isLoading: submitting,
|
|
|
|
|
|
onClose: () => emit('update:open', false),
|
|
|
|
|
|
loadData: loadModelData,
|
|
|
|
|
|
resetForm,
|
2026-07-24 01:45:38 +08:00
|
|
|
|
extraLoadingStates: [syncingOnlinePricing],
|
2025-12-10 20:52:44 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-07-16 16:30:51 +08:00
|
|
|
|
const autoFillMissingCachePrices = computed(() => (
|
|
|
|
|
|
!isEditMode.value && selectedModel.value === null
|
|
|
|
|
|
))
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
async function handleSubmit() {
|
2026-07-24 01:45:38 +08:00
|
|
|
|
if (syncingOnlinePricing.value) return
|
2025-12-10 20:52:44 +08:00
|
|
|
|
if (!form.value.name || !form.value.display_name) {
|
2026-02-03 18:48:39 +08:00
|
|
|
|
showError('请填写模型ID和名称')
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 12:27:09 +08:00
|
|
|
|
const pricingValidationError = tieredPricingEditorRef.value?.getValidationError()
|
|
|
|
|
|
if (pricingValidationError) {
|
|
|
|
|
|
showError(pricingValidationError, '价格配置错误')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 00:11:30 +08:00
|
|
|
|
const finalTieredPricing = tieredPricingEditorRef.value?.getFinalPricing() ?? tieredPricing.value
|
2026-04-23 14:42:51 +08:00
|
|
|
|
|
|
|
|
|
|
if (!finalTieredPricing?.tiers?.length) {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
showError('请配置至少一个价格阶梯')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
// Apply billing (video) pricing into config before cleaning/submitting.
|
|
|
|
|
|
applyVideoPricingToConfig()
|
|
|
|
|
|
|
2026-02-28 13:52:32 +08:00
|
|
|
|
// Auto-infer supported_capabilities from tiered pricing config
|
|
|
|
|
|
const caps = new Set(form.value.supported_capabilities || [])
|
2026-05-18 03:19:58 +08:00
|
|
|
|
if (tieredPricingHasImageOutputPricing(finalTieredPricing)) {
|
|
|
|
|
|
caps.add('image_generation')
|
|
|
|
|
|
}
|
2026-02-28 13:52:32 +08:00
|
|
|
|
form.value.supported_capabilities = caps.size > 0 ? [...caps] : []
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
submitting.value = true
|
|
|
|
|
|
try {
|
2026-07-24 01:45:38 +08:00
|
|
|
|
if (isEditMode.value && props.model) {
|
2026-05-03 17:32:41 +08:00
|
|
|
|
const updateData = buildGlobalModelUpdatePayload(form.value, finalTieredPricing)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
await updateGlobalModel(props.model.id, updateData)
|
|
|
|
|
|
success('模型更新成功')
|
|
|
|
|
|
} else {
|
2026-05-03 17:32:41 +08:00
|
|
|
|
const createData = buildGlobalModelCreatePayload(form.value, finalTieredPricing)
|
2026-07-23 13:29:28 +08:00
|
|
|
|
const createdModel = await createGlobalModel(createData)
|
|
|
|
|
|
existingModelsCache.value.unshift(createdModel)
|
2026-07-23 15:18:08 +08:00
|
|
|
|
if (selectedModel.value) {
|
|
|
|
|
|
setSource(createdModel.id, {
|
|
|
|
|
|
provider_id: selectedModel.value.providerId,
|
|
|
|
|
|
provider_name: selectedModel.value.providerName,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
success('模型创建成功')
|
2026-02-25 23:33:16 +08:00
|
|
|
|
clearSelection()
|
|
|
|
|
|
emit('success')
|
|
|
|
|
|
return
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
emit('update:open', false)
|
|
|
|
|
|
emit('success')
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
showError(parseApiError(err, isEditMode.value ? '更新失败' : '创建失败'), isEditMode.value ? '更新失败' : '创建失败')
|
2025-12-10 20:52:44 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
submitting.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-18 03:19:58 +08:00
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</script>
|