forked from boos/BoosAPI
fix route grouping and codex websocket regressions
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user