mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-02 15:52:26 +08:00
feat: implement upstream model import and batch model assignment with UI components
This commit is contained in:
@@ -5,6 +5,8 @@ import type {
|
|||||||
ModelUpdate,
|
ModelUpdate,
|
||||||
ModelCatalogResponse,
|
ModelCatalogResponse,
|
||||||
ProviderAvailableSourceModelsResponse,
|
ProviderAvailableSourceModelsResponse,
|
||||||
|
UpstreamModel,
|
||||||
|
ImportFromUpstreamResponse,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,3 +121,40 @@ export async function batchAssignModelsToProvider(
|
|||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询提供商的上游模型列表
|
||||||
|
*/
|
||||||
|
export async function queryProviderUpstreamModels(
|
||||||
|
providerId: string
|
||||||
|
): Promise<{
|
||||||
|
success: boolean
|
||||||
|
data: {
|
||||||
|
models: UpstreamModel[]
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
provider: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
display_name: string
|
||||||
|
}
|
||||||
|
}> {
|
||||||
|
const response = await client.post('/api/admin/provider-query/models', {
|
||||||
|
provider_id: providerId,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从上游提供商导入模型
|
||||||
|
*/
|
||||||
|
export async function importModelsFromUpstream(
|
||||||
|
providerId: string,
|
||||||
|
modelIds: string[]
|
||||||
|
): Promise<ImportFromUpstreamResponse> {
|
||||||
|
const response = await client.post(
|
||||||
|
`/api/admin/providers/${providerId}/import-from-upstream`,
|
||||||
|
{ model_ids: modelIds }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|||||||
@@ -495,3 +495,42 @@ export interface GlobalModelListResponse {
|
|||||||
models: GlobalModelResponse[]
|
models: GlobalModelResponse[]
|
||||||
total: number
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 上游模型导入相关 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上游模型(从提供商 API 获取的原始模型)
|
||||||
|
*/
|
||||||
|
export interface UpstreamModel {
|
||||||
|
id: string
|
||||||
|
owned_by?: string
|
||||||
|
display_name?: string
|
||||||
|
api_format?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入成功的模型信息
|
||||||
|
*/
|
||||||
|
export interface ImportFromUpstreamSuccessItem {
|
||||||
|
model_id: string
|
||||||
|
global_model_id: string
|
||||||
|
global_model_name: string
|
||||||
|
provider_model_id: string
|
||||||
|
created_global_model: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入失败的模型信息
|
||||||
|
*/
|
||||||
|
export interface ImportFromUpstreamErrorItem {
|
||||||
|
model_id: string
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从上游提供商导入模型响应
|
||||||
|
*/
|
||||||
|
export interface ImportFromUpstreamResponse {
|
||||||
|
success: ImportFromUpstreamSuccessItem[]
|
||||||
|
errors: ImportFromUpstreamErrorItem[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,29 +31,46 @@
|
|||||||
|
|
||||||
<!-- 左右对比布局 -->
|
<!-- 左右对比布局 -->
|
||||||
<div class="flex gap-2 items-stretch">
|
<div class="flex gap-2 items-stretch">
|
||||||
<!-- 左侧:可添加的模型 -->
|
<!-- 左侧:可添加的模型(分组折叠) -->
|
||||||
<div class="flex-1 space-y-2">
|
<div class="flex-1 space-y-2">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div class="flex items-center gap-2">
|
<p class="text-sm font-medium shrink-0">
|
||||||
<p class="text-sm font-medium">
|
可添加
|
||||||
可添加
|
</p>
|
||||||
</p>
|
<div class="flex-1 relative">
|
||||||
<Button
|
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||||
v-if="availableModels.length > 0"
|
<Input
|
||||||
variant="ghost"
|
v-model="searchQuery"
|
||||||
size="sm"
|
placeholder="搜索模型..."
|
||||||
class="h-6 px-2 text-xs"
|
class="pl-7 h-7 text-xs"
|
||||||
@click="toggleSelectAllLeft"
|
/>
|
||||||
>
|
|
||||||
{{ isAllLeftSelected ? '取消全选' : '全选' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<button
|
||||||
variant="secondary"
|
v-if="upstreamModelsLoaded"
|
||||||
class="text-xs"
|
type="button"
|
||||||
|
class="p-1.5 hover:bg-muted rounded-md transition-colors shrink-0"
|
||||||
|
title="刷新上游模型"
|
||||||
|
:disabled="fetchingUpstreamModels"
|
||||||
|
@click="fetchUpstreamModels(true)"
|
||||||
>
|
>
|
||||||
{{ availableModels.length }} 个
|
<RefreshCw
|
||||||
</Badge>
|
class="w-3.5 h-3.5"
|
||||||
|
:class="{ 'animate-spin': fetchingUpstreamModels }"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else-if="!fetchingUpstreamModels"
|
||||||
|
type="button"
|
||||||
|
class="p-1.5 hover:bg-muted rounded-md transition-colors shrink-0"
|
||||||
|
title="从提供商获取模型"
|
||||||
|
@click="fetchUpstreamModels"
|
||||||
|
>
|
||||||
|
<Zap class="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<Loader2
|
||||||
|
v-else
|
||||||
|
class="w-3.5 h-3.5 animate-spin text-muted-foreground shrink-0"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="border rounded-lg h-80 overflow-y-auto">
|
<div class="border rounded-lg h-80 overflow-y-auto">
|
||||||
<div
|
<div
|
||||||
@@ -63,7 +80,7 @@
|
|||||||
<Loader2 class="w-6 h-6 animate-spin text-primary" />
|
<Loader2 class="w-6 h-6 animate-spin text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="availableModels.length === 0"
|
v-else-if="totalAvailableCount === 0 && !upstreamModelsLoaded"
|
||||||
class="flex flex-col items-center justify-center h-full text-muted-foreground"
|
class="flex flex-col items-center justify-center h-full text-muted-foreground"
|
||||||
>
|
>
|
||||||
<Layers class="w-10 h-10 mb-2 opacity-30" />
|
<Layers class="w-10 h-10 mb-2 opacity-30" />
|
||||||
@@ -73,37 +90,142 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="p-2 space-y-1"
|
class="p-2 space-y-2"
|
||||||
>
|
>
|
||||||
|
<!-- 全局模型折叠组 -->
|
||||||
<div
|
<div
|
||||||
v-for="model in availableModels"
|
v-if="availableGlobalModels.length > 0 || !upstreamModelsLoaded"
|
||||||
:key="model.id"
|
class="border rounded-lg overflow-hidden"
|
||||||
class="flex items-center gap-2 p-2 rounded-lg border transition-colors"
|
|
||||||
:class="selectedLeftIds.includes(model.id)
|
|
||||||
? 'border-primary bg-primary/10'
|
|
||||||
: 'hover:bg-muted/50 cursor-pointer'"
|
|
||||||
@click="toggleLeftSelection(model.id)"
|
|
||||||
>
|
>
|
||||||
<Checkbox
|
<div class="flex items-center gap-2 px-3 py-2 bg-muted/30">
|
||||||
:checked="selectedLeftIds.includes(model.id)"
|
<button
|
||||||
@update:checked="toggleLeftSelection(model.id)"
|
type="button"
|
||||||
@click.stop
|
class="flex items-center gap-2 flex-1 hover:bg-muted/50 -mx-1 px-1 rounded transition-colors"
|
||||||
/>
|
@click="toggleGroupCollapse('global')"
|
||||||
<div class="flex-1 min-w-0">
|
>
|
||||||
<p class="font-medium text-sm truncate">
|
<ChevronDown
|
||||||
{{ model.display_name }}
|
class="w-4 h-4 transition-transform shrink-0"
|
||||||
</p>
|
:class="collapsedGroups.has('global') ? '-rotate-90' : ''"
|
||||||
<p class="text-xs text-muted-foreground truncate font-mono">
|
/>
|
||||||
{{ model.name }}
|
<span class="text-xs font-medium">
|
||||||
</p>
|
全局模型
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
({{ availableGlobalModels.length }})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="availableGlobalModels.length > 0"
|
||||||
|
type="button"
|
||||||
|
class="text-xs text-primary hover:underline shrink-0"
|
||||||
|
@click.stop="selectAllGlobalModels"
|
||||||
|
>
|
||||||
|
{{ isAllGlobalModelsSelected ? '取消' : '全选' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<div
|
||||||
:variant="model.is_active ? 'outline' : 'secondary'"
|
v-show="!collapsedGroups.has('global')"
|
||||||
:class="model.is_active ? 'text-green-600 border-green-500/60' : ''"
|
class="p-2 space-y-1 border-t"
|
||||||
class="text-xs shrink-0"
|
|
||||||
>
|
>
|
||||||
{{ model.is_active ? '活跃' : '停用' }}
|
<div
|
||||||
</Badge>
|
v-if="availableGlobalModels.length === 0"
|
||||||
|
class="py-4 text-center text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
所有全局模型均已关联
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="model in availableGlobalModels"
|
||||||
|
v-else
|
||||||
|
:key="model.id"
|
||||||
|
class="flex items-center gap-2 p-2 rounded-lg border transition-colors cursor-pointer"
|
||||||
|
:class="selectedGlobalModelIds.includes(model.id)
|
||||||
|
? 'border-primary bg-primary/10'
|
||||||
|
: 'hover:bg-muted/50'"
|
||||||
|
@click="toggleGlobalModelSelection(model.id)"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
:checked="selectedGlobalModelIds.includes(model.id)"
|
||||||
|
@update:checked="toggleGlobalModelSelection(model.id)"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-medium text-sm truncate">
|
||||||
|
{{ model.display_name }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-muted-foreground truncate font-mono">
|
||||||
|
{{ model.name }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
:variant="model.is_active ? 'outline' : 'secondary'"
|
||||||
|
:class="model.is_active ? 'text-green-600 border-green-500/60' : ''"
|
||||||
|
class="text-xs shrink-0"
|
||||||
|
>
|
||||||
|
{{ model.is_active ? '活跃' : '停用' }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 从提供商获取的模型折叠组 -->
|
||||||
|
<div
|
||||||
|
v-for="group in upstreamModelGroups"
|
||||||
|
:key="group.api_format"
|
||||||
|
class="border rounded-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 px-3 py-2 bg-muted/30">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 flex-1 hover:bg-muted/50 -mx-1 px-1 rounded transition-colors"
|
||||||
|
@click="toggleGroupCollapse(group.api_format)"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
class="w-4 h-4 transition-transform shrink-0"
|
||||||
|
:class="collapsedGroups.has(group.api_format) ? '-rotate-90' : ''"
|
||||||
|
/>
|
||||||
|
<span class="text-xs font-medium">
|
||||||
|
{{ API_FORMAT_LABELS[group.api_format] || group.api_format }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
({{ group.models.length }})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs text-primary hover:underline shrink-0"
|
||||||
|
@click.stop="selectAllUpstreamModels(group.api_format)"
|
||||||
|
>
|
||||||
|
{{ isUpstreamGroupAllSelected(group.api_format) ? '取消' : '全选' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-show="!collapsedGroups.has(group.api_format)"
|
||||||
|
class="p-2 space-y-1 border-t"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="model in group.models"
|
||||||
|
:key="model.id"
|
||||||
|
class="flex items-center gap-2 p-2 rounded-lg border transition-colors cursor-pointer"
|
||||||
|
:class="selectedUpstreamModelIds.includes(model.id)
|
||||||
|
? 'border-primary bg-primary/10'
|
||||||
|
: 'hover:bg-muted/50'"
|
||||||
|
@click="toggleUpstreamModelSelection(model.id)"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
:checked="selectedUpstreamModelIds.includes(model.id)"
|
||||||
|
@update:checked="toggleUpstreamModelSelection(model.id)"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-medium text-sm truncate">
|
||||||
|
{{ model.id }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-muted-foreground truncate font-mono">
|
||||||
|
{{ model.owned_by || model.id }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,8 +237,8 @@
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="w-9 h-8"
|
class="w-9 h-8"
|
||||||
:class="selectedLeftIds.length > 0 && !submittingAdd ? 'border-primary' : ''"
|
:class="totalSelectedCount > 0 && !submittingAdd ? 'border-primary' : ''"
|
||||||
:disabled="selectedLeftIds.length === 0 || submittingAdd"
|
:disabled="totalSelectedCount === 0 || submittingAdd"
|
||||||
title="添加选中"
|
title="添加选中"
|
||||||
@click="batchAddSelected"
|
@click="batchAddSelected"
|
||||||
>
|
>
|
||||||
@@ -127,7 +249,7 @@
|
|||||||
<ChevronRight
|
<ChevronRight
|
||||||
v-else
|
v-else
|
||||||
class="w-6 h-6 stroke-[3]"
|
class="w-6 h-6 stroke-[3]"
|
||||||
:class="selectedLeftIds.length > 0 && !submittingAdd ? 'text-primary' : ''"
|
:class="totalSelectedCount > 0 && !submittingAdd ? 'text-primary' : ''"
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -154,26 +276,18 @@
|
|||||||
<!-- 右侧:已添加的模型 -->
|
<!-- 右侧:已添加的模型 -->
|
||||||
<div class="flex-1 space-y-2">
|
<div class="flex-1 space-y-2">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<p class="text-sm font-medium">
|
||||||
<p class="text-sm font-medium">
|
已添加
|
||||||
已添加
|
</p>
|
||||||
</p>
|
<Button
|
||||||
<Button
|
v-if="existingModels.length > 0"
|
||||||
v-if="existingModels.length > 0"
|
variant="ghost"
|
||||||
variant="ghost"
|
size="sm"
|
||||||
size="sm"
|
class="h-6 px-2 text-xs"
|
||||||
class="h-6 px-2 text-xs"
|
@click="toggleSelectAllRight"
|
||||||
@click="toggleSelectAllRight"
|
|
||||||
>
|
|
||||||
{{ isAllRightSelected ? '取消全选' : '全选' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
class="text-xs"
|
|
||||||
>
|
>
|
||||||
{{ existingModels.length }} 个
|
{{ isAllRightSelected ? '取消' : '全选' }}
|
||||||
</Badge>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="border rounded-lg h-80 overflow-y-auto">
|
<div class="border rounded-lg h-80 overflow-y-auto">
|
||||||
<div
|
<div
|
||||||
@@ -238,11 +352,12 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { Layers, Loader2, ChevronRight, ChevronLeft } from 'lucide-vue-next'
|
import { Layers, Loader2, ChevronRight, ChevronLeft, ChevronDown, Zap, RefreshCw, Search } from 'lucide-vue-next'
|
||||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import Badge from '@/components/ui/badge.vue'
|
import Badge from '@/components/ui/badge.vue'
|
||||||
import Checkbox from '@/components/ui/checkbox.vue'
|
import Checkbox from '@/components/ui/checkbox.vue'
|
||||||
|
import Input from '@/components/ui/input.vue'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import {
|
import {
|
||||||
@@ -253,8 +368,13 @@ import {
|
|||||||
getProviderModels,
|
getProviderModels,
|
||||||
batchAssignModelsToProvider,
|
batchAssignModelsToProvider,
|
||||||
deleteModel,
|
deleteModel,
|
||||||
|
importModelsFromUpstream,
|
||||||
|
API_FORMAT_LABELS,
|
||||||
type Model
|
type Model
|
||||||
} from '@/api/endpoints'
|
} from '@/api/endpoints'
|
||||||
|
import { useUpstreamModelsCache, type UpstreamModel } from '../composables/useUpstreamModelsCache'
|
||||||
|
|
||||||
|
const { fetchModels: fetchCachedModels, clearCache, getCachedModels } = useUpstreamModelsCache()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -274,17 +394,27 @@ const { error: showError, success } = useToast()
|
|||||||
const loadingGlobalModels = ref(false)
|
const loadingGlobalModels = ref(false)
|
||||||
const submittingAdd = ref(false)
|
const submittingAdd = ref(false)
|
||||||
const submittingRemove = ref(false)
|
const submittingRemove = ref(false)
|
||||||
|
const fetchingUpstreamModels = ref(false)
|
||||||
|
const upstreamModelsLoaded = ref(false)
|
||||||
|
|
||||||
// 数据
|
// 数据
|
||||||
const allGlobalModels = ref<GlobalModelResponse[]>([])
|
const allGlobalModels = ref<GlobalModelResponse[]>([])
|
||||||
const existingModels = ref<Model[]>([])
|
const existingModels = ref<Model[]>([])
|
||||||
|
const upstreamModels = ref<UpstreamModel[]>([])
|
||||||
|
|
||||||
// 选择状态
|
// 选择状态
|
||||||
const selectedLeftIds = ref<string[]>([])
|
const selectedGlobalModelIds = ref<string[]>([])
|
||||||
|
const selectedUpstreamModelIds = ref<string[]>([])
|
||||||
const selectedRightIds = ref<string[]>([])
|
const selectedRightIds = ref<string[]>([])
|
||||||
|
|
||||||
// 计算可添加的模型(排除已关联的)
|
// 折叠状态
|
||||||
const availableModels = computed(() => {
|
const collapsedGroups = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// 搜索状态
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
// 计算可添加的全局模型(排除已关联的)
|
||||||
|
const availableGlobalModelsBase = computed(() => {
|
||||||
const existingGlobalModelIds = new Set(
|
const existingGlobalModelIds = new Set(
|
||||||
existingModels.value
|
existingModels.value
|
||||||
.filter(m => m.global_model_id)
|
.filter(m => m.global_model_id)
|
||||||
@@ -293,31 +423,123 @@ const availableModels = computed(() => {
|
|||||||
return allGlobalModels.value.filter(m => !existingGlobalModelIds.has(m.id))
|
return allGlobalModels.value.filter(m => !existingGlobalModelIds.has(m.id))
|
||||||
})
|
})
|
||||||
|
|
||||||
// 全选状态
|
// 搜索过滤后的全局模型
|
||||||
const isAllLeftSelected = computed(() =>
|
const availableGlobalModels = computed(() => {
|
||||||
availableModels.value.length > 0 &&
|
if (!searchQuery.value.trim()) return availableGlobalModelsBase.value
|
||||||
selectedLeftIds.value.length === availableModels.value.length
|
const query = searchQuery.value.toLowerCase()
|
||||||
)
|
return availableGlobalModelsBase.value.filter(m =>
|
||||||
|
m.name.toLowerCase().includes(query) ||
|
||||||
|
m.display_name.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算可添加的上游模型(排除已关联的)
|
||||||
|
const availableUpstreamModelsBase = computed(() => {
|
||||||
|
const existingModelNames = new Set(
|
||||||
|
existingModels.value.map(m => m.provider_model_name)
|
||||||
|
)
|
||||||
|
return upstreamModels.value.filter(m => !existingModelNames.has(m.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 搜索过滤后的上游模型
|
||||||
|
const availableUpstreamModels = computed(() => {
|
||||||
|
if (!searchQuery.value.trim()) return availableUpstreamModelsBase.value
|
||||||
|
const query = searchQuery.value.toLowerCase()
|
||||||
|
return availableUpstreamModelsBase.value.filter(m =>
|
||||||
|
m.id.toLowerCase().includes(query) ||
|
||||||
|
(m.owned_by && m.owned_by.toLowerCase().includes(query))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 按 API 格式分组的上游模型
|
||||||
|
const upstreamModelGroups = computed(() => {
|
||||||
|
const groups: Record<string, UpstreamModel[]> = {}
|
||||||
|
|
||||||
|
for (const model of availableUpstreamModels.value) {
|
||||||
|
const format = model.api_format || 'unknown'
|
||||||
|
if (!groups[format]) {
|
||||||
|
groups[format] = []
|
||||||
|
}
|
||||||
|
groups[format].push(model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按 API_FORMAT_LABELS 的顺序排序
|
||||||
|
const order = Object.keys(API_FORMAT_LABELS)
|
||||||
|
return Object.entries(groups)
|
||||||
|
.map(([api_format, models]) => ({ api_format, models }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aIndex = order.indexOf(a.api_format)
|
||||||
|
const bIndex = order.indexOf(b.api_format)
|
||||||
|
if (aIndex === -1 && bIndex === -1) return a.api_format.localeCompare(b.api_format)
|
||||||
|
if (aIndex === -1) return 1
|
||||||
|
if (bIndex === -1) return -1
|
||||||
|
return aIndex - bIndex
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 总可添加数量
|
||||||
|
const totalAvailableCount = computed(() => {
|
||||||
|
return availableGlobalModels.value.length + availableUpstreamModels.value.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// 总选中数量
|
||||||
|
const totalSelectedCount = computed(() => {
|
||||||
|
return selectedGlobalModelIds.value.length + selectedUpstreamModelIds.value.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// 全选状态
|
||||||
const isAllRightSelected = computed(() =>
|
const isAllRightSelected = computed(() =>
|
||||||
existingModels.value.length > 0 &&
|
existingModels.value.length > 0 &&
|
||||||
selectedRightIds.value.length === existingModels.value.length
|
selectedRightIds.value.length === existingModels.value.length
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 全局模型是否全选
|
||||||
|
const isAllGlobalModelsSelected = computed(() => {
|
||||||
|
if (availableGlobalModels.value.length === 0) return false
|
||||||
|
return availableGlobalModels.value.every(m => selectedGlobalModelIds.value.includes(m.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查某个上游组是否全选
|
||||||
|
function isUpstreamGroupAllSelected(apiFormat: string): boolean {
|
||||||
|
const group = upstreamModelGroups.value.find(g => g.api_format === apiFormat)
|
||||||
|
if (!group || group.models.length === 0) return false
|
||||||
|
return group.models.every(m => selectedUpstreamModelIds.value.includes(m.id))
|
||||||
|
}
|
||||||
|
|
||||||
// 监听打开状态
|
// 监听打开状态
|
||||||
watch(() => props.open, async (isOpen) => {
|
watch(() => props.open, async (isOpen) => {
|
||||||
if (isOpen && props.providerId) {
|
if (isOpen && props.providerId) {
|
||||||
await loadData()
|
await loadData()
|
||||||
} else {
|
} else {
|
||||||
// 重置状态
|
// 重置状态
|
||||||
selectedLeftIds.value = []
|
selectedGlobalModelIds.value = []
|
||||||
|
selectedUpstreamModelIds.value = []
|
||||||
selectedRightIds.value = []
|
selectedRightIds.value = []
|
||||||
|
upstreamModels.value = []
|
||||||
|
upstreamModelsLoaded.value = false
|
||||||
|
collapsedGroups.value = new Set()
|
||||||
|
searchQuery.value = ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 加载数据
|
// 加载数据
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
await Promise.all([loadGlobalModels(), loadExistingModels()])
|
await Promise.all([loadGlobalModels(), loadExistingModels()])
|
||||||
|
// 默认折叠全局模型组
|
||||||
|
collapsedGroups.value = new Set(['global'])
|
||||||
|
|
||||||
|
// 检查缓存,如果有缓存数据则直接使用
|
||||||
|
const cachedModels = getCachedModels(props.providerId)
|
||||||
|
if (cachedModels) {
|
||||||
|
upstreamModels.value = cachedModels
|
||||||
|
upstreamModelsLoaded.value = true
|
||||||
|
// 折叠所有上游模型组
|
||||||
|
for (const model of cachedModels) {
|
||||||
|
if (model.api_format) {
|
||||||
|
collapsedGroups.value.add(model.api_format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载全局模型列表
|
// 加载全局模型列表
|
||||||
@@ -342,13 +564,91 @@ async function loadExistingModels() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换左侧选择
|
// 从提供商获取模型
|
||||||
function toggleLeftSelection(id: string) {
|
async function fetchUpstreamModels(forceRefresh = false) {
|
||||||
const index = selectedLeftIds.value.indexOf(id)
|
if (forceRefresh) {
|
||||||
if (index === -1) {
|
clearCache(props.providerId)
|
||||||
selectedLeftIds.value.push(id)
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fetchingUpstreamModels.value = true
|
||||||
|
const result = await fetchCachedModels(props.providerId, forceRefresh)
|
||||||
|
if (result) {
|
||||||
|
if (result.error) {
|
||||||
|
showError(result.error, '错误')
|
||||||
|
} else {
|
||||||
|
upstreamModels.value = result.models
|
||||||
|
upstreamModelsLoaded.value = true
|
||||||
|
// 折叠所有上游模型组
|
||||||
|
const allGroups = new Set(collapsedGroups.value)
|
||||||
|
for (const model of result.models) {
|
||||||
|
if (model.api_format) {
|
||||||
|
allGroups.add(model.api_format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collapsedGroups.value = allGroups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fetchingUpstreamModels.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换折叠状态
|
||||||
|
function toggleGroupCollapse(group: string) {
|
||||||
|
if (collapsedGroups.value.has(group)) {
|
||||||
|
collapsedGroups.value.delete(group)
|
||||||
} else {
|
} else {
|
||||||
selectedLeftIds.value.splice(index, 1)
|
collapsedGroups.value.add(group)
|
||||||
|
}
|
||||||
|
// 触发响应式更新
|
||||||
|
collapsedGroups.value = new Set(collapsedGroups.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换全局模型选择
|
||||||
|
function toggleGlobalModelSelection(id: string) {
|
||||||
|
const index = selectedGlobalModelIds.value.indexOf(id)
|
||||||
|
if (index === -1) {
|
||||||
|
selectedGlobalModelIds.value.push(id)
|
||||||
|
} else {
|
||||||
|
selectedGlobalModelIds.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换上游模型选择
|
||||||
|
function toggleUpstreamModelSelection(id: string) {
|
||||||
|
const index = selectedUpstreamModelIds.value.indexOf(id)
|
||||||
|
if (index === -1) {
|
||||||
|
selectedUpstreamModelIds.value.push(id)
|
||||||
|
} else {
|
||||||
|
selectedUpstreamModelIds.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全选全局模型
|
||||||
|
function selectAllGlobalModels() {
|
||||||
|
const allIds = availableGlobalModels.value.map(m => m.id)
|
||||||
|
const allSelected = allIds.every(id => selectedGlobalModelIds.value.includes(id))
|
||||||
|
if (allSelected) {
|
||||||
|
selectedGlobalModelIds.value = selectedGlobalModelIds.value.filter(id => !allIds.includes(id))
|
||||||
|
} else {
|
||||||
|
const newIds = allIds.filter(id => !selectedGlobalModelIds.value.includes(id))
|
||||||
|
selectedGlobalModelIds.value.push(...newIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全选某个 API 格式的上游模型
|
||||||
|
function selectAllUpstreamModels(apiFormat: string) {
|
||||||
|
const group = upstreamModelGroups.value.find(g => g.api_format === apiFormat)
|
||||||
|
if (!group) return
|
||||||
|
|
||||||
|
const allIds = group.models.map(m => m.id)
|
||||||
|
const allSelected = allIds.every(id => selectedUpstreamModelIds.value.includes(id))
|
||||||
|
if (allSelected) {
|
||||||
|
selectedUpstreamModelIds.value = selectedUpstreamModelIds.value.filter(id => !allIds.includes(id))
|
||||||
|
} else {
|
||||||
|
const newIds = allIds.filter(id => !selectedUpstreamModelIds.value.includes(id))
|
||||||
|
selectedUpstreamModelIds.value.push(...newIds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,15 +662,6 @@ function toggleRightSelection(id: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全选/取消全选左侧
|
|
||||||
function toggleSelectAllLeft() {
|
|
||||||
if (isAllLeftSelected.value) {
|
|
||||||
selectedLeftIds.value = []
|
|
||||||
} else {
|
|
||||||
selectedLeftIds.value = availableModels.value.map(m => m.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 全选/取消全选右侧
|
// 全选/取消全选右侧
|
||||||
function toggleSelectAllRight() {
|
function toggleSelectAllRight() {
|
||||||
if (isAllRightSelected.value) {
|
if (isAllRightSelected.value) {
|
||||||
@@ -382,22 +673,41 @@ function toggleSelectAllRight() {
|
|||||||
|
|
||||||
// 批量添加选中的模型
|
// 批量添加选中的模型
|
||||||
async function batchAddSelected() {
|
async function batchAddSelected() {
|
||||||
if (selectedLeftIds.value.length === 0) return
|
if (totalSelectedCount.value === 0) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
submittingAdd.value = true
|
submittingAdd.value = true
|
||||||
const result = await batchAssignModelsToProvider(props.providerId, selectedLeftIds.value)
|
let totalSuccess = 0
|
||||||
|
const allErrors: string[] = []
|
||||||
|
|
||||||
if (result.success.length > 0) {
|
// 处理全局模型
|
||||||
success(`成功添加 ${result.success.length} 个模型`)
|
if (selectedGlobalModelIds.value.length > 0) {
|
||||||
|
const result = await batchAssignModelsToProvider(props.providerId, selectedGlobalModelIds.value)
|
||||||
|
totalSuccess += result.success.length
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
allErrors.push(...result.errors.map(e => e.error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.errors.length > 0) {
|
// 处理上游模型(调用 import-from-upstream API)
|
||||||
const errorMessages = result.errors.map(e => e.error).join(', ')
|
if (selectedUpstreamModelIds.value.length > 0) {
|
||||||
showError(`部分模型添加失败: ${errorMessages}`, '警告')
|
const result = await importModelsFromUpstream(props.providerId, selectedUpstreamModelIds.value)
|
||||||
|
totalSuccess += result.success.length
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
allErrors.push(...result.errors.map(e => e.error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedLeftIds.value = []
|
if (totalSuccess > 0) {
|
||||||
|
success(`成功添加 ${totalSuccess} 个模型`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allErrors.length > 0) {
|
||||||
|
showError(`部分模型添加失败: ${allErrors.slice(0, 3).join(', ')}${allErrors.length > 3 ? '...' : ''}`, '警告')
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedGlobalModelIds.value = []
|
||||||
|
selectedUpstreamModelIds.value = []
|
||||||
await loadExistingModels()
|
await loadExistingModels()
|
||||||
emit('changed')
|
emit('changed')
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -0,0 +1,777 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
:model-value="open"
|
||||||
|
:title="editingGroup ? '编辑模型映射' : '添加模型映射'"
|
||||||
|
:description="editingGroup ? '修改映射配置' : '为模型添加新的名称映射'"
|
||||||
|
:icon="Tag"
|
||||||
|
size="4xl"
|
||||||
|
@update:model-value="$emit('update:open', $event)"
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- 第一行:目标模型 | 作用域 -->
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<!-- 目标模型 -->
|
||||||
|
<div class="flex-1 space-y-1.5">
|
||||||
|
<Label class="text-xs">目标模型</Label>
|
||||||
|
<Select
|
||||||
|
v-model:open="modelSelectOpen"
|
||||||
|
:model-value="formData.modelId"
|
||||||
|
:disabled="!!editingGroup"
|
||||||
|
@update:model-value="formData.modelId = $event"
|
||||||
|
>
|
||||||
|
<SelectTrigger class="h-9">
|
||||||
|
<SelectValue placeholder="请选择模型" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem
|
||||||
|
v-for="model in models"
|
||||||
|
:key="model.id"
|
||||||
|
:value="model.id"
|
||||||
|
>
|
||||||
|
{{ model.global_model_display_name || model.provider_model_name }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 作用域 -->
|
||||||
|
<div class="flex-1 space-y-1.5">
|
||||||
|
<Label class="text-xs">作用域 <span class="text-muted-foreground font-normal">(不选则适用全部)</span></Label>
|
||||||
|
<div
|
||||||
|
v-if="providerApiFormats.length > 0"
|
||||||
|
class="flex flex-wrap gap-1.5 p-2 rounded-md border bg-muted/30 min-h-[36px]"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="format in providerApiFormats"
|
||||||
|
:key="format"
|
||||||
|
type="button"
|
||||||
|
class="px-2.5 py-0.5 rounded text-xs font-medium transition-colors"
|
||||||
|
:class="[
|
||||||
|
formData.apiFormats.includes(format)
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-background border border-border hover:bg-muted'
|
||||||
|
]"
|
||||||
|
@click="toggleApiFormat(format)"
|
||||||
|
>
|
||||||
|
{{ API_FORMAT_LABELS[format] || format }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="h-9 flex items-center text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
无可用格式
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 第二行:两栏布局 -->
|
||||||
|
<div class="flex gap-4 items-stretch">
|
||||||
|
<!-- 左侧:上游模型列表 -->
|
||||||
|
<div class="flex-1 space-y-2">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-sm font-medium shrink-0">
|
||||||
|
上游模型
|
||||||
|
</span>
|
||||||
|
<div class="flex-1 relative">
|
||||||
|
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
v-model="upstreamModelSearch"
|
||||||
|
placeholder="搜索模型..."
|
||||||
|
class="pl-7 h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="upstreamModelsLoaded"
|
||||||
|
type="button"
|
||||||
|
class="p-1.5 hover:bg-muted rounded-md transition-colors shrink-0"
|
||||||
|
title="刷新列表"
|
||||||
|
:disabled="refreshingUpstreamModels"
|
||||||
|
@click="refreshUpstreamModels"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
class="w-3.5 h-3.5"
|
||||||
|
:class="{ 'animate-spin': refreshingUpstreamModels }"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else-if="!fetchingUpstreamModels"
|
||||||
|
type="button"
|
||||||
|
class="p-1.5 hover:bg-muted rounded-md transition-colors shrink-0"
|
||||||
|
title="获取上游模型列表"
|
||||||
|
@click="fetchUpstreamModels"
|
||||||
|
>
|
||||||
|
<Zap class="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<Loader2
|
||||||
|
v-else
|
||||||
|
class="w-3.5 h-3.5 animate-spin text-muted-foreground shrink-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="border rounded-lg h-80 overflow-y-auto">
|
||||||
|
<template v-if="upstreamModelsLoaded">
|
||||||
|
<div
|
||||||
|
v-if="groupedAvailableUpstreamModels.length === 0"
|
||||||
|
class="flex flex-col items-center justify-center h-full text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Zap class="w-10 h-10 mb-2 opacity-30" />
|
||||||
|
<p class="text-sm">
|
||||||
|
{{ upstreamModelSearch ? '没有匹配的模型' : '所有模型已添加' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="p-2 space-y-2"
|
||||||
|
>
|
||||||
|
<!-- 按分组显示(可折叠) -->
|
||||||
|
<div
|
||||||
|
v-for="group in groupedAvailableUpstreamModels"
|
||||||
|
:key="group.api_format"
|
||||||
|
class="border rounded-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 px-3 py-2 bg-muted/30">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 flex-1 hover:bg-muted/50 -mx-1 px-1 rounded transition-colors"
|
||||||
|
@click="toggleGroupCollapse(group.api_format)"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
class="w-4 h-4 transition-transform shrink-0"
|
||||||
|
:class="collapsedGroups.has(group.api_format) ? '-rotate-90' : ''"
|
||||||
|
/>
|
||||||
|
<span class="text-xs font-medium">
|
||||||
|
{{ API_FORMAT_LABELS[group.api_format] || group.api_format }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
({{ group.models.length }})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-show="!collapsedGroups.has(group.api_format)"
|
||||||
|
class="p-2 space-y-1 border-t"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="model in group.models"
|
||||||
|
:key="model.id"
|
||||||
|
class="flex items-center gap-2 p-2 rounded-lg border transition-colors hover:bg-muted/30"
|
||||||
|
:title="model.id"
|
||||||
|
>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-medium text-sm truncate">
|
||||||
|
{{ model.id }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-muted-foreground truncate font-mono">
|
||||||
|
{{ model.owned_by || model.id }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="p-1 hover:bg-primary/10 rounded transition-colors shrink-0"
|
||||||
|
title="添加到映射"
|
||||||
|
@click="addUpstreamModel(model.id)"
|
||||||
|
>
|
||||||
|
<ChevronRight class="w-4 h-4 text-muted-foreground hover:text-primary" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 未加载状态 -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex flex-col items-center justify-center h-full text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Zap class="w-10 h-10 mb-2 opacity-30" />
|
||||||
|
<p class="text-sm">
|
||||||
|
点击右上角按钮
|
||||||
|
</p>
|
||||||
|
<p class="text-xs mt-1">
|
||||||
|
从上游获取可用模型
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧:映射名称列表 -->
|
||||||
|
<div class="flex-1 space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-sm font-medium">
|
||||||
|
映射名称
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="p-1.5 hover:bg-muted rounded-md transition-colors"
|
||||||
|
title="手动添加"
|
||||||
|
@click="addAliasItem"
|
||||||
|
>
|
||||||
|
<Plus class="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="border rounded-lg h-80 overflow-y-auto">
|
||||||
|
<div
|
||||||
|
v-if="formData.aliases.length === 0"
|
||||||
|
class="flex flex-col items-center justify-center h-full text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Tag class="w-10 h-10 mb-2 opacity-30" />
|
||||||
|
<p class="text-sm">
|
||||||
|
从左侧选择模型
|
||||||
|
</p>
|
||||||
|
<p class="text-xs mt-1">
|
||||||
|
或点击上方"手动添加"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="p-2 space-y-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(alias, index) in formData.aliases"
|
||||||
|
:key="`alias-${index}`"
|
||||||
|
class="group flex items-center gap-2 p-2 rounded-lg border transition-colors hover:bg-muted/30"
|
||||||
|
:class="[
|
||||||
|
draggedIndex === index ? 'bg-primary/5' : '',
|
||||||
|
dragOverIndex === index ? 'bg-primary/10 border-primary' : ''
|
||||||
|
]"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="handleDragStart(index, $event)"
|
||||||
|
@dragend="handleDragEnd"
|
||||||
|
@dragover.prevent="handleDragOver(index)"
|
||||||
|
@dragleave="handleDragLeave"
|
||||||
|
@drop="handleDrop(index)"
|
||||||
|
>
|
||||||
|
<!-- 删除按钮 -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="p-1 hover:bg-destructive/10 rounded transition-colors shrink-0"
|
||||||
|
title="移除"
|
||||||
|
@click="removeAliasItem(index)"
|
||||||
|
>
|
||||||
|
<ChevronLeft class="w-4 h-4 text-muted-foreground hover:text-destructive" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 优先级 -->
|
||||||
|
<div class="shrink-0">
|
||||||
|
<input
|
||||||
|
v-if="editingPriorityIndex === index"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
:value="alias.priority"
|
||||||
|
class="w-7 h-6 rounded bg-background border border-primary text-xs text-center focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
|
autofocus
|
||||||
|
@blur="finishEditPriority(index, $event)"
|
||||||
|
@keydown.enter="($event.target as HTMLInputElement).blur()"
|
||||||
|
@keydown.escape="cancelEditPriority"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="w-6 h-6 rounded bg-muted/50 flex items-center justify-center text-xs text-muted-foreground cursor-pointer hover:bg-primary/10 hover:text-primary"
|
||||||
|
title="点击编辑优先级"
|
||||||
|
@click.stop="startEditPriority(index)"
|
||||||
|
>
|
||||||
|
{{ alias.priority }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 名称显示/编辑 -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<Input
|
||||||
|
v-if="alias.isEditing"
|
||||||
|
v-model="alias.name"
|
||||||
|
placeholder="输入映射名称"
|
||||||
|
class="h-7 text-xs"
|
||||||
|
autofocus
|
||||||
|
@blur="alias.isEditing = false"
|
||||||
|
@keydown.enter="alias.isEditing = false"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="font-medium text-sm truncate cursor-pointer hover:text-primary"
|
||||||
|
title="点击编辑"
|
||||||
|
@click="alias.isEditing = true"
|
||||||
|
>
|
||||||
|
{{ alias.name || '点击输入名称' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 拖拽手柄 -->
|
||||||
|
<div class="cursor-grab active:cursor-grabbing text-muted-foreground/30 group-hover:text-muted-foreground shrink-0">
|
||||||
|
<GripVertical class="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 拖拽提示 -->
|
||||||
|
<div
|
||||||
|
v-if="formData.aliases.length > 1"
|
||||||
|
class="px-3 py-1.5 bg-muted/30 border-t text-xs text-muted-foreground text-center"
|
||||||
|
>
|
||||||
|
拖拽调整优先级顺序
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
@click="$emit('update:open', false)"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
:disabled="submitting || !formData.modelId || formData.aliases.length === 0 || !hasValidAliases"
|
||||||
|
@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'
|
||||||
|
import { Tag, Loader2, GripVertical, Zap, Search, RefreshCw, ChevronDown, ChevronRight, ChevronLeft, Plus } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
Dialog,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import {
|
||||||
|
API_FORMAT_LABELS,
|
||||||
|
type Model,
|
||||||
|
type ProviderModelAlias
|
||||||
|
} from '@/api/endpoints'
|
||||||
|
import { updateModel } from '@/api/endpoints/models'
|
||||||
|
import { useUpstreamModelsCache, type UpstreamModel } from '../composables/useUpstreamModelsCache'
|
||||||
|
|
||||||
|
interface FormAlias {
|
||||||
|
name: string
|
||||||
|
priority: number
|
||||||
|
isEditing?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AliasGroup {
|
||||||
|
model: Model
|
||||||
|
apiFormatsKey: string
|
||||||
|
apiFormats: string[]
|
||||||
|
aliases: ProviderModelAlias[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
providerId: string
|
||||||
|
providerApiFormats: string[]
|
||||||
|
models: Model[]
|
||||||
|
editingGroup?: AliasGroup | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:open': [value: boolean]
|
||||||
|
'saved': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { error: showError, success: showSuccess } = useToast()
|
||||||
|
const { fetchModels: fetchCachedModels, clearCache, getCachedModels } = useUpstreamModelsCache()
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const submitting = ref(false)
|
||||||
|
const modelSelectOpen = ref(false)
|
||||||
|
|
||||||
|
// 拖拽状态
|
||||||
|
const draggedIndex = ref<number | null>(null)
|
||||||
|
const dragOverIndex = ref<number | null>(null)
|
||||||
|
|
||||||
|
// 优先级编辑状态
|
||||||
|
const editingPriorityIndex = ref<number | null>(null)
|
||||||
|
|
||||||
|
// 快速添加(上游模型)状态
|
||||||
|
const fetchingUpstreamModels = ref(false)
|
||||||
|
const refreshingUpstreamModels = ref(false)
|
||||||
|
const upstreamModelsLoaded = ref(false)
|
||||||
|
const upstreamModels = ref<UpstreamModel[]>([])
|
||||||
|
const upstreamModelSearch = ref('')
|
||||||
|
|
||||||
|
// 分组折叠状态
|
||||||
|
const collapsedGroups = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const formData = ref<{
|
||||||
|
modelId: string
|
||||||
|
apiFormats: string[]
|
||||||
|
aliases: FormAlias[]
|
||||||
|
}>({
|
||||||
|
modelId: '',
|
||||||
|
apiFormats: [],
|
||||||
|
aliases: []
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查是否有有效的别名
|
||||||
|
const hasValidAliases = computed(() => {
|
||||||
|
return formData.value.aliases.some(a => a.name.trim())
|
||||||
|
})
|
||||||
|
|
||||||
|
// 过滤和排序后的上游模型列表
|
||||||
|
const filteredUpstreamModels = computed(() => {
|
||||||
|
const searchText = upstreamModelSearch.value.toLowerCase().trim()
|
||||||
|
let result = [...upstreamModels.value]
|
||||||
|
|
||||||
|
result.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
|
||||||
|
if (searchText) {
|
||||||
|
const keywords = searchText.split(/\s+/).filter(k => k.length > 0)
|
||||||
|
result = result.filter(m => {
|
||||||
|
const searchableText = `${m.id} ${m.owned_by || ''} ${m.api_format || ''}`.toLowerCase()
|
||||||
|
return keywords.every(keyword => searchableText.includes(keyword))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// 按 API 格式分组的上游模型列表
|
||||||
|
interface UpstreamModelGroup {
|
||||||
|
api_format: string
|
||||||
|
models: Array<{ id: string; owned_by?: string; api_format?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedAvailableUpstreamModels = computed<UpstreamModelGroup[]>(() => {
|
||||||
|
const addedNames = new Set(formData.value.aliases.map(a => a.name.trim()))
|
||||||
|
const availableModels = filteredUpstreamModels.value.filter(m => !addedNames.has(m.id))
|
||||||
|
|
||||||
|
const groups = new Map<string, UpstreamModelGroup>()
|
||||||
|
|
||||||
|
for (const model of availableModels) {
|
||||||
|
const format = model.api_format || 'UNKNOWN'
|
||||||
|
if (!groups.has(format)) {
|
||||||
|
groups.set(format, { api_format: format, models: [] })
|
||||||
|
}
|
||||||
|
groups.get(format)!.models.push(model)
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = Object.keys(API_FORMAT_LABELS)
|
||||||
|
return Array.from(groups.values()).sort((a, b) => {
|
||||||
|
const aIndex = order.indexOf(a.api_format)
|
||||||
|
const bIndex = order.indexOf(b.api_format)
|
||||||
|
if (aIndex === -1 && bIndex === -1) return a.api_format.localeCompare(b.api_format)
|
||||||
|
if (aIndex === -1) return 1
|
||||||
|
if (bIndex === -1) return -1
|
||||||
|
return aIndex - bIndex
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听打开状态
|
||||||
|
watch(() => props.open, (isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
initForm()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 初始化表单
|
||||||
|
function initForm() {
|
||||||
|
if (props.editingGroup) {
|
||||||
|
formData.value = {
|
||||||
|
modelId: props.editingGroup.model.id,
|
||||||
|
apiFormats: [...props.editingGroup.apiFormats],
|
||||||
|
aliases: props.editingGroup.aliases.map(a => ({ name: a.name, priority: a.priority }))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
formData.value = {
|
||||||
|
modelId: '',
|
||||||
|
apiFormats: [],
|
||||||
|
aliases: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 重置状态
|
||||||
|
editingPriorityIndex.value = null
|
||||||
|
draggedIndex.value = null
|
||||||
|
dragOverIndex.value = null
|
||||||
|
upstreamModelSearch.value = ''
|
||||||
|
collapsedGroups.value = new Set()
|
||||||
|
|
||||||
|
// 检查缓存,如果有缓存数据则直接使用
|
||||||
|
const cachedModels = getCachedModels(props.providerId)
|
||||||
|
if (cachedModels) {
|
||||||
|
upstreamModels.value = cachedModels
|
||||||
|
upstreamModelsLoaded.value = true
|
||||||
|
// 默认折叠所有分组
|
||||||
|
for (const model of cachedModels) {
|
||||||
|
if (model.api_format) {
|
||||||
|
collapsedGroups.value.add(model.api_format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
upstreamModelsLoaded.value = false
|
||||||
|
upstreamModels.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换 API 格式
|
||||||
|
function toggleApiFormat(format: string) {
|
||||||
|
const index = formData.value.apiFormats.indexOf(format)
|
||||||
|
if (index >= 0) {
|
||||||
|
formData.value.apiFormats.splice(index, 1)
|
||||||
|
} else {
|
||||||
|
formData.value.apiFormats.push(format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换分组折叠状态
|
||||||
|
function toggleGroupCollapse(apiFormat: string) {
|
||||||
|
if (collapsedGroups.value.has(apiFormat)) {
|
||||||
|
collapsedGroups.value.delete(apiFormat)
|
||||||
|
} else {
|
||||||
|
collapsedGroups.value.add(apiFormat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加别名项
|
||||||
|
function addAliasItem() {
|
||||||
|
const maxPriority = formData.value.aliases.length > 0
|
||||||
|
? Math.max(...formData.value.aliases.map(a => a.priority))
|
||||||
|
: 0
|
||||||
|
formData.value.aliases.push({ name: '', priority: maxPriority + 1, isEditing: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除别名项
|
||||||
|
function removeAliasItem(index: number) {
|
||||||
|
formData.value.aliases.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 拖拽排序 =====
|
||||||
|
function handleDragStart(index: number, event: DragEvent) {
|
||||||
|
draggedIndex.value = index
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd() {
|
||||||
|
draggedIndex.value = null
|
||||||
|
dragOverIndex.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(index: number) {
|
||||||
|
if (draggedIndex.value !== null && draggedIndex.value !== index) {
|
||||||
|
dragOverIndex.value = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave() {
|
||||||
|
dragOverIndex.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(targetIndex: number) {
|
||||||
|
const dragIndex = draggedIndex.value
|
||||||
|
if (dragIndex === null || dragIndex === targetIndex) {
|
||||||
|
dragOverIndex.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [...formData.value.aliases]
|
||||||
|
const draggedItem = items[dragIndex]
|
||||||
|
|
||||||
|
const originalPriorityMap = new Map<number, number>()
|
||||||
|
items.forEach((alias, idx) => {
|
||||||
|
originalPriorityMap.set(idx, alias.priority)
|
||||||
|
})
|
||||||
|
|
||||||
|
items.splice(dragIndex, 1)
|
||||||
|
items.splice(targetIndex, 0, draggedItem)
|
||||||
|
|
||||||
|
const groupNewPriority = new Map<number, number>()
|
||||||
|
let currentPriority = 1
|
||||||
|
|
||||||
|
items.forEach((alias) => {
|
||||||
|
const originalIdx = formData.value.aliases.findIndex(a => a === alias)
|
||||||
|
const originalPriority = originalIdx >= 0 ? originalPriorityMap.get(originalIdx)! : alias.priority
|
||||||
|
|
||||||
|
if (alias === draggedItem) {
|
||||||
|
alias.priority = currentPriority
|
||||||
|
currentPriority++
|
||||||
|
} else {
|
||||||
|
if (groupNewPriority.has(originalPriority)) {
|
||||||
|
alias.priority = groupNewPriority.get(originalPriority)!
|
||||||
|
} else {
|
||||||
|
groupNewPriority.set(originalPriority, currentPriority)
|
||||||
|
alias.priority = currentPriority
|
||||||
|
currentPriority++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
formData.value.aliases = items
|
||||||
|
draggedIndex.value = null
|
||||||
|
dragOverIndex.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 优先级编辑 =====
|
||||||
|
function startEditPriority(index: number) {
|
||||||
|
editingPriorityIndex.value = index
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishEditPriority(index: number, event: FocusEvent) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const newPriority = parseInt(input.value) || 1
|
||||||
|
formData.value.aliases[index].priority = Math.max(1, newPriority)
|
||||||
|
editingPriorityIndex.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditPriority() {
|
||||||
|
editingPriorityIndex.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 快速添加(上游模型)=====
|
||||||
|
async function fetchUpstreamModels() {
|
||||||
|
if (!props.providerId) return
|
||||||
|
|
||||||
|
upstreamModelSearch.value = ''
|
||||||
|
fetchingUpstreamModels.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchCachedModels(props.providerId)
|
||||||
|
if (result) {
|
||||||
|
if (result.error) {
|
||||||
|
showError(result.error, '错误')
|
||||||
|
} else {
|
||||||
|
upstreamModels.value = result.models
|
||||||
|
upstreamModelsLoaded.value = true
|
||||||
|
// 默认折叠所有分组
|
||||||
|
for (const model of result.models) {
|
||||||
|
if (model.api_format) {
|
||||||
|
collapsedGroups.value.add(model.api_format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fetchingUpstreamModels.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUpstreamModel(modelId: string) {
|
||||||
|
if (formData.value.aliases.some(a => a.name === modelId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxPriority = formData.value.aliases.length > 0
|
||||||
|
? Math.max(...formData.value.aliases.map(a => a.priority))
|
||||||
|
: 0
|
||||||
|
|
||||||
|
formData.value.aliases.push({ name: modelId, priority: maxPriority + 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshUpstreamModels() {
|
||||||
|
if (!props.providerId || refreshingUpstreamModels.value) return
|
||||||
|
|
||||||
|
refreshingUpstreamModels.value = true
|
||||||
|
clearCache(props.providerId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchCachedModels(props.providerId, true)
|
||||||
|
if (result) {
|
||||||
|
if (result.error) {
|
||||||
|
showError(result.error, '错误')
|
||||||
|
} else {
|
||||||
|
upstreamModels.value = result.models
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
refreshingUpstreamModels.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成作用域唯一键
|
||||||
|
function getApiFormatsKey(formats: string[] | undefined): string {
|
||||||
|
if (!formats || formats.length === 0) return ''
|
||||||
|
return [...formats].sort().join(',')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交表单
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (submitting.value) return
|
||||||
|
if (!formData.value.modelId || formData.value.aliases.length === 0) return
|
||||||
|
|
||||||
|
const validAliases = formData.value.aliases.filter(a => a.name.trim())
|
||||||
|
if (validAliases.length === 0) {
|
||||||
|
showError('请至少添加一个有效的映射名称', '错误')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const targetModel = props.models.find(m => m.id === formData.value.modelId)
|
||||||
|
if (!targetModel) {
|
||||||
|
showError('模型不存在', '错误')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentAliases = targetModel.provider_model_aliases || []
|
||||||
|
let newAliases: ProviderModelAlias[]
|
||||||
|
|
||||||
|
const buildAlias = (a: FormAlias): ProviderModelAlias => ({
|
||||||
|
name: a.name.trim(),
|
||||||
|
priority: a.priority,
|
||||||
|
...(formData.value.apiFormats.length > 0 ? { api_formats: formData.value.apiFormats } : {})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (props.editingGroup) {
|
||||||
|
const oldApiFormatsKey = props.editingGroup.apiFormatsKey
|
||||||
|
const oldAliasNames = new Set(props.editingGroup.aliases.map(a => a.name))
|
||||||
|
|
||||||
|
const filteredAliases = currentAliases.filter((a: ProviderModelAlias) => {
|
||||||
|
const currentKey = getApiFormatsKey(a.api_formats)
|
||||||
|
return !(currentKey === oldApiFormatsKey && oldAliasNames.has(a.name))
|
||||||
|
})
|
||||||
|
|
||||||
|
const existingNames = new Set(filteredAliases.map((a: ProviderModelAlias) => a.name))
|
||||||
|
const duplicates = validAliases.filter(a => existingNames.has(a.name.trim()))
|
||||||
|
if (duplicates.length > 0) {
|
||||||
|
showError(`以下映射名称已存在:${duplicates.map(d => d.name).join(', ')}`, '错误')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newAliases = [
|
||||||
|
...filteredAliases,
|
||||||
|
...validAliases.map(buildAlias)
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
const existingNames = new Set(currentAliases.map((a: ProviderModelAlias) => a.name))
|
||||||
|
const duplicates = validAliases.filter(a => existingNames.has(a.name.trim()))
|
||||||
|
if (duplicates.length > 0) {
|
||||||
|
showError(`以下映射名称已存在:${duplicates.map(d => d.name).join(', ')}`, '错误')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newAliases = [
|
||||||
|
...currentAliases,
|
||||||
|
...validAliases.map(buildAlias)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateModel(props.providerId, targetModel.id, {
|
||||||
|
provider_model_aliases: newAliases
|
||||||
|
})
|
||||||
|
|
||||||
|
showSuccess(props.editingGroup ? '映射组已更新' : '映射已添加')
|
||||||
|
emit('update:open', false)
|
||||||
|
emit('saved')
|
||||||
|
} catch (err: any) {
|
||||||
|
showError(err.response?.data?.detail || '操作失败', '错误')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -142,330 +142,14 @@
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- 添加/编辑映射对话框 -->
|
<!-- 添加/编辑映射对话框 -->
|
||||||
<Dialog
|
<ModelMappingDialog
|
||||||
v-model="dialogOpen"
|
v-model:open="dialogOpen"
|
||||||
:title="editingItem ? '编辑模型映射' : '添加模型映射'"
|
:provider-id="provider.id"
|
||||||
:description="editingItem ? '修改映射配置' : '为模型添加新的名称映射'"
|
:provider-api-formats="providerApiFormats"
|
||||||
:icon="Tag"
|
:models="models"
|
||||||
size="xl"
|
:editing-group="editingGroup"
|
||||||
>
|
@saved="onDialogSaved"
|
||||||
<div class="space-y-3">
|
/>
|
||||||
<!-- 第一行:目标模型 | 作用域 -->
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<!-- 目标模型 -->
|
|
||||||
<div class="flex-1 space-y-1.5">
|
|
||||||
<Label class="text-xs">目标模型</Label>
|
|
||||||
<Select
|
|
||||||
v-model:open="modelSelectOpen"
|
|
||||||
:model-value="formData.modelId"
|
|
||||||
:disabled="!!editingItem"
|
|
||||||
@update:model-value="formData.modelId = $event"
|
|
||||||
>
|
|
||||||
<SelectTrigger class="h-9">
|
|
||||||
<SelectValue placeholder="请选择模型" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem
|
|
||||||
v-for="model in models"
|
|
||||||
:key="model.id"
|
|
||||||
:value="model.id"
|
|
||||||
>
|
|
||||||
{{ model.global_model_display_name || model.provider_model_name }}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 作用域 -->
|
|
||||||
<div class="flex-1 space-y-1.5">
|
|
||||||
<Label class="text-xs">作用域 <span class="text-muted-foreground font-normal">(不选则适用全部)</span></Label>
|
|
||||||
<div
|
|
||||||
v-if="providerApiFormats.length > 0"
|
|
||||||
class="flex flex-wrap gap-1.5 p-2 rounded-md border bg-muted/30 min-h-[36px]"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
v-for="format in providerApiFormats"
|
|
||||||
:key="format"
|
|
||||||
type="button"
|
|
||||||
class="px-2.5 py-0.5 rounded text-xs font-medium transition-colors"
|
|
||||||
:class="[
|
|
||||||
formData.apiFormats.includes(format)
|
|
||||||
? 'bg-primary text-primary-foreground'
|
|
||||||
: 'bg-background border border-border hover:bg-muted'
|
|
||||||
]"
|
|
||||||
@click="toggleApiFormat(format)"
|
|
||||||
>
|
|
||||||
{{ API_FORMAT_LABELS[format] || format }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="h-9 flex items-center text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
无可用格式
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 第二行:上游模型 | 映射名称 -->
|
|
||||||
<div class="flex gap-4 h-[340px]">
|
|
||||||
<!-- 左侧:上游模型列表 -->
|
|
||||||
<div class="flex-1 flex flex-col border rounded-lg overflow-hidden">
|
|
||||||
<!-- 左侧头部:标题 + 搜索 + 操作按钮 -->
|
|
||||||
<div class="px-3 py-2 bg-muted/50 border-b flex items-center gap-2 shrink-0">
|
|
||||||
<span class="text-xs font-medium shrink-0">上游模型</span>
|
|
||||||
<!-- 搜索框 -->
|
|
||||||
<div class="flex-1 relative">
|
|
||||||
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
v-model="upstreamModelSearch"
|
|
||||||
placeholder="搜索模型..."
|
|
||||||
class="pl-7 h-7 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<!-- 操作按钮 -->
|
|
||||||
<button
|
|
||||||
v-if="upstreamModelsLoaded"
|
|
||||||
class="p-1.5 rounded hover:bg-muted transition-colors shrink-0"
|
|
||||||
title="刷新列表"
|
|
||||||
:disabled="refreshingUpstreamModels"
|
|
||||||
@click="refreshUpstreamModels"
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
class="w-3.5 h-3.5"
|
|
||||||
:class="{ 'animate-spin': refreshingUpstreamModels }"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-else-if="!fetchingUpstreamModels"
|
|
||||||
class="p-1.5 rounded hover:bg-muted transition-colors shrink-0"
|
|
||||||
title="获取上游模型列表"
|
|
||||||
@click="fetchUpstreamModels"
|
|
||||||
>
|
|
||||||
<Zap class="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
<Loader2
|
|
||||||
v-else
|
|
||||||
class="w-3.5 h-3.5 animate-spin text-muted-foreground shrink-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 模型列表 -->
|
|
||||||
<div class="flex-1 overflow-y-auto">
|
|
||||||
<template v-if="upstreamModelsLoaded">
|
|
||||||
<!-- 按分组显示(可折叠) -->
|
|
||||||
<div
|
|
||||||
v-for="group in groupedAvailableUpstreamModels"
|
|
||||||
:key="group.api_format"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="sticky top-0 z-10 px-3 py-1.5 bg-muted/80 backdrop-blur-sm border-b flex items-center justify-between cursor-pointer hover:bg-muted/90 transition-colors"
|
|
||||||
@click="toggleGroupCollapse(group.api_format)"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<ChevronRight
|
|
||||||
class="w-3.5 h-3.5 transition-transform"
|
|
||||||
:class="{ 'rotate-90': !collapsedGroups.has(group.api_format) }"
|
|
||||||
/>
|
|
||||||
<span class="text-xs font-medium">{{ API_FORMAT_LABELS[group.api_format] || group.api_format }}</span>
|
|
||||||
<span class="text-xs text-muted-foreground">({{ group.models.length }})</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="text-xs text-primary hover:underline"
|
|
||||||
@click.stop="addAllFromGroup(group.api_format)"
|
|
||||||
>
|
|
||||||
全部添加
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-show="!collapsedGroups.has(group.api_format)">
|
|
||||||
<div
|
|
||||||
v-for="model in group.models"
|
|
||||||
:key="model.id"
|
|
||||||
class="group flex items-center gap-2 px-3 py-1.5 hover:bg-muted/50 cursor-pointer transition-colors"
|
|
||||||
:title="model.id"
|
|
||||||
@click="addUpstreamModel(model.id)"
|
|
||||||
>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="font-mono text-xs truncate">
|
|
||||||
{{ model.id }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="model.owned_by"
|
|
||||||
class="text-xs text-muted-foreground truncate"
|
|
||||||
>
|
|
||||||
{{ model.owned_by }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Plus class="w-3.5 h-3.5 text-muted-foreground/50 group-hover:text-primary transition-colors shrink-0" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 空状态 -->
|
|
||||||
<div
|
|
||||||
v-if="groupedAvailableUpstreamModels.length === 0"
|
|
||||||
class="flex items-center justify-center h-full text-muted-foreground text-xs p-4"
|
|
||||||
>
|
|
||||||
{{ upstreamModelSearch ? '没有匹配的模型' : '所有模型已添加' }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 未加载状态 -->
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="flex flex-col items-center justify-center h-full text-muted-foreground p-4"
|
|
||||||
>
|
|
||||||
<Zap class="w-8 h-8 mb-2 opacity-30" />
|
|
||||||
<p class="text-xs text-center">
|
|
||||||
点击右上角按钮<br>从上游获取可用模型
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 右侧:映射模型(编辑模式下全宽) -->
|
|
||||||
<div class="flex-1 flex flex-col border rounded-lg overflow-hidden">
|
|
||||||
<div class="px-3 py-2 bg-primary/5 border-b flex items-center justify-between shrink-0">
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<span class="text-xs font-medium">映射名称</span>
|
|
||||||
<Badge
|
|
||||||
v-if="formData.aliases.length > 0"
|
|
||||||
variant="secondary"
|
|
||||||
class="text-xs h-5"
|
|
||||||
>
|
|
||||||
{{ formData.aliases.length }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
v-if="formData.aliases.length > 0"
|
|
||||||
class="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-destructive transition-colors"
|
|
||||||
title="清空"
|
|
||||||
@click="formData.aliases = []"
|
|
||||||
>
|
|
||||||
<Eraser class="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="p-1.5 rounded hover:bg-muted transition-colors"
|
|
||||||
title="手动添加"
|
|
||||||
@click="addAliasItem"
|
|
||||||
>
|
|
||||||
<Plus class="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 已选列表 -->
|
|
||||||
<div class="flex-1 overflow-y-auto">
|
|
||||||
<div
|
|
||||||
v-if="formData.aliases.length > 0"
|
|
||||||
class="divide-y divide-border/30"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="(alias, index) in formData.aliases"
|
|
||||||
:key="`alias-${index}`"
|
|
||||||
class="group flex items-center gap-1.5 px-2 py-1.5 hover:bg-muted/30 transition-colors"
|
|
||||||
:class="[
|
|
||||||
draggedIndex === index ? 'bg-primary/5' : '',
|
|
||||||
dragOverIndex === index ? 'bg-primary/10' : ''
|
|
||||||
]"
|
|
||||||
draggable="true"
|
|
||||||
@dragstart="handleDragStart(index, $event)"
|
|
||||||
@dragend="handleDragEnd"
|
|
||||||
@dragover.prevent="handleDragOver(index)"
|
|
||||||
@dragleave="handleDragLeave"
|
|
||||||
@drop="handleDrop(index)"
|
|
||||||
>
|
|
||||||
<!-- 拖拽手柄 -->
|
|
||||||
<div class="cursor-grab active:cursor-grabbing text-muted-foreground/30 group-hover:text-muted-foreground shrink-0">
|
|
||||||
<GripVertical class="w-3 h-3" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 优先级 -->
|
|
||||||
<div class="shrink-0">
|
|
||||||
<input
|
|
||||||
v-if="editingPriorityIndex === index"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
:value="alias.priority"
|
|
||||||
class="w-6 h-5 rounded bg-background border border-primary text-xs text-center focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
||||||
autofocus
|
|
||||||
@blur="finishEditPriority(index, $event)"
|
|
||||||
@keydown.enter="($event.target as HTMLInputElement).blur()"
|
|
||||||
@keydown.escape="cancelEditPriority"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="w-5 h-5 rounded bg-muted/50 flex items-center justify-center text-xs text-muted-foreground cursor-pointer hover:bg-primary/10 hover:text-primary"
|
|
||||||
title="点击编辑优先级"
|
|
||||||
@click.stop="startEditPriority(index)"
|
|
||||||
>
|
|
||||||
{{ alias.priority }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 名称输入 -->
|
|
||||||
<Input
|
|
||||||
v-model="alias.name"
|
|
||||||
placeholder="映射名称"
|
|
||||||
class="flex-1 h-6 text-xs px-2"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 删除按钮 -->
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="shrink-0 text-muted-foreground hover:text-destructive h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
@click="removeAliasItem(index)"
|
|
||||||
>
|
|
||||||
<X class="w-3 h-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 空状态 -->
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="flex flex-col items-center justify-center h-full text-muted-foreground p-4"
|
|
||||||
>
|
|
||||||
<Tag class="w-8 h-8 mb-2 opacity-30" />
|
|
||||||
<p class="text-xs text-center">
|
|
||||||
从左侧选择模型<br>或手动添加映射
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 拖拽提示 -->
|
|
||||||
<div
|
|
||||||
v-if="formData.aliases.length > 1"
|
|
||||||
class="px-3 py-1.5 bg-muted/30 border-t text-xs text-muted-foreground text-center shrink-0"
|
|
||||||
>
|
|
||||||
拖拽调整优先级顺序
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
@click="dialogOpen = false"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
:disabled="submitting || !formData.modelId || formData.aliases.length === 0 || !hasValidAliases"
|
|
||||||
@click="handleSubmit"
|
|
||||||
>
|
|
||||||
<Loader2
|
|
||||||
v-if="submitting"
|
|
||||||
class="w-4 h-4 mr-2 animate-spin"
|
|
||||||
/>
|
|
||||||
{{ editingItem ? '保存' : '添加' }}
|
|
||||||
</Button>
|
|
||||||
</template>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<!-- 删除确认对话框 -->
|
<!-- 删除确认对话框 -->
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
@@ -482,21 +166,10 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { Tag, Plus, Edit, Trash2, Loader2, GripVertical, X, Zap, Search, RefreshCw, ChevronRight, Eraser } from 'lucide-vue-next'
|
import { Tag, Plus, Edit, Trash2, ChevronRight } from 'lucide-vue-next'
|
||||||
import {
|
import { Card, Button, Badge } from '@/components/ui'
|
||||||
Card,
|
|
||||||
Button,
|
|
||||||
Badge,
|
|
||||||
Input,
|
|
||||||
Label,
|
|
||||||
Dialog,
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui'
|
|
||||||
import AlertDialog from '@/components/common/AlertDialog.vue'
|
import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||||
|
import ModelMappingDialog, { type AliasGroup } from '../ModelMappingDialog.vue'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import {
|
import {
|
||||||
getProviderModels,
|
getProviderModels,
|
||||||
@@ -505,17 +178,6 @@ import {
|
|||||||
type ProviderModelAlias
|
type ProviderModelAlias
|
||||||
} from '@/api/endpoints'
|
} from '@/api/endpoints'
|
||||||
import { updateModel } from '@/api/endpoints/models'
|
import { updateModel } from '@/api/endpoints/models'
|
||||||
import { adminApi } from '@/api/admin'
|
|
||||||
|
|
||||||
interface AliasItem {
|
|
||||||
model: Model
|
|
||||||
alias: ProviderModelAlias
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FormAlias {
|
|
||||||
name: string
|
|
||||||
priority: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
provider: any
|
provider: any
|
||||||
@@ -532,131 +194,22 @@ const loading = ref(false)
|
|||||||
const models = ref<Model[]>([])
|
const models = ref<Model[]>([])
|
||||||
const dialogOpen = ref(false)
|
const dialogOpen = ref(false)
|
||||||
const deleteConfirmOpen = ref(false)
|
const deleteConfirmOpen = ref(false)
|
||||||
const submitting = ref(false)
|
const editingGroup = ref<AliasGroup | null>(null)
|
||||||
const editingItem = ref<AliasItem | null>(null)
|
|
||||||
const deletingGroup = ref<AliasGroup | null>(null)
|
const deletingGroup = ref<AliasGroup | null>(null)
|
||||||
const modelSelectOpen = ref(false)
|
|
||||||
|
|
||||||
// 拖拽状态
|
// 列表展开状态
|
||||||
const draggedIndex = ref<number | null>(null)
|
|
||||||
const dragOverIndex = ref<number | null>(null)
|
|
||||||
|
|
||||||
// 优先级编辑状态
|
|
||||||
const editingPriorityIndex = ref<number | null>(null)
|
|
||||||
|
|
||||||
// 快速添加(上游模型)状态
|
|
||||||
const fetchingUpstreamModels = ref(false)
|
|
||||||
const refreshingUpstreamModels = ref(false)
|
|
||||||
const upstreamModelsLoaded = ref(false)
|
|
||||||
const upstreamModels = ref<Array<{ id: string; owned_by?: string; api_format?: string }>>([])
|
|
||||||
const upstreamModelSearch = ref('')
|
|
||||||
|
|
||||||
// 分组折叠状态(上游模型列表)
|
|
||||||
const collapsedGroups = ref<Set<string>>(new Set())
|
|
||||||
|
|
||||||
// 列表展开状态(映射组列表)
|
|
||||||
const expandedAliasGroups = ref<Set<string>>(new Set())
|
const expandedAliasGroups = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
// 上游模型缓存(按 Provider ID)
|
// 获取 Provider 支持的 API 格式
|
||||||
const upstreamModelsCache = ref<Map<string, {
|
|
||||||
models: Array<{ id: string; owned_by?: string; api_format?: string }>
|
|
||||||
timestamp: number
|
|
||||||
}>>(new Map())
|
|
||||||
const CACHE_TTL = 5 * 60 * 1000 // 5 分钟缓存
|
|
||||||
|
|
||||||
// 过滤和排序后的上游模型列表
|
|
||||||
const filteredUpstreamModels = computed(() => {
|
|
||||||
const searchText = upstreamModelSearch.value.toLowerCase().trim()
|
|
||||||
let result = [...upstreamModels.value]
|
|
||||||
|
|
||||||
// 按名称排序
|
|
||||||
result.sort((a, b) => a.id.localeCompare(b.id))
|
|
||||||
|
|
||||||
// 搜索过滤(支持空格分隔的多关键词 AND 搜索)
|
|
||||||
if (searchText) {
|
|
||||||
const keywords = searchText.split(/\s+/).filter(k => k.length > 0)
|
|
||||||
result = result.filter(m => {
|
|
||||||
const searchableText = `${m.id} ${m.owned_by || ''} ${m.api_format || ''}`.toLowerCase()
|
|
||||||
return keywords.every(keyword => searchableText.includes(keyword))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
// 按 API 格式分组的上游模型列表
|
|
||||||
interface UpstreamModelGroup {
|
|
||||||
api_format: string
|
|
||||||
models: Array<{ id: string; owned_by?: string; api_format?: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
// 可添加的上游模型(排除已添加的)按分组显示
|
|
||||||
const groupedAvailableUpstreamModels = computed<UpstreamModelGroup[]>(() => {
|
|
||||||
// 获取已添加的映射名称集合
|
|
||||||
const addedNames = new Set(formData.value.aliases.map(a => a.name.trim()))
|
|
||||||
|
|
||||||
// 过滤掉已添加的模型
|
|
||||||
const availableModels = filteredUpstreamModels.value.filter(m => !addedNames.has(m.id))
|
|
||||||
|
|
||||||
// 按 API 格式分组
|
|
||||||
const groups = new Map<string, UpstreamModelGroup>()
|
|
||||||
|
|
||||||
for (const model of availableModels) {
|
|
||||||
const format = model.api_format || 'UNKNOWN'
|
|
||||||
if (!groups.has(format)) {
|
|
||||||
groups.set(format, { api_format: format, models: [] })
|
|
||||||
}
|
|
||||||
groups.get(format)!.models.push(model)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按 API_FORMAT_LABELS 的键顺序排序
|
|
||||||
const order = Object.keys(API_FORMAT_LABELS)
|
|
||||||
return Array.from(groups.values()).sort((a, b) => {
|
|
||||||
const aIndex = order.indexOf(a.api_format)
|
|
||||||
const bIndex = order.indexOf(b.api_format)
|
|
||||||
// 未知格式排最后
|
|
||||||
if (aIndex === -1 && bIndex === -1) return a.api_format.localeCompare(b.api_format)
|
|
||||||
if (aIndex === -1) return 1
|
|
||||||
if (bIndex === -1) return -1
|
|
||||||
return aIndex - bIndex
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// 表单数据
|
|
||||||
const formData = ref<{
|
|
||||||
modelId: string
|
|
||||||
apiFormats: string[]
|
|
||||||
aliases: FormAlias[]
|
|
||||||
}>({
|
|
||||||
modelId: '',
|
|
||||||
apiFormats: [],
|
|
||||||
aliases: []
|
|
||||||
})
|
|
||||||
|
|
||||||
// 检查是否有有效的别名
|
|
||||||
const hasValidAliases = computed(() => {
|
|
||||||
return formData.value.aliases.some(a => a.name.trim())
|
|
||||||
})
|
|
||||||
|
|
||||||
// 获取 Provider 支持的 API 格式(按 API_FORMATS 定义的顺序排序)
|
|
||||||
const providerApiFormats = computed(() => {
|
const providerApiFormats = computed(() => {
|
||||||
const formats = props.provider?.api_formats
|
const formats = props.provider?.api_formats
|
||||||
if (Array.isArray(formats) && formats.length > 0) {
|
if (Array.isArray(formats) && formats.length > 0) {
|
||||||
// 按 API_FORMAT_LABELS 中的键顺序排序
|
|
||||||
const order = Object.keys(API_FORMAT_LABELS)
|
const order = Object.keys(API_FORMAT_LABELS)
|
||||||
return [...formats].sort((a, b) => order.indexOf(a) - order.indexOf(b))
|
return [...formats].sort((a, b) => order.indexOf(a) - order.indexOf(b))
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
|
|
||||||
// 分组数据结构
|
|
||||||
interface AliasGroup {
|
|
||||||
model: Model
|
|
||||||
apiFormatsKey: string // 作用域的唯一标识(排序后的格式数组 JSON)
|
|
||||||
apiFormats: string[] // 作用域
|
|
||||||
aliases: ProviderModelAlias[] // 该组的所有映射
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成作用域唯一键
|
// 生成作用域唯一键
|
||||||
function getApiFormatsKey(formats: string[] | undefined): string {
|
function getApiFormatsKey(formats: string[] | undefined): string {
|
||||||
if (!formats || formats.length === 0) return ''
|
if (!formats || formats.length === 0) return ''
|
||||||
@@ -689,12 +242,10 @@ const aliasGroups = computed<AliasGroup[]>(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 对每个组内的别名按优先级排序
|
|
||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
group.aliases.sort((a, b) => a.priority - b.priority)
|
group.aliases.sort((a, b) => a.priority - b.priority)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按模型名排序,同模型内按作用域排序
|
|
||||||
return groups.sort((a, b) => {
|
return groups.sort((a, b) => {
|
||||||
const nameA = (a.model.global_model_display_name || a.model.provider_model_name || '').toLowerCase()
|
const nameA = (a.model.global_model_display_name || a.model.provider_model_name || '').toLowerCase()
|
||||||
const nameB = (b.model.global_model_display_name || b.model.provider_model_name || '').toLowerCase()
|
const nameB = (b.model.global_model_display_name || b.model.provider_model_name || '').toLowerCase()
|
||||||
@@ -703,9 +254,6 @@ const aliasGroups = computed<AliasGroup[]>(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 当前编辑的分组
|
|
||||||
const editingGroup = ref<AliasGroup | null>(null)
|
|
||||||
|
|
||||||
// 加载模型
|
// 加载模型
|
||||||
async function loadModels() {
|
async function loadModels() {
|
||||||
try {
|
try {
|
||||||
@@ -728,25 +276,6 @@ const deleteConfirmDescription = computed(() => {
|
|||||||
return `确定要删除模型「${modelName}」在作用域「${scopeText}」下的 ${aliases.length} 个映射吗?\n\n映射名称:${aliasNames}`
|
return `确定要删除模型「${modelName}」在作用域「${scopeText}」下的 ${aliases.length} 个映射吗?\n\n映射名称:${aliasNames}`
|
||||||
})
|
})
|
||||||
|
|
||||||
// 切换 API 格式
|
|
||||||
function toggleApiFormat(format: string) {
|
|
||||||
const index = formData.value.apiFormats.indexOf(format)
|
|
||||||
if (index >= 0) {
|
|
||||||
formData.value.apiFormats.splice(index, 1)
|
|
||||||
} else {
|
|
||||||
formData.value.apiFormats.push(format)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换分组折叠状态(上游模型列表)
|
|
||||||
function toggleGroupCollapse(apiFormat: string) {
|
|
||||||
if (collapsedGroups.value.has(apiFormat)) {
|
|
||||||
collapsedGroups.value.delete(apiFormat)
|
|
||||||
} else {
|
|
||||||
collapsedGroups.value.add(apiFormat)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换映射组展开状态
|
// 切换映射组展开状态
|
||||||
function toggleAliasGroupExpand(groupKey: string) {
|
function toggleAliasGroupExpand(groupKey: string) {
|
||||||
if (expandedAliasGroups.value.has(groupKey)) {
|
if (expandedAliasGroups.value.has(groupKey)) {
|
||||||
@@ -756,147 +285,15 @@ function toggleAliasGroupExpand(groupKey: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加别名项
|
|
||||||
function addAliasItem() {
|
|
||||||
const maxPriority = formData.value.aliases.length > 0
|
|
||||||
? Math.max(...formData.value.aliases.map(a => a.priority))
|
|
||||||
: 0
|
|
||||||
formData.value.aliases.push({ name: '', priority: maxPriority + 1 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除别名项
|
|
||||||
function removeAliasItem(index: number) {
|
|
||||||
formData.value.aliases.splice(index, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 拖拽排序 =====
|
|
||||||
function handleDragStart(index: number, event: DragEvent) {
|
|
||||||
draggedIndex.value = index
|
|
||||||
if (event.dataTransfer) {
|
|
||||||
event.dataTransfer.effectAllowed = 'move'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragEnd() {
|
|
||||||
draggedIndex.value = null
|
|
||||||
dragOverIndex.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragOver(index: number) {
|
|
||||||
if (draggedIndex.value !== null && draggedIndex.value !== index) {
|
|
||||||
dragOverIndex.value = index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragLeave() {
|
|
||||||
dragOverIndex.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDrop(targetIndex: number) {
|
|
||||||
const dragIndex = draggedIndex.value
|
|
||||||
if (dragIndex === null || dragIndex === targetIndex) {
|
|
||||||
dragOverIndex.value = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = [...formData.value.aliases]
|
|
||||||
const draggedItem = items[dragIndex]
|
|
||||||
|
|
||||||
// 记录每个别名的原始优先级(在修改前)
|
|
||||||
const originalPriorityMap = new Map<number, number>()
|
|
||||||
items.forEach((alias, idx) => {
|
|
||||||
originalPriorityMap.set(idx, alias.priority)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 重排数组
|
|
||||||
items.splice(dragIndex, 1)
|
|
||||||
items.splice(targetIndex, 0, draggedItem)
|
|
||||||
|
|
||||||
// 按新顺序为每个组分配新的优先级
|
|
||||||
// 同组的别名保持相同的优先级(被拖动的别名单独成组)
|
|
||||||
const groupNewPriority = new Map<number, number>() // 原优先级 -> 新优先级
|
|
||||||
let currentPriority = 1
|
|
||||||
|
|
||||||
items.forEach((alias) => {
|
|
||||||
// 找到这个别名在原数组中的索引
|
|
||||||
const originalIdx = formData.value.aliases.findIndex(a => a === alias)
|
|
||||||
const originalPriority = originalIdx >= 0 ? originalPriorityMap.get(originalIdx)! : alias.priority
|
|
||||||
|
|
||||||
if (alias === draggedItem) {
|
|
||||||
// 被拖动的别名是独立的新组,获得当前优先级
|
|
||||||
alias.priority = currentPriority
|
|
||||||
currentPriority++
|
|
||||||
} else {
|
|
||||||
if (groupNewPriority.has(originalPriority)) {
|
|
||||||
// 这个组已经分配过优先级,使用相同的值
|
|
||||||
alias.priority = groupNewPriority.get(originalPriority)!
|
|
||||||
} else {
|
|
||||||
// 这个组第一次出现,分配新优先级
|
|
||||||
groupNewPriority.set(originalPriority, currentPriority)
|
|
||||||
alias.priority = currentPriority
|
|
||||||
currentPriority++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
formData.value.aliases = items
|
|
||||||
draggedIndex.value = null
|
|
||||||
dragOverIndex.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 优先级编辑 =====
|
|
||||||
function startEditPriority(index: number) {
|
|
||||||
editingPriorityIndex.value = index
|
|
||||||
}
|
|
||||||
|
|
||||||
function finishEditPriority(index: number, event: FocusEvent) {
|
|
||||||
const input = event.target as HTMLInputElement
|
|
||||||
const newPriority = parseInt(input.value) || 1
|
|
||||||
formData.value.aliases[index].priority = Math.max(1, newPriority)
|
|
||||||
editingPriorityIndex.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelEditPriority() {
|
|
||||||
editingPriorityIndex.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 打开添加对话框
|
// 打开添加对话框
|
||||||
function openAddDialog() {
|
function openAddDialog() {
|
||||||
editingItem.value = null
|
|
||||||
editingGroup.value = null
|
editingGroup.value = null
|
||||||
formData.value = {
|
|
||||||
modelId: '',
|
|
||||||
apiFormats: [],
|
|
||||||
aliases: []
|
|
||||||
}
|
|
||||||
// 重置状态
|
|
||||||
editingPriorityIndex.value = null
|
|
||||||
draggedIndex.value = null
|
|
||||||
dragOverIndex.value = null
|
|
||||||
// 重置上游模型状态
|
|
||||||
upstreamModelsLoaded.value = false
|
|
||||||
upstreamModels.value = []
|
|
||||||
upstreamModelSearch.value = ''
|
|
||||||
dialogOpen.value = true
|
dialogOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 编辑分组
|
// 编辑分组
|
||||||
function editGroup(group: AliasGroup) {
|
function editGroup(group: AliasGroup) {
|
||||||
editingGroup.value = group
|
editingGroup.value = group
|
||||||
editingItem.value = { model: group.model, alias: group.aliases[0] } // 保持兼容
|
|
||||||
formData.value = {
|
|
||||||
modelId: group.model.id,
|
|
||||||
apiFormats: [...group.apiFormats],
|
|
||||||
aliases: group.aliases.map(a => ({ name: a.name, priority: a.priority }))
|
|
||||||
}
|
|
||||||
// 重置状态
|
|
||||||
editingPriorityIndex.value = null
|
|
||||||
draggedIndex.value = null
|
|
||||||
dragOverIndex.value = null
|
|
||||||
// 重置上游模型状态
|
|
||||||
upstreamModelsLoaded.value = false
|
|
||||||
upstreamModels.value = []
|
|
||||||
upstreamModelSearch.value = ''
|
|
||||||
dialogOpen.value = true
|
dialogOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -913,11 +310,9 @@ async function confirmDelete() {
|
|||||||
const { model, aliases, apiFormatsKey } = deletingGroup.value
|
const { model, aliases, apiFormatsKey } = deletingGroup.value
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 从模型的别名列表中移除该分组的所有别名
|
|
||||||
const currentAliases = model.provider_model_aliases || []
|
const currentAliases = model.provider_model_aliases || []
|
||||||
const aliasNamesToRemove = new Set(aliases.map(a => a.name))
|
const aliasNamesToRemove = new Set(aliases.map(a => a.name))
|
||||||
const newAliases = currentAliases.filter((a: ProviderModelAlias) => {
|
const newAliases = currentAliases.filter((a: ProviderModelAlias) => {
|
||||||
// 只移除同一作用域的别名
|
|
||||||
const currentKey = getApiFormatsKey(a.api_formats)
|
const currentKey = getApiFormatsKey(a.api_formats)
|
||||||
return !(currentKey === apiFormatsKey && aliasNamesToRemove.has(a.name))
|
return !(currentKey === apiFormatsKey && aliasNamesToRemove.has(a.name))
|
||||||
})
|
})
|
||||||
@@ -936,89 +331,10 @@ async function confirmDelete() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提交表单
|
// 对话框保存后回调
|
||||||
async function handleSubmit() {
|
async function onDialogSaved() {
|
||||||
if (submitting.value) return
|
await loadModels()
|
||||||
if (!formData.value.modelId || formData.value.aliases.length === 0) return
|
emit('refresh')
|
||||||
|
|
||||||
// 过滤有效的别名
|
|
||||||
const validAliases = formData.value.aliases.filter(a => a.name.trim())
|
|
||||||
if (validAliases.length === 0) {
|
|
||||||
showError('请至少添加一个有效的映射名称', '错误')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
submitting.value = true
|
|
||||||
try {
|
|
||||||
const targetModel = models.value.find(m => m.id === formData.value.modelId)
|
|
||||||
if (!targetModel) {
|
|
||||||
showError('模型不存在', '错误')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentAliases = targetModel.provider_model_aliases || []
|
|
||||||
let newAliases: ProviderModelAlias[]
|
|
||||||
|
|
||||||
// 构建新的别名对象(带作用域)
|
|
||||||
const buildAlias = (a: FormAlias): ProviderModelAlias => ({
|
|
||||||
name: a.name.trim(),
|
|
||||||
priority: a.priority,
|
|
||||||
...(formData.value.apiFormats.length > 0 ? { api_formats: formData.value.apiFormats } : {})
|
|
||||||
})
|
|
||||||
|
|
||||||
if (editingGroup.value) {
|
|
||||||
// 编辑分组模式:替换该分组的所有别名
|
|
||||||
const oldApiFormatsKey = editingGroup.value.apiFormatsKey
|
|
||||||
const oldAliasNames = new Set(editingGroup.value.aliases.map(a => a.name))
|
|
||||||
|
|
||||||
// 移除旧分组的所有别名
|
|
||||||
const filteredAliases = currentAliases.filter((a: ProviderModelAlias) => {
|
|
||||||
const currentKey = getApiFormatsKey(a.api_formats)
|
|
||||||
return !(currentKey === oldApiFormatsKey && oldAliasNames.has(a.name))
|
|
||||||
})
|
|
||||||
|
|
||||||
// 检查新别名是否与其他分组的别名重复
|
|
||||||
const existingNames = new Set(filteredAliases.map((a: ProviderModelAlias) => a.name))
|
|
||||||
const duplicates = validAliases.filter(a => existingNames.has(a.name.trim()))
|
|
||||||
if (duplicates.length > 0) {
|
|
||||||
showError(`以下映射名称已存在:${duplicates.map(d => d.name).join(', ')}`, '错误')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加新的别名
|
|
||||||
newAliases = [
|
|
||||||
...filteredAliases,
|
|
||||||
...validAliases.map(buildAlias)
|
|
||||||
]
|
|
||||||
} else {
|
|
||||||
// 添加模式:检查是否重复并批量添加
|
|
||||||
const existingNames = new Set(currentAliases.map((a: ProviderModelAlias) => a.name))
|
|
||||||
const duplicates = validAliases.filter(a => existingNames.has(a.name.trim()))
|
|
||||||
if (duplicates.length > 0) {
|
|
||||||
showError(`以下映射名称已存在:${duplicates.map(d => d.name).join(', ')}`, '错误')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
newAliases = [
|
|
||||||
...currentAliases,
|
|
||||||
...validAliases.map(buildAlias)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateModel(props.provider.id, targetModel.id, {
|
|
||||||
provider_model_aliases: newAliases
|
|
||||||
})
|
|
||||||
|
|
||||||
showSuccess(editingGroup.value ? '映射组已更新' : '映射已添加')
|
|
||||||
dialogOpen.value = false
|
|
||||||
editingGroup.value = null
|
|
||||||
editingItem.value = null
|
|
||||||
await loadModels()
|
|
||||||
emit('refresh')
|
|
||||||
} catch (err: any) {
|
|
||||||
showError(err.response?.data?.detail || '操作失败', '错误')
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听 provider 变化
|
// 监听 provider 变化
|
||||||
@@ -1033,103 +349,4 @@ onMounted(() => {
|
|||||||
loadModels()
|
loadModels()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ===== 快速添加(上游模型)=====
|
|
||||||
async function fetchUpstreamModels() {
|
|
||||||
if (!props.provider?.id) return
|
|
||||||
|
|
||||||
const providerId = props.provider.id
|
|
||||||
upstreamModelSearch.value = ''
|
|
||||||
|
|
||||||
// 检查缓存
|
|
||||||
const cached = upstreamModelsCache.value.get(providerId)
|
|
||||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
||||||
upstreamModels.value = cached.models
|
|
||||||
upstreamModelsLoaded.value = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchingUpstreamModels.value = true
|
|
||||||
upstreamModels.value = []
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await adminApi.queryProviderModels(providerId)
|
|
||||||
if (response.success && response.data?.models) {
|
|
||||||
upstreamModels.value = response.data.models
|
|
||||||
// 写入缓存
|
|
||||||
upstreamModelsCache.value.set(providerId, {
|
|
||||||
models: response.data.models,
|
|
||||||
timestamp: Date.now()
|
|
||||||
})
|
|
||||||
upstreamModelsLoaded.value = true
|
|
||||||
} else {
|
|
||||||
showError(response.data?.error || '获取模型列表失败', '错误')
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
showError(err.response?.data?.detail || '获取模型列表失败', '错误')
|
|
||||||
} finally {
|
|
||||||
fetchingUpstreamModels.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加单个上游模型
|
|
||||||
function addUpstreamModel(modelId: string) {
|
|
||||||
// 检查是否已存在
|
|
||||||
if (formData.value.aliases.some(a => a.name === modelId)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxPriority = formData.value.aliases.length > 0
|
|
||||||
? Math.max(...formData.value.aliases.map(a => a.priority))
|
|
||||||
: 0
|
|
||||||
|
|
||||||
formData.value.aliases.push({ name: modelId, priority: maxPriority + 1 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加某个分组的所有模型
|
|
||||||
function addAllFromGroup(apiFormat: string) {
|
|
||||||
const group = groupedAvailableUpstreamModels.value.find(g => g.api_format === apiFormat)
|
|
||||||
if (!group) return
|
|
||||||
|
|
||||||
let maxPriority = formData.value.aliases.length > 0
|
|
||||||
? Math.max(...formData.value.aliases.map(a => a.priority))
|
|
||||||
: 0
|
|
||||||
|
|
||||||
for (const model of group.models) {
|
|
||||||
// 检查是否已存在
|
|
||||||
if (!formData.value.aliases.some(a => a.name === model.id)) {
|
|
||||||
maxPriority++
|
|
||||||
formData.value.aliases.push({ name: model.id, priority: maxPriority })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 刷新上游模型列表(清除缓存并重新获取)
|
|
||||||
async function refreshUpstreamModels() {
|
|
||||||
if (!props.provider?.id || refreshingUpstreamModels.value) return
|
|
||||||
|
|
||||||
const providerId = props.provider.id
|
|
||||||
refreshingUpstreamModels.value = true
|
|
||||||
|
|
||||||
// 清除缓存
|
|
||||||
upstreamModelsCache.value.delete(providerId)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await adminApi.queryProviderModels(providerId)
|
|
||||||
if (response.success && response.data?.models) {
|
|
||||||
upstreamModels.value = response.data.models
|
|
||||||
// 写入缓存
|
|
||||||
upstreamModelsCache.value.set(providerId, {
|
|
||||||
models: response.data.models,
|
|
||||||
timestamp: Date.now()
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
showError(response.data?.error || '刷新失败', '错误')
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
showError(err.response?.data?.detail || '刷新失败', '错误')
|
|
||||||
} finally {
|
|
||||||
refreshingUpstreamModels.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* 上游模型缓存 - 共享缓存,避免重复请求
|
||||||
|
*/
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { adminApi } from '@/api/admin'
|
||||||
|
import type { UpstreamModel } from '@/api/endpoints/types'
|
||||||
|
|
||||||
|
// 扩展类型,包含可能的额外字段
|
||||||
|
export type { UpstreamModel }
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
models: UpstreamModel[]
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchResult = { models: UpstreamModel[]; error?: string }
|
||||||
|
|
||||||
|
// 全局缓存(模块级别,所有组件共享)
|
||||||
|
const cache = new Map<string, CacheEntry>()
|
||||||
|
const CACHE_TTL = 5 * 60 * 1000 // 5分钟
|
||||||
|
|
||||||
|
// 进行中的请求(用于去重并发请求)
|
||||||
|
const pendingRequests = new Map<string, Promise<FetchResult>>()
|
||||||
|
|
||||||
|
// 请求状态
|
||||||
|
const loadingMap = ref<Map<string, boolean>>(new Map())
|
||||||
|
|
||||||
|
export function useUpstreamModelsCache() {
|
||||||
|
/**
|
||||||
|
* 获取上游模型列表
|
||||||
|
* @param providerId 提供商ID
|
||||||
|
* @param forceRefresh 是否强制刷新
|
||||||
|
* @returns 模型列表或 null(如果请求失败)
|
||||||
|
*/
|
||||||
|
async function fetchModels(
|
||||||
|
providerId: string,
|
||||||
|
forceRefresh = false
|
||||||
|
): Promise<FetchResult> {
|
||||||
|
// 检查缓存
|
||||||
|
if (!forceRefresh) {
|
||||||
|
const cached = cache.get(providerId)
|
||||||
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||||
|
return { models: cached.models }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有进行中的请求(非强制刷新时复用)
|
||||||
|
if (!forceRefresh && pendingRequests.has(providerId)) {
|
||||||
|
return pendingRequests.get(providerId)!
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新请求
|
||||||
|
const requestPromise = (async (): Promise<FetchResult> => {
|
||||||
|
try {
|
||||||
|
loadingMap.value.set(providerId, true)
|
||||||
|
const response = await adminApi.queryProviderModels(providerId)
|
||||||
|
|
||||||
|
if (response.success && response.data?.models) {
|
||||||
|
// 存入缓存
|
||||||
|
cache.set(providerId, {
|
||||||
|
models: response.data.models,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
return { models: response.data.models }
|
||||||
|
} else {
|
||||||
|
return { models: [], error: response.data?.error || '获取上游模型失败' }
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
return { models: [], error: err.response?.data?.detail || '获取上游模型失败' }
|
||||||
|
} finally {
|
||||||
|
loadingMap.value.set(providerId, false)
|
||||||
|
pendingRequests.delete(providerId)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
pendingRequests.set(providerId, requestPromise)
|
||||||
|
return requestPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缓存的模型(不发起请求)
|
||||||
|
*/
|
||||||
|
function getCachedModels(providerId: string): UpstreamModel[] | null {
|
||||||
|
const cached = cache.get(providerId)
|
||||||
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||||
|
return cached.models
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除指定提供商的缓存
|
||||||
|
*/
|
||||||
|
function clearCache(providerId: string) {
|
||||||
|
cache.delete(providerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否正在加载
|
||||||
|
*/
|
||||||
|
function isLoading(providerId: string): boolean {
|
||||||
|
return loadingMap.value.get(providerId) || false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fetchModels,
|
||||||
|
getCachedModels,
|
||||||
|
clearCache,
|
||||||
|
isLoading,
|
||||||
|
loadingMap
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -151,29 +151,46 @@ async def query_available_models(
|
|||||||
adapter_class = _get_adapter_for_format(api_format)
|
adapter_class = _get_adapter_for_format(api_format)
|
||||||
if not adapter_class:
|
if not adapter_class:
|
||||||
return [], f"Unknown API format: {api_format}"
|
return [], f"Unknown API format: {api_format}"
|
||||||
return await adapter_class.fetch_models(
|
models, error = await adapter_class.fetch_models(
|
||||||
client, base_url, api_key_value, extra_headers
|
client, base_url, api_key_value, extra_headers
|
||||||
)
|
)
|
||||||
|
# 确保所有模型都有 api_format 字段
|
||||||
|
for m in models:
|
||||||
|
if "api_format" not in m:
|
||||||
|
m["api_format"] = api_format
|
||||||
|
return models, error
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching models from {api_format} endpoint: {e}")
|
logger.error(f"Error fetching models from {api_format} endpoint: {e}")
|
||||||
return [], f"{api_format}: {str(e)}"
|
return [], f"{api_format}: {str(e)}"
|
||||||
|
|
||||||
|
# 限制并发请求数量,避免触发上游速率限制
|
||||||
|
MAX_CONCURRENT_REQUESTS = 5
|
||||||
|
semaphore = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS)
|
||||||
|
|
||||||
|
async def fetch_with_semaphore(
|
||||||
|
client: httpx.AsyncClient, config: dict
|
||||||
|
) -> tuple[list, Optional[str]]:
|
||||||
|
async with semaphore:
|
||||||
|
return await fetch_endpoint_models(client, config)
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
results = await asyncio.gather(
|
results = await asyncio.gather(
|
||||||
*[fetch_endpoint_models(client, c) for c in endpoint_configs]
|
*[fetch_with_semaphore(client, c) for c in endpoint_configs]
|
||||||
)
|
)
|
||||||
for models, error in results:
|
for models, error in results:
|
||||||
all_models.extend(models)
|
all_models.extend(models)
|
||||||
if error:
|
if error:
|
||||||
errors.append(error)
|
errors.append(error)
|
||||||
|
|
||||||
# 按 model id 去重(保留第一个)
|
# 按 model id + api_format 去重(保留第一个)
|
||||||
seen_ids: set[str] = set()
|
seen_keys: set[str] = set()
|
||||||
unique_models: list = []
|
unique_models: list = []
|
||||||
for model in all_models:
|
for model in all_models:
|
||||||
model_id = model.get("id")
|
model_id = model.get("id")
|
||||||
if model_id and model_id not in seen_ids:
|
api_format = model.get("api_format", "")
|
||||||
seen_ids.add(model_id)
|
unique_key = f"{model_id}:{api_format}"
|
||||||
|
if model_id and unique_key not in seen_keys:
|
||||||
|
seen_keys.add(unique_key)
|
||||||
unique_models.append(model)
|
unique_models.append(model)
|
||||||
|
|
||||||
error = "; ".join(errors) if errors else None
|
error = "; ".join(errors) if errors else None
|
||||||
|
|||||||
@@ -22,16 +22,18 @@ from src.models.api import (
|
|||||||
from src.models.pydantic_models import (
|
from src.models.pydantic_models import (
|
||||||
BatchAssignModelsToProviderRequest,
|
BatchAssignModelsToProviderRequest,
|
||||||
BatchAssignModelsToProviderResponse,
|
BatchAssignModelsToProviderResponse,
|
||||||
|
ImportFromUpstreamRequest,
|
||||||
|
ImportFromUpstreamResponse,
|
||||||
|
ImportFromUpstreamSuccessItem,
|
||||||
|
ImportFromUpstreamErrorItem,
|
||||||
|
ProviderAvailableSourceModel,
|
||||||
|
ProviderAvailableSourceModelsResponse,
|
||||||
)
|
)
|
||||||
from src.models.database import (
|
from src.models.database import (
|
||||||
GlobalModel,
|
GlobalModel,
|
||||||
Model,
|
Model,
|
||||||
Provider,
|
Provider,
|
||||||
)
|
)
|
||||||
from src.models.pydantic_models import (
|
|
||||||
ProviderAvailableSourceModel,
|
|
||||||
ProviderAvailableSourceModelsResponse,
|
|
||||||
)
|
|
||||||
from src.services.model.service import ModelService
|
from src.services.model.service import ModelService
|
||||||
|
|
||||||
router = APIRouter(tags=["Model Management"])
|
router = APIRouter(tags=["Model Management"])
|
||||||
@@ -158,6 +160,28 @@ async def batch_assign_global_models_to_provider(
|
|||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{provider_id}/import-from-upstream",
|
||||||
|
response_model=ImportFromUpstreamResponse,
|
||||||
|
)
|
||||||
|
async def import_models_from_upstream(
|
||||||
|
provider_id: str,
|
||||||
|
payload: ImportFromUpstreamRequest,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> ImportFromUpstreamResponse:
|
||||||
|
"""
|
||||||
|
从上游提供商导入模型
|
||||||
|
|
||||||
|
流程:
|
||||||
|
1. 根据 model_ids 检查全局模型是否存在(按 name 匹配)
|
||||||
|
2. 如不存在,自动创建新的 GlobalModel(使用默认配置)
|
||||||
|
3. 创建 Model 关联到当前 Provider
|
||||||
|
"""
|
||||||
|
adapter = AdminImportFromUpstreamAdapter(provider_id=provider_id, payload=payload)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
# -------- Adapters --------
|
# -------- Adapters --------
|
||||||
|
|
||||||
|
|
||||||
@@ -425,3 +449,130 @@ class AdminBatchAssignModelsToProviderAdapter(AdminApiAdapter):
|
|||||||
await invalidate_models_list_cache()
|
await invalidate_models_list_cache()
|
||||||
|
|
||||||
return BatchAssignModelsToProviderResponse(success=success, errors=errors)
|
return BatchAssignModelsToProviderResponse(success=success, errors=errors)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminImportFromUpstreamAdapter(AdminApiAdapter):
|
||||||
|
"""从上游提供商导入模型"""
|
||||||
|
|
||||||
|
provider_id: str
|
||||||
|
payload: ImportFromUpstreamRequest
|
||||||
|
|
||||||
|
async def handle(self, context): # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
provider = db.query(Provider).filter(Provider.id == self.provider_id).first()
|
||||||
|
if not provider:
|
||||||
|
raise NotFoundException("Provider not found", "provider")
|
||||||
|
|
||||||
|
success: list[ImportFromUpstreamSuccessItem] = []
|
||||||
|
errors: list[ImportFromUpstreamErrorItem] = []
|
||||||
|
|
||||||
|
# 默认阶梯计费配置(免费)
|
||||||
|
default_tiered_pricing = {
|
||||||
|
"tiers": [
|
||||||
|
{
|
||||||
|
"up_to": None,
|
||||||
|
"input_price_per_1m": 0.0,
|
||||||
|
"output_price_per_1m": 0.0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
for model_id in self.payload.model_ids:
|
||||||
|
# 输入验证:检查 model_id 长度
|
||||||
|
if not model_id or len(model_id) > 100:
|
||||||
|
errors.append(
|
||||||
|
ImportFromUpstreamErrorItem(
|
||||||
|
model_id=model_id[:50] + "..." if model_id and len(model_id) > 50 else model_id or "<empty>",
|
||||||
|
error="Invalid model_id: must be 1-100 characters",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 使用 savepoint 确保单个模型导入的原子性
|
||||||
|
savepoint = db.begin_nested()
|
||||||
|
try:
|
||||||
|
# 1. 检查是否已存在同名的 GlobalModel
|
||||||
|
global_model = (
|
||||||
|
db.query(GlobalModel).filter(GlobalModel.name == model_id).first()
|
||||||
|
)
|
||||||
|
created_global_model = False
|
||||||
|
|
||||||
|
if not global_model:
|
||||||
|
# 2. 创建新的 GlobalModel
|
||||||
|
global_model = GlobalModel(
|
||||||
|
name=model_id,
|
||||||
|
display_name=model_id,
|
||||||
|
default_tiered_pricing=default_tiered_pricing,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(global_model)
|
||||||
|
db.flush()
|
||||||
|
created_global_model = True
|
||||||
|
logger.info(
|
||||||
|
f"Created new GlobalModel: {model_id} during upstream import"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 检查是否已存在关联
|
||||||
|
existing = (
|
||||||
|
db.query(Model)
|
||||||
|
.filter(
|
||||||
|
Model.provider_id == self.provider_id,
|
||||||
|
Model.global_model_id == global_model.id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
# 已存在关联,提交 savepoint 并记录成功
|
||||||
|
savepoint.commit()
|
||||||
|
success.append(
|
||||||
|
ImportFromUpstreamSuccessItem(
|
||||||
|
model_id=model_id,
|
||||||
|
global_model_id=global_model.id,
|
||||||
|
global_model_name=global_model.name,
|
||||||
|
provider_model_id=existing.id,
|
||||||
|
created_global_model=created_global_model,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 4. 创建新的 Model 记录
|
||||||
|
new_model = Model(
|
||||||
|
provider_id=self.provider_id,
|
||||||
|
global_model_id=global_model.id,
|
||||||
|
provider_model_name=global_model.name,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(new_model)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# 提交 savepoint
|
||||||
|
savepoint.commit()
|
||||||
|
success.append(
|
||||||
|
ImportFromUpstreamSuccessItem(
|
||||||
|
model_id=model_id,
|
||||||
|
global_model_id=global_model.id,
|
||||||
|
global_model_name=global_model.name,
|
||||||
|
provider_model_id=new_model.id,
|
||||||
|
created_global_model=created_global_model,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# 回滚到 savepoint
|
||||||
|
savepoint.rollback()
|
||||||
|
raise e
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error importing model {model_id}: {e}")
|
||||||
|
errors.append(ImportFromUpstreamErrorItem(model_id=model_id, error=str(e)))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.info(
|
||||||
|
f"Imported {len(success)} models from upstream to provider {provider.name} by {context.user.username}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 清除 /v1/models 列表缓存
|
||||||
|
if success:
|
||||||
|
await invalidate_models_list_cache()
|
||||||
|
|
||||||
|
return ImportFromUpstreamResponse(success=success, errors=errors)
|
||||||
|
|||||||
@@ -301,6 +301,36 @@ class BatchAssignModelsToProviderResponse(BaseModel):
|
|||||||
errors: List[dict]
|
errors: List[dict]
|
||||||
|
|
||||||
|
|
||||||
|
class ImportFromUpstreamRequest(BaseModel):
|
||||||
|
"""从上游提供商导入模型请求"""
|
||||||
|
|
||||||
|
model_ids: List[str] = Field(..., min_length=1, description="上游模型 ID 列表")
|
||||||
|
|
||||||
|
|
||||||
|
class ImportFromUpstreamSuccessItem(BaseModel):
|
||||||
|
"""导入成功的模型信息"""
|
||||||
|
|
||||||
|
model_id: str = Field(..., description="上游模型 ID")
|
||||||
|
global_model_id: str = Field(..., description="GlobalModel ID")
|
||||||
|
global_model_name: str = Field(..., description="GlobalModel 名称")
|
||||||
|
provider_model_id: str = Field(..., description="Provider Model ID")
|
||||||
|
created_global_model: bool = Field(..., description="是否新创建了 GlobalModel")
|
||||||
|
|
||||||
|
|
||||||
|
class ImportFromUpstreamErrorItem(BaseModel):
|
||||||
|
"""导入失败的模型信息"""
|
||||||
|
|
||||||
|
model_id: str = Field(..., description="上游模型 ID")
|
||||||
|
error: str = Field(..., description="错误信息")
|
||||||
|
|
||||||
|
|
||||||
|
class ImportFromUpstreamResponse(BaseModel):
|
||||||
|
"""从上游提供商导入模型响应"""
|
||||||
|
|
||||||
|
success: List[ImportFromUpstreamSuccessItem]
|
||||||
|
errors: List[ImportFromUpstreamErrorItem]
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BatchAssignModelsToProviderRequest",
|
"BatchAssignModelsToProviderRequest",
|
||||||
"BatchAssignModelsToProviderResponse",
|
"BatchAssignModelsToProviderResponse",
|
||||||
@@ -311,6 +341,10 @@ __all__ = [
|
|||||||
"GlobalModelResponse",
|
"GlobalModelResponse",
|
||||||
"GlobalModelUpdate",
|
"GlobalModelUpdate",
|
||||||
"GlobalModelWithStats",
|
"GlobalModelWithStats",
|
||||||
|
"ImportFromUpstreamErrorItem",
|
||||||
|
"ImportFromUpstreamRequest",
|
||||||
|
"ImportFromUpstreamResponse",
|
||||||
|
"ImportFromUpstreamSuccessItem",
|
||||||
"ModelCapabilities",
|
"ModelCapabilities",
|
||||||
"ModelCatalogItem",
|
"ModelCatalogItem",
|
||||||
"ModelCatalogProviderDetail",
|
"ModelCatalogProviderDetail",
|
||||||
|
|||||||
Reference in New Issue
Block a user