Files
Aether/frontend/src/features/providers/components/ProviderModelFormDialog.vue
T

649 lines
22 KiB
Vue
Raw Normal View History

2025-12-10 20:52:44 +08:00
<template>
<Dialog
:model-value="open"
:title="isEditing ? '编辑模型配置' : '添加模型'"
:description="isEditing ? '修改模型价格配置' : '为此 Provider 添加模型实现'"
2025-12-10 20:52:44 +08:00
:icon="isEditing ? SquarePen : Layers"
size="xl"
@update:model-value="handleClose"
2025-12-10 20:52:44 +08:00
>
<form
class="space-y-4"
@submit.prevent="handleSubmit"
>
2025-12-10 20:52:44 +08:00
<!-- 添加模式:选择全局模型 -->
<div
v-if="!isEditing"
class="space-y-2"
>
2025-12-10 20:52:44 +08:00
<Label for="global-model">选择模型 *</Label>
<Select
:model-value="form.global_model_id"
:disabled="loadingGlobalModels"
@update:model-value="form.global_model_id = $event"
>
<SelectTrigger class="w-full">
<SelectValue :placeholder="loadingGlobalModels ? '加载中...' : '请选择模型'" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="model in availableGlobalModels"
:key="model.id"
:value="model.id"
>
{{ model.display_name }} ({{ model.name }})
</SelectItem>
</SelectContent>
</Select>
<p
v-if="availableGlobalModels.length === 0 && !loadingGlobalModels"
class="text-xs text-muted-foreground"
>
2025-12-10 20:52:44 +08:00
所有全局模型已添加到此 Provider
</p>
2026-05-03 17:32:41 +08:00
<div
v-if="selectedGlobalModelSupportsEmbedding"
class="rounded-lg border border-border/60 bg-muted/20 px-3 py-2"
>
<div class="text-sm font-medium">
Embedding
</div>
<p class="text-xs text-muted-foreground">
此模型将继承全局模型的 Embeddings 元数据,不按 Chat 能力处理。
</p>
</div>
2025-12-10 20:52:44 +08:00
</div>
<!-- 编辑模式:显示模型信息 -->
<div
v-else
class="rounded-lg border bg-muted/30 p-4"
>
2025-12-10 20:52:44 +08:00
<div class="flex items-start justify-between">
<div>
<p class="font-semibold text-lg">
{{ editingModel?.global_model_display_name || editingModel?.provider_model_name }}
</p>
<p class="text-sm text-muted-foreground font-mono">
{{ editingModel?.provider_model_name }}
</p>
2026-05-03 17:32:41 +08:00
<Badge
v-if="editingModelSupportsEmbedding"
variant="secondary"
class="mt-2 text-xs"
>
Embedding
</Badge>
2025-12-10 20:52:44 +08:00
</div>
</div>
</div>
<!-- 价格配置 -->
<div class="space-y-4">
<h4 class="font-semibold text-sm border-b pb-2">
价格配置
</h4>
<TieredPricingEditor
ref="tieredPricingEditorRef"
v-model="tieredPricing"
:show-cache1h="showCache1h"
/>
2025-12-10 20:52:44 +08:00
<!-- 按次计费 -->
<div class="flex items-center gap-3 pt-2 border-t">
<Label class="text-xs whitespace-nowrap">按次计费 ($/次)</Label>
<Input
:model-value="form.price_per_request ?? ''"
type="number"
step="0.001"
min="0"
class="w-32"
placeholder="留空使用默认值"
@update:model-value="(v) => form.price_per_request = parseNumberInput(v, { allowFloat: true })"
/>
<span class="text-xs text-muted-foreground">每次请求固定费用,留空使用全局模型默认值</span>
</div>
<!-- 视频计费(可选覆盖) -->
<div class="pt-3 border-t space-y-2">
<div class="text-sm font-medium">
视频计费(可选覆盖)
</div>
2025-12-10 20:52:44 +08:00
<div class="flex items-center gap-1.5 flex-wrap">
<Button
type="button"
variant="outline"
size="sm"
class="h-7 text-xs"
@click="() => { fillVideoResolutionPricePreset('common'); configTouched = true }"
>
通用
</Button>
<Button
type="button"
variant="outline"
size="sm"
class="h-7 text-xs"
@click="() => { fillVideoResolutionPricePreset('sora'); configTouched = true }"
>
Sora
</Button>
<Button
type="button"
variant="outline"
size="sm"
class="h-7 text-xs"
@click="() => { fillVideoResolutionPricePreset('veo'); configTouched = true }"
>
Veo
</Button>
<Button
type="button"
variant="outline"
size="sm"
class="h-7 text-xs"
@click="() => { addVideoResolutionPriceRow(); configTouched = true }"
>
<Plus class="w-3.5 h-3.5 mr-0.5" />
自定义
</Button>
</div>
<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"
@update:model-value="() => { configTouched = true }"
/>
<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 }); configTouched = true }"
/>
<Button
type="button"
variant="ghost"
size="icon"
class="h-7 w-7"
title="删除"
@click="() => { removeVideoResolutionPriceRow(idx); configTouched = true }"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
2025-12-10 20:52:44 +08:00
</div>
</div>
</form>
<template #footer>
<Button
variant="outline"
@click="handleClose(false)"
>
2025-12-10 20:52:44 +08:00
取消
</Button>
<Button
:disabled="submitting || (!isEditing && !form.global_model_id)"
@click="handleSubmit"
>
<Loader2
v-if="submitting"
class="w-4 h-4 mr-2 animate-spin"
/>
2025-12-10 20:52:44 +08:00
{{ isEditing ? '保存' : '添加' }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { parseApiError } from '@/utils/errorParser'
import { Loader2, Layers, SquarePen, Plus, Trash2 } from 'lucide-vue-next'
import {
Dialog,
Button,
Input,
Label,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
2026-05-03 17:32:41 +08:00
Badge,
} from '@/components/ui'
2025-12-10 20:52:44 +08:00
import { useToast } from '@/composables/useToast'
import { parseNumberInput, sortResolutionEntries } from '@/utils/form'
2025-12-10 20:52:44 +08:00
import { createModel, updateModel, getProviderModels } from '@/api/endpoints/models'
import { listGlobalModels, type GlobalModelResponse } from '@/api/global-models'
import TieredPricingEditor from '@/features/models/components/TieredPricingEditor.vue'
import type { Model, TieredPricingConfig } from '@/api/endpoints'
2026-05-03 17:32:41 +08:00
import {
buildProviderModelCreatePayload,
buildProviderModelUpdatePayload,
modelSupportsEmbedding,
} from './provider-model-form-helpers'
2025-12-10 20:52:44 +08:00
interface Props {
open: boolean
providerId: string
providerName?: string
editingModel?: Model | null
}
const props = withDefaults(defineProps<Props>(), {
providerName: '',
editingModel: null
})
const emit = defineEmits<{
'update:open': [value: boolean]
'saved': []
}>()
const { error: showError, success: showSuccess } = useToast()
const tieredPricingEditorRef = ref<InstanceType<typeof TieredPricingEditor> | null>(null)
2025-12-10 20:52:44 +08:00
const isEditing = computed(() => !!props.editingModel)
2026-05-03 17:32:41 +08:00
const selectedGlobalModel = computed(() => {
return availableGlobalModels.value.find(model => model.id === form.value.global_model_id) || null
})
const selectedGlobalModelSupportsEmbedding = computed(() => modelSupportsEmbedding(selectedGlobalModel.value))
const editingModelSupportsEmbedding = computed(() => {
return props.editingModel?.effective_supports_embedding === true
|| modelSupportsEmbedding(props.editingModel)
})
// 1h 缓存定价始终显示
const showCache1h = true
2025-12-10 20:52:44 +08:00
// 表单状态
const submitting = ref(false)
const loadingGlobalModels = ref(false)
const availableGlobalModels = ref<GlobalModelResponse[]>([])
// 阶梯计费配置
const tieredPricing = ref<TieredPricingConfig | null>(null)
// 跟踪用户是否修改了阶梯配置(用于判断是否提交)
const tieredPricingModified = ref(false)
// 保存原始配置用于比较
const originalTieredPricing = ref<string>('')
type VideoResolutionPriceRow = { resolution: string; price_per_second: number | undefined }
const configTouched = ref(false)
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 },
],
}
2025-12-10 20:52:44 +08:00
const form = ref({
global_model_id: '',
price_per_request: undefined as number | undefined,
config: {} as Record<string, unknown>,
2025-12-10 20:52:44 +08:00
// 能力配置
supports_vision: undefined as boolean | undefined,
supports_function_calling: undefined as boolean | undefined,
supports_streaming: undefined as boolean | undefined,
supports_extended_thinking: undefined as boolean | undefined,
supports_image_generation: undefined as boolean | undefined,
is_active: true
})
// 监听 open 变化
watch(() => props.open, async (newOpen) => {
if (newOpen) {
resetForm()
if (props.editingModel) {
// 编辑模式:填充表单
// 使用有效配置(合并全局模型的默认值)供用户查看和编辑
const effectiveConfig = props.editingModel.effective_config || props.editingModel.config || {}
2025-12-10 20:52:44 +08:00
form.value = {
global_model_id: props.editingModel.global_model_id || '',
// 显示有效的按次计费价格(继承自全局模型)
price_per_request: props.editingModel.effective_price_per_request ?? props.editingModel.price_per_request ?? undefined,
config: effectiveConfig ? JSON.parse(JSON.stringify(effectiveConfig)) : {},
2025-12-10 20:52:44 +08:00
supports_vision: props.editingModel.supports_vision ?? undefined,
supports_function_calling: props.editingModel.supports_function_calling ?? undefined,
supports_streaming: props.editingModel.supports_streaming ?? undefined,
supports_extended_thinking: props.editingModel.supports_extended_thinking ?? undefined,
supports_image_generation: props.editingModel.supports_image_generation ?? undefined,
is_active: props.editingModel.is_active
}
// 从有效配置中加载视频费用
loadVideoPricingFromConfig(effectiveConfig)
2025-12-10 20:52:44 +08:00
// 加载阶梯计费配置:优先使用 Provider 自定义配置,否则使用有效配置(继承自全局模型)
const pricing = props.editingModel.tiered_pricing || props.editingModel.effective_tiered_pricing
if (pricing) {
tieredPricing.value = JSON.parse(JSON.stringify(pricing))
}
} else {
// 添加模式:加载可用全局模型
await loadAvailableGlobalModels()
}
}
})
// 添加模式:选择全局模型时显示其阶梯计费配置(仅供预览)
// 注意:为保持继承关系,添加时只有用户修改了配置才提交 tiered_pricing
watch(() => form.value.global_model_id, (newId) => {
if (!isEditing.value && newId) {
const selectedModel = availableGlobalModels.value.find(m => m.id === newId)
if (selectedModel?.default_tiered_pricing) {
// 深拷贝阶梯计费配置用于预览
const pricingCopy = JSON.parse(JSON.stringify(selectedModel.default_tiered_pricing))
tieredPricing.value = pricingCopy
// 保存原始配置用于比较
originalTieredPricing.value = JSON.stringify(pricingCopy)
} else {
tieredPricing.value = null
originalTieredPricing.value = ''
}
tieredPricingModified.value = false
// 同时继承按次计费(仅供预览)
form.value.price_per_request = selectedModel?.default_price_per_request ?? undefined
}
})
// 监听阶梯配置变化,标记为已修改
watch(tieredPricing, (newValue) => {
if (!isEditing.value && originalTieredPricing.value) {
const newJson = JSON.stringify(newValue)
tieredPricingModified.value = newJson !== originalTieredPricing.value
}
}, { deep: true })
// 重置表单
function resetForm() {
form.value = {
global_model_id: '',
price_per_request: undefined,
config: {},
2025-12-10 20:52:44 +08:00
supports_vision: undefined,
supports_function_calling: undefined,
supports_streaming: undefined,
supports_extended_thinking: undefined,
supports_image_generation: undefined,
is_active: true
}
configTouched.value = false
videoResolutionPrices.value = []
2025-12-10 20:52:44 +08:00
tieredPricing.value = null
tieredPricingModified.value = false
originalTieredPricing.value = ''
availableGlobalModels.value = []
}
function getNested(obj: Record<string, unknown>, path: string): unknown {
if (!obj || typeof obj !== 'object') return undefined
const parts = path.split('.').filter(Boolean)
let cur: unknown = obj
for (const p of parts) {
if (!cur || typeof cur !== 'object') return undefined
cur = (cur as Record<string, unknown>)[p]
}
return cur
}
function setNested(obj: Record<string, unknown>, path: string, value: unknown) {
if (!obj || typeof obj !== 'object') return
const parts = path.split('.').filter(Boolean)
if (parts.length === 0) return
let cur: Record<string, unknown> = obj
for (let i = 0; i < parts.length - 1; i++) {
const p = parts[i]
if (!cur[p] || typeof cur[p] !== 'object') {
cur[p] = {}
}
cur = cur[p] as Record<string, unknown>
}
cur[parts[parts.length - 1]] = value
}
function deleteNested(obj: Record<string, unknown>, path: string) {
if (!obj || typeof obj !== 'object') return
const parts = path.split('.').filter(Boolean)
if (parts.length === 0) return
let cur: Record<string, unknown> = obj
for (let i = 0; i < parts.length - 1; i++) {
const p = parts[i]
if (!cur[p] || typeof cur[p] !== 'object') return
cur = cur[p] as Record<string, unknown>
}
delete cur[parts[parts.length - 1]]
}
function pruneEmptyBillingConfig(cfg: Record<string, unknown>) {
const billing = cfg.billing
if (!billing || typeof billing !== 'object') return
const billingObj = billing as Record<string, unknown>
const video = billingObj.video
if (video && typeof video === 'object' && Object.keys(video).length === 0) {
delete billingObj.video
}
if (Object.keys(billingObj).length === 0) {
delete cfg.billing
}
}
/**
* 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}`
}
return k
}
function loadVideoPricingFromConfig(cfg: Record<string, unknown>) {
const raw = getNested(cfg, 'billing.video.price_per_second_by_resolution')
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
// 按分辨率从低到高排序
const sortedEntries = sortResolutionEntries(Object.entries(raw))
videoResolutionPrices.value = sortedEntries.map(([k, v]) => ({
resolution: String(k),
price_per_second: typeof v === 'number' ? v : undefined,
}))
} else {
videoResolutionPrices.value = []
}
}
function applyVideoPricingToConfig(cfg: Record<string, unknown>) {
// 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(cfg)
}
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,
}))
}
function _copyVideoPricingFromSelectedGlobal() {
const gm = availableGlobalModels.value.find(m => m.id === form.value.global_model_id)
const cfg = gm?.config || {}
if (cfg && typeof cfg === 'object') {
const raw = getNested(cfg, 'billing.video.price_per_second_by_resolution')
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
videoResolutionPrices.value = Object.entries(raw).map(([k, v]) => ({
resolution: String(k),
price_per_second: typeof v === 'number' ? v : undefined,
}))
}
}
configTouched.value = true
}
2025-12-10 20:52:44 +08:00
// 加载可用的全局模型(排除已添加的)
async function loadAvailableGlobalModels() {
loadingGlobalModels.value = true
try {
const [globalModelsResponse, existingModels] = await Promise.all([
listGlobalModels({ limit: 1000, is_active: true }),
getProviderModels(props.providerId)
])
const allGlobalModels = globalModelsResponse.models || []
// 获取当前 provider 已添加的模型的 global_model_id 列表
const existingGlobalModelIds = new Set(
existingModels.map((m: Model) => m.global_model_id)
2025-12-10 20:52:44 +08:00
)
// 过滤掉已添加的模型
availableGlobalModels.value = allGlobalModels.filter(
(gm: GlobalModelResponse) => !existingGlobalModelIds.has(gm.id)
)
} catch (err: unknown) {
showError(parseApiError(err, '加载模型列表失败'), '错误')
2025-12-10 20:52:44 +08:00
} finally {
loadingGlobalModels.value = false
}
}
// 关闭对话框
function handleClose(value: boolean) {
if (!submitting.value) {
emit('update:open', value)
}
}
// 提交表单
async function handleSubmit() {
if (submitting.value) return
submitting.value = true
try {
// 获取包含自动计算缓存价格的最终数据
const finalTiers = tieredPricingEditorRef.value?.getFinalTiers()
const finalTieredPricing = finalTiers ? { tiers: finalTiers } : tieredPricing.value
// Apply billing (video) pricing into config.
applyVideoPricingToConfig(form.value.config)
const cleanConfig = form.value.config && Object.keys(form.value.config).length > 0
? form.value.config
: undefined
2025-12-10 20:52:44 +08:00
if (isEditing.value && props.editingModel) {
// 编辑模式
// 注意:使用 null 而不是 undefined 来显式清空字段(undefined 会被 JSON 序列化忽略)
2026-05-03 17:32:41 +08:00
await updateModel(props.providerId, props.editingModel.id, buildProviderModelUpdatePayload({
finalTieredPricing,
pricePerRequest: form.value.price_per_request,
cleanConfig,
supportsVision: form.value.supports_vision,
supportsFunctionCalling: form.value.supports_function_calling,
supportsStreaming: form.value.supports_streaming,
supportsExtendedThinking: form.value.supports_extended_thinking,
supportsImageGeneration: form.value.supports_image_generation,
isActive: form.value.is_active
}))
2025-12-10 20:52:44 +08:00
showSuccess('模型配置已更新')
} else {
// 添加模式:只有用户修改了配置才提交 tiered_pricing,否则保持继承关系
const selectedModel = availableGlobalModels.value.find(m => m.id === form.value.global_model_id)
2026-05-03 17:32:41 +08:00
await createModel(props.providerId, buildProviderModelCreatePayload({
globalModelId: form.value.global_model_id,
providerModelName: selectedModel?.name || '',
finalTieredPricing,
tieredPricingModified: tieredPricingModified.value,
pricePerRequest: form.value.price_per_request,
cleanConfig,
configTouched: configTouched.value,
supportsVision: form.value.supports_vision,
supportsFunctionCalling: form.value.supports_function_calling,
supportsStreaming: form.value.supports_streaming,
supportsExtendedThinking: form.value.supports_extended_thinking,
supportsImageGeneration: form.value.supports_image_generation,
isActive: form.value.is_active
}))
2025-12-10 20:52:44 +08:00
showSuccess('模型已添加')
}
emit('update:open', false)
emit('saved')
} catch (err: unknown) {
showError(parseApiError(err, isEditing.value ? '更新失败' : '添加失败'), '错误')
2025-12-10 20:52:44 +08:00
} finally {
submitting.value = false
}
}
</script>