Files
Aether/frontend/src/features/providers/components/provider-tabs/ModelAliasesTab.vue

353 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<Card class="overflow-hidden">
<!-- 标题头部 -->
<div class="p-4 border-b border-border/60">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold flex items-center gap-2">
模型名称映射
</h3>
<Button
variant="outline"
size="sm"
class="h-8"
@click="openAddDialog"
>
<Plus class="w-3.5 h-3.5 mr-1.5" />
添加映射
</Button>
</div>
</div>
<!-- 加载状态 -->
<div
v-if="loading"
class="flex items-center justify-center py-12"
>
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
<!-- 分组映射列表 -->
<div
v-else-if="aliasGroups.length > 0"
class="divide-y divide-border/40"
>
<div
v-for="group in aliasGroups"
:key="`${group.model.id}-${group.apiFormatsKey}`"
class="transition-colors"
>
<!-- 分组头部可点击展开 -->
<div
class="flex items-center justify-between px-4 py-3 hover:bg-muted/20 cursor-pointer"
@click="toggleAliasGroupExpand(`${group.model.id}-${group.apiFormatsKey}`)"
>
<div class="flex items-center gap-2 flex-1 min-w-0">
<!-- 展开/收起图标 -->
<ChevronRight
class="w-4 h-4 text-muted-foreground shrink-0 transition-transform"
:class="{ 'rotate-90': expandedAliasGroups.has(`${group.model.id}-${group.apiFormatsKey}`) }"
/>
<!-- 模型名称 -->
<span class="font-semibold text-sm truncate">
{{ group.model.global_model_display_name || group.model.provider_model_name }}
</span>
<!-- 作用域标签 -->
<div class="flex items-center gap-1 shrink-0">
<Badge
v-if="group.apiFormats.length === 0"
variant="outline"
class="text-xs"
>
全部
</Badge>
<Badge
v-for="format in group.apiFormats"
v-else
:key="format"
variant="outline"
class="text-xs"
>
{{ API_FORMAT_LABELS[format] || format }}
</Badge>
</div>
<!-- 映射数量 -->
<span class="text-xs text-muted-foreground shrink-0">
({{ group.aliases.length }} 个映射)
</span>
</div>
<!-- 操作按钮 -->
<div
class="flex items-center gap-1.5 ml-4 shrink-0"
@click.stop
>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="编辑映射组"
@click="editGroup(group)"
>
<Edit class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-destructive hover:text-destructive"
title="删除映射组"
@click="deleteGroup(group)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</div>
<!-- 展开的映射列表 -->
<div
v-show="expandedAliasGroups.has(`${group.model.id}-${group.apiFormatsKey}`)"
class="bg-muted/30 border-t border-border/30"
>
<div class="px-4 py-2 space-y-1">
<div
v-for="mapping in group.aliases"
:key="mapping.name"
class="flex items-center gap-2 py-1"
>
<!-- 优先级标签 -->
<span class="inline-flex items-center justify-center w-5 h-5 rounded bg-background border text-xs font-medium shrink-0">
{{ mapping.priority }}
</span>
<!-- 映射名称 -->
<span class="font-mono text-sm truncate">
{{ mapping.name }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div
v-else
class="p-8 text-center text-muted-foreground"
>
<Tag class="w-12 h-12 mx-auto mb-3 opacity-50" />
<p class="text-sm">
暂无模型映射
</p>
<p class="text-xs mt-1">
点击上方"添加映射"按钮为模型创建名称映射
</p>
</div>
</Card>
<!-- 添加/编辑映射对话框 -->
<ModelMappingDialog
v-model:open="dialogOpen"
:provider-id="provider.id"
:provider-api-formats="providerApiFormats"
:models="models"
:editing-group="editingGroup"
@saved="onDialogSaved"
/>
<!-- 删除确认对话框 -->
<AlertDialog
v-model="deleteConfirmOpen"
title="删除映射组"
:description="deleteConfirmDescription"
confirm-text="删除"
cancel-text="取消"
type="danger"
@confirm="confirmDelete"
@cancel="deleteConfirmOpen = false"
/>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { Tag, Plus, Edit, Trash2, ChevronRight } from 'lucide-vue-next'
import { Card, Button, Badge } from '@/components/ui'
import AlertDialog from '@/components/common/AlertDialog.vue'
import ModelMappingDialog, { type AliasGroup } from '../ModelMappingDialog.vue'
import { useToast } from '@/composables/useToast'
import {
getProviderModels,
API_FORMAT_LABELS,
type Model,
type ProviderModelAlias
} from '@/api/endpoints'
import { updateModel } from '@/api/endpoints/models'
const props = defineProps<{
provider: any
}>()
const emit = defineEmits<{
'refresh': []
}>()
const { error: showError, success: showSuccess } = useToast()
// 状态
const loading = ref(false)
const models = ref<Model[]>([])
const dialogOpen = ref(false)
const deleteConfirmOpen = ref(false)
const editingGroup = ref<AliasGroup | null>(null)
const deletingGroup = ref<AliasGroup | null>(null)
// 列表展开状态
const expandedAliasGroups = ref<Set<string>>(new Set())
// 获取 Provider 支持的 API 格式
const providerApiFormats = computed(() => {
const formats = props.provider?.api_formats
if (Array.isArray(formats) && formats.length > 0) {
const order = Object.keys(API_FORMAT_LABELS)
return [...formats].sort((a, b) => order.indexOf(a) - order.indexOf(b))
}
return []
})
// 生成作用域唯一键
function getApiFormatsKey(formats: string[] | undefined): string {
if (!formats || formats.length === 0) return ''
return [...formats].sort().join(',')
}
// 按"模型+作用域"分组的映射列表
const aliasGroups = computed<AliasGroup[]>(() => {
const groups: AliasGroup[] = []
const groupMap = new Map<string, AliasGroup>()
for (const model of models.value) {
if (!model.provider_model_mappings || !Array.isArray(model.provider_model_mappings)) continue
for (const alias of model.provider_model_mappings) {
const apiFormatsKey = getApiFormatsKey(alias.api_formats)
const groupKey = `${model.id}|${apiFormatsKey}`
if (!groupMap.has(groupKey)) {
const group: AliasGroup = {
model,
apiFormatsKey,
apiFormats: alias.api_formats || [],
aliases: []
}
groupMap.set(groupKey, group)
groups.push(group)
}
groupMap.get(groupKey)!.aliases.push(alias)
}
}
for (const group of groups) {
group.aliases.sort((a, b) => a.priority - b.priority)
}
return groups.sort((a, b) => {
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()
if (nameA !== nameB) return nameA.localeCompare(nameB)
return a.apiFormatsKey.localeCompare(b.apiFormatsKey)
})
})
// 加载模型
async function loadModels() {
try {
loading.value = true
models.value = await getProviderModels(props.provider.id)
} catch (err: any) {
showError(err.response?.data?.detail || '加载失败', '错误')
} finally {
loading.value = false
}
}
// 删除确认描述
const deleteConfirmDescription = computed(() => {
if (!deletingGroup.value) return ''
const { model, aliases, apiFormats } = deletingGroup.value
const modelName = model.global_model_display_name || model.provider_model_name
const scopeText = apiFormats.length === 0 ? '全部' : apiFormats.map(f => API_FORMAT_LABELS[f] || f).join(', ')
const aliasNames = aliases.map(a => a.name).join(', ')
return `确定要删除模型「${modelName}」在作用域「${scopeText}」下的 ${aliases.length} 个映射吗?\n\n映射名称${aliasNames}`
})
// 切换映射组展开状态
function toggleAliasGroupExpand(groupKey: string) {
if (expandedAliasGroups.value.has(groupKey)) {
expandedAliasGroups.value.delete(groupKey)
} else {
expandedAliasGroups.value.add(groupKey)
}
}
// 打开添加对话框
function openAddDialog() {
editingGroup.value = null
dialogOpen.value = true
}
// 编辑分组
function editGroup(group: AliasGroup) {
editingGroup.value = group
dialogOpen.value = true
}
// 删除分组
function deleteGroup(group: AliasGroup) {
deletingGroup.value = group
deleteConfirmOpen.value = true
}
// 确认删除
async function confirmDelete() {
if (!deletingGroup.value) return
const { model, aliases, apiFormatsKey } = deletingGroup.value
try {
const currentAliases = model.provider_model_mappings || []
const aliasNamesToRemove = new Set(aliases.map(a => a.name))
const newAliases = currentAliases.filter((a: ProviderModelAlias) => {
const currentKey = getApiFormatsKey(a.api_formats)
return !(currentKey === apiFormatsKey && aliasNamesToRemove.has(a.name))
})
await updateModel(props.provider.id, model.id, {
provider_model_mappings: newAliases.length > 0 ? newAliases : null
})
showSuccess('映射组已删除')
deleteConfirmOpen.value = false
deletingGroup.value = null
await loadModels()
emit('refresh')
} catch (err: any) {
showError(err.response?.data?.detail || '删除失败', '错误')
}
}
// 对话框保存后回调
async function onDialogSaved() {
await loadModels()
emit('refresh')
}
// 监听 provider 变化
watch(() => props.provider?.id, (newId) => {
if (newId) {
loadModels()
}
}, { immediate: true })
onMounted(() => {
if (props.provider?.id) {
loadModels()
}
})
</script>