1
0
forked from boos/BoosAPI

feat: enhance TokenRoutes with edit functionality and route visibility management

- Added editing capabilities for route management in TokenRoutes, allowing users to modify existing routes.
- Introduced a reset function for the route form to clear inputs after adding or editing routes.
- Implemented a visibility filter for routes, ensuring exact routes covered by group routes are hidden from the main list.
- Updated tests to cover new functionalities, including editing routes and verifying visibility logic.
- Created a new helper function to build a list of visible routes based on defined criteria.
- Enhanced schema compatibility checks for site configurations, ensuring necessary columns are added to the database.
This commit is contained in:
cita
2026-03-07 19:56:40 +08:00
parent 5ad5b87b16
commit ed05b37626
18 changed files with 1476 additions and 153 deletions
+124 -48
View File
@@ -34,6 +34,7 @@ import {
normalizeMissingTokenModels,
type MissingTokenModelsByName,
} from './helpers/routeMissingTokenHints.js';
import { buildVisibleRouteList } from './helpers/routeListVisibility.js';
type RouteSortBy = 'modelPattern' | 'channelCount';
type RouteSortDir = 'asc' | 'desc';
@@ -705,6 +706,7 @@ export default function TokenRoutes() {
const filterPanelPresence = useAnimatedVisibility(!filterCollapsed, 220);
const manualPanelPresence = useAnimatedVisibility(showManual, 220);
const [form, setForm] = useState({ modelPattern: '', displayName: '', displayIcon: '' });
const [editingRouteId, setEditingRouteId] = useState<number | null>(null);
const [saving, setSaving] = useState(false);
const [rebuilding, setRebuilding] = useState(false);
@@ -849,7 +851,8 @@ export default function TokenRoutes() {
};
const exactRouteCount = useMemo(
() => routes.filter((route) => isExactModelPattern(route.modelPattern)).length,
() => buildVisibleRouteList(routes, isExactModelPattern, matchesModelPattern)
.filter((route) => isExactModelPattern(route.modelPattern)).length,
[routes],
);
@@ -887,30 +890,65 @@ export default function TokenRoutes() {
&& !!form.modelPattern.trim()
&& !modelPatternError;
const resetRouteForm = () => {
setForm({ modelPattern: '', displayName: '', displayIcon: '' });
setEditingRouteId(null);
};
const handleAddRoute = async () => {
if (!form.modelPattern.trim()) return;
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;
setSaving(true);
try {
await api.addRoute({
modelPattern: form.modelPattern.trim(),
displayName: form.displayName.trim() ? form.displayName.trim() : undefined,
displayIcon: form.displayIcon.trim() ? form.displayIcon.trim() : undefined,
});
if (editingRouteId) {
const currentRoute = routes.find((route) => route.id === editingRouteId) || null;
const modelPatternChanged = !!currentRoute && currentRoute.modelPattern !== trimmedModelPattern;
await api.updateRoute(editingRouteId, {
modelPattern: trimmedModelPattern,
displayName: trimmedDisplayName,
displayIcon: trimmedDisplayIcon,
});
toast.success(modelPatternChanged ? tr('群组已更新并重新匹配通道') : tr('群组已更新'));
} else {
await api.addRoute({
modelPattern: trimmedModelPattern,
displayName: trimmedDisplayName,
displayIcon: trimmedDisplayIcon,
});
toast.success(tr('群组已创建'));
}
setShowManual(false);
setForm({ modelPattern: '', displayName: '', displayIcon: '' });
toast.success(tr('群组已创建'));
resetRouteForm();
await load();
} catch (e: any) {
toast.error(e.message || tr('创建群组失败'));
toast.error(e.message || (editingRouteId ? tr('更新群组失败') : tr('创建群组失败')));
} finally {
setSaving(false);
}
};
const handleEditRoute = (route: RouteRow) => {
setEditingRouteId(route.id);
setForm({
modelPattern: route.modelPattern || '',
displayName: route.displayName || '',
displayIcon: route.displayIcon || '',
});
setShowManual(true);
};
const handleCancelEditRoute = () => {
resetRouteForm();
setShowManual(false);
};
const handleDeleteRoute = async (routeId: number) => {
try {
await api.deleteRoute(routeId);
@@ -945,11 +983,16 @@ export default function TokenRoutes() {
return next;
}, [routes]);
const listVisibleRoutes = useMemo(
() => buildVisibleRouteList(routes, isExactModelPattern, matchesModelPattern),
[routes],
);
const brandList = useMemo(() => {
const grouped = new Map<string, { count: number; brand: BrandInfo }>();
let otherCount = 0;
for (const route of routes) {
for (const route of listVisibleRoutes) {
const brand = routeBrandById.get(route.id) || null;
if (!brand) {
otherCount++;
@@ -971,12 +1014,12 @@ export default function TokenRoutes() {
}),
otherCount,
};
}, [routes, routeBrandById]);
}, [listVisibleRoutes, routeBrandById]);
const siteList = useMemo(() => {
const grouped = new Map<string, { count: number; siteId: number }>();
for (const route of routes) {
for (const route of listVisibleRoutes) {
const seenSites = new Set<string>();
for (const channel of route.channels || []) {
const siteName = channel.site?.name;
@@ -997,7 +1040,7 @@ export default function TokenRoutes() {
if (a[1].count === b[1].count) return a[0].localeCompare(b[0]);
return b[1].count - a[1].count;
});
}, [routes]);
}, [listVisibleRoutes]);
const routeEndpointTypesByRouteId = useMemo(() => {
const index: Record<number, Set<string>> = {};
@@ -1031,7 +1074,7 @@ export default function TokenRoutes() {
const endpointTypeList = useMemo(() => {
const grouped = new Map<string, number>();
for (const route of routes) {
for (const route of listVisibleRoutes) {
const endpointTypes = routeEndpointTypesByRouteId[route.id] || new Set<string>();
for (const endpointType of endpointTypes) {
grouped.set(endpointType, (grouped.get(endpointType) || 0) + 1);
@@ -1041,7 +1084,7 @@ export default function TokenRoutes() {
if (a[1] === b[1]) return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' });
return b[1] - a[1];
});
}, [routes, routeEndpointTypesByRouteId]);
}, [listVisibleRoutes, routeEndpointTypesByRouteId]);
const routeBrandIconCandidates = useMemo(() => {
const byIcon = new Map<string, BrandInfo>();
@@ -1078,7 +1121,7 @@ export default function TokenRoutes() {
const routeIconSelectValue = routeIconOptionValues.has(form.displayIcon) ? form.displayIcon : '';
const groupRouteList = useMemo(() => (
routes
listVisibleRoutes
.filter((route) => !isExactModelPattern(route.modelPattern))
.map((route) => ({
id: route.id,
@@ -1092,15 +1135,15 @@ export default function TokenRoutes() {
if (a.channelCount === b.channelCount) return a.title.localeCompare(b.title, undefined, { sensitivity: 'base' });
return b.channelCount - a.channelCount;
})
), [routes, routeBrandById]);
), [listVisibleRoutes, routeBrandById]);
const activeGroupRoute = useMemo(() => {
if (typeof activeGroupFilter !== 'number') return null;
return routes.find((route) => route.id === activeGroupFilter) || null;
}, [activeGroupFilter, routes]);
return listVisibleRoutes.find((route) => route.id === activeGroupFilter) || null;
}, [activeGroupFilter, listVisibleRoutes]);
const sortedRoutes = useMemo(() => (
[...routes].sort((a, b) => {
[...listVisibleRoutes].sort((a, b) => {
if (sortBy === 'channelCount') {
const countCmp = (a.channels?.length ?? 0) - (b.channels?.length ?? 0);
if (countCmp !== 0) return sortDir === 'asc' ? countCmp : -countCmp;
@@ -1109,7 +1152,7 @@ export default function TokenRoutes() {
const nameCmp = a.modelPattern.localeCompare(b.modelPattern, undefined, { sensitivity: 'base' });
return sortDir === 'asc' ? nameCmp : -nameCmp;
})
), [routes, sortBy, sortDir]);
), [listVisibleRoutes, sortBy, sortDir]);
const filteredRoutes = useMemo(() => {
let list = sortedRoutes;
@@ -1618,11 +1661,21 @@ export default function TokenRoutes() {
</button>
<button
onClick={() => setShowManual((v) => !v)}
onClick={() => {
if (showManual) {
setShowManual(false);
resetRouteForm();
return;
}
setShowManual(true);
resetRouteForm();
}}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{showManual ? tr('收起群组创建') : tr('新建群组')}
{editingRouteId
? tr('取消编辑')
: (showManual ? tr('收起群组创建') : tr('新建群组'))}
</button>
</div>
</div>
@@ -1681,7 +1734,9 @@ export default function TokenRoutes() {
{manualPanelPresence.shouldRender && (
<div className={`card panel-presence ${manualPanelPresence.isVisible ? '' : 'is-closing'}`.trim()} style={{ padding: 20, marginBottom: 16 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
{tr('用于创建群组路由(聚合多个上游模型为一个下游模型名,即模型重定向);自动路由仍会保持开启。')}
{editingRouteId
? tr('编辑群组路由名称、图标和模型匹配规则;若修改正则,将按当前可用模型重新匹配自动通道。')
: tr('用于创建群组路由(聚合多个上游模型为一个下游模型名,即模型重定向);自动路由仍会保持开启。')}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 180px', gap: 10 }}>
@@ -1783,24 +1838,35 @@ export default function TokenRoutes() {
</div>
)}
<button
onClick={handleAddRoute}
disabled={!canSaveRoute}
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('创建群组')
)}
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<button
onClick={handleAddRoute}
disabled={!canSaveRoute}
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(editingRouteId ? '保存群组' : '创建群组')
)}
</button>
{editingRouteId ? (
<button
onClick={handleCancelEditRoute}
className="btn btn-ghost"
style={{ alignSelf: 'flex-start', border: '1px solid var(--color-border)' }}
>
{tr('取消编辑')}
</button>
) : null}
</div>
</div>
</div>
)}
@@ -1985,12 +2051,22 @@ export default function TokenRoutes() {
) : null}
</div>
<button
onClick={() => handleDeleteRoute(route.id)}
className="btn btn-link btn-link-danger"
>
{tr('删除路由')}
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{!exactRoute ? (
<button
onClick={() => handleEditRoute(route)}
className="btn btn-link"
>
{tr('编辑群组')}
</button>
) : null}
<button
onClick={() => handleDeleteRoute(route.id)}
className="btn btn-link btn-link-danger"
>
{tr('删除路由')}
</button>
</div>
</div>
{!exactRoute && (
@@ -0,0 +1,39 @@
export type RouteListVisibilityItem = {
id: number;
modelPattern: string;
displayName?: string | null;
enabled: boolean;
};
function hasCustomDisplayName(route: Pick<RouteListVisibilityItem, 'modelPattern' | 'displayName'>): boolean {
const displayName = (route.displayName || '').trim();
const modelPattern = (route.modelPattern || '').trim();
return !!displayName && displayName !== modelPattern;
}
export function buildVisibleRouteList<T extends RouteListVisibilityItem>(
routes: T[],
isExactModelPattern: (pattern: string) => boolean,
matchesModelPattern: (model: string, pattern: string) => boolean,
): T[] {
const coveringGroups = routes.filter((route) => (
route.enabled
&& !isExactModelPattern(route.modelPattern)
&& hasCustomDisplayName(route)
));
if (coveringGroups.length === 0) return routes;
return routes.filter((route) => {
if (!isExactModelPattern(route.modelPattern)) return true;
if (hasCustomDisplayName(route)) return true;
const exactModel = (route.modelPattern || '').trim();
if (!exactModel) return true;
return !coveringGroups.some((groupRoute) => (
groupRoute.id !== route.id
&& matchesModelPattern(exactModel, groupRoute.modelPattern)
));
});
}
@@ -10,6 +10,8 @@ const { apiMock } = vi.hoisted(() => ({
getModelTokenCandidates: vi.fn(),
getRouteDecisionsBatch: vi.fn(),
getRouteWideDecisionsBatch: vi.fn(),
updateRoute: vi.fn(),
addRoute: vi.fn(),
},
}));
@@ -40,6 +42,14 @@ function findButtonByText(root: ReactTestInstance, text: string): ReactTestInsta
));
}
function findInputByPlaceholder(root: ReactTestInstance, placeholderText: string): ReactTestInstance {
return root.find((node) => (
node.type === 'input'
&& typeof node.props.placeholder === 'string'
&& node.props.placeholder.includes(placeholderText)
));
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
@@ -53,6 +63,8 @@ describe('TokenRoutes grouped source models', () => {
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.updateRoute.mockResolvedValue({});
apiMock.addRoute.mockResolvedValue({});
});
afterEach(() => {
@@ -364,4 +376,208 @@ describe('TokenRoutes grouped source models', () => {
root?.unmount();
}
});
it('hides exact routes covered by a group route from the main route list', async () => {
apiMock.getRoutes.mockResolvedValue([
{
id: 1,
modelPattern: 'minimax-m2.1',
displayName: 'minimax-m2.1',
enabled: true,
channels: [],
},
{
id: 2,
modelPattern: 'minimaxai/minimax-m2.1',
displayName: 'minimaxai/minimax-m2.1',
enabled: true,
channels: [],
},
{
id: 3,
modelPattern: 're:^(minimax-m2\\.1|minimaxai/minimax-m2\\.1)$',
displayName: 'minimax2.1',
enabled: true,
channels: [],
},
]);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const normalizedText = collectText(root.root).replace(/\s+/g, '');
expect(normalizedText).toContain('共1条路由');
expect(normalizedText).not.toContain('共3条路由');
} finally {
root?.unmount();
}
});
it('enters edit mode and seeds the group form with the current route values', async () => {
apiMock.getRoutes.mockResolvedValue([
{
id: 31,
modelPattern: 're:^claude-(opus|sonnet)-4-6$',
displayName: 'claude-4-6-group',
displayIcon: 'anthropic',
enabled: true,
channels: [],
},
]);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
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.getRoutes
.mockResolvedValueOnce([
{
id: 41,
modelPattern: 're:^claude-.*$',
displayName: 'old-group',
displayIcon: '',
enabled: true,
channels: [],
},
])
.mockResolvedValueOnce([
{
id: 41,
modelPattern: 're:^claude-.*$',
displayName: 'new-group',
displayIcon: '',
enabled: true,
channels: [],
},
]);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, '编辑群组').props.onClick();
});
await flushMicrotasks();
await act(async () => {
findInputByPlaceholder(root.root, '群组显示名').props.onChange({ target: { value: 'new-group' } });
});
await act(async () => {
findButtonByText(root.root, '保存群组').props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateRoute).toHaveBeenCalledWith(41, expect.objectContaining({
displayName: 'new-group',
modelPattern: 're:^claude-.*$',
}));
expect(apiMock.getRoutes).toHaveBeenCalledTimes(2);
} finally {
root?.unmount();
}
});
it('reloads route data after saving an edited model pattern', async () => {
apiMock.getRoutes
.mockResolvedValueOnce([
{
id: 51,
modelPattern: 're:^claude-.*$',
displayName: 'group-a',
displayIcon: '',
enabled: true,
channels: [],
},
])
.mockResolvedValueOnce([
{
id: 51,
modelPattern: 're:^gemini-.*$',
displayName: 'group-a',
displayIcon: '',
enabled: true,
channels: [],
},
]);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, '编辑群组').props.onClick();
});
await flushMicrotasks();
await act(async () => {
findInputByPlaceholder(root.root, '模型匹配').props.onChange({ target: { value: 're:^gemini-.*$' } });
});
await act(async () => {
findButtonByText(root.root, '保存群组').props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateRoute).toHaveBeenCalledWith(51, expect.objectContaining({
modelPattern: 're:^gemini-.*$',
}));
expect(apiMock.getRoutes).toHaveBeenCalledTimes(2);
} finally {
root?.unmount();
}
});
});