From 37a86d13806da330c6dfb8ff4873887f49d78aba Mon Sep 17 00:00:00 2001 From: Cita Date: Wed, 1 Apr 2026 18:24:14 +0800 Subject: [PATCH] [codex] background route decision refresh (#370) * background route decision refresh * address route decision refresh review * fix route decision refresh typing --- ...tokens.route-decision-refresh-task.test.ts | 199 ++++++++++++++++++ src/server/routes/api/tokens.ts | 64 +++++- .../services/routeDecisionRefreshService.ts | 103 +++++++++ src/shared/tokenRouteContract.d.ts | 1 + src/shared/tokenRouteContract.js | 2 + src/shared/tokenRouteContract.test.ts | 6 + src/web/api.ts | 4 + src/web/i18n.supplement.ts | 2 + src/web/pages/TokenRoutes.tsx | 182 +++++++++------- src/web/pages/token-routes/RouteCard.tsx | 32 +++ .../tokenRoutes.refresh-decision.test.tsx | 102 ++++++++- .../pages/tokenRoutes.snapshot-load.test.tsx | 40 +++- 12 files changed, 647 insertions(+), 90 deletions(-) create mode 100644 src/server/routes/api/tokens.route-decision-refresh-task.test.ts create mode 100644 src/server/services/routeDecisionRefreshService.ts diff --git a/src/server/routes/api/tokens.route-decision-refresh-task.test.ts b/src/server/routes/api/tokens.route-decision-refresh-task.test.ts new file mode 100644 index 0000000..ac55f19 --- /dev/null +++ b/src/server/routes/api/tokens.route-decision-refresh-task.test.ts @@ -0,0 +1,199 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +type DbModule = typeof import('../../db/index.js'); +type BackgroundTaskModule = typeof import('../../services/backgroundTaskService.js'); +type TokenRouterModule = typeof import('../../services/tokenRouter.js'); + +describe('POST /api/routes/decision/refresh', () => { + let app: FastifyInstance; + let previousDataDir: string | undefined; + let db: DbModule['db']; + let schema: DbModule['schema']; + let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache']; + let tokenRouter: TokenRouterModule['tokenRouter']; + let getBackgroundTask: BackgroundTaskModule['getBackgroundTask']; + let resetBackgroundTasks: BackgroundTaskModule['__resetBackgroundTasksForTests']; + let dataDir = ''; + + beforeAll(async () => { + previousDataDir = process.env.DATA_DIR; + vi.resetModules(); + dataDir = mkdtempSync(join(tmpdir(), 'metapi-route-decision-refresh-task-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const routesModule = await import('./tokens.js'); + const tokenRouterModule = await import('../../services/tokenRouter.js'); + const backgroundTaskModule = await import('../../services/backgroundTaskService.js'); + db = dbModule.db; + schema = dbModule.schema; + invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache; + tokenRouter = tokenRouterModule.tokenRouter; + getBackgroundTask = backgroundTaskModule.getBackgroundTask; + resetBackgroundTasks = backgroundTaskModule.__resetBackgroundTasksForTests; + vi.spyOn(tokenRouter, 'refreshPricingReferenceCosts').mockResolvedValue(undefined); + vi.spyOn(tokenRouter, 'refreshRouteWidePricingReferenceCosts').mockResolvedValue(undefined); + + app = Fastify(); + await app.register(routesModule.tokensRoutes); + }); + + beforeEach(async () => { + resetBackgroundTasks(); + await db.delete(schema.routeChannels).run(); + await db.delete(schema.tokenRoutes).run(); + await db.delete(schema.accountTokens).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.sites).run(); + await db.delete(schema.settings).run(); + await db.delete(schema.events).run(); + invalidateTokenRouterCache(); + }); + + afterAll(async () => { + await app.close(); + invalidateTokenRouterCache(); + vi.restoreAllMocks(); + if (dataDir) { + try { + rmSync(dataDir, { recursive: true, force: true }); + } catch {} + } + if (previousDataDir === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = previousDataDir; + } + vi.resetModules(); + }); + + it('queues a background refresh task and persists refreshed snapshots for exact and wildcard routes', async () => { + const site = await db.insert(schema.sites).values({ + name: 'refresh-task-site', + url: 'https://refresh-task-site.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'refresh-task-user', + accessToken: 'refresh-task-access', + apiToken: 'refresh-task-api', + status: 'active', + }).returning().get(); + + const exactRoute = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-4o-mini', + enabled: true, + }).returning().get(); + + const duplicateExactRoute = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-4o-mini', + enabled: true, + }).returning().get(); + + const wildcardRoute = await db.insert(schema.tokenRoutes).values({ + modelPattern: 're:^claude-(opus|sonnet)-4-6$', + enabled: true, + }).returning().get(); + + const disabledRoute = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'disabled-model', + enabled: false, + }).returning().get(); + + await db.insert(schema.routeChannels).values([ + { + routeId: exactRoute.id, + accountId: account.id, + tokenId: null, + priority: 0, + weight: 10, + enabled: true, + }, + { + routeId: duplicateExactRoute.id, + accountId: account.id, + tokenId: null, + sourceModel: 'gpt-4o-mini', + priority: 0, + weight: 5, + enabled: true, + }, + { + routeId: wildcardRoute.id, + accountId: account.id, + tokenId: null, + sourceModel: 'claude-opus-4-6', + priority: 0, + weight: 10, + enabled: true, + }, + { + routeId: disabledRoute.id, + accountId: account.id, + tokenId: null, + priority: 0, + weight: 10, + enabled: true, + }, + ]).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/routes/decision/refresh', + payload: {}, + }); + + expect(response.statusCode).toBe(202); + const body = response.json() as { + success: boolean; + queued: boolean; + jobId: string; + status: string; + }; + expect(body.success).toBe(true); + expect(body.queued).toBe(true); + expect(body.jobId.length).toBeGreaterThan(10); + expect(body.status).toBe('pending'); + + for (let attempt = 0; attempt < 200; attempt += 1) { + const task = getBackgroundTask(body.jobId); + if (task?.status === 'succeeded') break; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + expect(getBackgroundTask(body.jobId)).toMatchObject({ status: 'succeeded' }); + + const routesResponse = await app.inject({ + method: 'GET', + url: '/api/routes', + }); + + expect(routesResponse.statusCode).toBe(200); + const routes = routesResponse.json() as Array<{ + id: number; + decisionSnapshot?: { matched?: boolean; routeId?: number; candidates?: Array } | null; + }>; + + const refreshedExactRoute = routes.find((route) => route.id === exactRoute.id); + const refreshedDuplicateExactRoute = routes.find((route) => route.id === duplicateExactRoute.id); + const refreshedWildcardRoute = routes.find((route) => route.id === wildcardRoute.id); + const refreshedDisabledRoute = routes.find((route) => route.id === disabledRoute.id); + + expect(refreshedExactRoute?.decisionSnapshot?.matched).toBe(true); + expect(refreshedExactRoute?.decisionSnapshot?.routeId).toBe(exactRoute.id); + expect(Array.isArray(refreshedExactRoute?.decisionSnapshot?.candidates)).toBe(true); + expect(refreshedDuplicateExactRoute?.decisionSnapshot?.matched).toBe(true); + expect(refreshedDuplicateExactRoute?.decisionSnapshot?.routeId).toBe(duplicateExactRoute.id); + expect(refreshedWildcardRoute?.decisionSnapshot?.matched).toBe(true); + expect(refreshedWildcardRoute?.decisionSnapshot?.routeId).toBe(wildcardRoute.id); + expect(refreshedDisabledRoute?.decisionSnapshot).toBeNull(); + }); +}); diff --git a/src/server/routes/api/tokens.ts b/src/server/routes/api/tokens.ts index 911b032..01deeda 100644 --- a/src/server/routes/api/tokens.ts +++ b/src/server/routes/api/tokens.ts @@ -13,7 +13,7 @@ import { type RouteRoutingStrategy, } from '../../services/routeRoutingStrategy.js'; import { invalidateTokenRouterCache, matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js'; -import { startBackgroundTask } from '../../services/backgroundTaskService.js'; +import { appendBackgroundTaskLog, startBackgroundTask } from '../../services/backgroundTaskService.js'; import { clearRouteDecisionSnapshot, clearRouteDecisionSnapshots, @@ -21,6 +21,11 @@ import { saveRouteDecisionSnapshots, } from '../../services/routeDecisionSnapshotStore.js'; import { clearRouteCooldown } from '../../services/routeCooldownService.js'; +import { + refreshAllRouteDecisionSnapshots, + ROUTE_DECISION_REFRESH_DEDUPE_KEY, + ROUTE_DECISION_REFRESH_TASK_TYPE, +} from '../../services/routeDecisionRefreshService.js'; import { normalizeTokenRouteMode, type RouteMode } from '../../../shared/tokenRouteContract.js'; import { parseRouteChannelBatchCreatePayload, @@ -945,11 +950,11 @@ export async function tokensRoutes(app: FastifyInstance) { return { success: true, decisions }; }); - app.post<{ Body: BatchRouteWideDecisionRouteIds }>('/api/routes/decision/route-wide/batch', async (request, reply) => { - const parsed = parseBatchRouteWideDecisionRouteIds(request.body); - if (!parsed.ok) { - return reply.code(400).send({ success: false, message: parsed.message }); - } + app.post<{ Body: BatchRouteWideDecisionRouteIds }>('/api/routes/decision/route-wide/batch', async (request, reply) => { + const parsed = parseBatchRouteWideDecisionRouteIds(request.body); + if (!parsed.ok) { + return reply.code(400).send({ success: false, message: parsed.message }); + } const decisions: Record>> = {}; const refreshedKeys = parsed.refreshPricingCatalog ? new Set() : undefined; @@ -966,10 +971,49 @@ export async function tokensRoutes(app: FastifyInstance) { snapshot: decisions[String(routeId)] ?? null, }))); } - - return { success: true, decisions }; - }); - + + return { success: true, decisions }; + }); + + app.post('/api/routes/decision/refresh', async (_request, reply) => { + let taskId = ''; + const { task, reused } = startBackgroundTask( + { + type: ROUTE_DECISION_REFRESH_TASK_TYPE, + title: '刷新路由选中概率', + dedupeKey: ROUTE_DECISION_REFRESH_DEDUPE_KEY, + successMessage: (currentTask) => { + const result = currentTask.result as { exactModelCount?: number; wildcardRouteCount?: number } | null; + const exactModelCount = result?.exactModelCount ?? 0; + const wildcardRouteCount = result?.wildcardRouteCount ?? 0; + return `路由选中概率刷新完成:精确模型 ${exactModelCount},通配符路由 ${wildcardRouteCount}`; + }, + failureMessage: (currentTask) => `路由选中概率刷新失败:${currentTask.error || 'unknown error'}`, + }, + async () => { + await Promise.resolve(); + return await refreshAllRouteDecisionSnapshots({ + refreshPricingCatalog: true, + onProgress: (message) => { + appendBackgroundTaskLog(taskId, message); + }, + }); + }, + ); + taskId = task.id; + + return reply.code(202).send({ + success: true, + queued: true, + reused, + jobId: task.id, + status: task.status, + message: reused + ? '路由选中概率刷新任务执行中,可稍后返回查看' + : '已开始后台刷新路由选中概率,可稍后返回查看', + }); + }); + // Create a route app.post<{ Body: unknown }>('/api/routes', async (request, reply) => { const parsedBody = parseTokenRouteCreatePayload(request.body); diff --git a/src/server/services/routeDecisionRefreshService.ts b/src/server/services/routeDecisionRefreshService.ts new file mode 100644 index 0000000..5702db3 --- /dev/null +++ b/src/server/services/routeDecisionRefreshService.ts @@ -0,0 +1,103 @@ +import { eq } from 'drizzle-orm'; +import { db, schema } from '../db/index.js'; +import { saveRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js'; +import { matchesModelPattern, tokenRouter } from './tokenRouter.js'; +import { ROUTE_DECISION_REFRESH_TASK_TYPE } from '../../shared/tokenRouteContract.js'; + +export { ROUTE_DECISION_REFRESH_TASK_TYPE }; +export const ROUTE_DECISION_REFRESH_DEDUPE_KEY = 'refresh-route-decision-snapshots'; + +function isExactModelPattern(modelPattern: string): boolean { + const normalized = modelPattern.trim(); + if (!normalized) return false; + if (normalized.toLowerCase().startsWith('re:')) return false; + return !/[\*\?]/.test(normalized); +} + +function normalizeModels(models: string[]): string[] { + return Array.from(new Set( + models + .map((model) => String(model || '').trim()) + .filter(Boolean), + )); +} + +function normalizeRouteIds(routeIds: number[]): number[] { + return Array.from(new Set( + routeIds + .map((routeId) => Math.trunc(routeId)) + .filter((routeId) => routeId > 0), + )); +} + +type RefreshOptions = { + refreshPricingCatalog?: boolean; + onProgress?: (message: string) => void; +}; + +export async function refreshAllRouteDecisionSnapshots(options: RefreshOptions = {}): Promise<{ + exactModelCount: number; + wildcardRouteCount: number; +}> { + const routes = await db.select({ + id: schema.tokenRoutes.id, + modelPattern: schema.tokenRoutes.modelPattern, + }).from(schema.tokenRoutes) + .where(eq(schema.tokenRoutes.enabled, true)) + .all(); + + const exactModels = normalizeModels( + routes + .filter((route) => isExactModelPattern(route.modelPattern)) + .map((route) => route.modelPattern), + ); + const wildcardRouteIds = normalizeRouteIds( + routes + .filter((route) => !isExactModelPattern(route.modelPattern)) + .map((route) => route.id), + ); + const refreshedKeys = options.refreshPricingCatalog ? new Set() : undefined; + + options.onProgress?.(`开始刷新路由概率:精确模型 ${exactModels.length},通配符路由 ${wildcardRouteIds.length}`); + + for (const [index, model] of exactModels.entries()) { + options.onProgress?.(`刷新精确模型概率 ${index + 1}/${exactModels.length}:${model}`); + const matchingRoutes = routes.filter((route) => ( + isExactModelPattern(route.modelPattern) && matchesModelPattern(model, route.modelPattern) + )); + const snapshotWrites: Array<{ routeId: number; snapshot: unknown }> = []; + for (const route of matchingRoutes) { + if (options.refreshPricingCatalog) { + await tokenRouter.refreshPricingReferenceCostsForRoute(route.id, model, { refreshedKeys }); + } + const decision = await tokenRouter.explainSelectionForRoute(route.id, model); + snapshotWrites.push({ + routeId: route.id, + snapshot: decision, + }); + } + if (snapshotWrites.length > 0) { + await saveRouteDecisionSnapshots(snapshotWrites); + } + } + + for (const [index, routeId] of wildcardRouteIds.entries()) { + options.onProgress?.(`刷新通配符路由概率 ${index + 1}/${wildcardRouteIds.length}:#${routeId}`); + if (options.refreshPricingCatalog) { + await tokenRouter.refreshRouteWidePricingReferenceCosts(routeId, { refreshedKeys }); + } + + const decision = await tokenRouter.explainSelectionRouteWide(routeId); + await saveRouteDecisionSnapshots([ + { + routeId, + snapshot: decision, + }, + ]); + } + + return { + exactModelCount: exactModels.length, + wildcardRouteCount: wildcardRouteIds.length, + }; +} diff --git a/src/shared/tokenRouteContract.d.ts b/src/shared/tokenRouteContract.d.ts index 5595a53..3ff86d5 100644 --- a/src/shared/tokenRouteContract.d.ts +++ b/src/shared/tokenRouteContract.d.ts @@ -1,3 +1,4 @@ +export declare const ROUTE_DECISION_REFRESH_TASK_TYPE = "route-decision.refresh"; export type RouteMode = 'pattern' | 'explicit_group'; export type RouteDecisionCandidate = { channelId: number; diff --git a/src/shared/tokenRouteContract.js b/src/shared/tokenRouteContract.js index c5d0667..63af49b 100644 --- a/src/shared/tokenRouteContract.js +++ b/src/shared/tokenRouteContract.js @@ -1,3 +1,5 @@ +export const ROUTE_DECISION_REFRESH_TASK_TYPE = 'route-decision.refresh'; + export function normalizeTokenRouteMode(routeMode) { return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern'; } diff --git a/src/shared/tokenRouteContract.test.ts b/src/shared/tokenRouteContract.test.ts index d984ee5..a8659ca 100644 --- a/src/shared/tokenRouteContract.test.ts +++ b/src/shared/tokenRouteContract.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; describe('token route contract', () => { + it('exports the route decision refresh task type', async () => { + const { ROUTE_DECISION_REFRESH_TASK_TYPE } = await import('./tokenRouteContract.js'); + + expect(ROUTE_DECISION_REFRESH_TASK_TYPE).toBe('route-decision.refresh'); + }); + it('normalizes unknown route modes to pattern', async () => { const { normalizeTokenRouteMode } = await import('./tokenRouteContract.js'); diff --git a/src/web/api.ts b/src/web/api.ts index 0d93a97..e607791 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -731,6 +731,10 @@ export const api = { body: JSON.stringify({ refreshModels, ...(wait ? { wait: true } : {}) }), timeoutMs: wait ? 150_000 : 30_000, }), + refreshRouteDecisionSnapshots: () => request('/api/routes/decision/refresh', { + method: 'POST', + body: JSON.stringify({}), + }), getRouteDecision: (model: string) => request(`/api/routes/decision?model=${encodeURIComponent(model)}`), getRouteDecisionsBatch: (models: string[], options?: { refreshPricingCatalog?: boolean; persistSnapshots?: boolean }) => request('/api/routes/decision/batch', { method: 'POST', diff --git a/src/web/i18n.supplement.ts b/src/web/i18n.supplement.ts index 5aaa411..8e7b4c0 100644 --- a/src/web/i18n.supplement.ts +++ b/src/web/i18n.supplement.ts @@ -455,6 +455,7 @@ export const zhToEnSupplemental: Record = { '生成已取消。': 'The build was canceled.', '生成已停止。': 'The build has stopped.', '生成中...': 'Generating...', + '已缓存': 'Cached', '失败': 'fail', '失败 (${countBy(\'failed\')})': 'Failure (${countBy(\'failed\')})', '失败避让': 'failure avoidance', @@ -740,6 +741,7 @@ export const zhToEnSupplemental: Record = { '自动重建': 'automatic rebuild', '自动重建完成(新增 ${createdRoutes} 条路由 / ${createdChannels} 个通道)': 'Automatic reconstruction completed (new ${createdRoutes} routes / ${createdChannels} channels)', '最大 Token 数': 'Maximum number of tokens', + '最近刷新': 'Last refreshed', '最近健康检查失败': 'Recent health check failed', '最近失败,优先避让(${RECENT_FAILURE_AVOID_SEC / 60} 分钟窗口)': 'Recent failure, priority avoidance (${RECENT_FAILURE_AVOID_SEC / 60} minute window)', '最近一次检查失败': 'The latest check failed', diff --git a/src/web/pages/TokenRoutes.tsx b/src/web/pages/TokenRoutes.tsx index 3609d35..80d751b 100644 --- a/src/web/pages/TokenRoutes.tsx +++ b/src/web/pages/TokenRoutes.tsx @@ -9,6 +9,7 @@ import { MobileCard, MobileField } from '../components/MobileCard.js'; import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js'; import { useIsMobile } from '../components/useIsMobile.js'; import { tr } from '../i18n.js'; +import { ROUTE_DECISION_REFRESH_TASK_TYPE } from '../../shared/tokenRouteContract.js'; import { buildRouteModelCandidatesIndex, type RouteCandidateView, @@ -42,7 +43,6 @@ import type { GroupRouteItem, } from './token-routes/types.js'; import { - AUTO_ROUTE_DECISION_LIMIT, ROUTE_RENDER_CHUNK, isExplicitGroupRoute, isExactModelPattern, @@ -161,73 +161,8 @@ export default function TokenRoutes() { const candidatesPromiseRef = useRef | null>(null); const candidatesVersionRef = useRef(0); const candidatesSeqRef = useRef(0); - - const loadRouteDecisions = async ( - routeRows: RouteSummaryRow[], - options?: { force?: boolean; refreshPricingCatalog?: boolean; persistSnapshots?: boolean }, - ) => { - const rows = routeRows || []; - const exactRoutes = rows.filter((route) => isRouteExactModel(route)); - const wildcardRouteIds = rows - .filter((route) => !isRouteExactModel(route)) - .map((route) => route.id); - - const requestedModels = Array.from(new Set(exactRoutes.map((route) => route.modelPattern))); - - const defaultState: Record = {}; - for (const route of rows) defaultState[route.id] = null; - - if (requestedModels.length === 0 && wildcardRouteIds.length === 0) { - setDecisionByRoute(defaultState); - setDecisionAutoSkipped(false); - return; - } - - const totalDecisionRequests = requestedModels.length + wildcardRouteIds.length; - if (!options?.force && totalDecisionRequests > AUTO_ROUTE_DECISION_LIMIT) { - setDecisionByRoute(defaultState); - setDecisionAutoSkipped(true); - return; - } - - setLoadingDecision(true); - try { - setDecisionAutoSkipped(false); - const decisionRequestOptions = options?.refreshPricingCatalog - ? { - refreshPricingCatalog: true as const, - ...(options?.persistSnapshots ? { persistSnapshots: true as const } : {}), - } - : options?.persistSnapshots - ? { persistSnapshots: true as const } - : undefined; - const [exactRes, wildcardRes] = await Promise.all([ - requestedModels.length > 0 - ? api.getRouteDecisionsBatch(requestedModels, decisionRequestOptions) - : Promise.resolve({ decisions: {} }), - wildcardRouteIds.length > 0 - ? api.getRouteWideDecisionsBatch(wildcardRouteIds, decisionRequestOptions) - : Promise.resolve({ decisions: {} }), - ]); - - const decisionMap = (exactRes?.decisions || {}) as Record; - const wildcardDecisionMap = (wildcardRes?.decisions || {}) as Record; - const next = { ...defaultState }; - for (const route of exactRoutes) { - next[route.id] = decisionMap[route.modelPattern] || null; - } - for (const routeId of wildcardRouteIds) { - next[routeId] = wildcardDecisionMap[String(routeId)] || null; - } - - setDecisionByRoute(next); - } catch { - setDecisionByRoute(defaultState); - setDecisionAutoSkipped(false); - } finally { - setLoadingDecision(false); - } - }; + const decisionRefreshWatchSeqRef = useRef(0); + const mountedRef = useRef(true); const loadCandidates = (force?: boolean) => { if (candidatesLoadedRef.current && !force) return; @@ -281,9 +216,97 @@ export default function TokenRoutes() { } }; + const loadRef = useRef(load); + loadRef.current = load; + + const toastRef = useRef(toast); + toastRef.current = toast; + + const monitorRouteDecisionRefreshTask = useCallback((taskId: string) => { + const normalizedTaskId = String(taskId || '').trim(); + if (!normalizedTaskId) return; + + const taskFetcher = (api as { getTask?: (id: string) => Promise }).getTask; + if (typeof taskFetcher !== 'function') { + setLoadingDecision(false); + return; + } + + const watchSeq = ++decisionRefreshWatchSeqRef.current; + setLoadingDecision(true); + setDecisionAutoSkipped(false); + + void (async () => { + while (mountedRef.current && decisionRefreshWatchSeqRef.current === watchSeq) { + try { + const taskResponse = await taskFetcher(normalizedTaskId) as { + task?: { status?: string; message?: string; error?: string | null }; + }; + const task = taskResponse?.task; + if (!task) { + throw new Error('路由选中概率任务不存在'); + } + + const status = String(task.status || '').trim(); + if (status === 'pending' || status === 'running') { + await new Promise((resolve) => setTimeout(resolve, 1200)); + continue; + } + + if (!mountedRef.current || decisionRefreshWatchSeqRef.current !== watchSeq) return; + await loadRef.current(); + if (!mountedRef.current || decisionRefreshWatchSeqRef.current !== watchSeq) return; + + setLoadingDecision(false); + if (status === 'succeeded') { + toastRef.current.success('路由选择概率已刷新'); + } else { + toastRef.current.error(String(task.message || task.error || '刷新路由选择概率失败')); + } + return; + } catch (error: any) { + if (!mountedRef.current || decisionRefreshWatchSeqRef.current !== watchSeq) return; + setLoadingDecision(false); + toastRef.current.error(error?.message || '刷新路由选择概率失败'); + return; + } + } + })(); + }, []); + + const resumeRouteDecisionRefreshTask = useCallback(async () => { + const tasksFetcher = (api as { getTasks?: (limit?: number) => Promise }).getTasks; + if (typeof tasksFetcher !== 'function') { + setLoadingDecision(false); + return; + } + + try { + const tasksResponse = await tasksFetcher(50) as { + tasks?: Array<{ id?: string; type?: string; status?: string }>; + }; + const runningTask = Array.isArray(tasksResponse?.tasks) + ? tasksResponse.tasks.find((task) => ( + String(task?.type || '').trim() === ROUTE_DECISION_REFRESH_TASK_TYPE + && (task?.status === 'pending' || task?.status === 'running') + )) + : null; + const taskId = String(runningTask?.id || '').trim(); + if (!taskId) { + setLoadingDecision(false); + return; + } + monitorRouteDecisionRefreshTask(taskId); + } catch { + setLoadingDecision(false); + } + }, [monitorRouteDecisionRefreshTask]); + useEffect(() => { + mountedRef.current = true; (async () => { try { + await resumeRouteDecisionRefreshTask(); await load(); } catch { toast.error('加载路由配置失败'); @@ -292,7 +315,11 @@ export default function TokenRoutes() { const scheduleIdle = typeof requestIdleCallback === 'function' ? requestIdleCallback : (cb: () => void) => setTimeout(cb, 0); scheduleIdle(() => loadCandidates()); })(); - }, []); + return () => { + mountedRef.current = false; + decisionRefreshWatchSeqRef.current += 1; + }; + }, [resumeRouteDecisionRefreshTask, toast]); const handleRebuild = async () => { try { @@ -318,10 +345,19 @@ export default function TokenRoutes() { const handleRefreshRouteDecisions = async () => { try { - await loadRouteDecisions(routeSummaries, { force: true, refreshPricingCatalog: true, persistSnapshots: true }); - toast.success('路由选择概率已刷新'); - } catch { - toast.error('刷新路由选择概率失败'); + const response = await api.refreshRouteDecisionSnapshots() as { + message?: string; + jobId?: string; + }; + const taskId = String(response?.jobId || '').trim(); + if (!taskId) { + throw new Error('刷新任务未返回 taskId'); + } + + toast.info(response?.message || '已开始后台刷新路由选中概率,可稍后返回查看'); + monitorRouteDecisionRefreshTask(taskId); + } catch (error: any) { + toast.error(error?.message || '刷新路由选择概率失败'); } }; diff --git a/src/web/pages/token-routes/RouteCard.tsx b/src/web/pages/token-routes/RouteCard.tsx index cbb8dee..0fa146c 100644 --- a/src/web/pages/token-routes/RouteCard.tsx +++ b/src/web/pages/token-routes/RouteCard.tsx @@ -21,6 +21,7 @@ import { BrandGlyph, InlineBrandIcon, type BrandInfo } from '../../components/Br import ModernSelect from '../../components/ModernSelect.js'; import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js'; import { tr } from '../../i18n.js'; +import { formatDateTimeMinuteLocal } from '../helpers/checkinLogTime.js'; import type { RouteSummaryRow, RouteChannel, @@ -240,6 +241,10 @@ function RouteCardInner({ const channelManagementDisabled = explicitGroupRoute; const title = resolveRouteTitle(route); const routingStrategy = normalizeRouteRoutingStrategyValue(route.routingStrategy); + const hasCachedDecisionSnapshot = !!route.decisionSnapshot; + const cachedDecisionTooltip = route.decisionRefreshedAt + ? `${tr('最近刷新')}: ${formatDateTimeMinuteLocal(route.decisionRefreshedAt)}` + : undefined; const routingStrategyOptions = [ { value: 'weighted', @@ -384,6 +389,15 @@ function RouteCardInner({ {route.channelCount} {tr('通道')} )} + {hasCachedDecisionSnapshot ? ( + + {tr('已缓存')} + + ) : null} {readOnlyRoute ? ( @@ -458,6 +472,15 @@ function RouteCardInner({ {route.channelCount} {tr('通道')} )} + {hasCachedDecisionSnapshot ? ( + + {tr('已缓存')} + + ) : null} {readOnlyRoute && ( {tr('0 通道')} @@ -523,6 +546,15 @@ function RouteCardInner({ {route.channelCount} {tr('通道')} + {hasCachedDecisionSnapshot ? ( + + {tr('已缓存')} + + ) : null} {explicitGroupRoute && explicitGroupSourceCount > 0 ? ( {explicitGroupSourceCount} {tr('来源模型')} diff --git a/src/web/pages/tokenRoutes.refresh-decision.test.tsx b/src/web/pages/tokenRoutes.refresh-decision.test.tsx index dd57036..f31df13 100644 --- a/src/web/pages/tokenRoutes.refresh-decision.test.tsx +++ b/src/web/pages/tokenRoutes.refresh-decision.test.tsx @@ -1,13 +1,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, create, type ReactTestInstance } from 'react-test-renderer'; +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'; import { MemoryRouter } from 'react-router-dom'; import { ToastProvider } from '../components/Toast.js'; +import { ROUTE_DECISION_REFRESH_TASK_TYPE } from '../../shared/tokenRouteContract.js'; import TokenRoutes from './TokenRoutes.js'; const { apiMock, getBrandMock } = vi.hoisted(() => ({ apiMock: { getRoutesSummary: vi.fn(), + refreshRouteDecisionSnapshots: vi.fn(), getRouteChannels: vi.fn(), + getTask: vi.fn(), + getTasks: vi.fn(), getModelTokenCandidates: vi.fn(), getRouteDecisionsBatch: vi.fn(), getRouteWideDecisionsBatch: vi.fn(), @@ -71,6 +75,9 @@ describe('TokenRoutes refresh decision action', () => { }, ]); apiMock.getRouteChannels.mockResolvedValue([]); + apiMock.refreshRouteDecisionSnapshots.mockResolvedValue({ queued: true, jobId: 'task-1', status: 'pending' }); + apiMock.getTasks.mockResolvedValue({ tasks: [] }); + apiMock.getTask.mockResolvedValue({ task: { id: 'task-1', status: 'succeeded' } }); apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} }); apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} }); apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} }); @@ -80,9 +87,39 @@ describe('TokenRoutes refresh decision action', () => { vi.clearAllMocks(); }); - it('passes refreshPricingCatalog and persistSnapshots when user clicks refresh selection probability', async () => { - let root!: WebTestRenderer; + it('queues a background snapshot refresh task when user clicks refresh selection probability', async () => { + let root!: ReactTestRenderer; try { + apiMock.getRoutesSummary + .mockResolvedValueOnce([ + { + id: 1, modelPattern: 'gpt-4o-mini', displayName: 'gpt-4o-mini', + displayIcon: null, modelMapping: null, enabled: true, + channelCount: 0, enabledChannelCount: 0, siteNames: [], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + { + id: 2, modelPattern: 're:^claude-(opus|sonnet)-4-6$', displayName: 'claude-group', + displayIcon: null, modelMapping: null, enabled: true, + channelCount: 0, enabledChannelCount: 0, siteNames: [], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + ]) + .mockResolvedValueOnce([ + { + id: 1, modelPattern: 'gpt-4o-mini', displayName: 'gpt-4o-mini', + displayIcon: null, modelMapping: null, enabled: true, + channelCount: 0, enabledChannelCount: 0, siteNames: [], + decisionSnapshot: { matched: true, candidates: [] }, decisionRefreshedAt: '2026-04-01T00:00:00.000Z', + }, + { + id: 2, modelPattern: 're:^claude-(opus|sonnet)-4-6$', displayName: 'claude-group', + displayIcon: null, modelMapping: null, enabled: true, + channelCount: 0, enabledChannelCount: 0, siteNames: [], + decisionSnapshot: { matched: true, candidates: [] }, decisionRefreshedAt: '2026-04-01T00:00:00.000Z', + }, + ]); + await act(async () => { root = create( @@ -99,9 +136,64 @@ describe('TokenRoutes refresh decision action', () => { await refreshButton.props.onClick(); }); await flushMicrotasks(); + await flushMicrotasks(); - expect(apiMock.getRouteDecisionsBatch).toHaveBeenCalledWith(['gpt-4o-mini'], { refreshPricingCatalog: true, persistSnapshots: true }); - expect(apiMock.getRouteWideDecisionsBatch).toHaveBeenCalledWith([2], { refreshPricingCatalog: true, persistSnapshots: true }); + expect(apiMock.refreshRouteDecisionSnapshots).toHaveBeenCalledTimes(1); + expect(apiMock.getTask).toHaveBeenCalledWith('task-1'); + expect(apiMock.getRoutesSummary).toHaveBeenCalledTimes(2); + expect(apiMock.getRouteDecisionsBatch).not.toHaveBeenCalled(); + expect(apiMock.getRouteWideDecisionsBatch).not.toHaveBeenCalled(); + } finally { + root?.unmount(); + } + }); + + it('resumes a running background probability refresh task when revisiting the page', async () => { + let root!: ReactTestRenderer; + try { + apiMock.getRoutesSummary + .mockResolvedValueOnce([ + { + id: 1, modelPattern: 'gpt-4o-mini', displayName: 'gpt-4o-mini', + displayIcon: null, modelMapping: null, enabled: true, + channelCount: 0, enabledChannelCount: 0, siteNames: [], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + ]) + .mockResolvedValueOnce([ + { + id: 1, modelPattern: 'gpt-4o-mini', displayName: 'gpt-4o-mini', + displayIcon: null, modelMapping: null, enabled: true, + channelCount: 0, enabledChannelCount: 0, siteNames: [], + decisionSnapshot: { matched: true, candidates: [] }, decisionRefreshedAt: '2026-04-01T00:00:00.000Z', + }, + ]); + apiMock.getTasks.mockResolvedValue({ + tasks: [ + { + id: 'task-restore', + type: ROUTE_DECISION_REFRESH_TASK_TYPE, + status: 'running', + }, + ], + }); + apiMock.getTask.mockResolvedValue({ task: { id: 'task-restore', status: 'succeeded' } }); + + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + await flushMicrotasks(); + + expect(apiMock.getTasks).toHaveBeenCalled(); + expect(apiMock.getTask).toHaveBeenCalledWith('task-restore'); + expect(apiMock.getRoutesSummary).toHaveBeenCalledTimes(2); } finally { root?.unmount(); } diff --git a/src/web/pages/tokenRoutes.snapshot-load.test.tsx b/src/web/pages/tokenRoutes.snapshot-load.test.tsx index 902f879..7d727fe 100644 --- a/src/web/pages/tokenRoutes.snapshot-load.test.tsx +++ b/src/web/pages/tokenRoutes.snapshot-load.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, create, type ReactTestInstance } from 'react-test-renderer'; +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'; import { MemoryRouter } from 'react-router-dom'; import { ToastProvider } from '../components/Toast.js'; import TokenRoutes from './TokenRoutes.js'; @@ -113,7 +113,7 @@ describe('TokenRoutes cached snapshot load', () => { }); it('shows cached probabilities immediately from getRoutes snapshot data', async () => { - let root!: WebTestRenderer; + let root!: ReactTestRenderer; try { await act(async () => { root = create( @@ -126,6 +126,8 @@ describe('TokenRoutes cached snapshot load', () => { }); await flushMicrotasks(); + expect(collectText(root.root)).toContain('已缓存'); + // Expand the route card to see channel details with probability const expandBtn = root.root.find((node) => node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'), @@ -142,4 +144,38 @@ describe('TokenRoutes cached snapshot load', () => { root?.unmount(); } }); + + it('keeps showing the cached snapshot marker after revisiting the page', async () => { + let root!: ReactTestRenderer; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + expect(collectText(root.root)).toContain('已缓存'); + + root.unmount(); + + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + expect(collectText(root.root)).toContain('已缓存'); + } finally { + root?.unmount(); + } + }); });