fix route grouping and codex websocket regressions

This commit is contained in:
cita
2026-03-18 20:38:01 +08:00
parent 332007d4fb
commit f769f43909
38 changed files with 2453 additions and 636 deletions
+81 -36
View File
@@ -30,6 +30,7 @@ import type {
GroupFilter,
RouteSummaryRow,
RouteRoutingStrategy,
RouteMode,
RouteDecision,
RouteIconOption,
MissingTokenRouteSiteActionItem,
@@ -40,7 +41,10 @@ import {
AUTO_ROUTE_DECISION_LIMIT,
ROUTE_RENDER_CHUNK,
isExactModelPattern,
isExplicitGroupRoute,
isRouteExactModel,
matchesModelPattern,
normalizeRouteMode,
resolveRouteTitle,
resolveRouteBrand,
resolveRouteIcon,
@@ -66,6 +70,24 @@ const ROUTE_ICON_OPTIONS: RouteIconOption[] = [
{ value: '', label: '自动品牌图标', description: '按模型匹配规则自动识别品牌', iconText: '✦' },
];
type RouteEditorForm = {
routeMode: RouteMode;
displayName: string;
displayIcon: string;
modelPattern: string;
sourceRouteIds: number[];
advancedOpen: boolean;
};
const EMPTY_ROUTE_FORM: RouteEditorForm = {
routeMode: 'explicit_group',
displayName: '',
displayIcon: '',
modelPattern: '',
sourceRouteIds: [],
advancedOpen: false,
};
export default function TokenRoutes() {
const navigate = useNavigate();
const [routeSummaries, setRouteSummaries] = useState<RouteSummaryRow[]>([]);
@@ -86,7 +108,7 @@ export default function TokenRoutes() {
const [sortDir, setSortDir] = useState<RouteSortDir>('desc');
const [showManual, setShowManual] = useState(false);
const [form, setForm] = useState({ modelPattern: '', displayName: '', displayIcon: '' });
const [form, setForm] = useState<RouteEditorForm>(EMPTY_ROUTE_FORM);
const [editingRouteId, setEditingRouteId] = useState<number | null>(null);
const [saving, setSaving] = useState(false);
const [rebuilding, setRebuilding] = useState(false);
@@ -120,9 +142,9 @@ export default function TokenRoutes() {
options?: { force?: boolean; refreshPricingCatalog?: boolean; persistSnapshots?: boolean },
) => {
const rows = routeRows || [];
const exactRoutes = rows.filter((route) => isExactModelPattern(route.modelPattern));
const exactRoutes = rows.filter((route) => isRouteExactModel(route));
const wildcardRouteIds = rows
.filter((route) => !isExactModelPattern(route.modelPattern))
.filter((route) => !isRouteExactModel(route))
.map((route) => route.id);
const requestedModels = Array.from(new Set<string>(exactRoutes.map((route) => route.modelPattern)));
@@ -204,7 +226,7 @@ export default function TokenRoutes() {
}
setDecisionByRoute(decisionPlaceholder);
setDecisionAutoSkipped(
summaries.some((route) => isExactModelPattern(route.modelPattern) && !route.decisionSnapshot),
summaries.some((route) => isRouteExactModel(route) && !route.decisionSnapshot),
);
};
@@ -249,7 +271,7 @@ export default function TokenRoutes() {
const exactRouteCount = useMemo(
() => buildVisibleRouteList(routeSummaries, isExactModelPattern, matchesModelPattern)
.filter((route) => isExactModelPattern(route.modelPattern)).length,
.filter((route) => isRouteExactModel(route)).length,
[routeSummaries],
);
@@ -263,9 +285,13 @@ export default function TokenRoutes() {
[routeSummaries, showZeroChannelRoutes, zeroChannelPlaceholderRoutes],
);
const canSaveRoute = !saving
&& !!form.modelPattern.trim()
&& !getModelPatternError(form.modelPattern);
const canSaveRoute = useMemo(() => {
if (saving) return false;
if (form.routeMode === 'explicit_group') {
return !!form.displayName.trim() && form.sourceRouteIds.length > 0;
}
return !!form.modelPattern.trim() && !getModelPatternError(form.modelPattern);
}, [form.displayName, form.modelPattern, form.routeMode, form.sourceRouteIds.length, saving]);
const previewModelSamples = useMemo(() => {
const names = new Set<string>();
@@ -274,7 +300,7 @@ export default function TokenRoutes() {
if (normalized) names.add(normalized);
}
for (const route of routeSummaries) {
if (!isExactModelPattern(route.modelPattern)) continue;
if (!isRouteExactModel(route)) continue;
const normalized = route.modelPattern.trim();
if (normalized) names.add(normalized);
}
@@ -283,38 +309,59 @@ export default function TokenRoutes() {
.slice(0, 800);
}, [modelCandidates, routeSummaries]);
const exactSourceRouteOptions = useMemo(
() => routeSummaries.filter((route) => isRouteExactModel(route)),
[routeSummaries],
);
const resetRouteForm = () => {
setForm({ modelPattern: '', displayName: '', displayIcon: '' });
setForm(EMPTY_ROUTE_FORM);
setEditingRouteId(null);
};
const handleAddRoute = async () => {
if (!form.modelPattern.trim()) return;
const modelPatternError = getModelPatternError(form.modelPattern);
if (modelPatternError) {
toast.error(modelPatternError);
return;
}
const trimmedModelPattern = form.modelPattern.trim();
const trimmedDisplayName = form.displayName.trim() ? form.displayName.trim() : undefined;
const trimmedDisplayIcon = form.displayIcon.trim() ? form.displayIcon.trim() : undefined;
const trimmedModelPattern = form.modelPattern.trim();
const routeMode = normalizeRouteMode(form.routeMode);
if (routeMode === 'explicit_group') {
if (!trimmedDisplayName) {
toast.error('请填写对外模型名');
return;
}
if (form.sourceRouteIds.length === 0) {
toast.error('请至少选择一个来源模型');
return;
}
} else {
if (!trimmedModelPattern) return;
const modelPatternError = getModelPatternError(form.modelPattern);
if (modelPatternError) {
toast.error(modelPatternError);
return;
}
}
setSaving(true);
try {
if (editingRouteId) {
const currentRoute = routeSummaries.find((route) => route.id === editingRouteId) || null;
const modelPatternChanged = !!currentRoute && currentRoute.modelPattern !== trimmedModelPattern;
const modelPatternChanged = routeMode === 'pattern' && !!currentRoute && currentRoute.modelPattern !== trimmedModelPattern;
await api.updateRoute(editingRouteId, {
modelPattern: trimmedModelPattern,
routeMode,
...(routeMode === 'pattern' ? { modelPattern: trimmedModelPattern } : {}),
displayName: trimmedDisplayName,
displayIcon: trimmedDisplayIcon,
...(routeMode === 'explicit_group' ? { sourceRouteIds: form.sourceRouteIds } : {}),
});
toast.success(modelPatternChanged ? tr('群组已更新并重新匹配通道') : tr('群组已更新'));
toast.success(routeMode === 'pattern' && modelPatternChanged ? tr('群组已更新并重新匹配通道') : tr('群组已更新'));
} else {
await api.addRoute({
modelPattern: trimmedModelPattern,
routeMode,
...(routeMode === 'pattern' ? { modelPattern: trimmedModelPattern } : {}),
displayName: trimmedDisplayName,
displayIcon: trimmedDisplayIcon,
...(routeMode === 'explicit_group' ? { sourceRouteIds: form.sourceRouteIds } : {}),
});
toast.success(tr('群组已创建'));
}
@@ -330,10 +377,14 @@ export default function TokenRoutes() {
const handleEditRoute = (route: RouteSummaryRow) => {
setEditingRouteId(route.id);
const routeMode = normalizeRouteMode(route.routeMode);
setForm({
routeMode,
modelPattern: route.modelPattern || '',
displayName: route.displayName || '',
displayIcon: normalizeRouteDisplayIconValue(route.displayIcon),
sourceRouteIds: routeMode === 'explicit_group' ? [...(route.sourceRouteIds || [])] : [],
advancedOpen: routeMode === 'pattern',
});
setShowManual(true);
};
@@ -403,9 +454,9 @@ export default function TokenRoutes() {
// Stable derived value: only changes when route patterns change (not on enabled toggle)
const routePatterns = useMemo(
() => visibleRouteRows.map((r) => ({ id: r.id, modelPattern: r.modelPattern })),
() => visibleRouteRows.map((r) => ({ id: r.id, modelPattern: r.modelPattern, routeMode: r.routeMode })),
// eslint-disable-next-line react-hooks/exhaustive-deps
[visibleRouteRows.map((r) => `${r.id}:${r.modelPattern}`).join(',')],
[visibleRouteRows.map((r) => `${r.id}:${r.modelPattern}:${r.routeMode || 'pattern'}`).join(',')],
);
const routeBrandById = useMemo(() => {
@@ -541,7 +592,7 @@ export default function TokenRoutes() {
const groupRouteList = useMemo<GroupRouteItem[]>(() => (
listVisibleRoutes
.filter((route) => !isExactModelPattern(route.modelPattern))
.filter((route) => !isRouteExactModel(route))
.map((route) => ({
id: route.id,
title: resolveRouteTitle(route),
@@ -577,7 +628,7 @@ export default function TokenRoutes() {
let list = sortedRoutes;
if (activeGroupFilter === '__all__') {
list = list.filter((route) => !isExactModelPattern(route.modelPattern));
list = list.filter((route) => !isRouteExactModel(route));
} else if (typeof activeGroupFilter === 'number') {
list = list.filter((route) => route.id === activeGroupFilter);
}
@@ -749,7 +800,7 @@ export default function TokenRoutes() {
);
const route = routeSummaries.find((r) => r.id === routeId);
if (route && isExactModelPattern(route.modelPattern)) {
if (route && isRouteExactModel(route)) {
try {
const res = await api.getRouteDecision(route.modelPattern);
setDecisionByRoute((prev) => ({
@@ -1008,20 +1059,13 @@ export default function TokenRoutes() {
<button
onClick={() => {
if (showManual) {
setShowManual(false);
resetRouteForm();
return;
}
setShowManual(true);
resetRouteForm();
setShowManual(true);
}}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{editingRouteId
? tr('取消编辑')
: (showManual ? tr('收起群组创建') : tr('新建群组'))}
{tr('新建群组')}
</button>
<button
@@ -1107,6 +1151,7 @@ export default function TokenRoutes() {
canSave={canSaveRoute}
routeIconSelectOptions={routeIconSelectOptions}
previewModelSamples={previewModelSamples}
exactSourceRouteOptions={exactSourceRouteOptions}
onSave={handleAddRoute}
onCancel={handleCancelEditRoute}
/>
+20 -4
View File
@@ -2,9 +2,19 @@ export type RouteListVisibilityItem = {
id: number;
modelPattern: string;
displayName?: string | null;
routeMode?: string | null;
sourceRouteIds?: number[];
enabled: boolean;
};
function normalizeRouteMode(routeMode: string | null | undefined): 'pattern' | 'explicit_group' {
return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern';
}
function isExplicitGroupRoute(route: Pick<RouteListVisibilityItem, 'routeMode'>): boolean {
return normalizeRouteMode(route.routeMode) === 'explicit_group';
}
function hasCustomDisplayName(route: Pick<RouteListVisibilityItem, 'modelPattern' | 'displayName'>): boolean {
const displayName = (route.displayName || '').trim();
const modelPattern = (route.modelPattern || '').trim();
@@ -18,19 +28,22 @@ export function buildVisibleRouteList<T extends RouteListVisibilityItem>(
): T[] {
const exactModelNames = new Set(
routes
.filter((route) => isExactModelPattern(route.modelPattern))
.filter((route) => !isExplicitGroupRoute(route) && isExactModelPattern(route.modelPattern))
.map((route) => (route.modelPattern || '').trim())
.filter(Boolean),
);
const coveringGroups = routes.filter((route) => (
route.enabled
&& !isExactModelPattern(route.modelPattern)
&& hasCustomDisplayName(route)
&& (
(isExplicitGroupRoute(route) && ((route.displayName || '').trim().length > 0) && (route.sourceRouteIds || []).length > 0)
|| (!isExplicitGroupRoute(route) && !isExactModelPattern(route.modelPattern) && hasCustomDisplayName(route))
)
));
if (coveringGroups.length === 0) return routes;
return routes.filter((route) => {
if (isExplicitGroupRoute(route)) return true;
if (!isExactModelPattern(route.modelPattern)) return true;
if (hasCustomDisplayName(route)) return true;
@@ -40,7 +53,10 @@ export function buildVisibleRouteList<T extends RouteListVisibilityItem>(
return !coveringGroups.some((groupRoute) => (
groupRoute.id !== route.id
&& !exactModelNames.has((groupRoute.displayName || '').trim())
&& matchesModelPattern(exactModel, groupRoute.modelPattern)
&& (
(isExplicitGroupRoute(groupRoute) && (groupRoute.sourceRouteIds || []).includes(route.id))
|| (!isExplicitGroupRoute(groupRoute) && matchesModelPattern(exactModel, groupRoute.modelPattern))
)
));
});
}
@@ -32,6 +32,7 @@ export type RouteCandidateView = {
export type RouteModelPatternLike = {
id: number;
modelPattern: string;
routeMode?: string | null;
};
const EMPTY_ROUTE_CANDIDATE_VIEW: RouteCandidateView = {
@@ -48,6 +49,10 @@ export function buildRouteModelCandidatesIndex(
const index: Record<number, RouteCandidateView> = {};
for (const route of routes || []) {
if (route.routeMode === 'explicit_group') {
index[route.id] = EMPTY_ROUTE_CANDIDATE_VIEW;
continue;
}
const modelPattern = (route.modelPattern || '').trim();
if (!modelPattern) {
index[route.id] = EMPTY_ROUTE_CANDIDATE_VIEW;
+428 -129
View File
@@ -1,24 +1,51 @@
import { useMemo } from 'react';
import { BrandGlyph } from '../../components/BrandIcon.js';
import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
import CenteredModal from '../../components/CenteredModal.js';
import ModernSelect from '../../components/ModernSelect.js';
import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js';
import { tr } from '../../i18n.js';
import type { RouteIconOption } from './types.js';
import { getModelPatternError, matchesModelPattern, normalizeRouteDisplayIconValue } from './utils.js';
import type { RouteIconOption, RouteMode, RouteSummaryRow } from './types.js';
import {
getModelPatternError,
isExactModelPattern,
matchesModelPattern,
normalizeRouteDisplayIconValue,
normalizeRouteMode,
} from './utils.js';
type RouteEditorForm = {
routeMode: RouteMode;
displayName: string;
displayIcon: string;
modelPattern: string;
sourceRouteIds: number[];
advancedOpen: boolean;
};
type ManualRoutePanelProps = {
show: boolean;
editingRouteId: number | null;
form: { modelPattern: string; displayName: string; displayIcon: string };
setForm: (updater: (f: { modelPattern: string; displayName: string; displayIcon: string }) => { modelPattern: string; displayName: string; displayIcon: string }) => void;
form: RouteEditorForm;
setForm: Dispatch<SetStateAction<RouteEditorForm>>;
saving: boolean;
canSave: boolean;
routeIconSelectOptions: RouteIconOption[];
previewModelSamples: string[];
exactSourceRouteOptions: RouteSummaryRow[];
onSave: () => void;
onCancel: () => void;
};
function renderRouteOptionLabel(route: RouteSummaryRow): string {
const displayName = (route.displayName || '').trim();
return displayName || route.modelPattern;
}
function toggleSourceRouteId(sourceRouteIds: number[], routeId: number): number[] {
if (sourceRouteIds.includes(routeId)) {
return sourceRouteIds.filter((id) => id !== routeId);
}
return [...sourceRouteIds, routeId].sort((a, b) => a - b);
}
export default function ManualRoutePanel({
show,
editingRouteId,
@@ -28,10 +55,20 @@ export default function ManualRoutePanel({
canSave,
routeIconSelectOptions,
previewModelSamples,
exactSourceRouteOptions,
onSave,
onCancel,
}: ManualRoutePanelProps) {
const presence = useAnimatedVisibility(show, 220);
const [sourceSearch, setSourceSearch] = useState('');
useEffect(() => {
if (!show) {
setSourceSearch('');
}
}, [show]);
const routeMode = normalizeRouteMode(form.routeMode);
const editingLegacyPatternGroup = editingRouteId !== null && routeMode === 'pattern';
const modelPatternError = useMemo(
() => getModelPatternError(form.modelPattern),
@@ -53,144 +90,406 @@ export default function ManualRoutePanel({
return previewModelSamples.filter((modelName) => matchesModelPattern(modelName, normalizedPattern));
}, [form.modelPattern, modelPatternError, previewModelSamples]);
if (!presence.shouldRender) return null;
const filteredSourceRoutes = useMemo(() => {
const normalizedSearch = sourceSearch.trim().toLowerCase();
if (!normalizedSearch) return exactSourceRouteOptions;
return exactSourceRouteOptions.filter((route) => {
const label = renderRouteOptionLabel(route).toLowerCase();
return label.includes(normalizedSearch) || route.modelPattern.toLowerCase().includes(normalizedSearch);
});
}, [exactSourceRouteOptions, sourceSearch]);
const selectedSourceRoutes = useMemo(() => {
const routeById = new Map(exactSourceRouteOptions.map((route) => [route.id, route]));
return form.sourceRouteIds
.map((routeId) => routeById.get(routeId))
.filter((route): route is RouteSummaryRow => !!route);
}, [exactSourceRouteOptions, form.sourceRouteIds]);
const footer = (
<>
<button
type="button"
onClick={onCancel}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{editingRouteId ? tr('取消编辑') : tr('取消')}
</button>
<button
type="button"
onClick={onSave}
disabled={!canSave}
className="btn btn-success"
>
{saving ? (
<>
<span
className="spinner spinner-sm"
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
/>{' '}
{tr('保存中...')}
</>
) : (
tr(editingRouteId ? '保存群组' : '创建群组')
)}
</button>
</>
);
return (
<div className={`card panel-presence ${presence.isVisible ? '' : 'is-closing'}`.trim()} style={{ padding: 20, marginBottom: 16 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
{editingRouteId
? tr('编辑群组路由名称、图标和模型匹配规则;若修改正则,将按当前可用模型重新匹配自动通道。')
: tr('用于创建群组路由(聚合多个上游模型为一个下游模型名,即模型重定向);自动路由仍会保持开启。')}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 180px', gap: 10 }}>
<input
placeholder={tr('群组显示名(可选,例如 claude-opus-4-6)')}
value={form.displayName}
onChange={(e) => setForm((f) => ({ ...f, displayName: e.target.value }))}
style={{
width: '100%',
padding: '10px 14px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: 13,
outline: 'none',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
}}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<ModernSelect
value={routeIconSelectValue}
onChange={(nextValue) => setForm((f) => ({ ...f, displayIcon: nextValue }))}
options={routeIconSelectOptions}
placeholder={tr('图标(可选,选择品牌图标)')}
emptyLabel={tr('暂无可选品牌图标')}
/>
</div>
</div>
<input
placeholder={tr('模型匹配(如 gpt-4o、claude-*、re:^claude-.*$)')}
value={form.modelPattern}
onChange={(e) => setForm((f) => ({ ...f, modelPattern: e.target.value }))}
style={{
width: '100%',
padding: '10px 14px',
border: `1px solid ${modelPatternError ? 'var(--color-danger)' : 'var(--color-border)'}`,
borderRadius: 'var(--radius-sm)',
fontSize: 13,
outline: 'none',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
fontFamily: 'var(--font-mono)',
}}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: -4 }}>
{tr('正则请使用 re: 前缀;例如 re:^claude-(opus|sonnet)-4-6$')}
</div>
{modelPatternError && (
<div style={{ fontSize: 12, color: 'var(--color-danger)', marginTop: -4 }}>
{modelPatternError}
</div>
)}
{form.modelPattern.trim() && !modelPatternError && (
<CenteredModal
open={show}
onClose={onCancel}
title={editingRouteId ? tr('编辑群组') : tr('新建群组')}
footer={footer}
maxWidth={860}
closeOnEscape
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{editingLegacyPatternGroup ? (
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
padding: '10px 12px',
background: 'var(--color-bg)',
borderRadius: 'var(--radius-md)',
padding: '12px 14px',
background: 'var(--color-bg-card)',
}}
>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}>
{tr('规则预览:命中样本')} {previewMatchedModels.length} / {previewModelSamples.length}
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 6 }}>
{tr('高级规则群组')}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('该群组使用高级模型匹配规则创建,普通模式不可编辑;修改后会按当前可用模型重新匹配自动通道。')}
</div>
</div>
) : routeMode === 'explicit_group' ? (
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
padding: '12px 14px',
background: 'var(--color-bg-card)',
}}
>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 6 }}>
{tr('模型重定向')}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('将多个现有模型合并为一个对外模型名,实现模型重定向。')}
</div>
</div>
) : (
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
padding: '12px 14px',
background: 'var(--color-bg-card)',
}}
>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 6 }}>
{tr('高级规则群组')}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('使用模型匹配规则创建群组,适合需要 regex / glob 的高级场景。')}
</div>
</div>
)}
{routeMode === 'explicit_group' ? (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr)', gap: 12 }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{tr('对外模型名')}</span>
<input
placeholder={tr('对外模型名(例如 claude-opus-4-6)')}
value={form.displayName}
onChange={(e) => setForm((current) => ({ ...current, displayName: e.target.value }))}
style={{
width: '100%',
padding: '10px 14px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: 13,
outline: 'none',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
}}
/>
</label>
</div>
{previewModelSamples.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('当前暂无可预览模型,请先同步模型。')}
</div>
) : previewMatchedModels.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('当前规则未命中任何样本模型。')}
</div>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{previewMatchedModels.slice(0, 12).map((modelName) => (
<code
key={modelName}
style={{
fontSize: 11,
padding: '2px 6px',
borderRadius: 6,
border: '1px solid var(--color-border)',
background: 'var(--color-bg-card)',
}}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
{tr('来源模型')}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('选择一个或多个现有精确模型路由作为来源。')}
</div>
</div>
{!editingRouteId && (
<button
type="button"
className="btn btn-link"
onClick={() => setForm((current) => ({ ...current, routeMode: 'pattern', advancedOpen: true }))}
>
{modelName}
</code>
))}
{tr('改用高级规则')}
</button>
)}
</div>
<input
placeholder={tr('搜索来源模型')}
value={sourceSearch}
onChange={(e) => setSourceSearch(e.target.value)}
style={{
width: '100%',
padding: '10px 14px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: 13,
outline: 'none',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
}}
/>
{selectedSourceRoutes.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{selectedSourceRoutes.map((route) => (
<button
key={`selected-${route.id}`}
type="button"
className="badge badge-info"
style={{ fontSize: 11, cursor: 'pointer' }}
onClick={() => setForm((current) => ({
...current,
sourceRouteIds: toggleSourceRouteId(current.sourceRouteIds, route.id),
}))}
>
{renderRouteOptionLabel(route)} ×
</button>
))}
</div>
)}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gap: 10,
maxHeight: 280,
overflowY: 'auto',
paddingRight: 4,
}}
>
{filteredSourceRoutes.map((route) => {
const selected = form.sourceRouteIds.includes(route.id);
const label = renderRouteOptionLabel(route);
return (
<button
key={route.id}
type="button"
onClick={() => setForm((current) => ({
...current,
sourceRouteIds: toggleSourceRouteId(current.sourceRouteIds, route.id),
}))}
className="btn btn-ghost"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: 6,
padding: '12px 14px',
border: `1px solid ${selected ? 'var(--color-primary)' : 'var(--color-border)'}`,
background: selected ? 'color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card))' : 'var(--color-bg-card)',
boxShadow: selected ? '0 0 0 1px color-mix(in srgb, var(--color-primary) 25%, transparent)' : 'none',
}}
>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>{label}</span>
{label !== route.modelPattern && (
<code style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{route.modelPattern}</code>
)}
<span style={{ fontSize: 11, color: selected ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>
{selected ? tr('已选中') : tr('点击添加')}
</span>
</button>
);
})}
</div>
{filteredSourceRoutes.length === 0 && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('没有匹配的来源模型。')}
</div>
)}
</div>
<details
open={form.advancedOpen}
onToggle={(event) => {
const nextOpen = (event.currentTarget as HTMLDetailsElement).open;
setForm((current) => ({ ...current, advancedOpen: nextOpen }));
}}
style={{
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
padding: '0 14px',
background: 'var(--color-bg-card)',
}}
>
<summary style={{ cursor: 'pointer', padding: '12px 0', fontSize: 13, fontWeight: 600 }}>
{tr('高级配置')}
</summary>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr)', gap: 12, paddingBottom: 14 }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{tr('群组图标')}</span>
<ModernSelect
value={routeIconSelectValue}
onChange={(nextValue) => setForm((current) => ({ ...current, displayIcon: nextValue }))}
options={routeIconSelectOptions}
placeholder={tr('图标(可选,选择品牌图标)')}
emptyLabel={tr('暂无可选品牌图标')}
/>
</label>
</div>
</details>
</>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{!editingLegacyPatternGroup && (
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
type="button"
className="btn btn-link"
onClick={() => setForm((current) => ({
...current,
routeMode: 'explicit_group',
advancedOpen: false,
}))}
>
{tr('返回简单模式')}
</button>
</div>
)}
{previewMatchedModels.length > 12 && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 8 }}>
{tr('仅展示前 12 个命中样本。')}
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 220px', gap: 12 }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{tr('群组显示名')}</span>
<input
placeholder={tr('群组显示名(可选,例如 claude-opus-4-6)')}
value={form.displayName}
onChange={(e) => setForm((current) => ({ ...current, displayName: e.target.value }))}
style={{
width: '100%',
padding: '10px 14px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: 13,
outline: 'none',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
}}
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{tr('群组图标')}</span>
<ModernSelect
value={routeIconSelectValue}
onChange={(nextValue) => setForm((current) => ({ ...current, displayIcon: nextValue }))}
options={routeIconSelectOptions}
placeholder={tr('图标(可选,选择品牌图标)')}
emptyLabel={tr('暂无可选品牌图标')}
/>
</label>
</div>
<label style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{tr('模型匹配')}</span>
<input
placeholder={tr('模型匹配(如 gpt-4o、claude-*、re:^claude-.*$)')}
value={form.modelPattern}
onChange={(e) => setForm((current) => ({ ...current, modelPattern: e.target.value }))}
style={{
width: '100%',
padding: '10px 14px',
border: `1px solid ${modelPatternError ? 'var(--color-danger)' : 'var(--color-border)'}`,
borderRadius: 'var(--radius-sm)',
fontSize: 13,
outline: 'none',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
fontFamily: 'var(--font-mono)',
}}
/>
</label>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: -4 }}>
{isExactModelPattern(form.modelPattern)
? tr('当前为精确模型匹配。')
: tr('正则请使用 re: 前缀;例如 re:^claude-(opus|sonnet)-4-6$')}
</div>
{modelPatternError && (
<div style={{ fontSize: 12, color: 'var(--color-danger)', marginTop: -4 }}>
{modelPatternError}
</div>
)}
{form.modelPattern.trim() && !modelPatternError && (
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
padding: '10px 12px',
background: 'var(--color-bg)',
}}
>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}>
{tr('规则预览:命中样本')} {previewMatchedModels.length} / {previewModelSamples.length}
</div>
{previewModelSamples.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('当前暂无可预览模型,请先同步模型。')}
</div>
) : previewMatchedModels.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('当前规则未命中任何样本模型。')}
</div>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{previewMatchedModels.slice(0, 12).map((modelName) => (
<code
key={modelName}
style={{
fontSize: 11,
padding: '2px 6px',
borderRadius: 6,
border: '1px solid var(--color-border)',
background: 'var(--color-bg-card)',
}}
>
{modelName}
</code>
))}
</div>
)}
{previewMatchedModels.length > 12 && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 8 }}>
{tr('仅展示前 12 个命中样本。')}
</div>
)}
</div>
)}
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<button
onClick={onSave}
disabled={!canSave}
className="btn btn-success"
style={{ alignSelf: 'flex-start' }}
>
{saving ? (
<>
<span
className="spinner spinner-sm"
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
/>{' '}
{tr('保存中...')}
</>
) : (
tr(editingRouteId ? '保存群组' : '创建群组')
)}
</button>
{editingRouteId ? (
<button
onClick={onCancel}
className="btn btn-ghost"
style={{ alignSelf: 'flex-start', border: '1px solid var(--color-border)' }}
>
{tr('取消编辑')}
</button>
) : null}
</div>
</div>
</div>
</CenteredModal>
);
}
+67 -28
View File
@@ -29,7 +29,8 @@ import type {
import type { RouteCandidateView, RouteTokenOption } from '../helpers/routeModelCandidatesIndex.js';
import { SortableChannelRow } from './SortableChannelRow.js';
import {
isExactModelPattern,
isRouteExactModel,
isExplicitGroupRoute,
resolveRouteTitle,
resolveRouteIcon,
buildSourceGroupKey,
@@ -113,8 +114,10 @@ function RouteCardInner({
onToggleSourceGroup,
}: RouteCardProps) {
const routeIcon = resolveRouteIcon(route);
const exactRoute = isExactModelPattern(route.modelPattern);
const exactRoute = isRouteExactModel(route);
const explicitGroupRoute = isExplicitGroupRoute(route);
const readOnlyRoute = route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true;
const channelManagementDisabled = explicitGroupRoute;
const title = resolveRouteTitle(route);
const routingStrategy = route.routingStrategy === 'round_robin' ? 'round_robin' : 'weighted';
const routingStrategyOptions = [
@@ -280,7 +283,7 @@ function RouteCardInner({
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{!readOnlyRoute && !exactRoute && (
{!readOnlyRoute && (explicitGroupRoute || !exactRoute) && (
<button onClick={() => onEdit(route)} className="btn btn-link">{tr('编辑群组')}</button>
)}
{!readOnlyRoute && <button onClick={() => onDelete(route.id)} className="btn btn-link btn-link-danger">{tr('删除路由')}</button>}
@@ -301,11 +304,15 @@ function RouteCardInner({
</div>
</div>
{!exactRoute && (
{explicitGroupRoute ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
{tr('该群组会将多个来源模型聚合为一个对外模型名;通道信息继承自来源模型,当前仅支持查看,不支持直接维护。')}
</div>
) : !exactRoute ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
{tr('通配符路由按请求实时决策;概率解释在当前路由内统一估算。')}
</div>
)}
) : null}
{!readOnlyRoute && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
@@ -328,7 +335,7 @@ function RouteCardInner({
{/* Missing token hints + Add channel button */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
{(missingTokenSiteItems.length > 0 || missingTokenGroupItems.length > 0) ? (
{!channelManagementDisabled && (missingTokenSiteItems.length > 0 || missingTokenGroupItems.length > 0) ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, flex: 1 }}>
{missingTokenSiteItems.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
@@ -366,7 +373,7 @@ function RouteCardInner({
)}
</div>
) : <div />}
{!readOnlyRoute && (
{!readOnlyRoute && !channelManagementDisabled && (
<button
onClick={() => onAddChannel(route.id)}
className="btn btn-ghost"
@@ -437,28 +444,60 @@ function RouteCardInner({
) : null}
<AnimatedCollapseSection open={isGroupExpanded}>
<SortableContext items={group.channels.map((c) => c.id)} strategy={verticalListSortingStrategy}>
{group.channels.map((channel) => {
const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || [];
const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0;
return (
<SortableChannelRow
{explicitGroupRoute ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{group.channels.map((channel) => (
<div
key={channel.id}
channel={channel}
decisionCandidate={decisionMap.get(channel.id)}
isExactRoute={exactRoute}
loadingDecision={loadingDecision}
isSavingPriority={savingPriority}
tokenOptions={tokenOptions}
activeTokenId={activeTokenId}
isUpdatingToken={!!updatingChannel[channel.id]}
onTokenDraftChange={onTokenDraftChange}
onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)}
onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
/>
);
})}
</SortableContext>
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
padding: '8px 12px',
borderLeft: '2px solid var(--color-primary)',
borderRadius: '0 var(--radius-sm) var(--radius-sm) 0',
background: 'rgba(79,70,229,0.02)',
}}
>
<span style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>
{channel.account?.username || `account-${channel.accountId}`}
</span>
<span className="badge badge-muted" style={{ fontSize: 10 }}>
{channel.site?.name || 'unknown'}
</span>
{channel.sourceModel ? (
<span className="badge badge-info" style={{ fontSize: 10 }}>
{channel.sourceModel}
</span>
) : null}
</div>
))}
</div>
) : (
<SortableContext items={group.channels.map((c) => c.id)} strategy={verticalListSortingStrategy}>
{group.channels.map((channel) => {
const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || [];
const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0;
return (
<SortableChannelRow
key={channel.id}
channel={channel}
decisionCandidate={decisionMap.get(channel.id)}
isExactRoute={exactRoute}
loadingDecision={loadingDecision}
isSavingPriority={savingPriority}
tokenOptions={tokenOptions}
activeTokenId={activeTokenId}
isUpdatingToken={!!updatingChannel[channel.id]}
onTokenDraftChange={onTokenDraftChange}
onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)}
onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
/>
);
})}
</SortableContext>
)}
</AnimatedCollapseSection>
{!isGroupExpanded && (
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', paddingLeft: 6 }}>
+5
View File
@@ -6,6 +6,7 @@ export type RouteSortDir = 'asc' | 'desc';
export type GroupFilter = null | '__all__' | number;
export type RouteRoutingStrategy = 'weighted' | 'round_robin';
export type RouteRowKind = 'persisted' | 'zero_channel';
export type RouteMode = 'pattern' | 'explicit_group';
export type RouteChannelDraft = {
accountId: number;
@@ -47,6 +48,8 @@ export type RouteRow = {
modelPattern: string;
displayName?: string | null;
displayIcon?: string | null;
routeMode?: RouteMode | null;
sourceRouteIds?: number[];
modelMapping?: string | null;
routingStrategy?: RouteRoutingStrategy | null;
decisionSnapshot?: RouteDecision | null;
@@ -60,6 +63,8 @@ export type RouteSummaryRow = {
modelPattern: string;
displayName: string | null;
displayIcon: string | null;
routeMode?: RouteMode | null;
sourceRouteIds?: number[];
modelMapping: string | null;
routingStrategy?: RouteRoutingStrategy | null;
enabled: boolean;
+13 -1
View File
@@ -1,6 +1,6 @@
import type { CSSProperties } from 'react';
import { getBrand, normalizeBrandIconKey, type BrandInfo } from '../../components/BrandIcon.js';
import type { RouteRow, RouteChannel, RouteDecisionCandidate, ChannelDecisionState, RouteSummaryRow } from './types.js';
import type { RouteRow, RouteChannel, RouteDecisionCandidate, ChannelDecisionState, RouteSummaryRow, RouteMode } from './types.js';
export const AUTO_ROUTE_DECISION_LIMIT = 80;
export const ROUTE_RENDER_CHUNK = 40;
@@ -48,6 +48,18 @@ export function isExactModelPattern(modelPattern: string): boolean {
return !/[\*\?]/.test(normalized);
}
export function normalizeRouteMode(routeMode: RouteMode | string | null | undefined): RouteMode {
return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern';
}
export function isExplicitGroupRoute(route: Pick<RouteRow | RouteSummaryRow, 'routeMode'>): boolean {
return normalizeRouteMode(route.routeMode) === 'explicit_group';
}
export function isRouteExactModel(route: Pick<RouteRow | RouteSummaryRow, 'modelPattern' | 'routeMode'>): boolean {
return !isExplicitGroupRoute(route) && isExactModelPattern(route.modelPattern);
}
export function parseRegexModelPattern(modelPattern: string): { regex: RegExp | null; error: string | null } {
if (!isRegexModelPattern(modelPattern)) return { regex: null, error: null };
const body = modelPattern.trim().slice(3).trim();
+120 -87
View File
@@ -855,16 +855,23 @@ describe('TokenRoutes grouped source models', () => {
}
});
it('enters edit mode and seeds the group form with the current route values', async () => {
it('opens a modal-based group creator and submits explicit-group sourceRouteIds', async () => {
apiMock.getRoutesSummary.mockResolvedValue([
{
id: 31, modelPattern: 're:^claude-(opus|sonnet)-4-6$', displayName: 'claude-4-6-group',
displayIcon: 'anthropic', modelMapping: null, enabled: true,
id: 11, modelPattern: 'claude-opus-4-5', displayName: null,
displayIcon: null, modelMapping: null, enabled: true,
routeMode: 'pattern', sourceRouteIds: [],
channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'],
decisionSnapshot: null, decisionRefreshedAt: null,
},
{
id: 12, modelPattern: 'claude-sonnet-4-5', displayName: null,
displayIcon: null, modelMapping: null, enabled: true,
routeMode: 'pattern', sourceRouteIds: [],
channelCount: 0, enabledChannelCount: 0, siteNames: [],
decisionSnapshot: null, decisionRefreshedAt: null,
},
]);
apiMock.getRouteChannels.mockResolvedValue([]);
let root: ReturnType<typeof create> | null = null;
try {
@@ -879,109 +886,45 @@ describe('TokenRoutes grouped source models', () => {
});
await flushMicrotasks();
// Expand the card to access edit button
const expandBtn = root.root.find((node) =>
node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
);
await act(async () => {
expandBtn.props.onClick();
});
await flushMicrotasks();
const editButton = findButtonByText(root.root, '编辑群组');
await act(async () => {
editButton.props.onClick();
});
await flushMicrotasks();
expect(findInputByPlaceholder(root.root, '群组显示名').props.value).toBe('claude-4-6-group');
expect(findInputByPlaceholder(root.root, '模型匹配').props.value).toBe('re:^claude-(opus|sonnet)-4-6$');
expect(collectText(root.root)).toContain('保存群组');
} finally {
root?.unmount();
}
});
it('updates route metadata from edit mode and reloads routes afterwards', async () => {
apiMock.getRoutesSummary
.mockResolvedValueOnce([
{
id: 41, modelPattern: 're:^claude-.*$', displayName: 'old-group',
displayIcon: '', modelMapping: null, enabled: true,
channelCount: 0, enabledChannelCount: 0, siteNames: [],
decisionSnapshot: null, decisionRefreshedAt: null,
},
])
.mockResolvedValueOnce([
{
id: 41, modelPattern: 're:^claude-.*$', displayName: 'new-group',
displayIcon: '', modelMapping: null, enabled: true,
channelCount: 0, enabledChannelCount: 0, siteNames: [],
decisionSnapshot: null, decisionRefreshedAt: null,
},
]);
apiMock.getRouteChannels.mockResolvedValue([]);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
// Expand the card
const expandBtn = root.root.find((node) =>
node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
);
await act(async () => {
expandBtn.props.onClick();
findButtonByText(root.root, '新建群组').props.onClick();
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, '编辑群组').props.onClick();
findInputByPlaceholder(root.root, '对外模型名').props.onChange({ target: { value: 'claude-opus-4-6' } });
});
await flushMicrotasks();
await act(async () => {
findInputByPlaceholder(root.root, '群组显示名').props.onChange({ target: { value: 'new-group' } });
});
await act(async () => {
findButtonByText(root.root, '保存群组').props.onClick();
findButtonByText(root.root, 'claude-opus-4-5').props.onClick();
findButtonByText(root.root, 'claude-sonnet-4-5').props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateRoute).toHaveBeenCalledWith(41, expect.objectContaining({
displayName: 'new-group',
modelPattern: 're:^claude-.*$',
await act(async () => {
findButtonByText(root.root, '创建群组').props.onClick();
});
await flushMicrotasks();
expect(collectText(root.root)).toContain('模型重定向');
expect(apiMock.addRoute).toHaveBeenCalledWith(expect.objectContaining({
routeMode: 'explicit_group',
displayName: 'claude-opus-4-6',
sourceRouteIds: [11, 12],
}));
expect(apiMock.getRoutesSummary).toHaveBeenCalledTimes(2);
} finally {
root?.unmount();
}
});
it('reloads route data after saving an edited model pattern', async () => {
it('edits legacy regex groups in advanced mode only', async () => {
apiMock.getRoutesSummary
.mockResolvedValueOnce([
{
id: 51, modelPattern: 're:^claude-.*$', displayName: 'group-a',
displayIcon: '', modelMapping: null, enabled: true,
channelCount: 0, enabledChannelCount: 0, siteNames: [],
decisionSnapshot: null, decisionRefreshedAt: null,
},
])
.mockResolvedValueOnce([
{
id: 51, modelPattern: 're:^gemini-.*$', displayName: 'group-a',
displayIcon: '', modelMapping: null, enabled: true,
routeMode: 'pattern', sourceRouteIds: [],
channelCount: 0, enabledChannelCount: 0, siteNames: [],
decisionSnapshot: null, decisionRefreshedAt: null,
},
@@ -1015,17 +958,107 @@ describe('TokenRoutes grouped source models', () => {
});
await flushMicrotasks();
expect(collectText(root.root)).toContain('高级规则群组');
expect(findInputByPlaceholder(root.root, '模型匹配').props.value).toBe('re:^claude-.*$');
expect(root.root.findAll((node) => typeof node.props?.placeholder === 'string' && node.props.placeholder.includes('搜索来源模型'))).toHaveLength(0);
expect(root.root.findAll((node) => typeof node.props?.placeholder === 'string' && node.props.placeholder.includes('对外模型名'))).toHaveLength(0);
} finally {
root?.unmount();
}
});
it('updates explicit-group sources from the modal and reloads routes afterwards', async () => {
apiMock.getRoutesSummary
.mockResolvedValueOnce([
{
id: 11, modelPattern: 'claude-opus-4-5', displayName: null,
displayIcon: null, modelMapping: null, enabled: true,
routeMode: 'pattern', sourceRouteIds: [],
channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'],
decisionSnapshot: null, decisionRefreshedAt: null,
},
{
id: 12, modelPattern: 'claude-sonnet-4-5', displayName: null,
displayIcon: null, modelMapping: null, enabled: true,
routeMode: 'pattern', sourceRouteIds: [],
channelCount: 1, enabledChannelCount: 1, siteNames: ['site-b'],
decisionSnapshot: null, decisionRefreshedAt: null,
},
{
id: 21, modelPattern: 'claude-opus-4-6', displayName: 'claude-opus-4-6',
displayIcon: '', modelMapping: null, enabled: true,
routeMode: 'explicit_group', sourceRouteIds: [11],
channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'],
decisionSnapshot: null, decisionRefreshedAt: null,
},
])
.mockResolvedValueOnce([
{
id: 11, modelPattern: 'claude-opus-4-5', displayName: null,
displayIcon: null, modelMapping: null, enabled: true,
routeMode: 'pattern', sourceRouteIds: [],
channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'],
decisionSnapshot: null, decisionRefreshedAt: null,
},
{
id: 12, modelPattern: 'claude-sonnet-4-5', displayName: null,
displayIcon: null, modelMapping: null, enabled: true,
routeMode: 'pattern', sourceRouteIds: [],
channelCount: 1, enabledChannelCount: 1, siteNames: ['site-b'],
decisionSnapshot: null, decisionRefreshedAt: null,
},
{
id: 21, modelPattern: 'claude-opus-4-6', displayName: 'claude-opus-4-6',
displayIcon: '', modelMapping: null, enabled: true,
routeMode: 'explicit_group', sourceRouteIds: [11, 12],
channelCount: 2, enabledChannelCount: 2, siteNames: ['site-a', 'site-b'],
decisionSnapshot: null, decisionRefreshedAt: null,
},
]);
apiMock.getRouteChannels.mockResolvedValue([]);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
findInputByPlaceholder(root.root, '模型匹配').props.onChange({ target: { value: 're:^gemini-.*$' } });
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const expandBtn = root.root.find((node) =>
node.type === 'div'
&& String(node.props.className || '').includes('route-card-collapsed')
&& collectText(node).includes('claude-opus-4-6'),
);
await act(async () => {
expandBtn.props.onClick();
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, '编辑群组').props.onClick();
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, 'claude-sonnet-4-5').props.onClick();
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, '保存群组').props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateRoute).toHaveBeenCalledWith(51, expect.objectContaining({
modelPattern: 're:^gemini-.*$',
expect(apiMock.updateRoute).toHaveBeenCalledWith(21, expect.objectContaining({
routeMode: 'explicit_group',
displayName: 'claude-opus-4-6',
sourceRouteIds: [11, 12],
}));
expect(apiMock.getRoutesSummary).toHaveBeenCalledTimes(2);
} finally {