mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 16:37:46 +08:00
feat(routing): simplify model scheduling configuration
This commit is contained in:
@@ -509,285 +509,17 @@
|
||||
@pending-change="routingFailoverPending = $event"
|
||||
/>
|
||||
|
||||
<section class="space-y-4 rounded-lg border border-border/60 p-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium">
|
||||
调度配置
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
先选择调度维度,再配置优先级模式、调度策略和提供商排序。
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-1 text-sm">
|
||||
<span class="text-muted-foreground">调度维度</span>
|
||||
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1">
|
||||
<button
|
||||
type="button"
|
||||
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
|
||||
:class="sortingScope === 'unified'
|
||||
? 'bg-primary/10 text-primary shadow-sm ring-1 ring-border'
|
||||
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
|
||||
@click="setSortingScope('unified')"
|
||||
>
|
||||
统一调度
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
|
||||
:class="sortingScope === 'per_model'
|
||||
? 'bg-primary/10 text-primary shadow-sm ring-1 ring-border'
|
||||
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
|
||||
@click="setSortingScope('per_model')"
|
||||
>
|
||||
区分模型
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 lg:grid-cols-2">
|
||||
<div class="space-y-1 text-sm">
|
||||
<span class="text-muted-foreground">优先级模式</span>
|
||||
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors"
|
||||
:class="firstStepPriorityMode === 'provider'
|
||||
? 'bg-primary/10 text-primary shadow-sm ring-1 ring-border'
|
||||
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
|
||||
:disabled="sortingScope === 'per_model' && !activePerModelPolicy"
|
||||
@click="updateFirstStepPriorityMode('provider')"
|
||||
>
|
||||
<Layers class="h-4 w-4" />
|
||||
Provider
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors"
|
||||
:class="firstStepPriorityMode === 'global_key'
|
||||
? 'bg-primary/10 text-primary shadow-sm ring-1 ring-border'
|
||||
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
|
||||
:disabled="sortingScope === 'per_model' && !activePerModelPolicy"
|
||||
@click="updateFirstStepPriorityMode('global_key')"
|
||||
>
|
||||
<Key class="h-4 w-4" />
|
||||
Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1 text-sm">
|
||||
<span class="text-muted-foreground">调度策略</span>
|
||||
<div class="grid grid-cols-3 gap-1 rounded-lg bg-muted/40 p-1">
|
||||
<button
|
||||
v-for="mode in schedulingModes"
|
||||
:key="mode.value"
|
||||
type="button"
|
||||
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
|
||||
:class="firstStepSchedulingMode === mode.value
|
||||
? 'bg-primary/10 text-primary shadow-sm ring-1 ring-border'
|
||||
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
|
||||
:disabled="sortingScope === 'per_model' && !activePerModelPolicy"
|
||||
@click="updateFirstStepSchedulingMode(mode.value)"
|
||||
>
|
||||
{{ mode.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="sortingScope === 'per_model' && !activePerModelPolicy"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
请先在下方选择一个模型,再配置该模型的优先级模式和调度策略。
|
||||
</p>
|
||||
|
||||
<section
|
||||
v-if="sortingScope === 'unified'"
|
||||
class="space-y-4"
|
||||
>
|
||||
<RoutingPriorityPolicyEditor
|
||||
:config="draft.config_json"
|
||||
:model="DEFAULT_ROUTING_POLICY_MODEL"
|
||||
:show-priority-mode="false"
|
||||
:show-scheduling-mode="false"
|
||||
subtitle="统一作用于当前策略的所有模型"
|
||||
@update:config="updateDraftConfig"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section v-else>
|
||||
<div class="mb-3">
|
||||
<h3 class="text-sm font-medium">
|
||||
按模型配置
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
选择模型后,在下方配置该模型的提供商排序。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex max-h-[560px] flex-col gap-3 overflow-hidden rounded-lg border border-border/60 p-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
v-model="globalModelSearch"
|
||||
placeholder="搜索模型"
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1 text-xs">
|
||||
<button
|
||||
v-for="filter in modelFilters"
|
||||
:key="filter.value"
|
||||
type="button"
|
||||
class="h-9 rounded-md px-3 font-medium transition-colors"
|
||||
:class="modelFilter === filter.value
|
||||
? 'bg-primary/10 text-primary shadow-sm ring-1 ring-border'
|
||||
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
|
||||
@click="modelFilter = filter.value"
|
||||
>
|
||||
{{ filter.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loadingGlobalModels"
|
||||
class="rounded-md border border-dashed border-border/70 px-3 py-6 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
正在加载模型
|
||||
</div>
|
||||
<div
|
||||
v-else-if="globalModelsError"
|
||||
class="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive"
|
||||
>
|
||||
{{ globalModelsError }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="modelRows.length === 0"
|
||||
class="rounded-md border border-dashed border-border/70 px-3 py-6 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
{{ globalModelSearch.trim() ? '未匹配到模型' : modelFilter === 'configured' ? '暂无已配置模型' : '暂无未配置模型' }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1"
|
||||
>
|
||||
<div
|
||||
v-for="row in modelRows"
|
||||
:key="row.name"
|
||||
class="rounded-lg border transition-colors"
|
||||
:class="selectedPerModelName === row.name
|
||||
? 'border-primary/50 bg-primary/5'
|
||||
: 'border-border/60'"
|
||||
>
|
||||
<div class="flex w-full items-center gap-3 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 flex-1 items-center gap-3 text-left text-sm"
|
||||
@click="selectGlobalModel(row.name)"
|
||||
>
|
||||
<span
|
||||
v-if="row.configured"
|
||||
class="h-2 w-2 shrink-0 rounded-full bg-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Plus
|
||||
v-else
|
||||
class="h-3.5 w-3.5 shrink-0 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate font-medium">{{ row.displayName }}</span>
|
||||
<span class="block truncate text-xs text-muted-foreground">{{ row.name }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<template v-if="selectedPerModelName === row.name && activePerModelPolicy">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0 text-muted-foreground/70 hover:text-foreground"
|
||||
:disabled="copySourceCandidates.length === 0"
|
||||
title="加载其他模型配置"
|
||||
>
|
||||
<Copy class="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
class="max-h-[320px] overflow-y-auto"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
v-for="source in copySourceCandidates"
|
||||
:key="source.model"
|
||||
@select="copyModelConfig(source.model)"
|
||||
>
|
||||
<span class="min-w-0">
|
||||
<span class="block truncate text-sm font-medium">{{ source.label }}</span>
|
||||
<span class="block truncate text-xs text-muted-foreground">{{ source.model }}</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0 text-muted-foreground/70 hover:text-foreground"
|
||||
:disabled="!canSaveCurrentModel"
|
||||
title="保存到草稿"
|
||||
@click="saveCurrentModel"
|
||||
>
|
||||
<Save class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="hasModelPolicy(activePerModelPolicy.model)"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0"
|
||||
:class="canRemoveCurrentModel ? 'text-muted-foreground/70 hover:text-destructive' : 'text-muted-foreground/30'"
|
||||
:disabled="!canRemoveCurrentModel"
|
||||
:title="canRemoveCurrentModel ? '移除当前模型排序' : '当前有未保存改动,不能移除'"
|
||||
@click="removePerModelPolicy(activePerModelPolicy.model)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0"
|
||||
@click="selectGlobalModel(row.name)"
|
||||
>
|
||||
<ChevronDown
|
||||
class="h-4 w-4 text-muted-foreground transition-transform"
|
||||
:class="selectedPerModelName === row.name ? 'rotate-180' : ''"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedPerModelName === row.name && activePerModelPolicy"
|
||||
class="border-t border-border/60 p-4"
|
||||
>
|
||||
<RoutingPriorityPolicyEditor
|
||||
:config="activeConfigForReading"
|
||||
:model="activePerModelPolicy.model"
|
||||
:model-id="globalModelIdFor(activePerModelPolicy.model)"
|
||||
:priority-mode="modelPriorityMode(activePerModelPolicy.model)"
|
||||
:scheduling-mode="modelSchedulingMode(activePerModelPolicy.model)"
|
||||
:show-priority-mode="false"
|
||||
:show-scheduling-mode="false"
|
||||
:subtitle="`仅作用于 ${activePerModelPolicy.model}`"
|
||||
@update:config="updateEditingConfig"
|
||||
@update:priority-mode="mode => activePerModelPolicy && updateModelPriorityMode(activePerModelPolicy.model, mode)"
|
||||
@update:scheduling-mode="mode => activePerModelPolicy && updateModelSchedulingMode(activePerModelPolicy.model, mode)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
<RoutingSchedulingPolicyEditor
|
||||
:key="draftGeneration"
|
||||
:config="draft.config_json"
|
||||
:global-models="globalModels"
|
||||
:loading-models="loadingGlobalModels"
|
||||
:models-error="globalModelsError"
|
||||
:disabled="saving"
|
||||
@update:config="updateDraftConfig"
|
||||
@validity-change="routingSchedulingValid = $event"
|
||||
@reload-models="loadGlobalModels()"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -812,16 +544,6 @@
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<AlertDialog
|
||||
v-model="switchModelDialogOpen"
|
||||
type="warning"
|
||||
title="切换模型"
|
||||
description="当前模型有未保存的改动,切换将丢弃这些改动,是否继续?"
|
||||
confirm-text="继续"
|
||||
@confirm="confirmSwitchModel"
|
||||
@cancel="cancelSwitchModel"
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
v-model="deleteDialogOpen"
|
||||
type="destructive"
|
||||
@@ -839,12 +561,8 @@ import { getI18nLocale } from '@/i18n'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
GripVertical,
|
||||
Key,
|
||||
Layers,
|
||||
Plus,
|
||||
Power,
|
||||
Save,
|
||||
@@ -868,30 +586,20 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui'
|
||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'
|
||||
import { AlertDialog } from '@/components/common'
|
||||
import HelpHint from '@/components/common/HelpHint.vue'
|
||||
import {
|
||||
DEFAULT_ROUTING_POLICY_MODEL,
|
||||
DEFAULT_STICKY_KEY_ATTEMPTS,
|
||||
copyPerModelRoutingConfig,
|
||||
createEmptyModelPolicy,
|
||||
createEmptyRoutingGroupConfig,
|
||||
getModelScheduling,
|
||||
isGeneratedModelSchedulingRule,
|
||||
modelSchedulingRuleId,
|
||||
isGeneratedSchedulingPolicyRule,
|
||||
normalizeRoutingGroupConfig,
|
||||
normalizeStickyKeyAttempts,
|
||||
removePerModelRoutingConfig,
|
||||
savePerModelRoutingConfig,
|
||||
setRoutingSortingScope,
|
||||
upsertModelSchedulingRule,
|
||||
type RoutingGroupConfig,
|
||||
type RoutingPriorityMode,
|
||||
type RoutingSchedulingMode,
|
||||
type RoutingSortingScope,
|
||||
} from '@/features/routing/utils/routingPolicy'
|
||||
import { RoutingFailoverPolicyEditor, RoutingPriorityPolicyEditor } from '@/features/routing/components'
|
||||
import { RoutingFailoverPolicyEditor, RoutingSchedulingPolicyEditor } from '@/features/routing/components'
|
||||
import { normalizeRoutingFailoverPolicy, validateRoutingFailoverPolicy, type RoutingFailoverPolicy } from '@/features/routing/utils/routingFailover'
|
||||
import {
|
||||
createRoutingGroup,
|
||||
@@ -916,13 +624,6 @@ interface RoutingGroupDraft {
|
||||
updated_at?: number | null
|
||||
}
|
||||
|
||||
type ModelFilter = 'configured' | 'unconfigured'
|
||||
|
||||
const modelFilters: Array<{ value: ModelFilter; label: string }> = [
|
||||
{ value: 'unconfigured', label: '未配置' },
|
||||
{ value: 'configured', label: '已配置' },
|
||||
]
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -938,12 +639,8 @@ const selectedGroupId = ref<string | null>(null)
|
||||
const draft = ref<RoutingGroupDraft | null>(null)
|
||||
const routingFailoverPolicyEditor = ref<{ commitJsonDrafts: () => boolean } | null>(null)
|
||||
const routingFailoverPending = ref(false)
|
||||
const routingSchedulingValid = ref(true)
|
||||
const savedDraftSnapshot = ref<string | null>(null)
|
||||
const sortingScope = ref<RoutingSortingScope>('unified')
|
||||
const selectedPerModelName = ref<string | null>(null)
|
||||
const editingConfig = ref<RoutingGroupConfig | null>(null)
|
||||
const globalModelSearch = ref('')
|
||||
const modelFilter = ref<ModelFilter>('unconfigured')
|
||||
const globalModels = ref<GlobalModelResponse[]>([])
|
||||
const loadingGlobalModels = ref(false)
|
||||
const globalModelsError = ref<string | null>(null)
|
||||
@@ -957,37 +654,12 @@ const dragOverGroupId = ref<string | null>(null)
|
||||
const isCreating = ref(false)
|
||||
const draftGeneration = ref(0)
|
||||
|
||||
const switchModelTarget = ref<string | null>(null)
|
||||
const switchModelDialogOpen = ref(false)
|
||||
const deleteDialogOpen = ref(false)
|
||||
const listDeleteTarget = ref<RoutingGroupRecord | null>(null)
|
||||
|
||||
const isCreateRoute = computed(() => route.name === 'RoutingProfileCreate')
|
||||
const routeGroupId = computed(() => paramToString(route.params.groupId))
|
||||
const isDetailView = computed(() => isCreateRoute.value || route.name === 'RoutingProfileDetail')
|
||||
const perModelPolicies = computed(() => {
|
||||
return draft.value?.config_json.model_policies
|
||||
.filter(policy => policy.model !== DEFAULT_ROUTING_POLICY_MODEL)
|
||||
?? []
|
||||
})
|
||||
const activePerModelPolicy = computed(() => {
|
||||
if (!selectedPerModelName.value) return null
|
||||
const existing = perModelPolicies.value.find(policy => policy.model === selectedPerModelName.value)
|
||||
if (existing) return existing
|
||||
return createEmptyModelPolicy(selectedPerModelName.value)
|
||||
})
|
||||
const firstStepPriorityMode = computed<RoutingPriorityMode>(() => {
|
||||
if (sortingScope.value === 'per_model' && activePerModelPolicy.value) {
|
||||
return modelPriorityMode(activePerModelPolicy.value.model)
|
||||
}
|
||||
return draft.value?.config_json.default_policy.priority_mode ?? 'provider'
|
||||
})
|
||||
const firstStepSchedulingMode = computed<RoutingSchedulingMode>(() => {
|
||||
if (sortingScope.value === 'per_model' && activePerModelPolicy.value) {
|
||||
return modelSchedulingMode(activePerModelPolicy.value.model)
|
||||
}
|
||||
return draft.value?.config_json.default_policy.scheduling_mode ?? 'cache_affinity'
|
||||
})
|
||||
const keepPriorityOnConversion = computed<boolean>(() => (
|
||||
draft.value?.config_json.default_policy.keep_priority_on_conversion ?? false
|
||||
))
|
||||
@@ -1003,55 +675,6 @@ const cyberContinueFailover = computed<boolean>(() => (
|
||||
const cancelOnClientDisconnect = computed<boolean>(() => (
|
||||
draft.value?.config_json.default_policy.cancel_on_client_disconnect ?? false
|
||||
))
|
||||
interface ModelRow {
|
||||
name: string
|
||||
displayName: string
|
||||
configured: boolean
|
||||
}
|
||||
|
||||
const modelRows = computed<ModelRow[]>(() => {
|
||||
const query = globalModelSearch.value.trim().toLowerCase()
|
||||
const seen = new Set<string>()
|
||||
const rows: ModelRow[] = []
|
||||
|
||||
for (const policy of perModelPolicies.value) {
|
||||
const name = policy.model
|
||||
const found = globalModels.value.find(item => item.name === name)
|
||||
rows.push({
|
||||
name,
|
||||
displayName: found?.display_name || name,
|
||||
configured: true,
|
||||
})
|
||||
seen.add(name)
|
||||
}
|
||||
|
||||
for (const model of globalModels.value) {
|
||||
if (seen.has(model.name)) continue
|
||||
rows.push({
|
||||
name: model.name,
|
||||
displayName: model.display_name || model.name,
|
||||
configured: false,
|
||||
})
|
||||
}
|
||||
|
||||
return rows
|
||||
.filter(row => {
|
||||
if (modelFilter.value === 'configured' && !row.configured) return false
|
||||
if (modelFilter.value === 'unconfigured' && row.configured) return false
|
||||
if (!query) return true
|
||||
return (
|
||||
row.name.toLowerCase().includes(query)
|
||||
|| row.displayName.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
.sort((left, right) => {
|
||||
if (left.configured !== right.configured) {
|
||||
return left.configured ? -1 : 1
|
||||
}
|
||||
return left.name.localeCompare(right.name)
|
||||
})
|
||||
})
|
||||
|
||||
function normalizeRecord(group: RoutingGroupRecord): RoutingGroupRecord {
|
||||
return {
|
||||
...group,
|
||||
@@ -1103,14 +726,11 @@ function paramToString(value: unknown): string | null {
|
||||
function clearDraftState(): void {
|
||||
draftGeneration.value += 1
|
||||
routingFailoverPending.value = false
|
||||
routingSchedulingValid.value = true
|
||||
isCreating.value = false
|
||||
selectedGroupId.value = null
|
||||
draft.value = null
|
||||
savedDraftSnapshot.value = null
|
||||
selectedPerModelName.value = null
|
||||
editingConfig.value = null
|
||||
switchModelTarget.value = null
|
||||
switchModelDialogOpen.value = false
|
||||
deleteDialogOpen.value = false
|
||||
listDeleteTarget.value = null
|
||||
}
|
||||
@@ -1119,12 +739,11 @@ function selectGroup(group: RoutingGroupRecord): void {
|
||||
const normalized = normalizeRecord(group)
|
||||
draftGeneration.value += 1
|
||||
routingFailoverPending.value = false
|
||||
routingSchedulingValid.value = true
|
||||
isCreating.value = false
|
||||
selectedGroupId.value = normalized.id
|
||||
draft.value = buildDraft(normalized)
|
||||
savedDraftSnapshot.value = draftSnapshotValue(draft.value)
|
||||
syncEditorStateFromConfig(draft.value.config_json)
|
||||
resetEditingConfig()
|
||||
}
|
||||
|
||||
function setDraftEnabled(value: boolean): void {
|
||||
@@ -1135,6 +754,7 @@ function setDraftEnabled(value: boolean): void {
|
||||
function startCreate(): void {
|
||||
draftGeneration.value += 1
|
||||
routingFailoverPending.value = false
|
||||
routingSchedulingValid.value = true
|
||||
isCreating.value = true
|
||||
selectedGroupId.value = null
|
||||
draft.value = {
|
||||
@@ -1147,8 +767,6 @@ function startCreate(): void {
|
||||
updated_at: null,
|
||||
}
|
||||
savedDraftSnapshot.value = null
|
||||
syncEditorStateFromConfig(draft.value.config_json)
|
||||
resetEditingConfig()
|
||||
}
|
||||
|
||||
function syncRouteState(): void {
|
||||
@@ -1199,124 +817,34 @@ function schedulingModeLabel(mode: RoutingSchedulingMode): string {
|
||||
}
|
||||
|
||||
function groupSortingScopeLabel(group: RoutingGroupRecord): string {
|
||||
return hasPerModelSorting(normalizeRoutingGroupConfig(group.config_json)) ? '区分模型' : '统一调度'
|
||||
return hasPerModelSorting(normalizeRoutingGroupConfig(group.config_json)) ? '指定模型' : '全部模型'
|
||||
}
|
||||
|
||||
function groupSchedulingSummary(group: RoutingGroupRecord): string {
|
||||
const config = normalizeRoutingGroupConfig(group.config_json)
|
||||
if (hasPerModelSorting(config)) return '按模型配置'
|
||||
if (hasPerModelSorting(config)) return '按适用范围配置'
|
||||
return schedulingModeLabel(config.default_policy.scheduling_mode)
|
||||
}
|
||||
|
||||
function updateDraftConfig(value: RoutingGroupConfig): void {
|
||||
if (!draft.value) return
|
||||
draft.value.config_json = normalizeRoutingGroupConfig(value)
|
||||
syncSelectedPerModelPolicy()
|
||||
}
|
||||
|
||||
function resetEditingConfig(): void {
|
||||
if (!draft.value) {
|
||||
editingConfig.value = null
|
||||
return
|
||||
}
|
||||
editingConfig.value = cloneConfig(draft.value.config_json)
|
||||
}
|
||||
|
||||
function updateEditingConfig(value: RoutingGroupConfig): void {
|
||||
editingConfig.value = normalizeRoutingGroupConfig(value)
|
||||
}
|
||||
|
||||
const editingDirty = computed(() => {
|
||||
if (!editingConfig.value || !draft.value) return false
|
||||
return JSON.stringify(editingConfig.value) !== JSON.stringify(draft.value.config_json)
|
||||
})
|
||||
|
||||
const draftDirty = computed(() => {
|
||||
if (!draft.value) return false
|
||||
if (isCreating.value) return true
|
||||
return routingFailoverPending.value || savedDraftSnapshot.value !== draftSnapshotValue(draft.value)
|
||||
})
|
||||
|
||||
const canSaveDraft = computed(() => {
|
||||
const hasPendingCurrentModel = perModelEditingActive.value
|
||||
&& Boolean(activePerModelPolicy.value)
|
||||
&& (editingDirty.value || !currentModelPersisted.value)
|
||||
return Boolean(draft.value)
|
||||
&& !saving.value
|
||||
&& draftDirty.value
|
||||
&& !hasPendingCurrentModel
|
||||
&& !(perModelEditingActive.value && perModelPolicies.value.length === 0)
|
||||
})
|
||||
|
||||
const currentModelPersisted = computed(() => {
|
||||
const model = activePerModelPolicy.value?.model
|
||||
return model ? hasModelPolicy(model) : false
|
||||
})
|
||||
|
||||
const canSaveCurrentModel = computed(() => {
|
||||
return Boolean(activePerModelPolicy.value)
|
||||
&& !saving.value
|
||||
&& (editingDirty.value || !currentModelPersisted.value)
|
||||
})
|
||||
|
||||
const canRemoveCurrentModel = computed(() => {
|
||||
return Boolean(activePerModelPolicy.value)
|
||||
&& currentModelPersisted.value
|
||||
&& !saving.value
|
||||
&& !editingDirty.value
|
||||
})
|
||||
|
||||
function syncEditorStateFromConfig(config: RoutingGroupConfig): void {
|
||||
const normalized = normalizeRoutingGroupConfig(config)
|
||||
sortingScope.value = hasPerModelSorting(normalized) ? 'per_model' : 'unified'
|
||||
syncSelectedPerModelPolicy()
|
||||
}
|
||||
const canSaveDraft = computed(() => Boolean(draft.value)
|
||||
&& !saving.value
|
||||
&& draftDirty.value
|
||||
&& routingSchedulingValid.value)
|
||||
|
||||
function hasPerModelSorting(config: RoutingGroupConfig): boolean {
|
||||
return config.model_policies.some(policy => policy.model !== DEFAULT_ROUTING_POLICY_MODEL)
|
||||
|| config.rules.some(isGeneratedModelSchedulingRule)
|
||||
}
|
||||
|
||||
function setSortingScope(scope: RoutingSortingScope): void {
|
||||
if (!draft.value) return
|
||||
sortingScope.value = scope
|
||||
if (scope === 'unified') {
|
||||
const next = setRoutingSortingScope(draft.value.config_json, scope)
|
||||
updateDraftConfig(next)
|
||||
resetEditingConfig()
|
||||
return
|
||||
}
|
||||
resetEditingConfig()
|
||||
}
|
||||
|
||||
function updateFirstStepPriorityMode(mode: RoutingPriorityMode): void {
|
||||
if (!draft.value) return
|
||||
if (sortingScope.value === 'per_model' && activePerModelPolicy.value) {
|
||||
updateModelPriorityMode(activePerModelPolicy.value.model, mode)
|
||||
return
|
||||
}
|
||||
updateDraftConfig({
|
||||
...draft.value.config_json,
|
||||
default_policy: {
|
||||
...draft.value.config_json.default_policy,
|
||||
priority_mode: mode,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function updateFirstStepSchedulingMode(mode: RoutingSchedulingMode): void {
|
||||
if (!draft.value) return
|
||||
if (sortingScope.value === 'per_model' && activePerModelPolicy.value) {
|
||||
updateModelSchedulingMode(activePerModelPolicy.value.model, mode)
|
||||
return
|
||||
}
|
||||
updateDraftConfig({
|
||||
...draft.value.config_json,
|
||||
default_policy: {
|
||||
...draft.value.config_json.default_policy,
|
||||
scheduling_mode: mode,
|
||||
},
|
||||
})
|
||||
|| config.rules.some(rule => isGeneratedModelSchedulingRule(rule) || isGeneratedSchedulingPolicyRule(rule))
|
||||
}
|
||||
|
||||
function updateStickyKeyAttempts(value: string | number): void {
|
||||
@@ -1359,155 +887,6 @@ function updateRoutingFailoverPolicy(value: RoutingFailoverPolicy): void {
|
||||
if (!draft.value) return
|
||||
const patch = normalizeRoutingFailoverPolicy(value)
|
||||
Object.assign(draft.value.config_json.default_policy, patch)
|
||||
if (editingConfig.value) {
|
||||
Object.assign(editingConfig.value.default_policy, normalizeRoutingFailoverPolicy(patch))
|
||||
}
|
||||
}
|
||||
|
||||
function removePerModelPolicy(model: string): void {
|
||||
if (!draft.value) return
|
||||
if (perModelEditingActive.value && editingDirty.value) {
|
||||
showError('请先保存当前改动后再移除模型')
|
||||
return
|
||||
}
|
||||
const next = removePerModelRoutingConfig(draft.value.config_json, model)
|
||||
if (selectedPerModelName.value === model) {
|
||||
selectedPerModelName.value = null
|
||||
}
|
||||
modelFilter.value = 'unconfigured'
|
||||
updateDraftConfig(next)
|
||||
resetEditingConfig()
|
||||
}
|
||||
|
||||
function selectGlobalModel(model: string): void {
|
||||
if (!model) return
|
||||
if (model === selectedPerModelName.value) {
|
||||
resetEditingConfig()
|
||||
selectedPerModelName.value = null
|
||||
return
|
||||
}
|
||||
const shouldAddModel = !hasModelPolicy(model)
|
||||
if (perModelEditingActive.value && editingDirty.value) {
|
||||
switchModelTarget.value = model
|
||||
switchModelDialogOpen.value = true
|
||||
return
|
||||
}
|
||||
if (shouldAddModel) {
|
||||
resetEditingConfig()
|
||||
}
|
||||
selectedPerModelName.value = model
|
||||
}
|
||||
|
||||
function confirmSwitchModel(): void {
|
||||
const target = switchModelTarget.value
|
||||
if (target) {
|
||||
resetEditingConfig()
|
||||
selectedPerModelName.value = target
|
||||
}
|
||||
switchModelTarget.value = null
|
||||
switchModelDialogOpen.value = false
|
||||
}
|
||||
|
||||
function cancelSwitchModel(): void {
|
||||
switchModelTarget.value = null
|
||||
}
|
||||
|
||||
function hasModelPolicy(model: string): boolean {
|
||||
if (perModelPolicies.value.some(policy => policy.model === model)) return true
|
||||
const ruleId = modelSchedulingRuleId(model)
|
||||
return draft.value?.config_json.rules.some(rule => rule.id === ruleId) ?? false
|
||||
}
|
||||
|
||||
const copySourceCandidates = computed(() => {
|
||||
if (!draft.value) return []
|
||||
const current = selectedPerModelName.value
|
||||
return perModelPolicies.value
|
||||
.filter(policy => policy.model !== current)
|
||||
.map(policy => ({
|
||||
model: policy.model,
|
||||
label: globalModelLabel(policy.model),
|
||||
}))
|
||||
})
|
||||
|
||||
function copyModelConfig(sourceModel: string): void {
|
||||
if (!draft.value || !editingConfig.value) return
|
||||
const target = selectedPerModelName.value
|
||||
if (!target || target === sourceModel) return
|
||||
const next = copyPerModelRoutingConfig(
|
||||
editingConfig.value,
|
||||
draft.value.config_json,
|
||||
sourceModel,
|
||||
target,
|
||||
)
|
||||
updateEditingConfig(next)
|
||||
success(`已加载 ${globalModelLabel(sourceModel)} 的配置,点击保存生效`)
|
||||
}
|
||||
|
||||
function syncSelectedPerModelPolicy(): void {
|
||||
if (selectedPerModelName.value) return
|
||||
const firstConfigured = perModelPolicies.value[0]?.model
|
||||
selectedPerModelName.value = firstConfigured ?? null
|
||||
}
|
||||
|
||||
const perModelEditingActive = computed(() => sortingScope.value === 'per_model')
|
||||
|
||||
const activeConfigForReading = computed<RoutingGroupConfig>(() => {
|
||||
if (perModelEditingActive.value && editingConfig.value) return editingConfig.value
|
||||
return draft.value?.config_json ?? createEmptyRoutingGroupConfig()
|
||||
})
|
||||
|
||||
function modelPriorityMode(model: string): RoutingPriorityMode {
|
||||
return getModelScheduling(activeConfigForReading.value, model).priority_mode
|
||||
}
|
||||
|
||||
function modelSchedulingMode(model: string): RoutingSchedulingMode {
|
||||
return getModelScheduling(activeConfigForReading.value, model).scheduling_mode
|
||||
}
|
||||
|
||||
function updateModelPriorityMode(model: string, mode: RoutingPriorityMode): void {
|
||||
if (!draft.value) return
|
||||
const baseConfig = perModelEditingActive.value && editingConfig.value
|
||||
? editingConfig.value
|
||||
: draft.value.config_json
|
||||
const current = getModelScheduling(baseConfig, model)
|
||||
const next = upsertModelSchedulingRule(baseConfig, model, {
|
||||
priority_mode: mode,
|
||||
scheduling_mode: current.scheduling_mode,
|
||||
})
|
||||
if (perModelEditingActive.value) {
|
||||
updateEditingConfig(next)
|
||||
return
|
||||
}
|
||||
updateDraftConfig(next)
|
||||
}
|
||||
|
||||
function updateModelSchedulingMode(model: string, mode: RoutingSchedulingMode): void {
|
||||
if (!draft.value) return
|
||||
const baseConfig = perModelEditingActive.value && editingConfig.value
|
||||
? editingConfig.value
|
||||
: draft.value.config_json
|
||||
const current = getModelScheduling(baseConfig, model)
|
||||
const next = upsertModelSchedulingRule(baseConfig, model, {
|
||||
priority_mode: current.priority_mode,
|
||||
scheduling_mode: mode,
|
||||
})
|
||||
if (perModelEditingActive.value) {
|
||||
updateEditingConfig(next)
|
||||
return
|
||||
}
|
||||
updateDraftConfig(next)
|
||||
}
|
||||
|
||||
function globalModelLabel(modelName: string): string {
|
||||
const model = globalModels.value.find(item => item.name === modelName)
|
||||
if (!model) return modelName
|
||||
if (!model.display_name || model.display_name === model.name) return model.name
|
||||
return `${model.display_name} (${model.name})`
|
||||
}
|
||||
|
||||
function globalModelIdFor(modelName: string): string | undefined {
|
||||
const normalizedName = modelName.trim()
|
||||
return globalModels.value.find(item => item.name.trim() === normalizedName)?.id
|
||||
}
|
||||
|
||||
function replaceGroup(group: RoutingGroupRecord, select = true): void {
|
||||
@@ -1679,8 +1058,8 @@ async function saveDraft(): Promise<void> {
|
||||
return
|
||||
}
|
||||
const config = cloneConfig(draft.value.config_json)
|
||||
if (sortingScope.value === 'per_model' && perModelPolicies.value.length === 0) {
|
||||
showError('按模型排序时至少选择一个模型')
|
||||
if (!routingSchedulingValid.value) {
|
||||
showError('请为每条调度配置选择适用模型')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1733,20 +1112,6 @@ async function saveDraft(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function saveCurrentModel(): void {
|
||||
if (!draft.value || !editingConfig.value) return
|
||||
const model = selectedPerModelName.value
|
||||
if (!model) {
|
||||
showError('请先选择模型')
|
||||
return
|
||||
}
|
||||
const next = savePerModelRoutingConfig(editingConfig.value, model)
|
||||
updateDraftConfig(next)
|
||||
modelFilter.value = 'configured'
|
||||
resetEditingConfig()
|
||||
success('当前模型配置已保存到草稿,点击外层保存后生效')
|
||||
}
|
||||
|
||||
function deleteDraft(): void {
|
||||
if (!draft.value?.id) return
|
||||
listDeleteTarget.value = null
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, nextTick, reactive, type App } from 'vue'
|
||||
import RoutingProfiles from '../RoutingProfiles.vue'
|
||||
import { createEmptyRoutingGroupConfig, getModelScheduling, savePerModelRoutingConfig } from '@/features/routing/utils/routingPolicy'
|
||||
import { createSchedulingPolicy, readSchedulingPolicies, writeSchedulingPolicies } from '@/features/routing/utils/schedulingPolicies'
|
||||
import type { RoutingGroupRecord, RoutingGroupUpdateRequest } from '@/api/routing-profiles'
|
||||
|
||||
const routingApi = vi.hoisted(() => ({
|
||||
@@ -11,17 +12,19 @@ const routingApi = vi.hoisted(() => ({
|
||||
deleteRoutingGroup: vi.fn(),
|
||||
}))
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }))
|
||||
const globalModelsApi = vi.hoisted(() => ({ getGlobalModels: vi.fn() }))
|
||||
const route = reactive({ name: 'RoutingProfileDetail', params: { groupId: 'strategy-a' } })
|
||||
|
||||
vi.mock('@/api/routing-profiles', () => routingApi)
|
||||
vi.mock('@/api/global-models', () => ({ getGlobalModels: vi.fn().mockResolvedValue({ models: [] }) }))
|
||||
vi.mock('@/api/global-models', () => globalModelsApi)
|
||||
vi.mock('@/composables/useToast', () => ({ useToast: () => toast }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => route, useRouter: () => ({ replace: vi.fn(), push: vi.fn() }) }))
|
||||
vi.mock('@/utils/logger', () => ({ log: { error: vi.fn(), warn: vi.fn() } }))
|
||||
vi.mock('@/features/routing/components', async () => ({
|
||||
RoutingFailoverPolicyEditor: (await import('@/features/routing/components/RoutingFailoverPolicyEditor.vue')).default,
|
||||
RoutingPriorityPolicyEditor: { render: () => null },
|
||||
RoutingSchedulingPolicyEditor: (await import('@/features/routing/components/RoutingSchedulingPolicyEditor.vue')).default,
|
||||
}))
|
||||
vi.mock('@/features/routing/components/RoutingPriorityPolicyEditor.vue', () => ({ default: { render: () => null } }))
|
||||
|
||||
const mounted: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
@@ -86,6 +89,15 @@ async function editJson(root: HTMLElement, section: string, value: string) {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
})
|
||||
globalModelsApi.getGlobalModels.mockResolvedValue({ models: [
|
||||
{ id: 'id-a', name: 'model-a', display_name: '模型 A' },
|
||||
{ id: 'id-b', name: 'model-b', display_name: '模型 B' },
|
||||
] })
|
||||
route.name = 'RoutingProfileDetail'
|
||||
route.params.groupId = 'strategy-a'
|
||||
})
|
||||
@@ -95,6 +107,7 @@ afterEach(() => {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('RoutingProfiles failover persistence', () => {
|
||||
@@ -144,13 +157,10 @@ describe('RoutingProfiles failover persistence', () => {
|
||||
expect(routingApi.updateRoutingGroup.mock.calls[0][1].config_json.default_policy.failover_rules.success_failover_patterns).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves global failover edits while saving an independently edited model', async () => {
|
||||
it('saves scoped scheduling and global failover edits together without a per-model save', async () => {
|
||||
const strategy = group('strategy-a')
|
||||
strategy.config_json = savePerModelRoutingConfig(strategy.config_json, 'model-a')
|
||||
const root = await mountPage([strategy])
|
||||
const configured = [...root.querySelectorAll<HTMLButtonElement>('button')].find(control => control.textContent?.trim() === '已配置')
|
||||
configured?.click()
|
||||
await nextTick()
|
||||
const loadBalance = [...root.querySelectorAll<HTMLButtonElement>('button')].find(control => control.textContent?.trim() === '负载均衡')
|
||||
if (!loadBalance) throw new Error('Missing model scheduling control')
|
||||
loadBalance.click()
|
||||
@@ -159,9 +169,6 @@ describe('RoutingProfiles failover persistence', () => {
|
||||
button(root, '添加错误终止规则').click()
|
||||
await nextTick()
|
||||
await input(root, '终止规则 1 状态码', '429')
|
||||
expect(button(root, '保存').disabled).toBe(true)
|
||||
element<HTMLButtonElement>(root, 'button[title="保存到草稿"]').click()
|
||||
await nextTick()
|
||||
expect(button(root, '保存').disabled).toBe(false)
|
||||
button(root, '保存').click()
|
||||
await flush()
|
||||
@@ -172,4 +179,69 @@ describe('RoutingProfiles failover persistence', () => {
|
||||
expect(getModelScheduling(saved, 'model-a').scheduling_mode).toBe('load_balance')
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks saving an empty scope and persists all selected models in one strategy', async () => {
|
||||
const root = await mountPage()
|
||||
const byText = (text: string) => {
|
||||
const found = [...root.querySelectorAll<HTMLButtonElement>('button')].find(control => control.textContent?.trim() === text)
|
||||
if (!found) throw new Error(`Missing control: ${text}`)
|
||||
return found
|
||||
}
|
||||
button(root, '区分模型').click()
|
||||
await flush()
|
||||
expect(button(root, '保存').disabled).toBe(true)
|
||||
element<HTMLInputElement>(document.body, 'input[aria-label="选择模型 model-a"]').click()
|
||||
await flush()
|
||||
button(document.body, '清空已选').click()
|
||||
await flush()
|
||||
expect(button(root, '保存').disabled).toBe(true)
|
||||
for (const model of ['model-a', 'model-b']) {
|
||||
element<HTMLInputElement>(document.body, `input[aria-label="选择模型 ${model}"]`).click()
|
||||
await nextTick()
|
||||
}
|
||||
const done = [...document.querySelectorAll<HTMLButtonElement>('[aria-label="全局模型选择列表"] button')]
|
||||
.find(control => control.textContent?.trim() === '完成选择')
|
||||
done?.click()
|
||||
await flush()
|
||||
byText('负载均衡').click()
|
||||
await nextTick()
|
||||
expect(button(root, '保存').disabled).toBe(false)
|
||||
button(root, '保存').click()
|
||||
await flush()
|
||||
const saved = routingApi.updateRoutingGroup.mock.calls[0][1].config_json
|
||||
expect(saved.model_policies.map((policy: { model: string }) => policy.model)).toEqual(['model-a', 'model-b'])
|
||||
expect(saved.rules).toHaveLength(1)
|
||||
expect(getModelScheduling(saved, 'model-a').scheduling_mode).toBe('load_balance')
|
||||
expect(getModelScheduling(saved, 'model-b').scheduling_mode).toBe('load_balance')
|
||||
expect(getModelScheduling(saved, 'other-model').scheduling_mode).toBe('cache_affinity')
|
||||
expect(root.querySelectorAll('section[aria-label^="调度配置 "]')).toHaveLength(1)
|
||||
expect(button(root, '保存').disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('saves one all-model configuration after switching from multiple model-specific configurations', async () => {
|
||||
const strategy = group('strategy-a')
|
||||
const first = { ...createSchedulingPolicy(strategy.config_json), models: ['model-a'], schedulingMode: 'fixed_order' as const }
|
||||
const second = { ...createSchedulingPolicy(strategy.config_json), models: ['model-b'], schedulingMode: 'load_balance' as const }
|
||||
strategy.config_json = writeSchedulingPolicies(strategy.config_json, [first, second])
|
||||
const root = await mountPage([strategy])
|
||||
expect(button(root, '区分模型').getAttribute('aria-pressed')).toBe('true')
|
||||
expect(root.querySelectorAll('section[aria-label^="调度配置 "]')).toHaveLength(2)
|
||||
button(root, '全部模型').click()
|
||||
await flush()
|
||||
expect(root.querySelectorAll('section[aria-label^="调度配置 "]')).toHaveLength(1)
|
||||
expect(root.querySelector('[aria-label="添加调度配置"]')).toBeNull()
|
||||
expect(button(root, '保存').disabled).toBe(false)
|
||||
button(root, '保存').click()
|
||||
await flush()
|
||||
expect(routingApi.updateRoutingGroup).toHaveBeenCalledOnce()
|
||||
const saved = routingApi.updateRoutingGroup.mock.calls[0][1].config_json
|
||||
expect(readSchedulingPolicies(saved)).toHaveLength(1)
|
||||
expect(readSchedulingPolicies(saved)[0]).toMatchObject({ scope: 'all', models: [], schedulingMode: 'fixed_order' })
|
||||
expect(saved.rules).toEqual([])
|
||||
expect(saved.model_policies.map((policy: { model: string }) => policy.model)).toEqual(['*'])
|
||||
expect(getModelScheduling(saved, 'model-b').scheduling_mode).toBe('fixed_order')
|
||||
expect(getModelScheduling(saved, 'future-model').scheduling_mode).toBe('fixed_order')
|
||||
expect(button(root, '保存').disabled).toBe(true)
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user