forked from boos/BoosAPI
feat: add round robin routing and improve token binding UX
This commit is contained in:
@@ -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