Files
Aether/frontend/src/features/models/components/ModelMappingsTab.vue
T

591 lines
18 KiB
Vue
Raw Normal View History

2026-01-13 16:04:15 +08:00
<template>
<Card class="overflow-hidden">
<!-- 表头 -->
<div class="px-4 py-3 border-b border-border/60">
<div class="flex items-center justify-between">
<div class="flex items-baseline gap-2">
<h4 class="text-sm font-semibold">
映射规则
</h4>
2026-01-13 16:04:15 +08:00
<span class="text-xs text-muted-foreground">
支持正则表达式 ({{ localMappings.length }}/{{ MAX_MAPPINGS_PER_MODEL }})
2026-01-13 16:04:15 +08:00
</span>
</div>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="添加规则"
:disabled="localMappings.length >= MAX_MAPPINGS_PER_MODEL"
@click="addMapping"
>
<Plus class="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="刷新"
:disabled="props.loading"
@click="$emit('refresh')"
>
<RefreshCw
class="w-4 h-4"
:class="{ 'animate-spin': props.loading }"
/>
</Button>
</div>
2026-01-13 16:04:15 +08:00
</div>
</div>
<!-- 规则列表 -->
<div
v-if="localMappings.length > 0"
class="divide-y"
>
2026-01-13 16:04:15 +08:00
<div
v-for="(mapping, index) in localMappings"
2026-01-13 16:04:15 +08:00
:key="index"
>
<!-- 规则行 -->
<div
class="px-4 py-3 flex items-center gap-3 cursor-pointer hover:bg-muted/30 transition-colors"
@click="toggleExpand(index)"
>
<ChevronRight
class="w-4 h-4 text-muted-foreground transition-transform flex-shrink-0"
:class="{ 'rotate-90': expandedIndex === index }"
/>
<div class="flex-1 min-w-0">
<Input
v-model="localMappings[index]"
2026-01-13 16:04:15 +08:00
placeholder="例如: claude-haiku-.*"
:class="`font-mono text-sm ${normalizedMappings[index] && !mappingValidations[index].valid ? 'border-destructive' : ''}`"
2026-01-13 16:04:15 +08:00
@click.stop
@input="markDirty"
/>
<!-- 验证错误提示 -->
<div
v-if="normalizedMappings[index] && !mappingValidations[index].valid"
2026-01-13 16:04:15 +08:00
class="flex items-center gap-1 mt-1 text-xs text-destructive"
>
<AlertCircle class="w-3 h-3" />
<span>{{ mappingValidations[index].error }}</span>
2026-01-13 16:04:15 +08:00
</div>
</div>
<!-- 匹配统计 -->
<Badge
v-if="mappingValidations[index].valid && mappingMatchCounts[index] > 0"
2026-01-13 16:04:15 +08:00
variant="secondary"
class="text-xs flex-shrink-0 h-6 leading-none"
>
{{ mappingMatchCounts[index] }} 匹配
2026-01-13 16:04:15 +08:00
</Badge>
<Badge
v-else-if="normalizedMappings[index] && mappingValidations[index].valid"
2026-01-13 16:04:15 +08:00
variant="outline"
class="text-xs text-muted-foreground flex-shrink-0 h-6 leading-none"
>
无匹配
</Badge>
<!-- 操作按钮 -->
<div class="flex items-center gap-1 flex-shrink-0">
<Button
v-if="isDirty"
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground hover:text-primary"
title="保存"
:disabled="saving || hasValidationErrors"
@click.stop="saveMappings"
2026-01-13 16:04:15 +08:00
>
<Save
v-if="!saving"
class="w-4 h-4"
/>
<RefreshCw
v-else
class="w-4 h-4 animate-spin"
/>
2026-01-13 16:04:15 +08:00
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground hover:text-destructive"
title="删除"
:disabled="saving"
@click.stop="removeMapping(index)"
2026-01-13 16:04:15 +08:00
>
<Trash2 class="w-4 h-4" />
</Button>
</div>
</div>
<!-- 展开内容:匹配的 Key 列表(按提供商分组) -->
2026-01-13 16:04:15 +08:00
<div
v-if="expandedIndex === index"
class="border-t bg-muted/10 px-4 py-3"
>
<div
v-if="loadingPreview"
class="flex items-center justify-center py-4"
>
2026-01-13 16:04:15 +08:00
<RefreshCw class="w-4 h-4 animate-spin text-muted-foreground" />
</div>
<div
v-else-if="expandedGroups.length === 0"
class="text-center py-4"
>
2026-01-13 16:04:15 +08:00
<p class="text-sm text-muted-foreground">
{{ normalizedMappings[index] ? '此规则暂无匹配的 Key 白名单' : '请输入映射规则' }}
2026-01-13 16:04:15 +08:00
</p>
</div>
<div
v-else
class="space-y-3"
>
<!-- 按提供商分组 -->
2026-01-13 16:04:15 +08:00
<div
v-for="group in expandedGroups"
:key="group.providerId"
class="bg-background rounded-md border overflow-hidden"
2026-01-13 16:04:15 +08:00
>
<!-- 提供商标题 -->
<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>
2026-01-13 16:04:15 +08:00
<Badge
v-if="group.isLinked"
2026-01-13 16:04:15 +08:00
variant="secondary"
class="text-xs"
2026-01-13 16:04:15 +08:00
>
已关联
2026-01-13 16:04:15 +08:00
</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>
2026-01-13 16:04:15 +08:00
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div
v-else
class="text-center py-32"
2026-01-13 16:04:15 +08:00
>
<GitMerge class="w-10 h-10 mx-auto text-muted-foreground/30 mb-3" />
<p class="text-sm text-muted-foreground">
暂无映射规则
2026-01-13 16:04:15 +08:00
</p>
</div>
</Card>
</template>
<script setup lang="ts">
import { ref, watch, onUnmounted, computed } from 'vue'
2026-01-13 16:04:15 +08:00
import { Card, Button, Input, Badge } from '@/components/ui'
import { Plus, Trash2, GitMerge, RefreshCw, ChevronRight, Save, AlertCircle, Link } from 'lucide-vue-next'
2026-01-13 16:04:15 +08:00
import { updateGlobalModel, getGlobalModel, getGlobalModelRoutingPreview } from '@/api/global-models'
import type { ModelRoutingPreviewResponse } from '@/api/endpoints/types'
import { log } from '@/utils/logger'
import { useToast } from '@/composables/useToast'
import {
MAX_MAPPINGS_PER_MODEL,
MAX_MODEL_NAME_LENGTH,
createLRURegexCache,
getCompiledModelMappingRegex,
validateModelMappingPattern,
type ValidationResult,
} from '@/features/models/utils/model-mapping-regex'
2026-01-13 16:04:15 +08:00
const props = defineProps<{
globalModelId: string
modelName: string
mappings: string[]
loading?: boolean
routingData?: ModelRoutingPreviewResponse | null // 外部传入的 routing 数据
loadingPreview?: boolean // 外部传入的加载状态
2026-01-13 16:04:15 +08:00
}>()
const emit = defineEmits<{
update: [mappings: string[]]
refresh: []
linkProvider: [providerId: string]
linkProviders: [providerIds: string[]] // 批量关联
2026-01-13 16:04:15 +08:00
}>()
const { success: toastSuccess, error: toastError } = useToast()
// 本地状态
const localMappings = ref<string[]>([...props.mappings])
const originalMappings = ref<string[]>([...props.mappings]) // 用于保存失败时恢复
2026-01-13 16:04:15 +08:00
const isDirty = ref(false)
const saving = ref(false)
const expandedIndex = ref<number | null>(null)
// 统一以 trim 后的规则做预览/校验(保存时也会 trim),避免前后端行为不一致
const normalizedMappings = computed(() => localMappings.value.map(m => m.trim()))
const mappingValidations = computed<ValidationResult[]>(() => {
return normalizedMappings.value.map(pattern => {
if (!pattern) return { valid: true }
return validateModelMappingPattern(pattern)
})
})
// 匹配预览状态 - 优先使用外部传入的数据
const internalLoadingPreview = ref(false)
const internalRoutingData = ref<ModelRoutingPreviewResponse | null>(null)
// 计算属性:优先使用外部传入的数据
const loadingPreview = computed(() => props.loadingPreview ?? internalLoadingPreview.value)
const routingData = computed(() => props.routingData ?? internalRoutingData.value)
2026-01-13 16:04:15 +08:00
const REGEX_CACHE_MAX_SIZE = 100
const regexCache = createLRURegexCache(REGEX_CACHE_MAX_SIZE)
const matchCountCache = new Map<string, number>()
2026-01-13 16:04:15 +08:00
interface MatchedKeyForMapping {
2026-01-13 16:04:15 +08:00
keyId: string
keyName: string
maskedKey: string
providerName: string
providerId: string
2026-01-13 16:04:15 +08:00
matchedModels: string[]
}
interface ProviderGroup {
providerId: string
providerName: string
keys: MatchedKeyForMapping[]
isLinked: boolean // 是否已关联到当前模型
}
2026-01-13 16:04:15 +08:00
/**
* 检查是否有验证错误
*/
const hasValidationErrors = computed(() => {
return mappingValidations.value.some((result, index) => {
return normalizedMappings.value[index] !== '' && !result.valid
2026-01-13 16:04:15 +08:00
})
})
function computeMatchCount(pattern: string): number {
if (!routingData.value) return 0
const cached = matchCountCache.get(pattern)
if (cached !== undefined) {
return cached
2026-01-13 16:04:15 +08:00
}
const regex = getCompiledModelMappingRegex(pattern, regexCache)
if (!regex) {
matchCountCache.set(pattern, 0)
return 0
2026-01-13 16:04:15 +08:00
}
const keyToMatchedModels = new Map<string, Set<string>>()
for (const keyItem of routingData.value.all_keys_whitelist || []) {
if (!keyItem.allowed_models || keyItem.allowed_models.length === 0) continue
for (const allowedModel of keyItem.allowed_models) {
if (allowedModel.length > MAX_MODEL_NAME_LENGTH) continue
if (!regex.test(allowedModel)) continue
let modelSet = keyToMatchedModels.get(keyItem.key_id)
if (!modelSet) {
modelSet = new Set()
keyToMatchedModels.set(keyItem.key_id, modelSet)
}
modelSet.add(allowedModel)
2026-01-13 16:04:15 +08:00
}
}
let total = 0
for (const models of keyToMatchedModels.values()) {
total += models.size
2026-01-13 16:04:15 +08:00
}
matchCountCache.set(pattern, total)
return total
2026-01-13 16:04:15 +08:00
}
const mappingMatchCounts = computed(() => {
if (!routingData.value) {
return normalizedMappings.value.map(() => 0)
}
return normalizedMappings.value.map((pattern, index) => {
if (!pattern) return 0
if (!mappingValidations.value[index]?.valid) return 0
return computeMatchCount(pattern)
})
})
// 获取指定映射匹配的 Key 列表(使用全局 Key 白名单数据做实时匹配)
function getMatchedKeysForMapping(mapping: string): MatchedKeyForMapping[] {
if (!routingData.value) return []
const pattern = mapping.trim()
if (!pattern) return []
const regex = getCompiledModelMappingRegex(pattern, regexCache)
if (!regex) return []
2026-01-13 16:04:15 +08:00
const keyMap = new Map<string, MatchedKeyForMapping>()
2026-01-13 16:04:15 +08:00
// 使用 all_keys_whitelist 进行实时匹配(包含所有 Provider 的 Key)
for (const keyItem of routingData.value.all_keys_whitelist || []) {
if (!keyItem.allowed_models || keyItem.allowed_models.length === 0) continue
2026-01-13 16:04:15 +08:00
const matchedModels: string[] = []
for (const allowedModel of keyItem.allowed_models) {
if (allowedModel.length > MAX_MODEL_NAME_LENGTH) continue
if (regex.test(allowedModel)) matchedModels.push(allowedModel)
}
2026-01-13 16:04:15 +08:00
if (matchedModels.length > 0) {
const existing = keyMap.get(keyItem.key_id)
if (existing) {
const mergedModels = new Set([...existing.matchedModels, ...matchedModels])
existing.matchedModels = Array.from(mergedModels)
} else {
keyMap.set(keyItem.key_id, {
keyId: keyItem.key_id,
keyName: keyItem.key_name,
maskedKey: keyItem.masked_key,
providerName: keyItem.provider_name,
providerId: keyItem.provider_id,
matchedModels,
})
2026-01-13 16:04:15 +08:00
}
}
}
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())
}
2026-01-13 16:04:15 +08:00
function toggleExpand(index: number) {
expandedIndex.value = expandedIndex.value === index ? null : index
}
const expandedGroups = computed<ProviderGroup[]>(() => {
if (expandedIndex.value === null) return []
const pattern = normalizedMappings.value[expandedIndex.value] || ''
if (!pattern) return []
const validation = mappingValidations.value[expandedIndex.value]
if (validation && !validation.valid) return []
return getMatchedKeysGroupedByProvider(pattern)
})
watch(() => props.mappings, (newAliases) => {
localMappings.value = [...newAliases]
originalMappings.value = [...newAliases]
2026-01-13 16:04:15 +08:00
isDirty.value = false
}, { deep: true })
// globalModelId 变化时清空缓存并重新加载预览
watch(() => props.globalModelId, () => {
regexCache.clear()
loadMatchPreview()
})
function markDirty() {
isDirty.value = true
}
function addMapping() {
if (localMappings.value.length >= MAX_MAPPINGS_PER_MODEL) {
toastError(`最多支持 ${MAX_MAPPINGS_PER_MODEL} 条映射规则`)
2026-01-13 16:04:15 +08:00
return
}
localMappings.value.push('')
2026-01-13 16:04:15 +08:00
isDirty.value = true
expandedIndex.value = localMappings.value.length - 1
2026-01-13 16:04:15 +08:00
}
async function removeMapping(index: number) {
localMappings.value.splice(index, 1)
2026-01-13 16:04:15 +08:00
if (expandedIndex.value === index) {
expandedIndex.value = null
} else if (expandedIndex.value !== null && expandedIndex.value > index) {
expandedIndex.value--
}
// 删除后自动保存(仅在当前无校验错误时)
if (hasValidationErrors.value) {
toastError('存在无效映射规则,请修正后再保存')
isDirty.value = true
return
}
await saveMappings()
2026-01-13 16:04:15 +08:00
}
async function saveMappings() {
if (hasValidationErrors.value) {
toastError('存在无效映射规则,无法保存')
return
}
const cleanedMappings = localMappings.value
2026-01-13 16:04:15 +08:00
.map(a => a.trim())
.filter(a => a.length > 0)
saving.value = true
try {
const currentModel = await getGlobalModel(props.globalModelId)
const currentConfig = currentModel.config || {}
const updatedConfig = {
...currentConfig,
model_mappings: cleanedMappings.length > 0 ? cleanedMappings : undefined,
2026-01-13 16:04:15 +08:00
}
if (!updatedConfig.model_mappings || updatedConfig.model_mappings.length === 0) {
delete updatedConfig.model_mappings
2026-01-13 16:04:15 +08:00
}
await updateGlobalModel(props.globalModelId, {
config: updatedConfig,
})
localMappings.value = cleanedMappings
originalMappings.value = [...cleanedMappings] // 更新原始值
2026-01-13 16:04:15 +08:00
isDirty.value = false
// 收集所有未关联的提供商 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} 个提供商...`)
emit('linkProviders', unlinkedProviderIds)
} else {
toastSuccess('映射规则已保存')
}
// 保存成功后刷新数据
emit('update', cleanedMappings)
emit('refresh')
2026-01-13 16:04:15 +08:00
} catch (err) {
log.error('保存映射规则失败:', err)
2026-01-13 16:04:15 +08:00
toastError('保存失败,请重试')
// 保存失败时恢复到原始值
localMappings.value = [...originalMappings.value]
2026-01-13 16:04:15 +08:00
isDirty.value = false
} finally {
saving.value = false
}
}
// 刷新数据(通知父组件刷新,或在无外部数据时自行加载)
2026-01-13 16:04:15 +08:00
async function loadMatchPreview() {
// 如果有外部传入的数据,通知父组件刷新
if (props.routingData !== undefined) {
emit('refresh')
return
}
2026-01-13 16:04:15 +08:00
// 清空正则缓存,确保使用最新数据
regexCache.clear()
matchCountCache.clear()
internalLoadingPreview.value = true
2026-01-13 16:04:15 +08:00
try {
internalRoutingData.value = await getGlobalModelRoutingPreview(props.globalModelId)
2026-01-13 16:04:15 +08:00
} catch (err) {
log.error('加载匹配预览失败:', err)
} finally {
internalLoadingPreview.value = false
2026-01-13 16:04:15 +08:00
}
}
// 监听外部 routingData 变化,清空缓存
watch(() => props.routingData, () => {
regexCache.clear()
matchCountCache.clear()
2026-01-13 16:04:15 +08:00
})
// 组件卸载时清理缓存,防止内存泄漏
onUnmounted(() => {
regexCache.clear()
matchCountCache.clear()
2026-01-13 16:04:15 +08:00
})
// 暴露刷新方法给父组件
defineExpose({
refresh: loadMatchPreview
})
2026-01-13 16:04:15 +08:00
</script>