2025-12-20 02:01:17 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<Dialog
|
|
|
|
|
|
:model-value="open"
|
|
|
|
|
|
:title="editingGroup ? '编辑模型映射' : '添加模型映射'"
|
2026-02-09 13:24:42 +08:00
|
|
|
|
:description="editingGroup ? '修改映射配置' : '将提供商模型映射到客户端模型'"
|
2025-12-20 02:01:17 +08:00
|
|
|
|
:icon="Tag"
|
2026-01-14 14:28:13 +08:00
|
|
|
|
size="lg"
|
2025-12-20 02:01:17 +08:00
|
|
|
|
@update:model-value="$emit('update:open', $event)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="space-y-4">
|
2026-01-14 14:28:13 +08:00
|
|
|
|
<!-- 目标模型选择 -->
|
|
|
|
|
|
<div class="space-y-1.5">
|
2026-02-09 13:24:42 +08:00
|
|
|
|
<Label class="text-xs">客户端模型</Label>
|
2026-01-14 14:28:13 +08:00
|
|
|
|
<Select
|
|
|
|
|
|
:model-value="formData.modelId"
|
|
|
|
|
|
:disabled="!!editingGroup"
|
|
|
|
|
|
@update:model-value="handleModelChange"
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger class="h-9">
|
2026-02-09 13:24:42 +08:00
|
|
|
|
<SelectValue placeholder="请选择客户端模型" />
|
2026-01-14 14:28:13 +08:00
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem
|
|
|
|
|
|
v-for="model in models"
|
|
|
|
|
|
:key="model.id"
|
|
|
|
|
|
:value="model.id"
|
2025-12-20 02:01:17 +08:00
|
|
|
|
>
|
2026-01-14 14:28:13 +08:00
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
|
<span class="font-medium">{{ model.global_model_display_name || model.provider_model_name }}</span>
|
|
|
|
|
|
<span class="text-xs text-muted-foreground font-mono">{{ model.provider_model_name }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
<p class="text-xs text-muted-foreground">
|
2026-02-09 13:24:42 +08:00
|
|
|
|
客户端请求此模型时,将路由到选中的提供商模型
|
2026-01-14 14:28:13 +08:00
|
|
|
|
</p>
|
2025-12-20 02:01:17 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-07 00:48:15 +08:00
|
|
|
|
<!-- 端点限制 -->
|
|
|
|
|
|
<div class="space-y-1.5">
|
|
|
|
|
|
<div class="flex items-center justify-between gap-2">
|
|
|
|
|
|
<Label class="text-xs">限制端点</Label>
|
|
|
|
|
|
<span class="text-xs text-muted-foreground">{{ endpointScopeSummary }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<MultiSelect
|
|
|
|
|
|
v-model="selectedEndpointIds"
|
|
|
|
|
|
:options="endpointOptions"
|
|
|
|
|
|
placeholder="全部端点"
|
|
|
|
|
|
empty-text="暂无端点"
|
|
|
|
|
|
no-results-text="未找到端点"
|
|
|
|
|
|
trigger-class="h-9 rounded-md"
|
|
|
|
|
|
dropdown-min-width="24rem"
|
|
|
|
|
|
:search-threshold="4"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<p class="text-xs text-muted-foreground">
|
|
|
|
|
|
默认对全部端点生效;选择端点后,此映射只在选中的端点上生效
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-14 14:28:13 +08:00
|
|
|
|
<!-- 映射名称选择面板 -->
|
|
|
|
|
|
<div class="space-y-1.5">
|
2026-02-09 13:24:42 +08:00
|
|
|
|
<Label class="text-xs">提供商模型</Label>
|
2026-01-23 12:50:25 +08:00
|
|
|
|
<!-- 搜索栏 -->
|
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
|
<div class="flex-1 relative">
|
|
|
|
|
|
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
|
|
|
|
<Input
|
|
|
|
|
|
v-model="searchQuery"
|
2026-02-09 13:24:42 +08:00
|
|
|
|
placeholder="搜索或添加自定义提供商模型..."
|
2026-01-23 12:50:25 +08:00
|
|
|
|
class="pl-8 h-9"
|
2025-12-20 02:01:17 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-01-23 12:50:25 +08:00
|
|
|
|
<!-- 已选数量徽章 -->
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="selectedNames.length === 0"
|
|
|
|
|
|
class="h-7 px-2.5 text-xs rounded-md flex items-center bg-muted text-muted-foreground shrink-0"
|
|
|
|
|
|
>
|
|
|
|
|
|
未选择
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="h-7 px-2.5 text-xs rounded-md flex items-center bg-primary/10 text-primary shrink-0"
|
|
|
|
|
|
>
|
|
|
|
|
|
已选 {{ selectedNames.length }} 个
|
|
|
|
|
|
</span>
|
2026-02-28 11:44:08 +08:00
|
|
|
|
<!-- 刷新上游模型按钮 -->
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-if="upstreamModelsLoaded"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="p-2 hover:bg-muted rounded-md transition-colors shrink-0"
|
|
|
|
|
|
:disabled="fetchingUpstreamModels"
|
|
|
|
|
|
title="刷新上游模型"
|
|
|
|
|
|
@click="fetchUpstreamModels()"
|
|
|
|
|
|
>
|
|
|
|
|
|
<RefreshCw
|
|
|
|
|
|
class="w-4 h-4"
|
|
|
|
|
|
:class="{ 'animate-spin': fetchingUpstreamModels }"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-else-if="!fetchingUpstreamModels"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="p-2 hover:bg-muted rounded-md transition-colors shrink-0"
|
|
|
|
|
|
title="从提供商获取模型"
|
|
|
|
|
|
@click="fetchUpstreamModels()"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Zap class="w-4 h-4" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<Loader2
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="w-4 h-4 animate-spin text-muted-foreground shrink-0"
|
|
|
|
|
|
/>
|
2026-01-23 12:50:25 +08:00
|
|
|
|
</div>
|
2026-01-14 14:28:13 +08:00
|
|
|
|
|
2026-01-23 12:50:25 +08:00
|
|
|
|
<!-- 模型列表 -->
|
|
|
|
|
|
<div class="border rounded-lg overflow-hidden">
|
2026-02-09 13:24:42 +08:00
|
|
|
|
<div class="min-h-60 max-h-80 overflow-y-auto">
|
2026-01-14 14:28:13 +08:00
|
|
|
|
<!-- 加载中 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="loadingModels"
|
|
|
|
|
|
class="flex items-center justify-center py-12"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Loader2 class="w-6 h-6 animate-spin text-primary" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
<!-- 添加自定义映射名称(搜索内容不在列表中时显示) -->
|
2025-12-20 02:01:17 +08:00
|
|
|
|
<div
|
2026-01-14 14:28:13 +08:00
|
|
|
|
v-if="searchQuery && canAddAsCustom"
|
|
|
|
|
|
class="px-3 py-2 border-b bg-background sticky top-0 z-30"
|
2025-12-20 02:01:17 +08:00
|
|
|
|
>
|
|
|
|
|
|
<div
|
2026-01-14 14:28:13 +08:00
|
|
|
|
class="flex items-center justify-between px-3 py-2 rounded-lg border border-dashed hover:border-primary hover:bg-primary/5 cursor-pointer transition-colors"
|
|
|
|
|
|
@click="addCustomName"
|
2025-12-20 02:01:17 +08:00
|
|
|
|
>
|
2026-01-14 14:28:13 +08:00
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
|
<Plus class="w-4 h-4 text-muted-foreground" />
|
|
|
|
|
|
<span class="text-sm font-mono">{{ searchQuery }}</span>
|
2025-12-20 02:01:17 +08:00
|
|
|
|
</div>
|
2026-02-09 13:24:42 +08:00
|
|
|
|
<span class="text-xs text-muted-foreground">添加自定义提供商模型</span>
|
2026-01-14 14:28:13 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 自定义映射名称 -->
|
|
|
|
|
|
<div v-if="customNames.length > 0">
|
|
|
|
|
|
<div
|
2026-02-28 11:44:08 +08:00
|
|
|
|
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-20 cursor-pointer hover:bg-muted/80 transition-colors"
|
|
|
|
|
|
@click="toggleGroupCollapse('custom')"
|
2026-01-14 14:28:13 +08:00
|
|
|
|
>
|
|
|
|
|
|
<div class="flex items-center gap-2">
|
2026-02-28 11:44:08 +08:00
|
|
|
|
<ChevronDown
|
|
|
|
|
|
class="w-4 h-4 transition-transform shrink-0"
|
|
|
|
|
|
:class="collapsedGroups.has('custom') ? '-rotate-90' : ''"
|
|
|
|
|
|
/>
|
2026-02-09 13:24:42 +08:00
|
|
|
|
<span class="text-xs font-medium">自定义模型</span>
|
2026-01-14 14:28:13 +08:00
|
|
|
|
<span class="text-xs text-muted-foreground">({{ customNames.length }})</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-02-28 11:44:08 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-show="!collapsedGroups.has('custom')"
|
|
|
|
|
|
class="space-y-1 p-2"
|
|
|
|
|
|
>
|
2025-12-20 02:01:17 +08:00
|
|
|
|
<div
|
2026-01-14 14:28:13 +08:00
|
|
|
|
v-for="name in sortedCustomNames"
|
|
|
|
|
|
:key="name"
|
|
|
|
|
|
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
|
|
|
|
|
|
@click="toggleName(name)"
|
2025-12-20 02:01:17 +08:00
|
|
|
|
>
|
|
|
|
|
|
<div
|
2026-01-14 14:28:13 +08:00
|
|
|
|
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
|
|
|
|
|
|
:class="selectedNames.includes(name) ? 'bg-primary border-primary' : ''"
|
2025-12-20 02:01:17 +08:00
|
|
|
|
>
|
2026-01-14 14:28:13 +08:00
|
|
|
|
<Check
|
|
|
|
|
|
v-if="selectedNames.includes(name)"
|
|
|
|
|
|
class="w-3 h-3 text-primary-foreground"
|
|
|
|
|
|
/>
|
2025-12-20 02:01:17 +08:00
|
|
|
|
</div>
|
2026-01-14 14:28:13 +08:00
|
|
|
|
<span class="text-sm font-mono truncate flex-1">{{ name }}</span>
|
2025-12-20 02:01:17 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-14 14:28:13 +08:00
|
|
|
|
|
2026-02-28 11:44:08 +08:00
|
|
|
|
<!-- 上游模型 -->
|
|
|
|
|
|
<template v-if="filteredUpstreamModels.length > 0">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-20 cursor-pointer hover:bg-muted/80 transition-colors"
|
|
|
|
|
|
@click="toggleGroupCollapse('upstream')"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
|
<ChevronDown
|
|
|
|
|
|
class="w-4 h-4 transition-transform shrink-0"
|
|
|
|
|
|
:class="collapsedGroups.has('upstream') ? '-rotate-90' : ''"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span class="text-xs font-medium">上游模型</span>
|
|
|
|
|
|
<span class="text-xs text-muted-foreground">({{ upstreamModelNames.length }})</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="text-xs text-primary hover:underline"
|
|
|
|
|
|
@click.stop="toggleAllUpstreamModels"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ isAllUpstreamModelsSelected ? '取消全选' : '全选' }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-show="!collapsedGroups.has('upstream')"
|
|
|
|
|
|
class="space-y-1 p-2"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="name in filteredUpstreamModels"
|
|
|
|
|
|
:key="name"
|
|
|
|
|
|
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
|
|
|
|
|
|
@click="toggleName(name)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
|
|
|
|
|
|
:class="selectedNames.includes(name) ? 'bg-primary border-primary' : ''"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Check
|
|
|
|
|
|
v-if="selectedNames.includes(name)"
|
|
|
|
|
|
class="w-3 h-3 text-primary-foreground"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span class="text-sm font-mono truncate flex-1">{{ name }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
2026-01-14 14:28:13 +08:00
|
|
|
|
<!-- 空状态 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="showEmptyState"
|
|
|
|
|
|
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Tag class="w-10 h-10 mb-2 opacity-30" />
|
|
|
|
|
|
<p class="text-sm">
|
|
|
|
|
|
{{ searchQuery ? '无匹配结果' : '暂无可选模型' }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p class="text-xs mt-1">
|
2026-02-09 13:24:42 +08:00
|
|
|
|
输入模型名称后点击添加自定义提供商模型
|
2026-01-14 14:28:13 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2025-12-20 02:01:17 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
@click="$emit('update:open', false)"
|
|
|
|
|
|
>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
2026-01-14 14:28:13 +08:00
|
|
|
|
:disabled="submitting || !formData.modelId || selectedNames.length === 0"
|
2025-12-20 02:01:17 +08:00
|
|
|
|
@click="handleSubmit"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Loader2
|
|
|
|
|
|
v-if="submitting"
|
|
|
|
|
|
class="w-4 h-4 mr-2 animate-spin"
|
|
|
|
|
|
/>
|
|
|
|
|
|
{{ editingGroup ? '保存' : '添加' }}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { ref, computed, watch } from 'vue'
|
2026-02-28 11:44:08 +08:00
|
|
|
|
import { Tag, Loader2, Plus, Search, Check, ChevronDown, RefreshCw, Zap } from 'lucide-vue-next'
|
2025-12-20 02:01:17 +08:00
|
|
|
|
import {
|
|
|
|
|
|
Button,
|
|
|
|
|
|
Input,
|
|
|
|
|
|
Label,
|
|
|
|
|
|
Dialog,
|
|
|
|
|
|
Select,
|
|
|
|
|
|
SelectContent,
|
|
|
|
|
|
SelectItem,
|
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
|
SelectValue,
|
|
|
|
|
|
} from '@/components/ui'
|
2026-05-07 00:48:15 +08:00
|
|
|
|
import MultiSelect from '@/components/common/MultiSelect.vue'
|
2025-12-20 02:01:17 +08:00
|
|
|
|
import { useToast } from '@/composables/useToast'
|
2026-02-22 00:43:41 +08:00
|
|
|
|
import { parseApiError } from '@/utils/errorParser'
|
2025-12-20 02:01:17 +08:00
|
|
|
|
import {
|
|
|
|
|
|
type Model,
|
2026-05-07 00:48:15 +08:00
|
|
|
|
type ProviderEndpoint,
|
2026-01-29 22:58:37 +08:00
|
|
|
|
type ProviderModelAlias,
|
2026-02-28 11:44:08 +08:00
|
|
|
|
type UpstreamModel,
|
2025-12-20 02:01:17 +08:00
|
|
|
|
} from '@/api/endpoints'
|
|
|
|
|
|
import { updateModel } from '@/api/endpoints/models'
|
2026-05-07 00:48:15 +08:00
|
|
|
|
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
2026-02-28 11:44:08 +08:00
|
|
|
|
import { useUpstreamModelsCache } from '../composables/useUpstreamModelsCache'
|
2025-12-20 02:01:17 +08:00
|
|
|
|
|
|
|
|
|
|
export interface AliasGroup {
|
|
|
|
|
|
model: Model
|
2026-02-27 13:54:46 +08:00
|
|
|
|
/** @deprecated */
|
2025-12-20 02:01:17 +08:00
|
|
|
|
apiFormatsKey: string
|
2026-02-27 13:54:46 +08:00
|
|
|
|
/** @deprecated */
|
2025-12-20 02:01:17 +08:00
|
|
|
|
apiFormats: string[]
|
2026-05-07 00:48:15 +08:00
|
|
|
|
endpointIdsKey: string
|
|
|
|
|
|
endpointIds: string[]
|
2025-12-20 02:01:17 +08:00
|
|
|
|
aliases: ProviderModelAlias[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
|
|
open: boolean
|
|
|
|
|
|
providerId: string
|
2026-02-27 13:54:46 +08:00
|
|
|
|
/** @deprecated */
|
2026-01-14 14:28:13 +08:00
|
|
|
|
providerApiFormats?: string[]
|
2026-05-07 00:48:15 +08:00
|
|
|
|
endpoints?: ProviderEndpoint[]
|
2025-12-20 02:01:17 +08:00
|
|
|
|
models: Model[]
|
|
|
|
|
|
editingGroup?: AliasGroup | null
|
2026-01-11 02:06:48 +08:00
|
|
|
|
preselectedModelId?: string | null
|
2026-02-28 11:44:08 +08:00
|
|
|
|
hasAutoFetchKey?: boolean
|
2025-12-20 02:01:17 +08:00
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
|
'update:open': [value: boolean]
|
|
|
|
|
|
'saved': []
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const { error: showError, success: showSuccess } = useToast()
|
2026-02-28 11:44:08 +08:00
|
|
|
|
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
|
2025-12-20 02:01:17 +08:00
|
|
|
|
|
2026-05-07 00:48:15 +08:00
|
|
|
|
type EndpointOption = {
|
|
|
|
|
|
value: string
|
|
|
|
|
|
label: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-20 02:01:17 +08:00
|
|
|
|
// 状态
|
|
|
|
|
|
const submitting = ref(false)
|
2026-01-14 14:28:13 +08:00
|
|
|
|
const loadingModels = ref(false)
|
2026-02-28 11:44:08 +08:00
|
|
|
|
const fetchingUpstreamModels = ref(false)
|
|
|
|
|
|
const upstreamModelsLoaded = ref(false)
|
2025-12-20 02:01:17 +08:00
|
|
|
|
|
2026-01-14 14:28:13 +08:00
|
|
|
|
// 搜索
|
|
|
|
|
|
const searchQuery = ref('')
|
|
|
|
|
|
|
2026-02-28 11:44:08 +08:00
|
|
|
|
// 折叠状态
|
|
|
|
|
|
const collapsedGroups = ref<Set<string>>(new Set())
|
|
|
|
|
|
|
|
|
|
|
|
// 上游模型
|
|
|
|
|
|
const upstreamModels = ref<UpstreamModel[]>([])
|
|
|
|
|
|
|
2025-12-20 02:01:17 +08:00
|
|
|
|
// 表单数据
|
|
|
|
|
|
const formData = ref<{
|
|
|
|
|
|
modelId: string
|
|
|
|
|
|
}>({
|
2026-01-14 14:28:13 +08:00
|
|
|
|
modelId: ''
|
2025-12-20 02:01:17 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-14 14:28:13 +08:00
|
|
|
|
// 选中的映射名称
|
|
|
|
|
|
const selectedNames = ref<string[]>([])
|
|
|
|
|
|
|
2026-05-07 00:48:15 +08:00
|
|
|
|
// 选中的端点 ID;空数组表示全部端点
|
|
|
|
|
|
const selectedEndpointIds = ref<string[]>([])
|
|
|
|
|
|
|
2026-01-14 14:28:13 +08:00
|
|
|
|
// 自定义名称列表(手动添加的)
|
|
|
|
|
|
const allCustomNames = ref<string[]>([])
|
|
|
|
|
|
|
2026-05-07 00:48:15 +08:00
|
|
|
|
const endpointOptions = computed<EndpointOption[]>(() => {
|
|
|
|
|
|
return (props.endpoints ?? []).map((endpoint) => {
|
|
|
|
|
|
const status = endpoint.is_active ? '' : '(停用)'
|
|
|
|
|
|
return {
|
|
|
|
|
|
value: endpoint.id,
|
|
|
|
|
|
label: `${formatApiFormat(endpoint.api_format)}${status}`,
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const normalizedSelectedEndpointIds = computed(() => {
|
|
|
|
|
|
const validIds = new Set(endpointOptions.value.map(option => option.value))
|
|
|
|
|
|
const selected = normalizeStringList(selectedEndpointIds.value)
|
|
|
|
|
|
if (selected.length === 0) {
|
|
|
|
|
|
return undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
const invalidSelected = selected.filter(endpointId => !validIds.has(endpointId))
|
|
|
|
|
|
const selectedValidCount = selected.filter(endpointId => validIds.has(endpointId)).length
|
|
|
|
|
|
if (validIds.size > 0 && invalidSelected.length === 0 && selectedValidCount === validIds.size) {
|
|
|
|
|
|
return undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
return selected
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const endpointScopeSummary = computed(() => {
|
|
|
|
|
|
const selected = normalizedSelectedEndpointIds.value
|
|
|
|
|
|
if (!selected || selected.length === 0) return '全部端点'
|
|
|
|
|
|
return `${selected.length} 个端点`
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-28 11:44:08 +08:00
|
|
|
|
// 所有已知名称集合
|
|
|
|
|
|
const allKnownNames = computed(() => {
|
|
|
|
|
|
const set = new Set<string>()
|
|
|
|
|
|
upstreamModels.value.forEach(m => set.add(m.id))
|
|
|
|
|
|
return set
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 上游模型名称列表(去重后)
|
|
|
|
|
|
const upstreamModelNames = computed(() => {
|
|
|
|
|
|
const names = new Set<string>()
|
|
|
|
|
|
upstreamModels.value.forEach(m => {
|
|
|
|
|
|
names.add(m.id)
|
|
|
|
|
|
})
|
|
|
|
|
|
return Array.from(names).sort()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 自定义名称列表(排除上游模型中已有的)
|
2026-01-14 14:28:13 +08:00
|
|
|
|
const customNames = computed(() => {
|
2026-02-28 11:44:08 +08:00
|
|
|
|
const upstreamSet = new Set(upstreamModelNames.value)
|
|
|
|
|
|
return allCustomNames.value.filter(name => !upstreamSet.has(name))
|
2026-01-14 14:28:13 +08:00
|
|
|
|
})
|
2025-12-20 02:01:17 +08:00
|
|
|
|
|
2026-01-14 14:28:13 +08:00
|
|
|
|
// 排序后的自定义名称
|
|
|
|
|
|
const sortedCustomNames = computed(() => {
|
|
|
|
|
|
const search = searchQuery.value.toLowerCase().trim()
|
|
|
|
|
|
if (!search) return customNames.value
|
|
|
|
|
|
|
|
|
|
|
|
const matched: string[] = []
|
|
|
|
|
|
const unmatched: string[] = []
|
|
|
|
|
|
for (const name of customNames.value) {
|
|
|
|
|
|
if (name.toLowerCase().includes(search)) {
|
|
|
|
|
|
matched.push(name)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
unmatched.push(name)
|
|
|
|
|
|
}
|
2025-12-20 02:01:17 +08:00
|
|
|
|
}
|
2026-01-14 14:28:13 +08:00
|
|
|
|
return [...matched, ...unmatched]
|
2025-12-20 02:01:17 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-14 14:28:13 +08:00
|
|
|
|
// 判断搜索内容是否可以作为自定义名称添加
|
|
|
|
|
|
const canAddAsCustom = computed(() => {
|
|
|
|
|
|
const search = searchQuery.value.trim()
|
|
|
|
|
|
if (!search) return false
|
|
|
|
|
|
if (selectedNames.value.includes(search)) return false
|
|
|
|
|
|
if (allCustomNames.value.includes(search)) return false
|
2026-02-28 11:44:08 +08:00
|
|
|
|
if (allKnownNames.value.has(search)) return false
|
2026-01-14 14:28:13 +08:00
|
|
|
|
return true
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-28 11:44:08 +08:00
|
|
|
|
// 过滤后的上游模型
|
|
|
|
|
|
const filteredUpstreamModels = computed(() => {
|
|
|
|
|
|
if (!searchQuery.value.trim()) return upstreamModelNames.value
|
|
|
|
|
|
const query = searchQuery.value.toLowerCase()
|
|
|
|
|
|
return upstreamModelNames.value.filter(name => name.toLowerCase().includes(query))
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-01-14 14:28:13 +08:00
|
|
|
|
// 空状态判断
|
|
|
|
|
|
const showEmptyState = computed(() => {
|
2026-02-28 11:44:08 +08:00
|
|
|
|
return filteredUpstreamModels.value.length === 0 && customNames.value.length === 0
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 上游模型是否全选
|
|
|
|
|
|
const isAllUpstreamModelsSelected = computed(() => {
|
|
|
|
|
|
if (filteredUpstreamModels.value.length === 0) return false
|
|
|
|
|
|
return filteredUpstreamModels.value.every(name => selectedNames.value.includes(name))
|
2026-01-14 14:28:13 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 切换名称选中状态
|
|
|
|
|
|
function toggleName(name: string) {
|
|
|
|
|
|
const idx = selectedNames.value.indexOf(name)
|
|
|
|
|
|
if (idx === -1) {
|
|
|
|
|
|
selectedNames.value.push(name)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
selectedNames.value.splice(idx, 1)
|
|
|
|
|
|
}
|
2025-12-20 02:01:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-14 14:28:13 +08:00
|
|
|
|
// 添加自定义名称
|
|
|
|
|
|
function addCustomName() {
|
|
|
|
|
|
const name = searchQuery.value.trim()
|
|
|
|
|
|
if (name && !selectedNames.value.includes(name)) {
|
|
|
|
|
|
selectedNames.value.push(name)
|
2026-02-28 11:44:08 +08:00
|
|
|
|
if (!allKnownNames.value.has(name) && !allCustomNames.value.includes(name)) {
|
2026-01-14 14:28:13 +08:00
|
|
|
|
allCustomNames.value.push(name)
|
2025-12-28 19:40:07 +08:00
|
|
|
|
}
|
2026-01-14 14:28:13 +08:00
|
|
|
|
searchQuery.value = ''
|
2025-12-28 19:40:07 +08:00
|
|
|
|
}
|
2026-01-14 14:28:13 +08:00
|
|
|
|
}
|
2025-12-28 19:40:07 +08:00
|
|
|
|
|
2026-02-28 11:44:08 +08:00
|
|
|
|
// 全选/取消全选上游模型
|
|
|
|
|
|
function toggleAllUpstreamModels() {
|
|
|
|
|
|
const allNames = filteredUpstreamModels.value
|
|
|
|
|
|
if (isAllUpstreamModelsSelected.value) {
|
|
|
|
|
|
selectedNames.value = selectedNames.value.filter(name => !allNames.includes(name))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
allNames.forEach(name => {
|
|
|
|
|
|
if (!selectedNames.value.includes(name)) {
|
|
|
|
|
|
selectedNames.value.push(name)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 00:48:15 +08:00
|
|
|
|
function normalizeStringList(values: string[] | undefined): string[] {
|
|
|
|
|
|
const seen = new Set<string>()
|
|
|
|
|
|
const result: string[] = []
|
|
|
|
|
|
for (const value of values ?? []) {
|
|
|
|
|
|
const normalized = value.trim()
|
|
|
|
|
|
if (!normalized || seen.has(normalized)) continue
|
|
|
|
|
|
seen.add(normalized)
|
|
|
|
|
|
result.push(normalized)
|
|
|
|
|
|
}
|
|
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getScopeKey(values: string[] | undefined): string {
|
|
|
|
|
|
return normalizeStringList(values).sort().join(',')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function scopesOverlap(left: string[] | undefined, right: string[] | undefined): boolean {
|
|
|
|
|
|
const leftValues = normalizeStringList(left)
|
|
|
|
|
|
const rightValues = normalizeStringList(right)
|
|
|
|
|
|
if (leftValues.length === 0 || rightValues.length === 0) return true
|
|
|
|
|
|
const rightSet = new Set(rightValues)
|
|
|
|
|
|
return leftValues.some(value => rightSet.has(value))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function findDuplicateNames(
|
|
|
|
|
|
existingAliases: ProviderModelAlias[],
|
|
|
|
|
|
names: string[],
|
|
|
|
|
|
endpointIds: string[] | undefined,
|
|
|
|
|
|
apiFormats: string[] | undefined = undefined,
|
|
|
|
|
|
): string[] {
|
|
|
|
|
|
const duplicates = new Set<string>()
|
|
|
|
|
|
for (const rawName of names) {
|
|
|
|
|
|
const name = rawName.trim()
|
|
|
|
|
|
if (!name) continue
|
|
|
|
|
|
const duplicate = existingAliases.some((alias) => {
|
|
|
|
|
|
return alias.name === name
|
|
|
|
|
|
&& scopesOverlap(alias.endpoint_ids, endpointIds)
|
|
|
|
|
|
&& scopesOverlap(alias.api_formats, apiFormats)
|
|
|
|
|
|
})
|
|
|
|
|
|
if (duplicate) duplicates.add(name)
|
|
|
|
|
|
}
|
|
|
|
|
|
return Array.from(duplicates)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-28 11:44:08 +08:00
|
|
|
|
// 切换折叠状态
|
|
|
|
|
|
function toggleGroupCollapse(group: string) {
|
|
|
|
|
|
if (collapsedGroups.value.has(group)) {
|
|
|
|
|
|
collapsedGroups.value.delete(group)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
collapsedGroups.value.add(group)
|
|
|
|
|
|
}
|
|
|
|
|
|
collapsedGroups.value = new Set(collapsedGroups.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 从提供商获取模型(使用缓存)
|
|
|
|
|
|
async function fetchUpstreamModels() {
|
|
|
|
|
|
if (!props.providerId) return
|
|
|
|
|
|
try {
|
|
|
|
|
|
loadingModels.value = true
|
|
|
|
|
|
fetchingUpstreamModels.value = true
|
|
|
|
|
|
const result = await fetchCachedModels(props.providerId)
|
|
|
|
|
|
if (result.models.length > 0) {
|
|
|
|
|
|
upstreamModels.value = result.models
|
|
|
|
|
|
upstreamModelsLoaded.value = true
|
|
|
|
|
|
// 获取上游模型后,将不在上游列表中的已选名称添加到自定义列表
|
|
|
|
|
|
const upstreamIds = new Set(result.models.map(m => m.id))
|
|
|
|
|
|
const customFromSelected = selectedNames.value.filter(name => !upstreamIds.has(name))
|
|
|
|
|
|
const mergedCustom = new Set([...allCustomNames.value, ...customFromSelected])
|
|
|
|
|
|
allCustomNames.value = Array.from(mergedCustom).filter(name => !upstreamIds.has(name))
|
|
|
|
|
|
}
|
|
|
|
|
|
if (result.error) {
|
|
|
|
|
|
showError(result.error, '获取上游模型失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
showError(parseApiError(err, '获取上游模型列表失败'), '错误')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loadingModels.value = false
|
|
|
|
|
|
fetchingUpstreamModels.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-20 02:01:17 +08:00
|
|
|
|
// 监听打开状态
|
2026-01-14 14:28:13 +08:00
|
|
|
|
watch(() => props.open, async (isOpen) => {
|
2025-12-20 02:01:17 +08:00
|
|
|
|
if (isOpen) {
|
|
|
|
|
|
initForm()
|
2026-02-28 11:44:08 +08:00
|
|
|
|
if (props.hasAutoFetchKey) {
|
|
|
|
|
|
await fetchUpstreamModels()
|
|
|
|
|
|
}
|
2025-12-20 02:01:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化表单
|
|
|
|
|
|
function initForm() {
|
|
|
|
|
|
if (props.editingGroup) {
|
|
|
|
|
|
formData.value = {
|
2026-01-14 14:28:13 +08:00
|
|
|
|
modelId: props.editingGroup.model.id
|
2025-12-20 02:01:17 +08:00
|
|
|
|
}
|
2026-02-09 13:24:42 +08:00
|
|
|
|
const existingNames = props.editingGroup.aliases.map(a => a.name)
|
|
|
|
|
|
selectedNames.value = [...existingNames]
|
2026-05-07 00:48:15 +08:00
|
|
|
|
selectedEndpointIds.value = normalizeStringList(props.editingGroup.endpointIds)
|
2026-02-09 13:24:42 +08:00
|
|
|
|
allCustomNames.value = [...existingNames]
|
2025-12-20 02:01:17 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
formData.value = {
|
2026-01-14 14:28:13 +08:00
|
|
|
|
modelId: props.preselectedModelId || ''
|
2025-12-20 02:01:17 +08:00
|
|
|
|
}
|
2026-01-14 14:28:13 +08:00
|
|
|
|
selectedNames.value = []
|
2026-05-07 00:48:15 +08:00
|
|
|
|
selectedEndpointIds.value = []
|
2026-01-14 14:28:13 +08:00
|
|
|
|
allCustomNames.value = []
|
2025-12-20 02:01:17 +08:00
|
|
|
|
}
|
2026-01-14 14:28:13 +08:00
|
|
|
|
searchQuery.value = ''
|
2026-02-28 11:44:08 +08:00
|
|
|
|
upstreamModels.value = []
|
|
|
|
|
|
upstreamModelsLoaded.value = false
|
|
|
|
|
|
collapsedGroups.value = new Set()
|
2025-12-20 02:01:17 +08:00
|
|
|
|
}
|
2025-12-25 19:36:29 +08:00
|
|
|
|
|
2025-12-25 00:02:56 +08:00
|
|
|
|
// 处理模型选择变更
|
|
|
|
|
|
function handleModelChange(value: string) {
|
|
|
|
|
|
formData.value.modelId = value
|
2025-12-20 02:01:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 生成作用域唯一键
|
|
|
|
|
|
function getApiFormatsKey(formats: string[] | undefined): string {
|
2026-05-07 00:48:15 +08:00
|
|
|
|
return getScopeKey(formats)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getEndpointIdsKey(endpointIds: string[] | undefined): string {
|
|
|
|
|
|
return getScopeKey(endpointIds)
|
2025-12-20 02:01:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 提交表单
|
|
|
|
|
|
async function handleSubmit() {
|
|
|
|
|
|
if (submitting.value) return
|
2026-01-14 14:28:13 +08:00
|
|
|
|
if (!formData.value.modelId || selectedNames.value.length === 0) return
|
2025-12-20 02:01:17 +08:00
|
|
|
|
|
|
|
|
|
|
submitting.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const targetModel = props.models.find(m => m.id === formData.value.modelId)
|
|
|
|
|
|
if (!targetModel) {
|
|
|
|
|
|
showError('模型不存在', '错误')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-20 02:39:10 +08:00
|
|
|
|
const currentAliases = targetModel.provider_model_mappings || []
|
2025-12-20 02:01:17 +08:00
|
|
|
|
let newAliases: ProviderModelAlias[]
|
2026-05-07 00:48:15 +08:00
|
|
|
|
const nextEndpointIds = normalizedSelectedEndpointIds.value
|
2025-12-20 02:01:17 +08:00
|
|
|
|
|
2026-01-14 14:28:13 +08:00
|
|
|
|
const buildAliases = (names: string[]): ProviderModelAlias[] => {
|
2026-05-07 00:48:15 +08:00
|
|
|
|
return names.map((name) => {
|
|
|
|
|
|
const alias: ProviderModelAlias = {
|
|
|
|
|
|
name: name.trim(),
|
|
|
|
|
|
priority: 1
|
|
|
|
|
|
}
|
|
|
|
|
|
if (nextEndpointIds && nextEndpointIds.length > 0) {
|
|
|
|
|
|
alias.endpoint_ids = nextEndpointIds
|
|
|
|
|
|
}
|
|
|
|
|
|
return alias
|
|
|
|
|
|
})
|
2026-01-14 14:28:13 +08:00
|
|
|
|
}
|
2025-12-20 02:01:17 +08:00
|
|
|
|
|
|
|
|
|
|
if (props.editingGroup) {
|
|
|
|
|
|
const oldApiFormatsKey = props.editingGroup.apiFormatsKey
|
2026-05-07 00:48:15 +08:00
|
|
|
|
const oldEndpointIdsKey = props.editingGroup.endpointIdsKey
|
2025-12-20 02:01:17 +08:00
|
|
|
|
const oldAliasNames = new Set(props.editingGroup.aliases.map(a => a.name))
|
|
|
|
|
|
|
|
|
|
|
|
const filteredAliases = currentAliases.filter((a: ProviderModelAlias) => {
|
|
|
|
|
|
const currentKey = getApiFormatsKey(a.api_formats)
|
2026-05-07 00:48:15 +08:00
|
|
|
|
const currentEndpointIdsKey = getEndpointIdsKey(a.endpoint_ids)
|
|
|
|
|
|
return !(currentKey === oldApiFormatsKey && currentEndpointIdsKey === oldEndpointIdsKey && oldAliasNames.has(a.name))
|
2025-12-20 02:01:17 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-07 00:48:15 +08:00
|
|
|
|
const duplicates = findDuplicateNames(filteredAliases, selectedNames.value, nextEndpointIds)
|
2025-12-20 02:01:17 +08:00
|
|
|
|
if (duplicates.length > 0) {
|
2026-01-14 14:28:13 +08:00
|
|
|
|
showError(`以下映射名称已存在:${duplicates.join(', ')}`, '错误')
|
2025-12-20 02:01:17 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
newAliases = [
|
|
|
|
|
|
...filteredAliases,
|
2026-01-14 14:28:13 +08:00
|
|
|
|
...buildAliases(selectedNames.value)
|
2025-12-20 02:01:17 +08:00
|
|
|
|
]
|
|
|
|
|
|
} else {
|
2026-05-07 00:48:15 +08:00
|
|
|
|
const duplicates = findDuplicateNames(currentAliases, selectedNames.value, nextEndpointIds)
|
2025-12-20 02:01:17 +08:00
|
|
|
|
if (duplicates.length > 0) {
|
2026-01-14 14:28:13 +08:00
|
|
|
|
showError(`以下映射名称已存在:${duplicates.join(', ')}`, '错误')
|
2025-12-20 02:01:17 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
newAliases = [
|
|
|
|
|
|
...currentAliases,
|
2026-01-14 14:28:13 +08:00
|
|
|
|
...buildAliases(selectedNames.value)
|
2025-12-20 02:01:17 +08:00
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await updateModel(props.providerId, targetModel.id, {
|
2025-12-20 02:39:10 +08:00
|
|
|
|
provider_model_mappings: newAliases
|
2025-12-20 02:01:17 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
showSuccess(props.editingGroup ? '映射组已更新' : '映射已添加')
|
|
|
|
|
|
emit('update:open', false)
|
|
|
|
|
|
emit('saved')
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
showError(parseApiError(err, '操作失败'), '错误')
|
2025-12-20 02:01:17 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
submitting.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|