forked from boos/BoosAPI
feat: add round robin routing and improve token binding UX
This commit is contained in:
@@ -190,4 +190,134 @@ describe('Models marketplace text', () => {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('limits expanded account and pricing detail to the selected site filter', async () => {
|
||||
apiMock.getModelsMarketplace.mockResolvedValue({
|
||||
models: [
|
||||
{
|
||||
name: 'gpt-4o',
|
||||
accountCount: 2,
|
||||
tokenCount: 3,
|
||||
avgLatency: 500,
|
||||
successRate: 96,
|
||||
description: 'demo model',
|
||||
tags: ['chat'],
|
||||
supportedEndpointTypes: ['openai'],
|
||||
pricingSources: [
|
||||
{
|
||||
siteId: 1,
|
||||
siteName: '站点 A',
|
||||
accountId: 1,
|
||||
username: 'user-a',
|
||||
ownerBy: null,
|
||||
enableGroups: [],
|
||||
groupPricing: {
|
||||
default: {
|
||||
quotaType: 0,
|
||||
inputPerMillion: 1,
|
||||
outputPerMillion: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
siteId: 2,
|
||||
siteName: '站点 B',
|
||||
accountId: 2,
|
||||
username: 'user-b',
|
||||
ownerBy: null,
|
||||
enableGroups: [],
|
||||
groupPricing: {
|
||||
default: {
|
||||
quotaType: 0,
|
||||
inputPerMillion: 3,
|
||||
outputPerMillion: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
accounts: [
|
||||
{
|
||||
id: 1,
|
||||
site: '站点 A',
|
||||
username: 'user-a',
|
||||
latency: 320,
|
||||
balance: 12.5,
|
||||
tokens: [
|
||||
{ id: 1, name: 'token-a-1', isDefault: true },
|
||||
{ id: 2, name: 'token-a-2', isDefault: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
site: '站点 B',
|
||||
username: 'user-b',
|
||||
latency: 680,
|
||||
balance: 8.4,
|
||||
tokens: [
|
||||
{ id: 3, name: 'token-b-1', isDefault: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/models']}>
|
||||
<ToastProvider>
|
||||
<Models />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const siteFilterItem = root!.root.find((node) => (
|
||||
node.type === 'div'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('filter-item')
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('站点 A')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
siteFilterItem.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const cards = root!.root.findAll((node) => (
|
||||
node.type === 'div'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('model-card')
|
||||
&& typeof node.props.onClick === 'function'
|
||||
));
|
||||
expect(cards.length).toBeGreaterThan(0);
|
||||
|
||||
await act(async () => {
|
||||
cards[0]!.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const expandedSections = root!.root.findAll((node) => (
|
||||
node.type === 'div'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('model-card-expand')
|
||||
));
|
||||
expect(expandedSections.length).toBe(1);
|
||||
|
||||
const expandedText = collectText(expandedSections[0]!);
|
||||
expect(expandedText).toContain('站点 A');
|
||||
expect(expandedText).toContain('user-a');
|
||||
expect(expandedText).toContain('token-a-1');
|
||||
expect(expandedText).not.toContain('站点 B');
|
||||
expect(expandedText).not.toContain('user-b');
|
||||
expect(expandedText).not.toContain('token-b-1');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -292,26 +292,50 @@ export default function Models() {
|
||||
});
|
||||
}, [data.models, search, activeSite, activeBrand, sortBy, sortDir]);
|
||||
|
||||
// Keep expanded detail consistent with filters (especially site filter).
|
||||
// The list-level filter uses "model has at least one account on this site" semantics;
|
||||
// once a model is shown, its detail should honor the active site as well.
|
||||
const detailModels = useMemo(() => {
|
||||
if (!activeSite) return filteredModels;
|
||||
return filteredModels.map((model) => {
|
||||
const accounts = model.accounts.filter((account) => account.site === activeSite);
|
||||
const pricingSources = model.pricingSources.filter((source) => source.siteName === activeSite);
|
||||
const latencyValues = accounts
|
||||
.map((account) => account.latency)
|
||||
.filter((latency): latency is number => typeof latency === 'number' && Number.isFinite(latency));
|
||||
return {
|
||||
...model,
|
||||
accounts,
|
||||
pricingSources,
|
||||
accountCount: accounts.length,
|
||||
tokenCount: accounts.reduce((sum, account) => sum + account.tokens.length, 0),
|
||||
avgLatency: latencyValues.length > 0
|
||||
? Math.round(latencyValues.reduce((sum, latency) => sum + latency, 0) / latencyValues.length)
|
||||
: model.avgLatency,
|
||||
};
|
||||
});
|
||||
}, [filteredModels, activeSite]);
|
||||
|
||||
/* ---- pagination ---- */
|
||||
const totalPages = Math.max(1, Math.ceil(filteredModels.length / pageSize));
|
||||
const safePageVal = Math.min(page, totalPages);
|
||||
const paged = filteredModels.slice((safePageVal - 1) * pageSize, safePageVal * pageSize);
|
||||
const paged = detailModels.slice((safePageVal - 1) * pageSize, safePageVal * pageSize);
|
||||
|
||||
useEffect(() => { setPage(1); }, [search, activeSite, activeBrand, pageSize]);
|
||||
|
||||
/* ---- stats ---- */
|
||||
const totalCoverageSlots = filteredModels.reduce((s, m) => s + m.accountCount, 0);
|
||||
const totalCoverageSlots = detailModels.reduce((s, m) => s + m.accountCount, 0);
|
||||
const uniqueAccountCount = (() => {
|
||||
const ids = new Set<number>();
|
||||
for (const model of filteredModels) {
|
||||
for (const model of detailModels) {
|
||||
for (const account of model.accounts) {
|
||||
ids.add(account.id);
|
||||
}
|
||||
}
|
||||
return ids.size;
|
||||
})();
|
||||
const avgLatency = filteredModels.length
|
||||
? Math.round(filteredModels.reduce((s, m) => s + m.avgLatency, 0) / filteredModels.length)
|
||||
const avgLatency = detailModels.length
|
||||
? Math.round(detailModels.reduce((s, m) => s + m.avgLatency, 0) / detailModels.length)
|
||||
: 0;
|
||||
|
||||
/* ---- copy ---- */
|
||||
@@ -898,5 +922,3 @@ export default function Models() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
RouteSortDir,
|
||||
GroupFilter,
|
||||
RouteSummaryRow,
|
||||
RouteRoutingStrategy,
|
||||
RouteDecision,
|
||||
RouteIconOption,
|
||||
MissingTokenRouteSiteActionItem,
|
||||
@@ -88,6 +89,7 @@ export default function TokenRoutes() {
|
||||
const [channelTokenDraft, setChannelTokenDraft] = useState<Record<number, number>>({});
|
||||
const [updatingChannel, setUpdatingChannel] = useState<Record<number, boolean>>({});
|
||||
const [savingPriorityByRoute, setSavingPriorityByRoute] = useState<Record<number, boolean>>({});
|
||||
const [updatingRoutingStrategyByRoute, setUpdatingRoutingStrategyByRoute] = useState<Record<number, boolean>>({});
|
||||
|
||||
const [decisionByRoute, setDecisionByRoute] = useState<Record<number, RouteDecision | null>>({});
|
||||
const [loadingDecision, setLoadingDecision] = useState(false);
|
||||
@@ -349,6 +351,32 @@ export default function TokenRoutes() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoutingStrategyChange = async (route: RouteSummaryRow, routingStrategy: RouteRoutingStrategy) => {
|
||||
const currentStrategy = route.routingStrategy === 'round_robin' ? 'round_robin' : 'weighted';
|
||||
if (routingStrategy === currentStrategy) return;
|
||||
|
||||
setUpdatingRoutingStrategyByRoute((prev) => ({ ...prev, [route.id]: true }));
|
||||
setRouteSummaries((prev) => prev.map((item) => (
|
||||
item.id === route.id
|
||||
? { ...item, routingStrategy }
|
||||
: item
|
||||
)));
|
||||
try {
|
||||
await api.updateRoute(route.id, { routingStrategy });
|
||||
toast.success(routingStrategy === 'round_robin' ? '已切换为轮询策略' : '已切换为权重随机策略');
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setRouteSummaries((prev) => prev.map((item) => (
|
||||
item.id === route.id
|
||||
? { ...item, routingStrategy: currentStrategy }
|
||||
: item
|
||||
)));
|
||||
toast.error(e.message || '更新路由策略失败');
|
||||
} finally {
|
||||
setUpdatingRoutingStrategyByRoute((prev) => ({ ...prev, [route.id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Stable derived value: only changes when route patterns change (not on enabled toggle)
|
||||
const routePatterns = useMemo(
|
||||
() => routeSummaries.map((r) => ({ id: r.id, modelPattern: r.modelPattern })),
|
||||
@@ -761,6 +789,12 @@ export default function TokenRoutes() {
|
||||
const handleToggleEnabledRef = useRef(handleToggleRouteEnabled);
|
||||
handleToggleEnabledRef.current = handleToggleRouteEnabled;
|
||||
const stableToggleEnabled = useCallback((route: RouteSummaryRow) => { handleToggleEnabledRef.current(route); }, []);
|
||||
const handleRoutingStrategyChangeRef = useRef(handleRoutingStrategyChange);
|
||||
handleRoutingStrategyChangeRef.current = handleRoutingStrategyChange;
|
||||
const stableRoutingStrategyChange = useCallback(
|
||||
(route: RouteSummaryRow, strategy: RouteRoutingStrategy) => handleRoutingStrategyChangeRef.current(route, strategy),
|
||||
[],
|
||||
);
|
||||
const stableTokenDraftChange = useCallback(
|
||||
(channelId: number, tokenId: number) => setChannelTokenDraft((prev) => ({ ...prev, [channelId]: tokenId })),
|
||||
[],
|
||||
@@ -995,6 +1029,7 @@ export default function TokenRoutes() {
|
||||
>
|
||||
<MobileField label="模型" value={route.modelPattern} />
|
||||
<MobileField label="通道" value={route.channelCount} />
|
||||
<MobileField label="策略" value={route.routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机')} />
|
||||
<MobileField label="状态" value={route.enabled ? tr('启用') : tr('禁用')} />
|
||||
<div className="mobile-card-actions">
|
||||
<button
|
||||
@@ -1019,6 +1054,8 @@ export default function TokenRoutes() {
|
||||
onEdit={stableEditRoute}
|
||||
onDelete={stableDeleteRoute}
|
||||
onToggleEnabled={stableToggleEnabled}
|
||||
onRoutingStrategyChange={stableRoutingStrategyChange}
|
||||
updatingRoutingStrategy={!!updatingRoutingStrategyByRoute[route.id]}
|
||||
channels={channelsByRouteId[route.id]}
|
||||
loadingChannels={!!loadingChannelsByRouteId[route.id]}
|
||||
routeDecision={decisionByRoute[route.id] || null}
|
||||
|
||||
@@ -6,6 +6,11 @@ import { useToast } from '../../components/Toast.js';
|
||||
import { tr } from '../../i18n.js';
|
||||
import type { RouteCandidateView, RouteAccountOption, RouteTokenOption } from '../helpers/routeModelCandidatesIndex.js';
|
||||
import type { RouteMissingTokenHint } from '../helpers/routeMissingTokenHints.js';
|
||||
import {
|
||||
buildFixedTokenOptionDescription,
|
||||
buildFixedTokenOptionLabel,
|
||||
describeTokenBinding,
|
||||
} from './tokenBindingPresentation.js';
|
||||
|
||||
type ChannelSelection = {
|
||||
accountId: number;
|
||||
@@ -79,13 +84,10 @@ export default function AddChannelModal({
|
||||
return next;
|
||||
}
|
||||
const tokens = candidateView.tokenOptionsByAccountId[account.id] || [];
|
||||
const defaultToken = tokens.find((t) => t.isDefault) || tokens[0] || null;
|
||||
return {
|
||||
...prev,
|
||||
[account.id]: {
|
||||
accountId: account.id,
|
||||
tokenId: defaultToken?.id,
|
||||
sourceModel: defaultToken?.sourceModel,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -191,6 +193,7 @@ export default function AddChannelModal({
|
||||
const tokens = candidateView.tokenOptionsByAccountId[account.id] || [];
|
||||
const selection = selectedAccounts[account.id];
|
||||
const isExisting = existingChannelAccountIds?.has(account.id);
|
||||
const tokenBinding = describeTokenBinding(tokens, selection?.tokenId || 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -219,7 +222,7 @@ export default function AddChannelModal({
|
||||
|
||||
{isSelected && tokens.length > 0 && (
|
||||
<div style={{ marginTop: 6, paddingLeft: 24 }} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 4 }}>{tr('令牌')}:</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 4 }}>{tr('令牌绑定')}:</div>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={(() => {
|
||||
@@ -235,14 +238,25 @@ export default function AddChannelModal({
|
||||
updateTokenForAccount(account.id, Number.parseInt(tokenRaw, 10) || 0, sourceParts.join('::'));
|
||||
}}
|
||||
options={[
|
||||
{ value: '0', label: tr('默认令牌') },
|
||||
{
|
||||
value: '0',
|
||||
label: tr('跟随账号默认'),
|
||||
description: tokenBinding.followOptionDescription,
|
||||
},
|
||||
...tokens.map((token: RouteTokenOption) => ({
|
||||
value: `${token.id}::${token.sourceModel || ''}`,
|
||||
label: `${token.name}${token.isDefault ? '(默认)' : ''}${token.sourceModel ? ` [${token.sourceModel}]` : ''}`,
|
||||
label: buildFixedTokenOptionLabel(token, {
|
||||
includeDefaultTag: true,
|
||||
includeSourceModel: true,
|
||||
}),
|
||||
description: buildFixedTokenOptionDescription(token),
|
||||
})),
|
||||
]}
|
||||
placeholder={tr('选择令牌')}
|
||||
placeholder={tr('选择绑定方式')}
|
||||
/>
|
||||
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
|
||||
{tokenBinding.helperText}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -14,9 +14,17 @@ import {
|
||||
sortableKeyboardCoordinates,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { BrandGlyph, InlineBrandIcon, type BrandInfo } from '../../components/BrandIcon.js';
|
||||
import ModernSelect from '../../components/ModernSelect.js';
|
||||
import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js';
|
||||
import { tr } from '../../i18n.js';
|
||||
import type { RouteSummaryRow, RouteChannel, RouteDecision, RouteDecisionCandidate, MissingTokenRouteSiteActionItem } from './types.js';
|
||||
import type {
|
||||
RouteSummaryRow,
|
||||
RouteChannel,
|
||||
RouteDecision,
|
||||
RouteDecisionCandidate,
|
||||
MissingTokenRouteSiteActionItem,
|
||||
RouteRoutingStrategy,
|
||||
} from './types.js';
|
||||
import type { RouteCandidateView, RouteTokenOption } from '../helpers/routeModelCandidatesIndex.js';
|
||||
import { SortableChannelRow } from './SortableChannelRow.js';
|
||||
import {
|
||||
@@ -34,6 +42,8 @@ type RouteCardProps = {
|
||||
onEdit: (route: RouteSummaryRow) => void;
|
||||
onDelete: (routeId: number) => void;
|
||||
onToggleEnabled: (route: RouteSummaryRow) => void;
|
||||
onRoutingStrategyChange: (route: RouteSummaryRow, strategy: RouteRoutingStrategy) => void;
|
||||
updatingRoutingStrategy: boolean;
|
||||
// Channel data (loaded on demand)
|
||||
channels: RouteChannel[] | undefined;
|
||||
loadingChannels: boolean;
|
||||
@@ -79,6 +89,8 @@ function RouteCardInner({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleEnabled,
|
||||
onRoutingStrategyChange,
|
||||
updatingRoutingStrategy,
|
||||
channels,
|
||||
loadingChannels,
|
||||
routeDecision,
|
||||
@@ -100,6 +112,19 @@ function RouteCardInner({
|
||||
const routeIcon = resolveRouteIcon(route);
|
||||
const exactRoute = isExactModelPattern(route.modelPattern);
|
||||
const title = resolveRouteTitle(route);
|
||||
const routingStrategy = route.routingStrategy === 'round_robin' ? 'round_robin' : 'weighted';
|
||||
const routingStrategyOptions = [
|
||||
{
|
||||
value: 'weighted',
|
||||
label: tr('权重随机'),
|
||||
description: tr('按优先级、权重和成本信号综合选择'),
|
||||
},
|
||||
{
|
||||
value: 'round_robin',
|
||||
label: tr('轮询'),
|
||||
description: tr('按全局顺序轮流调用,忽略优先级,连续失败 3 次后进入分级冷却'),
|
||||
},
|
||||
] as const;
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||
@@ -172,6 +197,10 @@ function RouteCardInner({
|
||||
{route.channelCount} {tr('通道')}
|
||||
</span>
|
||||
|
||||
<span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}>
|
||||
{routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机')}
|
||||
</span>
|
||||
|
||||
<svg
|
||||
width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"
|
||||
style={{ flexShrink: 0, color: 'var(--color-text-muted)' }}
|
||||
@@ -251,6 +280,23 @@ function RouteCardInner({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
||||
{tr('路由策略')}
|
||||
</div>
|
||||
<div style={{ minWidth: 220, maxWidth: 320, flex: '1 1 220px' }}>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={routingStrategy}
|
||||
disabled={updatingRoutingStrategy}
|
||||
onChange={(nextValue) => onRoutingStrategyChange(route, nextValue as RouteRoutingStrategy)}
|
||||
options={routingStrategyOptions.map((option) => ({ ...option }))}
|
||||
placeholder={tr('选择路由策略')}
|
||||
emptyLabel={tr('暂无可选策略')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Missing token hints + Add channel button */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||
{missingTokenSiteItems.length > 0 ? (
|
||||
|
||||
@@ -3,6 +3,11 @@ import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import ModernSelect from '../../components/ModernSelect.js';
|
||||
import type { SortableChannelRowProps } from './types.js';
|
||||
import {
|
||||
buildFixedTokenOptionDescription,
|
||||
buildFixedTokenOptionLabel,
|
||||
describeTokenBinding,
|
||||
} from './tokenBindingPresentation.js';
|
||||
import { getChannelDecisionState, getPriorityTagStyle, getProbabilityColor } from './utils.js';
|
||||
|
||||
export function SortableChannelRow({
|
||||
@@ -48,6 +53,7 @@ export function SortableChannelRow({
|
||||
};
|
||||
|
||||
const decisionState = getChannelDecisionState(decisionCandidate, channel, isExactRoute, loadingDecision);
|
||||
const tokenBinding = describeTokenBinding(tokenOptions, activeTokenId, channel.token?.name ?? null);
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={rowStyle}>
|
||||
@@ -105,11 +111,29 @@ export function SortableChannelRow({
|
||||
className="badge"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
background: 'color-mix(in srgb, var(--color-info) 15%, transparent)',
|
||||
color: 'var(--color-info)',
|
||||
background: tokenBinding.isFollowingAccountDefault
|
||||
? 'color-mix(in srgb, var(--color-info) 15%, transparent)'
|
||||
: 'color-mix(in srgb, var(--color-warning) 15%, transparent)',
|
||||
color: tokenBinding.isFollowingAccountDefault ? 'var(--color-info)' : 'var(--color-warning)',
|
||||
}}
|
||||
>
|
||||
{channel.token?.name || '默认令牌'}
|
||||
{tokenBinding.bindingModeLabel}
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="badge"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
background: 'color-mix(in srgb, var(--color-primary) 10%, transparent)',
|
||||
color: 'var(--color-primary)',
|
||||
maxWidth: 220,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
data-tooltip={`当前生效:${tokenBinding.effectiveTokenName}`}
|
||||
>
|
||||
当前生效:{tokenBinding.effectiveTokenName}
|
||||
</span>
|
||||
|
||||
{channel.sourceModel ? (
|
||||
@@ -174,28 +198,36 @@ export function SortableChannelRow({
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ minWidth: 150, flex: 1 }}>
|
||||
<div style={{ minWidth: 220, flex: 1 }}>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={String(activeTokenId || 0)}
|
||||
onChange={(nextValue) => onTokenDraftChange(channel.id, Number.parseInt(nextValue, 10) || 0)}
|
||||
disabled={isUpdatingToken}
|
||||
options={[
|
||||
{ value: '0', label: '默认令牌' },
|
||||
{
|
||||
value: '0',
|
||||
label: '跟随账号默认',
|
||||
description: tokenBinding.followOptionDescription,
|
||||
},
|
||||
...tokenOptions.map((token) => ({
|
||||
value: String(token.id),
|
||||
label: `${token.name}${token.isDefault ? '(默认)' : ''}`,
|
||||
label: buildFixedTokenOptionLabel(token, { includeDefaultTag: true }),
|
||||
description: buildFixedTokenOptionDescription(token),
|
||||
})),
|
||||
]}
|
||||
placeholder="默认令牌"
|
||||
placeholder="选择令牌绑定方式"
|
||||
/>
|
||||
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
|
||||
{tokenBinding.helperText}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onSaveToken}
|
||||
disabled={isUpdatingToken}
|
||||
className="btn btn-link btn-link-info"
|
||||
>
|
||||
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '改令牌'}
|
||||
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildFixedTokenOptionDescription,
|
||||
buildFixedTokenOptionLabel,
|
||||
describeTokenBinding,
|
||||
} from './tokenBindingPresentation.js';
|
||||
|
||||
describe('tokenBindingPresentation', () => {
|
||||
it('describes follow-account-default mode with the current default token', () => {
|
||||
const result = describeTokenBinding([
|
||||
{ id: 1, name: 'token-a', isDefault: true },
|
||||
{ id: 2, name: 'token-b', isDefault: false },
|
||||
], 0);
|
||||
|
||||
expect(result.bindingModeLabel).toBe('跟随账号默认');
|
||||
expect(result.effectiveTokenName).toBe('token-a');
|
||||
expect(result.helperText).toContain('以后账号默认变化时会自动切换');
|
||||
expect(result.followOptionDescription).toContain('当前生效:token-a');
|
||||
});
|
||||
|
||||
it('describes fixed mode when the selected token is also the account default', () => {
|
||||
const result = describeTokenBinding([
|
||||
{ id: 1, name: 'default', isDefault: true },
|
||||
{ id: 2, name: 'backup', isDefault: false },
|
||||
], 1);
|
||||
|
||||
expect(result.bindingModeLabel).toBe('固定令牌');
|
||||
expect(result.effectiveTokenName).toBe('default');
|
||||
expect(result.helperText).toContain('它目前也是账号默认');
|
||||
expect(result.helperText).toContain('不会跟着变');
|
||||
});
|
||||
|
||||
it('formats fixed token options with clearer labels and descriptions', () => {
|
||||
const token = {
|
||||
id: 3,
|
||||
name: 'shared-token',
|
||||
isDefault: true,
|
||||
sourceModel: 'gpt-4o-mini',
|
||||
};
|
||||
|
||||
expect(buildFixedTokenOptionLabel(token, {
|
||||
includeDefaultTag: true,
|
||||
includeSourceModel: true,
|
||||
})).toBe('固定使用:shared-token(当前账号默认) [gpt-4o-mini]');
|
||||
expect(buildFixedTokenOptionDescription(token)).toContain('以后不会自动跟随');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
export type TokenBindingOption = {
|
||||
id: number;
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
sourceModel?: string;
|
||||
};
|
||||
|
||||
export type TokenBindingPresentation = {
|
||||
isFollowingAccountDefault: boolean;
|
||||
bindingModeLabel: string;
|
||||
effectiveTokenName: string;
|
||||
helperText: string;
|
||||
followOptionDescription: string;
|
||||
};
|
||||
|
||||
export function getDefaultTokenOption(options: TokenBindingOption[]): TokenBindingOption | null {
|
||||
return options.find((option) => option.isDefault) || null;
|
||||
}
|
||||
|
||||
export function describeTokenBinding(
|
||||
options: TokenBindingOption[],
|
||||
activeTokenId: number,
|
||||
fallbackTokenName?: string | null,
|
||||
): TokenBindingPresentation {
|
||||
const defaultToken = getDefaultTokenOption(options);
|
||||
const selectedToken = activeTokenId
|
||||
? options.find((option) => option.id === activeTokenId) || null
|
||||
: null;
|
||||
|
||||
if (!activeTokenId) {
|
||||
const effectiveTokenName = defaultToken?.name || fallbackTokenName || '未设置默认令牌';
|
||||
return {
|
||||
isFollowingAccountDefault: true,
|
||||
bindingModeLabel: '跟随账号默认',
|
||||
effectiveTokenName,
|
||||
helperText: defaultToken
|
||||
? `跟随账号默认。当前生效的是「${defaultToken.name}」,以后账号默认变化时会自动切换。`
|
||||
: '跟随账号默认。当前账号还没有默认令牌。',
|
||||
followOptionDescription: defaultToken
|
||||
? `当前生效:${defaultToken.name};以后账号默认变化时会自动切换`
|
||||
: '以后账号默认变化时会自动切换',
|
||||
};
|
||||
}
|
||||
|
||||
const effectiveTokenName = selectedToken?.name || fallbackTokenName || `token-${activeTokenId}`;
|
||||
return {
|
||||
isFollowingAccountDefault: false,
|
||||
bindingModeLabel: '固定令牌',
|
||||
effectiveTokenName,
|
||||
helperText: selectedToken?.isDefault
|
||||
? `已固定到「${effectiveTokenName}」。它目前也是账号默认,但以后账号默认变化时,这个通道不会跟着变。`
|
||||
: `已固定到「${effectiveTokenName}」,不会随账号默认变化。`,
|
||||
followOptionDescription: defaultToken
|
||||
? `当前生效:${defaultToken.name};以后账号默认变化时会自动切换`
|
||||
: '以后账号默认变化时会自动切换',
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFixedTokenOptionLabel(
|
||||
token: TokenBindingOption,
|
||||
options: {
|
||||
includeDefaultTag?: boolean;
|
||||
includeSourceModel?: boolean;
|
||||
} = {},
|
||||
): string {
|
||||
let label = `固定使用:${token.name}`;
|
||||
if (options.includeDefaultTag && token.isDefault) {
|
||||
label += '(当前账号默认)';
|
||||
}
|
||||
if (options.includeSourceModel && token.sourceModel) {
|
||||
label += ` [${token.sourceModel}]`;
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
export function buildFixedTokenOptionDescription(token: TokenBindingOption): string {
|
||||
return token.isDefault
|
||||
? '固定到这条令牌;它目前也是账号默认,但以后不会自动跟随'
|
||||
: '固定到这条令牌;不随账号默认变化';
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { BrandInfo } from '../../components/BrandIcon.js';
|
||||
export type RouteSortBy = 'modelPattern' | 'channelCount';
|
||||
export type RouteSortDir = 'asc' | 'desc';
|
||||
export type GroupFilter = null | '__all__' | number;
|
||||
export type RouteRoutingStrategy = 'weighted' | 'round_robin';
|
||||
|
||||
export type RouteChannelDraft = {
|
||||
accountId: number;
|
||||
@@ -46,6 +47,7 @@ export type RouteRow = {
|
||||
displayName?: string | null;
|
||||
displayIcon?: string | null;
|
||||
modelMapping?: string | null;
|
||||
routingStrategy?: RouteRoutingStrategy | null;
|
||||
decisionSnapshot?: RouteDecision | null;
|
||||
decisionRefreshedAt?: string | null;
|
||||
enabled: boolean;
|
||||
@@ -58,6 +60,7 @@ export type RouteSummaryRow = {
|
||||
displayName: string | null;
|
||||
displayIcon: string | null;
|
||||
modelMapping: string | null;
|
||||
routingStrategy?: RouteRoutingStrategy | null;
|
||||
enabled: boolean;
|
||||
channelCount: number;
|
||||
enabledChannelCount: number;
|
||||
|
||||
Reference in New Issue
Block a user