Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 112 additions & 37 deletions web/src/components/routes/ClientTypeRoutesContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Used by both global routes and project routes
*/

import { useState, useMemo } from 'react';
import { useState, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, RefreshCw, Zap } from 'lucide-react';
import {
Expand Down Expand Up @@ -38,7 +38,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { useStreamingRequests } from '@/hooks/use-streaming';
import { getClientName, getClientColor } from '@/components/icons/client-icons';
import { getProviderColor, type ProviderType } from '@/lib/theme';
import type { ClientType, Provider } from '@/lib/transport';
import type { ClientType, Provider, ProviderStats } from '@/lib/transport';
import {
SortableProviderRow,
ProviderRowContent,
Expand All @@ -58,6 +58,44 @@ const PROVIDER_TYPE_LABELS: Record<Exclude<ProviderTypeKey, 'custom'>, string> =
codex: 'Codex',
};

function isSameProviderStats(a: ProviderStats, b: ProviderStats): boolean {
return (
a.providerID === b.providerID &&
a.totalRequests === b.totalRequests &&
a.successfulRequests === b.successfulRequests &&
a.failedRequests === b.failedRequests &&
a.successRate === b.successRate &&
a.activeRequests === b.activeRequests &&
a.totalInputTokens === b.totalInputTokens &&
a.totalOutputTokens === b.totalOutputTokens &&
a.totalCacheRead === b.totalCacheRead &&
a.totalCacheWrite === b.totalCacheWrite &&
a.totalCost === b.totalCost
);
}

function useStableProviderStats(stats: Record<number, ProviderStats>) {
const prevRef = useRef<Record<number, ProviderStats>>({});

return useMemo(() => {
const prev = prevRef.current;
const next: Record<number, ProviderStats> = {};

for (const [key, value] of Object.entries(stats)) {
const id = Number(key);
const prevValue = prev[id];
if (prevValue && isSameProviderStats(prevValue, value)) {
next[id] = prevValue;
} else {
next[id] = value;
}
}

prevRef.current = next;
return next;
}, [stats]);
}

interface ClientTypeRoutesContentProps {
clientType: ClientType;
projectID: number; // 0 for global routes
Expand All @@ -84,6 +122,7 @@ function ClientTypeRoutesContentInner({
const { t } = useTranslation();
const [activeId, setActiveId] = useState<string | null>(null);
const { data: providerStats = {} } = useProviderStats(clientType, projectID || undefined);
const stableProviderStats = useStableProviderStats(providerStats);
const queryClient = useQueryClient();

// 订阅请求更新事件,确保 providerStats 实时刷新
Expand All @@ -102,7 +141,6 @@ function ClientTypeRoutesContentInner({

const { data: allRoutes, isLoading: routesLoading } = useRoutes();
const { data: providers = [], isLoading: providersLoading } = useProviders();
const { countsByProviderAndClient } = useStreamingRequests();

const createRoute = useCreateRoute();
const toggleRoute = useToggleRoute();
Expand All @@ -116,42 +154,62 @@ function ClientTypeRoutesContentInner({
return allRoutes?.filter((r) => r.clientType === clientType && r.projectID === projectID) || [];
}, [allRoutes, clientType, projectID]);

const normalizedQuery = useMemo(() => searchQuery.trim().toLowerCase(), [searchQuery]);

const providerById = useMemo(() => {
const map = new Map<number, Provider>();
for (const provider of providers) {
map.set(Number(provider.id), provider);
}
return map;
}, [providers]);

const routeByProviderId = useMemo(() => {
const map = new Map<number, (typeof clientRoutes)[number]>();
for (const route of clientRoutes) {
map.set(Number(route.providerID), route);
}
return map;
}, [clientRoutes]);

// Build provider config items
const items = useMemo((): ProviderConfigItem[] => {
const allItems = providers.map((provider) => {
const route = clientRoutes.find((r) => Number(r.providerID) === Number(provider.id)) || null;
const allItems: ProviderConfigItem[] = [];

for (const route of clientRoutes) {
const provider = providerById.get(Number(route.providerID));
if (!provider) continue;
const isNative = (provider.supportedClientTypes || []).includes(clientType);
return {
allItems.push({
id: `${clientType}-provider-${provider.id}`,
provider,
route,
enabled: route?.isEnabled ?? false,
enabled: route.isEnabled ?? false,
isNative,
};
});
});
}

// Only show providers that have routes
let filteredItems = allItems.filter((item) => item.route);
let filteredItems = allItems;

// Apply search filter
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
if (normalizedQuery) {
filteredItems = filteredItems.filter(
(item) =>
item.provider.name.toLowerCase().includes(query) ||
item.provider.type.toLowerCase().includes(query),
item.provider.name.toLowerCase().includes(normalizedQuery) ||
item.provider.type.toLowerCase().includes(normalizedQuery),
);
}

return filteredItems.sort((a, b) => {
if (a.route && b.route) return a.route.position - b.route.position;
if (a.route && !b.route) return -1;
if (!a.route && b.route) return 1;
if (a.isNative && !b.isNative) return -1;
if (!a.isNative && b.isNative) return 1;
const posDiff = (a.route?.position ?? 0) - (b.route?.position ?? 0);
if (posDiff !== 0) return posDiff;
if (a.isNative !== b.isNative) return a.isNative ? -1 : 1;
return a.provider.name.localeCompare(b.provider.name);
});
}, [providers, clientRoutes, clientType, searchQuery]);
}, [clientRoutes, clientType, normalizedQuery, providerById]);

const streamingThrottleMs = items.length > 200 ? 1000 : 0;
const { countsByProviderAndClient } = useStreamingRequests({ throttleMs: streamingThrottleMs });

// Get available providers (without routes yet), grouped by type and sorted alphabetically
const groupedAvailableProviders = useMemo((): Record<ProviderTypeKey, Provider[]> => {
Expand All @@ -162,16 +220,14 @@ function ClientTypeRoutesContentInner({
custom: [],
};

let available = providers.filter((p) => {
const hasRoute = clientRoutes.some((r) => Number(r.providerID) === Number(p.id));
return !hasRoute;
});
let available = providers.filter((p) => !routeByProviderId.has(Number(p.id)));

// Apply search filter
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
if (normalizedQuery) {
available = available.filter(
(p) => p.name.toLowerCase().includes(query) || p.type.toLowerCase().includes(query),
(p) =>
p.name.toLowerCase().includes(normalizedQuery) ||
p.type.toLowerCase().includes(normalizedQuery),
);
}

Expand All @@ -191,14 +247,32 @@ function ClientTypeRoutesContentInner({
}

return groups;
}, [providers, clientRoutes, searchQuery]);
}, [providers, normalizedQuery, routeByProviderId]);

// Check if there are any available providers
const hasAvailableProviders = useMemo(() => {
return PROVIDER_TYPE_ORDER.some((type) => groupedAvailableProviders[type].length > 0);
}, [groupedAvailableProviders]);

const activeItem = activeId ? items.find((item) => item.id === activeId) : null;
const itemsById = useMemo(() => {
const map = new Map<string, ProviderConfigItem>();
for (const item of items) {
map.set(item.id, item);
}
return map;
}, [items]);

const itemIds = useMemo(() => items.map((item) => item.id), [items]);

const itemIndexById = useMemo(() => {
const map = new Map<string, number>();
items.forEach((item, index) => {
map.set(item.id, index);
});
return map;
}, [items]);

const activeItem = activeId ? itemsById.get(activeId) ?? null : null;

const handleToggle = (item: ProviderConfigItem) => {
if (item.route) {
Expand Down Expand Up @@ -244,10 +318,11 @@ function ClientTypeRoutesContentInner({

if (!over || active.id === over.id) return;

const oldIndex = items.findIndex((item) => item.id === active.id);
const newIndex = items.findIndex((item) => item.id === over.id);
const oldIndex = itemIndexById.get(active.id as string);
const newIndex = itemIndexById.get(over.id as string);

if (oldIndex === -1 || newIndex === -1) return;
if (oldIndex === undefined || newIndex === undefined) return;
if (oldIndex === newIndex) return;

const newItems = arrayMove(items, oldIndex, newIndex);

Expand Down Expand Up @@ -305,7 +380,7 @@ function ClientTypeRoutesContentInner({
onDragEnd={handleDragEnd}
>
<SortableContext
items={items.map((item) => item.id)}
items={itemIds}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
Expand All @@ -318,7 +393,7 @@ function ClientTypeRoutesContentInner({
streamingCount={
countsByProviderAndClient.get(`${item.provider.id}:${clientType}`) || 0
}
stats={providerStats[item.provider.id]}
stats={stableProviderStats[item.provider.id]}
isToggling={toggleRoute.isPending || createRoute.isPending}
onToggle={() => handleToggle(item)}
onDelete={item.route ? () => handleDeleteRoute(item.route!.id) : undefined}
Expand All @@ -331,12 +406,12 @@ function ClientTypeRoutesContentInner({
{activeItem && (
<ProviderRowContent
item={activeItem}
index={items.findIndex((i) => i.id === activeItem.id)}
index={itemIndexById.get(activeItem.id) ?? 0}
clientType={clientType}
streamingCount={
countsByProviderAndClient.get(`${activeItem.provider.id}:${clientType}`) || 0
}
stats={providerStats[activeItem.provider.id]}
stats={stableProviderStats[activeItem.provider.id]}
isToggling={false}
isOverlay
onToggle={() => {}}
Expand Down
80 changes: 64 additions & 16 deletions web/src/hooks/use-streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ export interface StreamingState {
countsByRoute: Map<number, number>;
}

export interface StreamingOptions {
/** 事件更新节流间隔(毫秒),0 表示不节流 */
throttleMs?: number;
}

/**
* 判断请求是否为活跃状态
*/
Expand All @@ -33,9 +38,37 @@ function isActiveRequest(request: ProxyRequest): boolean {
* 通过 WebSocket 事件更新状态
* 注意:React Query 缓存更新由 useProxyRequestUpdates 处理
*/
export function useStreamingRequests(): StreamingState {
export function useStreamingRequests(options: StreamingOptions = {}): StreamingState {
const [activeRequests, setActiveRequests] = useState<Map<string, ProxyRequest>>(new Map());
const activeRequestsRef = useRef<Map<string, ProxyRequest>>(new Map());
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isInitialized = useRef(false);
const throttleMs = options.throttleMs ?? 0;

const scheduleFlush = useCallback(() => {
if (throttleMs <= 0) {
return;
}
if (flushTimerRef.current) {
return;
}
flushTimerRef.current = setTimeout(() => {
flushTimerRef.current = null;
setActiveRequests(new Map(activeRequestsRef.current));
}, throttleMs);
}, [throttleMs]);

const applyState = useCallback(
(next: Map<string, ProxyRequest>) => {
activeRequestsRef.current = next;
if (throttleMs <= 0) {
setActiveRequests(next);
return;
}
scheduleFlush();
},
[scheduleFlush, throttleMs],
);

// 从 API 加载当前活跃请求
const loadActiveRequests = useCallback(async () => {
Expand All @@ -53,30 +86,28 @@ export function useStreamingRequests(): StreamingState {
console.warn('getActiveProxyRequests returned non-array:', activeList);
}

setActiveRequests(activeMap);
applyState(activeMap);
} catch (error) {
console.error('Failed to load active requests:', error);
}
}, []);
}, [applyState]);

// 处理请求更新
const handleRequestUpdate = useCallback((request: ProxyRequest) => {
setActiveRequests((prev) => {
const next = new Map(prev);

if (isActiveRequest(request)) {
// PENDING 或 IN_PROGRESS 的请求添加到活动列表
next.set(request.requestID, request);
} else {
// 已完成、失败、取消或拒绝的请求从活动列表中移除
next.delete(request.requestID);
}
const next = new Map(activeRequestsRef.current);

if (isActiveRequest(request)) {
// PENDING 或 IN_PROGRESS 的请求添加到活动列表
next.set(request.requestID, request);
} else {
// 已完成、失败、取消或拒绝的请求从活动列表中移除
next.delete(request.requestID);
}

return next;
});
applyState(next);
// 注意:不要在这里调用 invalidateQueries,会导致重复请求
// React Query 缓存更新由 useProxyRequestUpdates 处理
}, []);
}, [applyState]);

useEffect(() => {
const transport = getTransport();
Expand All @@ -102,9 +133,26 @@ export function useStreamingRequests(): StreamingState {
return () => {
unsubscribe();
unsubscribeReconnect();
if (flushTimerRef.current) {
clearTimeout(flushTimerRef.current);
flushTimerRef.current = null;
}
};
}, [handleRequestUpdate, loadActiveRequests]);

useEffect(() => {
if (throttleMs <= 0) {
if (flushTimerRef.current) {
clearTimeout(flushTimerRef.current);
flushTimerRef.current = null;
}
// 关闭节流时立即刷出缓冲状态,避免清理定时器后丢失一次更新。
setActiveRequests(new Map(activeRequestsRef.current));
}
// 让 effect 与最新的 handleRequestUpdate 逻辑保持一致(依赖变更触发重新刷出)。
void handleRequestUpdate;
}, [throttleMs, handleRequestUpdate]);

return useMemo((): StreamingState => {
// 计算按 clientType 和 providerID 的统计
const countsByClient = new Map<ClientType, number>();
Expand Down
Loading