mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
feat: 实现 GlobalModel 别名匹配系统
主要更改: - GlobalModel 支持 model_aliases 配置,允许使用正则表达式定义别名规则 - Provider Key 的 allowed_models 现在可以通过别名规则匹配 GlobalModel - 新增 ModelAliasesTab 组件用于管理模型别名配置 - Provider 详情页新增别名映射预览功能,展示 Key 白名单与 GlobalModel 别名的匹配关系 - 路由预览 API 返回 Key 的 allowed_models 信息 安全特性: - 使用 regex 库的原生超时保护(100ms)防止 ReDoS 攻击 - 别名规则数量限制(50 条/模型)和长度限制(200 字符) - 别名映射预览 API 添加超时保护和结果截断 其他改进: - GlobalModel 更新/删除时使用行级锁防止并发竞态 - 缓存失效逻辑优化,支持异步清理和正则缓存清空 - 路由 Tab 布局重构,使用 flexbox 替代绝对定位
This commit is contained in:
@@ -367,17 +367,106 @@
|
||||
@edit-model="handleEditModel"
|
||||
@delete-model="handleDeleteModel"
|
||||
@batch-assign="handleBatchAssign"
|
||||
@add-mapping="handleAddMapping"
|
||||
/>
|
||||
|
||||
<!-- 模型名称映射 -->
|
||||
<ModelAliasesTab
|
||||
v-if="provider"
|
||||
ref="modelAliasesTabRef"
|
||||
:key="`aliases-${provider.id}`"
|
||||
:provider="provider"
|
||||
@refresh="handleRelatedDataRefresh"
|
||||
/>
|
||||
<!-- 别名映射预览 -->
|
||||
<Card
|
||||
v-if="aliasMappingLoading || (aliasMappingPreview && aliasMappingPreview.total_matches > 0)"
|
||||
class="overflow-hidden"
|
||||
>
|
||||
<div class="px-4 py-3 border-b border-border/60">
|
||||
<h3 class="text-sm font-semibold">
|
||||
别名映射预览
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="aliasMappingLoading" class="flex items-center justify-center py-8">
|
||||
<RefreshCw class="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<!-- GlobalModel 列表 -->
|
||||
<div v-else class="divide-y divide-border/40">
|
||||
<div
|
||||
v-for="(gmInfo, gmIndex) in computedAliasMappingByModel"
|
||||
:key="gmInfo.global_model_id"
|
||||
>
|
||||
<!-- GlobalModel 行 -->
|
||||
<div
|
||||
class="px-4 py-3 flex items-center gap-3 cursor-pointer hover:bg-muted/30 transition-colors"
|
||||
@click="toggleAliasExpand(gmIndex)"
|
||||
>
|
||||
<ChevronRight
|
||||
class="w-4 h-4 text-muted-foreground transition-transform flex-shrink-0"
|
||||
:class="{ 'rotate-90': aliasExpandedIndex === gmIndex }"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium truncate">{{ gmInfo.display_name }}</span>
|
||||
<Badge
|
||||
v-if="!gmInfo.is_active"
|
||||
variant="outline"
|
||||
class="text-[10px] px-1.5 py-0 text-muted-foreground flex-shrink-0"
|
||||
>
|
||||
停用
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span class="font-mono">{{ gmInfo.global_model_name }}</span>
|
||||
<span class="text-muted-foreground/50">|</span>
|
||||
<span class="font-mono text-primary/80">{{ gmInfo.alias_patterns.join(' / ') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="text-xs flex-shrink-0"
|
||||
>
|
||||
{{ gmInfo.matched_keys.length }} Key · {{ gmInfo.total_models }} 模型
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<!-- 展开内容:匹配的 Key 列表 -->
|
||||
<div
|
||||
v-if="aliasExpandedIndex === gmIndex"
|
||||
class="border-t bg-muted/10 px-4 py-3"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="keyItem in gmInfo.matched_keys"
|
||||
:key="keyItem.key_id"
|
||||
class="bg-background rounded-md border p-3"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm mb-2">
|
||||
<Key class="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<span class="font-medium truncate">{{ keyItem.key_name || '未命名密钥' }}</span>
|
||||
<span class="text-xs text-muted-foreground font-mono ml-auto flex-shrink-0">
|
||||
{{ keyItem.masked_key }}
|
||||
</span>
|
||||
<Badge
|
||||
v-if="!keyItem.is_active"
|
||||
variant="secondary"
|
||||
class="text-[10px] px-1.5 py-0 flex-shrink-0"
|
||||
>
|
||||
禁用
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<Badge
|
||||
v-for="match in keyItem.matches"
|
||||
:key="match.allowed_model"
|
||||
variant="secondary"
|
||||
class="text-xs font-mono"
|
||||
:title="`匹配规则: ${match.alias_pattern}`"
|
||||
>
|
||||
{{ match.allowed_model }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
@@ -485,7 +574,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, nextTick } from 'vue'
|
||||
import {
|
||||
Server,
|
||||
Plus,
|
||||
Key,
|
||||
ChevronRight,
|
||||
@@ -493,13 +581,9 @@ import {
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
X,
|
||||
Loader2,
|
||||
Power,
|
||||
GripVertical,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ExternalLink,
|
||||
Shield
|
||||
} from 'lucide-vue-next'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
@@ -508,12 +592,11 @@ import Badge from '@/components/ui/badge.vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { getProvider, getProviderEndpoints } from '@/api/endpoints'
|
||||
import { getProvider, getProviderEndpoints, getProviderAliasMappingPreview, type ProviderAliasMappingPreviewResponse } from '@/api/endpoints'
|
||||
import {
|
||||
KeyFormDialog,
|
||||
KeyAllowedModelsEditDialog,
|
||||
ModelsTab,
|
||||
ModelAliasesTab,
|
||||
BatchAssignModelsDialog
|
||||
} from '@/features/providers/components'
|
||||
import EndpointFormDialog from '@/features/providers/components/EndpointFormDialog.vue'
|
||||
@@ -592,8 +675,83 @@ const deleteModelConfirmOpen = ref(false)
|
||||
const modelToDelete = ref<Model | null>(null)
|
||||
const batchAssignDialogOpen = ref(false)
|
||||
|
||||
// ModelAliasesTab 组件引用
|
||||
const modelAliasesTabRef = ref<InstanceType<typeof ModelAliasesTab> | null>(null)
|
||||
// 别名映射预览状态
|
||||
const aliasMappingPreview = ref<ProviderAliasMappingPreviewResponse | null>(null)
|
||||
const aliasMappingLoading = ref(false)
|
||||
const aliasExpandedIndex = ref<number | null>(null)
|
||||
|
||||
// 切换别名展开
|
||||
function toggleAliasExpand(index: number) {
|
||||
aliasExpandedIndex.value = aliasExpandedIndex.value === index ? null : index
|
||||
}
|
||||
|
||||
// 按 GlobalModel 分组的别名映射数据
|
||||
interface MatchedKeyItem {
|
||||
key_id: string
|
||||
key_name: string
|
||||
masked_key: string
|
||||
is_active: boolean
|
||||
matches: { allowed_model: string; alias_pattern: string }[]
|
||||
}
|
||||
|
||||
interface GlobalModelAliasInfo {
|
||||
global_model_id: string
|
||||
global_model_name: string
|
||||
display_name: string
|
||||
is_active: boolean
|
||||
alias_patterns: string[]
|
||||
matched_keys: MatchedKeyItem[]
|
||||
total_models: number
|
||||
}
|
||||
|
||||
const computedAliasMappingByModel = computed<GlobalModelAliasInfo[]>(() => {
|
||||
if (!aliasMappingPreview.value) return []
|
||||
|
||||
// 按 GlobalModel 分组
|
||||
const modelMap = new Map<string, GlobalModelAliasInfo>()
|
||||
|
||||
for (const keyInfo of aliasMappingPreview.value.keys) {
|
||||
for (const gm of keyInfo.matching_global_models) {
|
||||
if (!modelMap.has(gm.global_model_id)) {
|
||||
// 收集所有匹配用到的别名规则(去重)
|
||||
const patterns = new Set<string>()
|
||||
for (const match of gm.matched_models) {
|
||||
patterns.add(match.alias_pattern)
|
||||
}
|
||||
|
||||
modelMap.set(gm.global_model_id, {
|
||||
global_model_id: gm.global_model_id,
|
||||
global_model_name: gm.global_model_name,
|
||||
display_name: gm.display_name,
|
||||
is_active: gm.is_active,
|
||||
alias_patterns: Array.from(patterns),
|
||||
matched_keys: [],
|
||||
total_models: 0,
|
||||
})
|
||||
}
|
||||
|
||||
const modelInfo = modelMap.get(gm.global_model_id)!
|
||||
|
||||
// 更新别名规则集合(可能来自不同 Key 的匹配)
|
||||
for (const match of gm.matched_models) {
|
||||
if (!modelInfo.alias_patterns.includes(match.alias_pattern)) {
|
||||
modelInfo.alias_patterns.push(match.alias_pattern)
|
||||
}
|
||||
}
|
||||
|
||||
modelInfo.matched_keys.push({
|
||||
key_id: keyInfo.key_id,
|
||||
key_name: keyInfo.key_name,
|
||||
masked_key: keyInfo.masked_key,
|
||||
is_active: keyInfo.is_active,
|
||||
matches: gm.matched_models,
|
||||
})
|
||||
modelInfo.total_models += gm.matched_models.length
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(modelMap.values())
|
||||
})
|
||||
|
||||
// 拖动排序相关状态(旧的端点级别拖拽,保留以兼容)
|
||||
const dragState = ref({
|
||||
@@ -625,9 +783,7 @@ const hasBlockingDialogOpen = computed(() =>
|
||||
deleteKeyConfirmOpen.value ||
|
||||
modelFormDialogOpen.value ||
|
||||
deleteModelConfirmOpen.value ||
|
||||
batchAssignDialogOpen.value ||
|
||||
// 检测 ModelAliasesTab 子组件的 Dialog 是否打开
|
||||
modelAliasesTabRef.value?.dialogOpen
|
||||
batchAssignDialogOpen.value
|
||||
)
|
||||
|
||||
// 所有密钥的扁平列表(带端点信息)
|
||||
@@ -665,6 +821,7 @@ watch(() => props.providerId, (newId) => {
|
||||
if (newId && props.open) {
|
||||
loadProvider()
|
||||
loadEndpoints()
|
||||
loadAliasMappingPreview()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
@@ -673,6 +830,7 @@ watch(() => props.open, (newOpen) => {
|
||||
if (newOpen && props.providerId) {
|
||||
loadProvider()
|
||||
loadEndpoints()
|
||||
loadAliasMappingPreview()
|
||||
} else if (!newOpen) {
|
||||
// 重置所有状态
|
||||
provider.value = null
|
||||
@@ -696,6 +854,10 @@ watch(() => props.open, (newOpen) => {
|
||||
|
||||
// 清除已显示的密钥(安全考虑)
|
||||
revealedKeys.value.clear()
|
||||
|
||||
// 重置别名映射预览
|
||||
aliasMappingPreview.value = null
|
||||
aliasExpandedIndex.value = null
|
||||
}
|
||||
})
|
||||
|
||||
@@ -722,11 +884,6 @@ function toggleEndpoint(endpointId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRelatedDataRefresh() {
|
||||
await loadProvider()
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
// 显示端点管理对话框
|
||||
function showAddEndpointDialog() {
|
||||
endpointDialogOpen.value = true
|
||||
@@ -962,11 +1119,6 @@ function handleBatchAssign() {
|
||||
batchAssignDialogOpen.value = true
|
||||
}
|
||||
|
||||
// 处理添加映射(从 ModelsTab 触发)
|
||||
function handleAddMapping(model: Model) {
|
||||
modelAliasesTabRef.value?.openAddDialogForModel(model.id)
|
||||
}
|
||||
|
||||
// 处理批量关联完成
|
||||
async function handleBatchAssignChanged() {
|
||||
await loadProvider()
|
||||
@@ -1375,6 +1527,25 @@ async function loadEndpoints() {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载别名映射预览
|
||||
async function loadAliasMappingPreview() {
|
||||
if (!props.providerId) return
|
||||
|
||||
aliasMappingLoading.value = true
|
||||
try {
|
||||
aliasMappingPreview.value = await getProviderAliasMappingPreview(props.providerId)
|
||||
} catch (err: any) {
|
||||
// 404 静默处理(Provider 不存在或无别名配置)
|
||||
if (err.response?.status !== 404) {
|
||||
console.warn('加载别名映射预览失败:', err)
|
||||
showError('加载别名映射预览失败')
|
||||
}
|
||||
aliasMappingPreview.value = null
|
||||
} finally {
|
||||
aliasMappingLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 添加 ESC 键监听
|
||||
useEscapeKey(() => {
|
||||
if (props.open) {
|
||||
|
||||
@@ -8,7 +8,5 @@ export { default as ProviderModelFormDialog } from './ProviderModelFormDialog.vu
|
||||
export { default as ProviderDetailDrawer } from './ProviderDetailDrawer.vue'
|
||||
export { default as EndpointHealthTimeline } from './EndpointHealthTimeline.vue'
|
||||
export { default as BatchAssignModelsDialog } from './BatchAssignModelsDialog.vue'
|
||||
export { default as ModelAliasDialog } from './ModelAliasDialog.vue'
|
||||
|
||||
export { default as ModelsTab } from './provider-tabs/ModelsTab.vue'
|
||||
export { default as ModelAliasesTab } from './provider-tabs/ModelAliasesTab.vue'
|
||||
|
||||
@@ -117,15 +117,6 @@
|
||||
</td>
|
||||
<td class="align-top px-4 py-3">
|
||||
<div class="flex justify-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="添加映射"
|
||||
@click="addMapping(model)"
|
||||
>
|
||||
<Link class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -179,7 +170,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Box, Edit, Trash2, Layers, Power, Copy, Link } from 'lucide-vue-next'
|
||||
import { Box, Edit, Trash2, Layers, Power, Copy } from 'lucide-vue-next'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
@@ -195,7 +186,6 @@ const emit = defineEmits<{
|
||||
'editModel': [model: Model]
|
||||
'deleteModel': [model: Model]
|
||||
'batchAssign': []
|
||||
'addMapping': [model: Model]
|
||||
}>()
|
||||
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
@@ -315,11 +305,6 @@ function deleteModel(model: Model) {
|
||||
emit('deleteModel', model)
|
||||
}
|
||||
|
||||
// 添加映射
|
||||
function addMapping(model: Model) {
|
||||
emit('addMapping', model)
|
||||
}
|
||||
|
||||
// 打开批量关联对话框
|
||||
function openBatchAssignDialog() {
|
||||
emit('batchAssign')
|
||||
|
||||
Reference in New Issue
Block a user