feat: implement upstream model import and batch model assignment with UI components

This commit is contained in:
fawney19
2025-12-20 02:01:17 +08:00
parent df9f9a9f4f
commit e2e7996a54
9 changed files with 1609 additions and 913 deletions

View File

@@ -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
}

View File

@@ -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[]
}

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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",