mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat(models): 模型映射支持按提供商分组并自动关联
- 映射匹配结果按提供商分组显示,支持单个关联 - 保存映射规则时自动批量关联未关联的提供商 - 优化模型详情加载逻辑,并行获取最新数据 - 移除冗余的调度器日志输出
This commit is contained in:
@@ -437,10 +437,13 @@
|
||||
<div v-show="detailTab === 'mappings'">
|
||||
<ModelMappingsTab
|
||||
v-if="model"
|
||||
ref="modelMappingsTabRef"
|
||||
:global-model-id="model.id"
|
||||
:model-name="model.name"
|
||||
:mappings="model.config?.model_mappings || []"
|
||||
@update="handleMappingsUpdate"
|
||||
@link-provider="(providerId) => $emit('linkProvider', providerId)"
|
||||
@link-providers="(providerIds) => $emit('linkProviders', providerIds)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -500,6 +503,8 @@ const emit = defineEmits<{
|
||||
'deleteProvider': [provider: any]
|
||||
'toggleProviderStatus': [provider: any]
|
||||
'refreshModel': []
|
||||
'linkProvider': [providerId: string]
|
||||
'linkProviders': [providerIds: string[]]
|
||||
}>()
|
||||
const { success: showSuccess, error: showError } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
@@ -513,6 +518,8 @@ interface Props {
|
||||
|
||||
// RoutingTab 引用
|
||||
const routingTabRef = ref<InstanceType<typeof RoutingTab> | null>(null)
|
||||
// ModelMappingsTab 引用
|
||||
const modelMappingsTabRef = ref<InstanceType<typeof ModelMappingsTab> | null>(null)
|
||||
|
||||
// 将 RoutingProviderInfo 转换为父组件期望的格式
|
||||
function convertRoutingProviderToLegacyFormat(provider: RoutingProviderInfo) {
|
||||
@@ -542,6 +549,7 @@ function handleDeleteProviderFromRouting(provider: RoutingProviderInfo) {
|
||||
// 刷新路由数据
|
||||
function refreshRoutingData() {
|
||||
routingTabRef.value?.loadRoutingData?.()
|
||||
modelMappingsTabRef.value?.refresh?.()
|
||||
}
|
||||
|
||||
// 处理模型映射更新
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 展开内容:匹配的 Key 列表 -->
|
||||
<!-- 展开内容:匹配的 Key 列表(按提供商分组) -->
|
||||
<div
|
||||
v-if="expandedIndex === index"
|
||||
class="border-t bg-muted/10 px-4 py-3"
|
||||
@@ -117,34 +117,65 @@
|
||||
<RefreshCw class="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="getMatchedKeysForMapping(mapping).length === 0" class="text-center py-4">
|
||||
<div v-else-if="getMatchedKeysGroupedByProvider(mapping).length === 0" class="text-center py-4">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ mapping.trim() ? '此规则暂无匹配的 Key 白名单' : '请输入映射规则' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div v-else class="space-y-3">
|
||||
<!-- 按提供商分组 -->
|
||||
<div
|
||||
v-for="item in getMatchedKeysForMapping(mapping)"
|
||||
:key="item.keyId"
|
||||
class="bg-background rounded-md border p-3"
|
||||
v-for="group in getMatchedKeysGroupedByProvider(mapping)"
|
||||
:key="group.providerId"
|
||||
class="bg-background rounded-md border overflow-hidden"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 text-sm mb-2">
|
||||
<span class="text-muted-foreground">{{ item.providerName }}</span>
|
||||
<span class="text-muted-foreground">/</span>
|
||||
<span class="font-medium">{{ item.keyName }}</span>
|
||||
<span class="text-muted-foreground">·</span>
|
||||
<code class="text-xs text-muted-foreground/70">{{ item.maskedKey }}</code>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<!-- 提供商标题 -->
|
||||
<div class="px-3 py-2 bg-muted/30 border-b flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-sm font-medium">{{ group.providerName }}</span>
|
||||
<span class="text-xs text-muted-foreground ml-2">({{ group.keys.length }} Key)</span>
|
||||
</div>
|
||||
<Badge
|
||||
v-for="model in item.matchedModels"
|
||||
:key="model"
|
||||
v-if="group.isLinked"
|
||||
variant="secondary"
|
||||
class="text-xs font-mono"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ model }}
|
||||
已关联
|
||||
</Badge>
|
||||
<Button
|
||||
v-else
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="关联到当前模型"
|
||||
@click="$emit('linkProvider', group.providerId)"
|
||||
>
|
||||
<Link class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<!-- Key 列表 -->
|
||||
<div class="divide-y divide-border/50">
|
||||
<div
|
||||
v-for="keyItem in group.keys"
|
||||
:key="keyItem.keyId"
|
||||
class="px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 text-sm mb-1.5">
|
||||
<span class="font-medium">{{ keyItem.keyName }}</span>
|
||||
<code class="text-xs text-muted-foreground/70">{{ keyItem.maskedKey }}</code>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Badge
|
||||
v-for="model in keyItem.matchedModels"
|
||||
:key="model"
|
||||
variant="secondary"
|
||||
class="text-xs font-mono"
|
||||
>
|
||||
{{ model }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -168,7 +199,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { Card, Button, Input, Badge } from '@/components/ui'
|
||||
import { Plus, Trash2, GitMerge, RefreshCw, ChevronRight, Save, AlertCircle } from 'lucide-vue-next'
|
||||
import { Plus, Trash2, GitMerge, RefreshCw, ChevronRight, Save, AlertCircle, Link } from 'lucide-vue-next'
|
||||
import { updateGlobalModel, getGlobalModel, getGlobalModelRoutingPreview } from '@/api/global-models'
|
||||
import type { ModelRoutingPreviewResponse } from '@/api/endpoints/types'
|
||||
import { log } from '@/utils/logger'
|
||||
@@ -183,6 +214,8 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
update: [mappings: string[]]
|
||||
refresh: []
|
||||
linkProvider: [providerId: string]
|
||||
linkProviders: [providerIds: string[]] // 批量关联
|
||||
}>()
|
||||
// 安全限制常量(与后端保持一致)
|
||||
const MAX_MAPPINGS_PER_MODEL = 50
|
||||
@@ -279,9 +312,17 @@ interface MatchedKeyForMapping {
|
||||
keyName: string
|
||||
maskedKey: string
|
||||
providerName: string
|
||||
providerId: string
|
||||
matchedModels: string[]
|
||||
}
|
||||
|
||||
interface ProviderGroup {
|
||||
providerId: string
|
||||
providerName: string
|
||||
keys: MatchedKeyForMapping[]
|
||||
isLinked: boolean // 是否已关联到当前模型
|
||||
}
|
||||
|
||||
interface ValidationResult {
|
||||
valid: boolean
|
||||
error?: string
|
||||
@@ -410,6 +451,7 @@ function getMatchedKeysForMapping(mapping: string): MatchedKeyForMapping[] {
|
||||
keyName: keyItem.key_name,
|
||||
maskedKey: keyItem.masked_key,
|
||||
providerName: keyItem.provider_name,
|
||||
providerId: keyItem.provider_id,
|
||||
matchedModels,
|
||||
})
|
||||
}
|
||||
@@ -419,6 +461,33 @@ function getMatchedKeysForMapping(mapping: string): MatchedKeyForMapping[] {
|
||||
return Array.from(keyMap.values())
|
||||
}
|
||||
|
||||
// 按提供商分组匹配的 Key
|
||||
function getMatchedKeysGroupedByProvider(mapping: string): ProviderGroup[] {
|
||||
const keys = getMatchedKeysForMapping(mapping)
|
||||
const providerMap = new Map<string, ProviderGroup>()
|
||||
|
||||
// 获取已关联的提供商 ID 集合
|
||||
const linkedProviderIds = new Set(
|
||||
(routingData.value?.providers || []).map(p => p.id)
|
||||
)
|
||||
|
||||
for (const key of keys) {
|
||||
const existing = providerMap.get(key.providerId)
|
||||
if (existing) {
|
||||
existing.keys.push(key)
|
||||
} else {
|
||||
providerMap.set(key.providerId, {
|
||||
providerId: key.providerId,
|
||||
providerName: key.providerName,
|
||||
keys: [key],
|
||||
isLinked: linkedProviderIds.has(key.providerId),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(providerMap.values())
|
||||
}
|
||||
|
||||
// 获取指定映射的匹配数量
|
||||
function getMatchCount(mapping: string): number {
|
||||
return getMatchedKeysForMapping(mapping).reduce((sum, item) => sum + item.matchedModels.length, 0)
|
||||
@@ -492,8 +561,26 @@ async function saveMappings() {
|
||||
originalMappings.value = [...cleanedMappings] // 更新原始值
|
||||
isDirty.value = false
|
||||
|
||||
toastSuccess('映射规则已保存')
|
||||
emit('update', cleanedMappings)
|
||||
// 收集所有未关联的提供商 ID
|
||||
const unlinkedProviderIds: string[] = []
|
||||
for (const mapping of cleanedMappings) {
|
||||
const groups = getMatchedKeysGroupedByProvider(mapping)
|
||||
for (const group of groups) {
|
||||
if (!group.isLinked && !unlinkedProviderIds.includes(group.providerId)) {
|
||||
unlinkedProviderIds.push(group.providerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 自动关联未关联的提供商
|
||||
if (unlinkedProviderIds.length > 0) {
|
||||
toastSuccess(`映射规则已保存,正在关联 ${unlinkedProviderIds.length} 个提供商...`)
|
||||
// linkProviders 处理完成后会由父组件统一刷新数据,无需再 emit update
|
||||
emit('linkProviders', unlinkedProviderIds)
|
||||
} else {
|
||||
toastSuccess('映射规则已保存')
|
||||
emit('update', cleanedMappings)
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('保存映射规则失败:', err)
|
||||
toastError('保存失败,请重试')
|
||||
@@ -526,4 +613,9 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
regexCache.clear()
|
||||
})
|
||||
|
||||
// 暴露刷新方法给父组件
|
||||
defineExpose({
|
||||
refresh: loadMatchPreview
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -431,6 +431,8 @@
|
||||
@delete-provider="confirmDeleteProviderImplementation"
|
||||
@toggle-provider-status="toggleProviderStatus"
|
||||
@refresh-model="refreshSelectedModel"
|
||||
@link-provider="linkProviderToModel"
|
||||
@link-providers="linkProvidersToModel"
|
||||
/>
|
||||
|
||||
<!-- 批量添加关联提供商对话框 -->
|
||||
@@ -953,11 +955,23 @@ function handleRowClick(event: MouseEvent, model: GlobalModelResponse) {
|
||||
}
|
||||
|
||||
async function selectModel(model: GlobalModelResponse) {
|
||||
// 先显示缓存数据,提升响应速度
|
||||
selectedModel.value = model
|
||||
detailTab.value = 'basic'
|
||||
|
||||
// 加载该模型的关联提供商
|
||||
await loadModelProviders(model.id)
|
||||
// 并行加载最新模型数据和关联提供商
|
||||
const [latestModel] = await Promise.all([
|
||||
getGlobalModel(model.id).catch(err => {
|
||||
log.error('获取最新模型数据失败:', err)
|
||||
return null
|
||||
}),
|
||||
loadModelProviders(model.id)
|
||||
])
|
||||
|
||||
// 更新为最新数据(如果获取成功)
|
||||
if (latestModel) {
|
||||
selectedModel.value = latestModel
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新当前选中的模型数据
|
||||
@@ -1036,6 +1050,37 @@ function openAddProviderDialog() {
|
||||
})
|
||||
}
|
||||
|
||||
// 关联指定提供商到当前模型
|
||||
async function linkProviderToModel(providerId: string) {
|
||||
await linkProvidersToModel([providerId])
|
||||
}
|
||||
|
||||
// 批量关联提供商到当前模型
|
||||
async function linkProvidersToModel(providerIds: string[]) {
|
||||
if (!selectedModel.value || providerIds.length === 0) return
|
||||
|
||||
try {
|
||||
const result = await batchAssignToProviders(selectedModel.value.id, {
|
||||
provider_ids: providerIds,
|
||||
create_models: true
|
||||
})
|
||||
|
||||
// 显示关联结果
|
||||
if (result.errors.length > 0) {
|
||||
showError(`${result.errors.length} 个提供商关联失败`, '部分失败')
|
||||
} else {
|
||||
success(`${providerIds.length} 个提供商已关联`)
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
await loadModelProviders(selectedModel.value.id)
|
||||
await loadGlobalModels()
|
||||
modelDetailDrawerRef.value?.refreshRoutingData?.()
|
||||
} catch (err: any) {
|
||||
showError(parseApiError(err, '关联失败'), '错误')
|
||||
}
|
||||
}
|
||||
|
||||
// 处理批量添加 Provider 对话框关闭事件
|
||||
function handleBatchAddProvidersDialogUpdate(value: boolean) {
|
||||
// 只有在不处于提交状态时才允许关闭
|
||||
|
||||
Reference in New Issue
Block a user