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