diff --git a/src/web/pages/token-routes/AddChannelModal.tsx b/src/web/pages/token-routes/AddChannelModal.tsx new file mode 100644 index 0000000..ddaefd1 --- /dev/null +++ b/src/web/pages/token-routes/AddChannelModal.tsx @@ -0,0 +1,224 @@ +import { useState, useMemo } from 'react'; +import CenteredModal from '../../components/CenteredModal.js'; +import ModernSelect from '../../components/ModernSelect.js'; +import { api } from '../../api.js'; +import { useToast } from '../../components/Toast.js'; +import { tr } from '../../i18n.js'; +import type { RouteCandidateView, RouteAccountOption, RouteTokenOption } from '../helpers/routeModelCandidatesIndex.js'; + +type ChannelSelection = { + accountId: number; + tokenId?: number; + sourceModel?: string; +}; + +type AddChannelModalProps = { + open: boolean; + onClose: () => void; + routeId: number; + routeTitle: string; + candidateView: RouteCandidateView; + onSuccess: () => void; +}; + +export default function AddChannelModal({ + open, + onClose, + routeId, + routeTitle, + candidateView, + onSuccess, +}: AddChannelModalProps) { + const toast = useToast(); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedAccounts, setSelectedAccounts] = useState>({}); + const [submitting, setSubmitting] = useState(false); + + const filteredAccounts = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + if (!q) return candidateView.accountOptions; + return candidateView.accountOptions.filter((option) => + option.label.toLowerCase().includes(q), + ); + }, [candidateView.accountOptions, searchQuery]); + + const selectedCount = Object.keys(selectedAccounts).length; + + const toggleAccount = (account: RouteAccountOption) => { + setSelectedAccounts((prev) => { + if (prev[account.id]) { + const next = { ...prev }; + delete next[account.id]; + 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, + }, + }; + }); + }; + + const updateTokenForAccount = (accountId: number, tokenId: number, sourceModel: string) => { + setSelectedAccounts((prev) => { + if (!prev[accountId]) return prev; + return { + ...prev, + [accountId]: { + ...prev[accountId], + tokenId: tokenId || undefined, + sourceModel: sourceModel || undefined, + }, + }; + }); + }; + + const handleSubmit = async () => { + const channels = Object.values(selectedAccounts); + if (channels.length === 0) return; + + setSubmitting(true); + try { + const result = await api.batchAddChannels(routeId, channels); + const msg = `已添加 ${result.created} 个通道` + + (result.skipped > 0 ? `,跳过 ${result.skipped} 个重复` : '') + + (result.errors.length > 0 ? `,${result.errors.length} 个错误` : ''); + toast.success(msg); + setSelectedAccounts({}); + setSearchQuery(''); + onSuccess(); + onClose(); + } catch (e: any) { + toast.error(e.message || '批量添加通道失败'); + } finally { + setSubmitting(false); + } + }; + + const handleClose = () => { + if (!submitting) { + setSelectedAccounts({}); + setSearchQuery(''); + onClose(); + } + }; + + return ( + + + {tr('已选')} {selectedCount} {tr('个通道')} + +
+ + +
+ + } + > +
+
+ + + + setSearchQuery(e.target.value)} + placeholder={tr('搜索账号...')} + /> +
+ +
+ {filteredAccounts.length === 0 ? ( +
+ {candidateView.accountOptions.length === 0 ? tr('当前没有可用的账号') : tr('没有匹配的账号')} +
+ ) : ( + filteredAccounts.map((account) => { + const isSelected = !!selectedAccounts[account.id]; + const tokens = candidateView.tokenOptionsByAccountId[account.id] || []; + const selection = selectedAccounts[account.id]; + + return ( +
+
toggleAccount(account)} + > + toggleAccount(account)} + style={{ cursor: 'pointer' }} + /> + {account.label} +
+ + {isSelected && tokens.length > 0 && ( +
+
{tr('令牌')}:
+ { + if (!selection?.tokenId) return '0'; + return `${selection.tokenId}::${selection.sourceModel || ''}`; + })()} + onChange={(nextValue) => { + if (nextValue === '0') { + updateTokenForAccount(account.id, 0, ''); + return; + } + const [tokenRaw, ...sourceParts] = nextValue.split('::'); + updateTokenForAccount(account.id, Number.parseInt(tokenRaw, 10) || 0, sourceParts.join('::')); + }} + options={[ + { value: '0', label: tr('默认令牌') }, + ...tokens.map((token: RouteTokenOption) => ({ + value: `${token.id}::${token.sourceModel || ''}`, + label: `${token.name}${token.isDefault ? '(默认)' : ''}${token.sourceModel ? ` [${token.sourceModel}]` : ''}`, + })), + ]} + placeholder={tr('选择令牌')} + /> +
+ )} +
+ ); + }) + )} +
+
+
+ ); +} diff --git a/src/web/pages/token-routes/ManualRoutePanel.tsx b/src/web/pages/token-routes/ManualRoutePanel.tsx new file mode 100644 index 0000000..ed9ca66 --- /dev/null +++ b/src/web/pages/token-routes/ManualRoutePanel.tsx @@ -0,0 +1,196 @@ +import { useMemo } from 'react'; +import { BrandGlyph } from '../../components/BrandIcon.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'; + +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; + saving: boolean; + canSave: boolean; + routeIconSelectOptions: RouteIconOption[]; + previewModelSamples: string[]; + onSave: () => void; + onCancel: () => void; +}; + +export default function ManualRoutePanel({ + show, + editingRouteId, + form, + setForm, + saving, + canSave, + routeIconSelectOptions, + previewModelSamples, + onSave, + onCancel, +}: ManualRoutePanelProps) { + const presence = useAnimatedVisibility(show, 220); + + const modelPatternError = useMemo( + () => getModelPatternError(form.modelPattern), + [form.modelPattern], + ); + + const routeIconOptionValues = useMemo( + () => new Set(routeIconSelectOptions.map((option) => option.value)), + [routeIconSelectOptions], + ); + + const routeIconSelectValue = routeIconOptionValues.has(normalizeRouteDisplayIconValue(form.displayIcon)) + ? normalizeRouteDisplayIconValue(form.displayIcon) + : ''; + + const previewMatchedModels = useMemo(() => { + const normalizedPattern = form.modelPattern.trim(); + if (!normalizedPattern || modelPatternError) return [] as string[]; + return previewModelSamples.filter((modelName) => matchesModelPattern(modelName, normalizedPattern)); + }, [form.modelPattern, modelPatternError, previewModelSamples]); + + if (!presence.shouldRender) return null; + + return ( +
+
+ {editingRouteId + ? tr('编辑群组路由名称、图标和模型匹配规则;若修改正则,将按当前可用模型重新匹配自动通道。') + : tr('用于创建群组路由(聚合多个上游模型为一个下游模型名,即模型重定向);自动路由仍会保持开启。')} +
+
+
+ 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)', + }} + /> +
+ setForm((f) => ({ ...f, displayIcon: nextValue }))} + options={routeIconSelectOptions} + placeholder={tr('图标(可选,选择品牌图标)')} + emptyLabel={tr('暂无可选品牌图标')} + /> +
+
+ 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)', + }} + /> +
+ {tr('正则请使用 re: 前缀;例如 re:^claude-(opus|sonnet)-4-6$')} +
+ {modelPatternError && ( +
+ {modelPatternError} +
+ )} + {form.modelPattern.trim() && !modelPatternError && ( +
+
+ {tr('规则预览:命中样本')} {previewMatchedModels.length} / {previewModelSamples.length} +
+ + {previewModelSamples.length === 0 ? ( +
+ {tr('当前暂无可预览模型,请先同步模型。')} +
+ ) : previewMatchedModels.length === 0 ? ( +
+ {tr('当前规则未命中任何样本模型。')} +
+ ) : ( +
+ {previewMatchedModels.slice(0, 12).map((modelName) => ( + + {modelName} + + ))} +
+ )} + + {previewMatchedModels.length > 12 && ( +
+ {tr('仅展示前 12 个命中样本。')} +
+ )} +
+ )} + +
+ + {editingRouteId ? ( + + ) : null} +
+
+
+ ); +} diff --git a/src/web/pages/token-routes/RouteCard.tsx b/src/web/pages/token-routes/RouteCard.tsx new file mode 100644 index 0000000..58a0cc8 --- /dev/null +++ b/src/web/pages/token-routes/RouteCard.tsx @@ -0,0 +1,383 @@ +import { useState, type ReactNode } from 'react'; +import { + DndContext, + KeyboardSensor, + PointerSensor, + closestCenter, + type DragEndEvent, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + SortableContext, + verticalListSortingStrategy, + sortableKeyboardCoordinates, +} from '@dnd-kit/sortable'; +import { BrandGlyph, InlineBrandIcon, type BrandInfo } from '../../components/BrandIcon.js'; +import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js'; +import { tr } from '../../i18n.js'; +import type { RouteSummaryRow, RouteChannel, RouteDecision, RouteDecisionCandidate, MissingTokenRouteSiteActionItem } from './types.js'; +import type { RouteCandidateView, RouteTokenOption } from '../helpers/routeModelCandidatesIndex.js'; +import { SortableChannelRow } from './SortableChannelRow.js'; +import { + isExactModelPattern, + resolveRouteTitle, + resolveRouteIcon, + buildSourceGroupKey, +} from './utils.js'; + +type RouteCardProps = { + route: RouteSummaryRow; + brand: BrandInfo | null; + expanded: boolean; + onToggleExpand: () => void; + onEdit: () => void; + onDelete: () => void; + onToggleEnabled: () => void; + // Channel data (loaded on demand) + channels: RouteChannel[] | undefined; + loadingChannels: boolean; + // Decision data + routeDecision: RouteDecision | null; + loadingDecision: boolean; + // Channel interaction + candidateView: RouteCandidateView; + channelTokenDraft: Record; + updatingChannel: Record; + savingPriority: boolean; + onTokenDraftChange: (channelId: number, tokenId: number) => void; + onSaveToken: (channelId: number, accountId: number) => void; + onDeleteChannel: (channelId: number) => void; + onChannelDragEnd: (event: DragEndEvent) => void; + // Missing token hints + missingTokenSiteItems: MissingTokenRouteSiteActionItem[]; + onCreateTokenForMissing: (accountId: number, modelName: string) => void; + // Add channel + onAddChannel: () => void; + // Source group expansion + expandedSourceGroupMap: Record; + onToggleSourceGroup: (groupKey: string) => void; +}; + +function AnimatedCollapseSection({ open, children }: { open: boolean; children: ReactNode }) { + const presence = useAnimatedVisibility(open, 220); + if (!presence.shouldRender) return null; + return ( +
+
+ {children} +
+
+ ); +} + +export default function RouteCard({ + route, + brand, + expanded, + onToggleExpand, + onEdit, + onDelete, + onToggleEnabled, + channels, + loadingChannels, + routeDecision, + loadingDecision, + candidateView, + channelTokenDraft, + updatingChannel, + savingPriority, + onTokenDraftChange, + onSaveToken, + onDeleteChannel, + onChannelDragEnd, + missingTokenSiteItems, + onCreateTokenForMissing, + onAddChannel, + expandedSourceGroupMap, + onToggleSourceGroup, +}: RouteCardProps) { + const routeIcon = resolveRouteIcon(route); + const exactRoute = isExactModelPattern(route.modelPattern); + const title = resolveRouteTitle(route); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + const decisionMap = new Map( + (routeDecision?.candidates || []).map((c) => [c.channelId, c]), + ); + + const channelGroups = (() => { + if (!channels || channels.length === 0) return []; + const groups = new Map(); + for (const channel of channels) { + const key = (channel.sourceModel || '').trim() || '__ungrouped__'; + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(channel); + } + return Array.from(groups.entries()) + .sort((a, b) => { + if (a[0] === '__ungrouped__') return 1; + if (b[0] === '__ungrouped__') return -1; + return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' }); + }) + .map(([sourceModel, chans]) => ({ + sourceModel: sourceModel === '__ungrouped__' ? '' : sourceModel, + channels: chans, + })); + })(); + + // Collapsed card + if (!expanded) { + return ( +
+
+ + {routeIcon.kind === 'brand' ? ( + + ) : routeIcon.kind === 'text' ? ( + {routeIcon.value} + ) : brand ? ( + + ) : ( + + )} + + + + {title} + + + {route.displayName && route.displayName.trim() !== route.modelPattern ? ( + {route.modelPattern} + ) : null} + + + + + {route.channelCount} {tr('通道')} + + +
+ + + + +
+
+ ); + } + + // Expanded card + return ( +
+ {/* Header */} +
+
+ + {routeIcon.kind === 'brand' ? ( + + ) : routeIcon.kind === 'text' ? ( + + {routeIcon.value} + + ) : brand ? ( + + ) : ( + + )} + {title} + + {route.displayName && route.displayName.trim() !== route.modelPattern ? ( + {route.modelPattern} + ) : null} + + + {route.channelCount} {tr('通道')} + + {savingPriority && ( + {tr('排序保存中')} + )} +
+ +
+ {!exactRoute && ( + + )} + + +
+
+ + {!exactRoute && ( +
+ {tr('通配符路由按请求实时决策;概率解释在当前路由内统一估算。')} +
+ )} + + {/* Missing token hints + Add channel button */} +
+ {missingTokenSiteItems.length > 0 ? ( +
+ {tr('待注册站点')}: + {missingTokenSiteItems.map((item) => ( + + ))} +
+ ) :
} + +
+ + {/* Channel list */} + {loadingChannels ? ( +
+ + {tr('加载通道中...')} +
+ ) : channels && channels.length > 0 ? ( +
+ +
+ {channelGroups.map((group) => { + const groupKey = buildSourceGroupKey(route.id, group.sourceModel || ''); + const supportsCollapse = !exactRoute && !!group.sourceModel; + const isGroupExpanded = supportsCollapse ? !!expandedSourceGroupMap[groupKey] : true; + + return ( +
+ {group.sourceModel ? ( + supportsCollapse ? ( + + ) : ( +
+ 来源模型 + + {group.sourceModel} + + {group.channels.length} 通道 +
+ ) + ) : null} + + + c.id)} strategy={verticalListSortingStrategy}> + {group.channels.map((channel) => { + const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || []; + const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0; + return ( + onSaveToken(channel.id, channel.accountId)} + onDeleteChannel={() => onDeleteChannel(channel.id)} + /> + ); + })} + + + {!isGroupExpanded && ( +
+ 已收起,点击展开查看通道 +
+ )} +
+ ); + })} +
+
+
+ ) : ( +
暂无通道
+ )} +
+ ); +} diff --git a/src/web/pages/token-routes/RouteFilterBar.tsx b/src/web/pages/token-routes/RouteFilterBar.tsx new file mode 100644 index 0000000..b0bf30a --- /dev/null +++ b/src/web/pages/token-routes/RouteFilterBar.tsx @@ -0,0 +1,263 @@ +import type { ReactNode } from 'react'; +import { BrandGlyph, InlineBrandIcon, hashColor, type BrandInfo } from '../../components/BrandIcon.js'; +import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js'; +import { tr } from '../../i18n.js'; +import type { GroupFilter, GroupRouteItem } from './types.js'; +import { resolveEndpointTypeIconModel, siteAvatarLetters } from './utils.js'; + +type RouteFilterBarProps = { + totalRouteCount: number; + activeBrand: string | null; + setActiveBrand: (brand: string | null) => void; + activeSite: string | null; + setActiveSite: (site: string | null) => void; + activeEndpointType: string | null; + setActiveEndpointType: (endpointType: string | null) => void; + activeGroupFilter: GroupFilter; + setActiveGroupFilter: (filter: GroupFilter) => void; + brandList: { list: [string, { count: number; brand: BrandInfo }][]; otherCount: number }; + siteList: [string, { count: number; siteId: number }][]; + endpointTypeList: [string, number][]; + groupRouteList: GroupRouteItem[]; + collapsed: boolean; + onToggle: () => void; +}; + +function FilterChip({ + active, + label, + count, + icon, + onClick, +}: { + active: boolean; + label: string; + count?: number; + icon?: ReactNode; + onClick: () => void; +}) { + return ( + + ); +} + +function FilterRow({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ); +} + +function ActiveFilterSummary({ + activeBrand, + activeSite, + activeGroupFilter, + activeEndpointType, +}: Pick) { + const tags: string[] = []; + if (activeBrand) tags.push(`品牌=${activeBrand === '__other__' ? '其他' : activeBrand}`); + if (activeSite) tags.push(`站点=${activeSite}`); + if (activeGroupFilter === '__all__') tags.push('群组=全部'); + else if (typeof activeGroupFilter === 'number') tags.push(`群组=#${activeGroupFilter}`); + if (activeEndpointType) tags.push(`能力=${activeEndpointType}`); + + if (tags.length === 0) return {tr('全部')}; + return {tags.join(', ')}; +} + +export default function RouteFilterBar(props: RouteFilterBarProps) { + const { + totalRouteCount, + activeBrand, + setActiveBrand, + activeSite, + setActiveSite, + activeEndpointType, + setActiveEndpointType, + activeGroupFilter, + setActiveGroupFilter, + brandList, + siteList, + endpointTypeList, + groupRouteList, + collapsed, + onToggle, + } = props; + + const expandPresence = useAnimatedVisibility(!collapsed, 220); + + return ( +
+ {/* Collapsed summary */} + + + {/* Expanded panel */} + {expandPresence.shouldRender && ( +
+ {/* Brand row */} + + ✦} + onClick={() => setActiveBrand(null)} + /> + {brandList.list.map(([brandName, { count, brand }]) => ( + } + onClick={() => setActiveBrand(activeBrand === brandName ? null : brandName)} + /> + ))} + {brandList.otherCount > 0 && ( + ?} + onClick={() => setActiveBrand(activeBrand === '__other__' ? null : '__other__')} + /> + )} + + + {/* Site row */} + {siteList.length > 0 && ( + + ⚡} + onClick={() => setActiveSite(null)} + /> + {siteList.map(([siteName, { count }]) => ( + + {siteAvatarLetters(siteName)} + + } + onClick={() => setActiveSite(activeSite === siteName ? null : siteName)} + /> + ))} + + )} + + {/* Group row */} + + ◎} + onClick={() => setActiveGroupFilter(activeGroupFilter === '__all__' ? null : '__all__')} + /> + {groupRouteList.map((groupRoute) => ( + + ) : groupRoute.icon.kind === 'text' ? ( + {groupRoute.icon.value} + ) : groupRoute.brand ? ( + + ) : ( + + ) + } + onClick={() => setActiveGroupFilter(activeGroupFilter === groupRoute.id ? null : groupRoute.id)} + /> + ))} + + + {/* Endpoint type row */} + + ⚙} + onClick={() => setActiveEndpointType(null)} + /> + {endpointTypeList.map(([endpointType, count]) => { + const iconModel = resolveEndpointTypeIconModel(endpointType); + return ( + : ⚙} + onClick={() => setActiveEndpointType(activeEndpointType === endpointType ? null : endpointType)} + /> + ); + })} + {endpointTypeList.length === 0 && ( + {tr('暂无接口能力数据')} + )} + + +
+ +
+
+ )} +
+ ); +} diff --git a/src/web/pages/token-routes/SortableChannelRow.tsx b/src/web/pages/token-routes/SortableChannelRow.tsx new file mode 100644 index 0000000..cbe7643 --- /dev/null +++ b/src/web/pages/token-routes/SortableChannelRow.tsx @@ -0,0 +1,210 @@ +import type { CSSProperties } from 'react'; +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 { getChannelDecisionState, getPriorityTagStyle, getProbabilityColor } from './utils.js'; + +export function SortableChannelRow({ + channel, + decisionCandidate, + isExactRoute, + loadingDecision, + isSavingPriority, + tokenOptions, + activeTokenId, + isUpdatingToken, + onTokenDraftChange, + onSaveToken, + onDeleteChannel, +}: SortableChannelRowProps) { + const { + attributes, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: channel.id, + disabled: isSavingPriority, + }); + + const rowStyle: CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.72 : 1, + zIndex: isDragging ? 10 : 1, + display: 'grid', + gridTemplateColumns: 'minmax(0, 1fr) auto auto', + alignItems: 'center', + gap: 8, + padding: '8px 12px', + borderLeft: '2px solid var(--color-primary)', + borderRadius: '0 var(--radius-sm) var(--radius-sm) 0', + background: isDragging ? 'rgba(59,130,246,0.08)' : 'rgba(79,70,229,0.02)', + boxShadow: isDragging ? 'var(--shadow-sm)' : 'none', + }; + + const decisionState = getChannelDecisionState(decisionCandidate, channel, isExactRoute, loadingDecision); + + return ( +
+
+ + + + P{channel.priority ?? 0} + + + + {channel.account?.username || `account-${channel.accountId}`} + + + + {channel.site?.name || 'unknown'} + + + + {channel.token?.name || '默认令牌'} + + + {channel.sourceModel ? ( + + {channel.sourceModel} + + ) : null} + + {channel.manualOverride ? ( + + 手动配置 + + ) : null} + +
+ 选中概率 +
+
+
+
+ 0 ? 'var(--color-text-secondary)' : decisionState.reasonColor, + fontVariantNumeric: 'tabular-nums', + whiteSpace: 'nowrap', + }} + > + {decisionState.probability.toFixed(1)}% + +
+ + 成功/失败 + + {channel.successCount || 0} + / + {channel.failCount || 0} + +
+
+ +
+
+ onTokenDraftChange(channel.id, Number.parseInt(nextValue, 10) || 0)} + disabled={isUpdatingToken} + options={[ + { value: '0', label: '默认令牌' }, + ...tokenOptions.map((token) => ({ + value: String(token.id), + label: `${token.name}${token.isDefault ? '(默认)' : ''}`, + })), + ]} + placeholder="默认令牌" + /> +
+ +
+ + +
+ ); +} diff --git a/src/web/pages/token-routes/types.ts b/src/web/pages/token-routes/types.ts new file mode 100644 index 0000000..3d71960 --- /dev/null +++ b/src/web/pages/token-routes/types.ts @@ -0,0 +1,145 @@ +import type { ReactNode } from 'react'; +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 RouteChannelDraft = { + accountId: number; + tokenId: number; + sourceModel: string; +}; + +export type RouteChannel = { + id: number; + accountId: number; + tokenId: number | null; + sourceModel?: string | null; + priority: number; + weight: number; + enabled: boolean; + manualOverride: boolean; + successCount: number; + failCount: number; + cooldownUntil?: string | null; + account?: { + username: string | null; + }; + site?: { + id: number; + name: string | null; + platform: string | null; + }; + token?: { + id: number; + name: string; + accountId: number; + enabled: boolean; + isDefault: boolean; + } | null; +}; + +export type RouteRow = { + id: number; + modelPattern: string; + displayName?: string | null; + displayIcon?: string | null; + modelMapping?: string | null; + decisionSnapshot?: RouteDecision | null; + decisionRefreshedAt?: string | null; + enabled: boolean; + channels: RouteChannel[]; +}; + +export type RouteSummaryRow = { + id: number; + modelPattern: string; + displayName: string | null; + displayIcon: string | null; + modelMapping: string | null; + enabled: boolean; + channelCount: number; + enabledChannelCount: number; + siteNames: string[]; + decisionSnapshot: RouteDecision | null; + decisionRefreshedAt: string | null; +}; + +export type RouteDecisionCandidate = { + channelId: number; + accountId: number; + username: string; + siteName: string; + tokenName: string; + priority: number; + weight: number; + eligible: boolean; + recentlyFailed: boolean; + avoidedByRecentFailure: boolean; + probability: number; + reason: string; +}; + +export type RouteDecision = { + requestedModel: string; + actualModel: string; + matched: boolean; + selectedChannelId?: number; + selectedLabel?: string; + summary: string[]; + candidates: RouteDecisionCandidate[]; +}; + +export type ChannelDecisionState = { + probability: number; + showBar: boolean; + reasonText: string; + reasonColor: string; +}; + +export type RouteTokenOption = { + id: number; + name: string; + isDefault: boolean; + sourceModel?: string; +}; + +export type RouteIconOption = { + value: string; + label: string; + description?: string; + iconNode?: ReactNode; + iconUrl?: string; + iconText?: string; +}; + +export type MissingTokenRouteSiteActionItem = { + key: string; + siteName: string; + accountId: number; + accountLabel: string; +}; + +export type SortableChannelRowProps = { + channel: RouteChannel; + decisionCandidate?: RouteDecisionCandidate; + isExactRoute: boolean; + loadingDecision: boolean; + isSavingPriority: boolean; + tokenOptions: RouteTokenOption[]; + activeTokenId: number; + isUpdatingToken: boolean; + onTokenDraftChange: (channelId: number, tokenId: number) => void; + onSaveToken: () => void; + onDeleteChannel: () => void; +}; + +export type GroupRouteItem = { + id: number; + title: string; + icon: { kind: 'none' } | { kind: 'text'; value: string } | { kind: 'brand'; value: string }; + brand: BrandInfo | null; + modelPattern: string; + channelCount: number; +}; diff --git a/src/web/pages/token-routes/useRouteChannels.ts b/src/web/pages/token-routes/useRouteChannels.ts new file mode 100644 index 0000000..b1c96d4 --- /dev/null +++ b/src/web/pages/token-routes/useRouteChannels.ts @@ -0,0 +1,42 @@ +import { useState, useCallback } from 'react'; +import { api } from '../../api.js'; +import { normalizeChannels } from './utils.js'; +import type { RouteChannel } from './types.js'; + +export function useRouteChannels() { + const [channelsByRouteId, setChannelsByRouteId] = useState>({}); + const [loadingChannelsByRouteId, setLoadingChannelsByRouteId] = useState>({}); + + const loadChannels = useCallback(async (routeId: number, force = false) => { + if (!force && channelsByRouteId[routeId]) return channelsByRouteId[routeId]; + setLoadingChannelsByRouteId((prev) => ({ ...prev, [routeId]: true })); + try { + const channels = await api.getRouteChannels(routeId); + const sorted = normalizeChannels(channels || []); + setChannelsByRouteId((prev) => ({ ...prev, [routeId]: sorted })); + return sorted; + } finally { + setLoadingChannelsByRouteId((prev) => ({ ...prev, [routeId]: false })); + } + }, [channelsByRouteId]); + + const invalidateChannels = useCallback((routeId: number) => { + setChannelsByRouteId((prev) => { + const next = { ...prev }; + delete next[routeId]; + return next; + }); + }, []); + + const setChannels = useCallback((routeId: number, channels: RouteChannel[]) => { + setChannelsByRouteId((prev) => ({ ...prev, [routeId]: channels })); + }, []); + + return { + channelsByRouteId, + loadingChannelsByRouteId, + loadChannels, + invalidateChannels, + setChannels, + }; +} diff --git a/src/web/pages/token-routes/utils.ts b/src/web/pages/token-routes/utils.ts new file mode 100644 index 0000000..f07058c --- /dev/null +++ b/src/web/pages/token-routes/utils.ts @@ -0,0 +1,321 @@ +import type { CSSProperties } from 'react'; +import { getBrand, normalizeBrandIconKey, type BrandInfo } from '../../components/BrandIcon.js'; +import type { RouteRow, RouteChannel, RouteDecisionCandidate, ChannelDecisionState, RouteSummaryRow } from './types.js'; + +export const AUTO_ROUTE_DECISION_LIMIT = 80; +export const ROUTE_RENDER_CHUNK = 40; +export const ROUTE_BRAND_ICON_PREFIX = 'brand:'; + +export const ENDPOINT_TYPE_ICON_MODEL_MAP: Record = { + openai: 'chatgpt', + gemini: 'gemini', + anthropic: 'claude', + anthroic: 'claude', + claude: 'claude', +}; + +export const PLATFORM_ENDPOINT_FALLBACK_MAP: Record = { + openai: ['openai'], + 'new-api': ['openai'], + 'one-api': ['openai'], + 'one-hub': ['openai'], + 'done-hub': ['openai'], + sub2api: ['openai'], + veloera: ['openai'], + cliproxyapi: ['openai'], + claude: ['anthropic'], + gemini: ['gemini'], + anyrouter: ['openai', 'anthropic'], +}; + +export const PLATFORM_ALIASES: Record = { + anthropic: 'claude', + google: 'gemini', + 'new api': 'new-api', + newapi: 'new-api', + 'one api': 'one-api', + oneapi: 'one-api', +}; + +export function isRegexModelPattern(modelPattern: string): boolean { + return modelPattern.trim().toLowerCase().startsWith('re:'); +} + +export function isExactModelPattern(modelPattern: string): boolean { + const normalized = modelPattern.trim(); + if (!normalized) return false; + if (isRegexModelPattern(normalized)) return false; + return !/[\*\?\[]/.test(normalized); +} + +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(); + if (!body) return { regex: null, error: 're: 后缺少正则表达式' }; + try { + return { regex: new RegExp(body), error: null }; + } catch (error) { + return { regex: null, error: (error as Error)?.message || '无效正则' }; + } +} + +function globToRegexSource(glob: string): string { + let source = ''; + for (let i = 0; i < glob.length; i += 1) { + const ch = glob[i]; + if (ch === '*') { + source += '.*'; + continue; + } + if (ch === '?') { + source += '.'; + continue; + } + if (ch === '[') { + const closeIndex = glob.indexOf(']', i + 1); + if (closeIndex > i + 1) { + source += glob.slice(i, closeIndex + 1); + i = closeIndex; + continue; + } + source += '\\['; + continue; + } + source += ch.replace(/[\\^$+?.()|{}]/g, '\\$&'); + } + return source; +} + +function matchesGlobPattern(model: string, pattern: string): boolean { + try { + return new RegExp(`^${globToRegexSource(pattern)}$`).test(model); + } catch { + return false; + } +} + +export function matchesModelPattern(model: string, pattern: string): boolean { + const normalized = (pattern || '').trim(); + if (!normalized) return false; + if (normalized === model) return true; + + if (isRegexModelPattern(normalized)) { + const parsed = parseRegexModelPattern(normalized); + return !!parsed.regex && parsed.regex.test(model); + } + + return matchesGlobPattern(model, normalized); +} + +export function getModelPatternError(modelPattern: string): string | null { + const normalized = modelPattern.trim(); + if (!normalized) return null; + if (!isRegexModelPattern(normalized)) return null; + const parsed = parseRegexModelPattern(normalized); + if (!parsed.error) return null; + return `模型匹配正则错误:${parsed.error}`; +} + +export function resolveRouteTitle(route: Pick): string { + const title = (route.displayName || '').trim(); + return title || route.modelPattern; +} + +export function resolveRouteBrand(route: Pick): BrandInfo | null { + const displayName = (route.displayName || '').trim(); + if (displayName) { + const byDisplayName = getBrand(displayName); + if (byDisplayName) return byDisplayName; + } + return getBrand(route.modelPattern); +} + +export function toBrandIconValue(icon: string): string { + return `${ROUTE_BRAND_ICON_PREFIX}${icon}`; +} + +export function parseBrandIconValue(raw: string): string | null { + const normalized = (raw || '').trim(); + if (!normalized.startsWith(ROUTE_BRAND_ICON_PREFIX)) return null; + const icon = normalized.slice(ROUTE_BRAND_ICON_PREFIX.length).trim(); + return normalizeBrandIconKey(icon); +} + +export function normalizeRouteDisplayIconValue(raw: string | null | undefined): string { + const normalized = (raw || '').trim(); + const brandIcon = parseBrandIconValue(normalized); + if (brandIcon) return toBrandIconValue(brandIcon); + return normalized; +} + +export function resolveEndpointTypeIconModel(endpointType: string): string | null { + const key = String(endpointType || '').trim().toLowerCase(); + if (!key) return null; + return ENDPOINT_TYPE_ICON_MODEL_MAP[key] || null; +} + +export function normalizePlatformKey(platform: string | null | undefined): string { + const raw = String(platform || '').trim().toLowerCase(); + if (!raw) return ''; + return PLATFORM_ALIASES[raw] || raw; +} + +export function inferEndpointTypesFromPlatform(platform: string | null | undefined): string[] { + const key = normalizePlatformKey(platform); + if (!key) return []; + const mapped = PLATFORM_ENDPOINT_FALLBACK_MAP[key]; + if (Array.isArray(mapped) && mapped.length > 0) return mapped; + + if (key.includes('claude') || key.includes('anthropic')) return ['anthropic']; + if (key.includes('gemini')) return ['gemini']; + if (key.includes('openai') || key.includes('new-api') || key.includes('one-api')) return ['openai']; + return []; +} + +export function siteAvatarLetters(siteName: string): string { + const normalized = String(siteName || '').trim(); + if (!normalized) return 'S'; + const parts = normalized.replace(/[-_/.]/g, ' ').split(/\s+/).filter(Boolean); + if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); + const compact = normalized.replace(/\s+/g, ''); + return compact.slice(0, 2).toUpperCase(); +} + +export function resolveRouteIcon(route: Pick): { kind: 'none' } | { kind: 'text'; value: string } | { kind: 'brand'; value: string } { + const icon = (route.displayIcon || '').trim(); + if (!icon) return { kind: 'none' }; + const brandIcon = parseBrandIconValue(icon); + if (brandIcon) return { kind: 'brand', value: brandIcon }; + return { kind: 'text', value: icon }; +} + +export function normalizeChannels(channels: RouteChannel[]): RouteChannel[] { + return [...(channels || [])].sort((a, b) => { + const pa = a.priority ?? 0; + const pb = b.priority ?? 0; + if (pa === pb) return (a.id ?? 0) - (b.id ?? 0); + return pa - pb; + }); +} + +export function normalizeRoutes(routeRows: any[]): RouteRow[] { + return (routeRows || []).map((route) => ({ + ...(route as RouteRow), + channels: normalizeChannels(route.channels || []), + })); +} + +export function buildSourceGroupKey(routeId: number, sourceModel: string): string { + const normalizedSourceModel = sourceModel.trim() || '__ungrouped__'; + return `${routeId}::${normalizedSourceModel}`; +} + +export function getPriorityTagStyle(priority: number): CSSProperties { + if (priority <= 0) { + return { + background: 'color-mix(in srgb, var(--color-success) 16%, transparent)', + color: 'var(--color-success)', + }; + } + + if (priority === 1) { + return { + background: 'color-mix(in srgb, var(--color-info) 16%, transparent)', + color: 'var(--color-info)', + }; + } + + return { + background: 'rgba(100,116,139,0.18)', + color: 'var(--color-text-secondary)', + }; +} + +export function getProbabilityColor(probability: number): string { + if (probability >= 80) return 'var(--color-success)'; + if (probability >= 60) return 'color-mix(in srgb, var(--color-success) 50%, var(--color-warning))'; + if (probability >= 40) return 'var(--color-warning)'; + if (probability >= 20) return 'color-mix(in srgb, var(--color-warning) 45%, var(--color-danger))'; + if (probability > 0) return 'var(--color-danger)'; + return 'var(--color-border)'; +} + +export function getChannelDecisionState( + candidate: RouteDecisionCandidate | undefined, + channel: RouteChannel, + isExactRoute: boolean, + loadingDecision: boolean, +): ChannelDecisionState { + if (!isExactRoute && !candidate) { + return { + probability: 0, + showBar: true, + reasonText: loadingDecision ? '计算中...' : '实时决策', + reasonColor: 'var(--color-text-muted)', + }; + } + + if (!candidate) { + return { + probability: 0, + showBar: true, + reasonText: loadingDecision ? '计算中...' : '无可用通道', + reasonColor: 'var(--color-text-muted)', + }; + } + + if (candidate.avoidedByRecentFailure) { + return { + probability: 0, + showBar: true, + reasonText: '失败避让', + reasonColor: 'var(--color-warning)', + }; + } + + if (!candidate.eligible) { + const nowIso = new Date().toISOString(); + const cooldownActive = !!channel.cooldownUntil && channel.cooldownUntil > nowIso; + if (cooldownActive || candidate.reason.includes('冷却中')) { + return { + probability: 0, + showBar: true, + reasonText: '冷却中', + reasonColor: 'var(--color-danger)', + }; + } + + return { + probability: 0, + showBar: true, + reasonText: candidate.reason || '不可用', + reasonColor: 'var(--color-text-muted)', + }; + } + + const probability = Number(candidate.probability || 0); + if (probability <= 0) { + if (candidate.recentlyFailed) { + return { + probability: 0, + showBar: false, + reasonText: '近期失败', + reasonColor: 'var(--color-warning)', + }; + } + + return { + probability: 0, + showBar: false, + reasonText: candidate.reason || '概率为 0%', + reasonColor: 'var(--color-text-muted)', + }; + } + + return { + probability, + showBar: true, + reasonText: '', + reasonColor: 'var(--color-text-muted)', + }; +}