diff --git a/frontend/components/dashboard/scan-terminal/scan-terminal-client.ts b/frontend/components/dashboard/scan-terminal/scan-terminal-client.ts index 9337bbda..226e6e31 100644 --- a/frontend/components/dashboard/scan-terminal/scan-terminal-client.ts +++ b/frontend/components/dashboard/scan-terminal/scan-terminal-client.ts @@ -54,10 +54,16 @@ type AiCityReadOptions = { city: string; forceRefresh?: boolean; locale: string; + requestKey?: string; onProgress?: (progress: AiCityStreamProgress) => void; signal?: AbortSignal; }; +const AI_CITY_READ_MAX_CONCURRENT_STREAMS = 2; +const pendingAiCityReadRequests = new Map>(); +const queuedAiCityReadTasks: Array<() => void> = []; +let activeAiCityReadStreams = 0; + function getRemoteError(error: unknown) { return error instanceof Error ? error.message : String(error); } @@ -265,13 +271,34 @@ async function getMarketScan(city: string, options: MarketScanQueryOptions = {}) ); } -async function streamAiCityRead({ +function runQueuedAiCityReadTask(task: () => Promise, onQueued?: () => void) { + return new Promise((resolve, reject) => { + const run = () => { + activeAiCityReadStreams += 1; + task() + .then(resolve, reject) + .finally(() => { + activeAiCityReadStreams = Math.max(0, activeAiCityReadStreams - 1); + const next = queuedAiCityReadTasks.shift(); + if (next) next(); + }); + }; + if (activeAiCityReadStreams < AI_CITY_READ_MAX_CONCURRENT_STREAMS) { + run(); + } else { + onQueued?.(); + queuedAiCityReadTasks.push(run); + } + }); +} + +async function streamAiCityReadRequest({ city, forceRefresh = false, locale, onProgress, signal, -}: AiCityReadOptions) { +}: Omit) { const headers = await buildBrowserBackendHeaders({ Accept: "text/event-stream", "Content-Type": "application/json", @@ -317,6 +344,31 @@ async function streamAiCityRead({ return readAiCityForecastStream(response, locale, onProgress); } +function streamAiCityRead(options: AiCityReadOptions) { + const pendingKey = options.requestKey || ""; + const pending = pendingKey ? pendingAiCityReadRequests.get(pendingKey) : null; + if (pending) return pending; + + const request = runQueuedAiCityReadTask( + () => streamAiCityReadRequest(options), + () => { + options.onProgress?.({ + stage: "queued", + message_en: + "AI observation read is queued behind the cities already streaming...", + message_zh: "AI 观测解读已排队,正在等待前面的城市完成流式生成…", + }); + }, + ).finally(() => { + if (pendingKey) pendingAiCityReadRequests.delete(pendingKey); + }); + + if (pendingKey) { + pendingAiCityReadRequests.set(pendingKey, request); + } + return request; +} + export const scanTerminalClient = { getCityDetail, getMarketScan, diff --git a/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts b/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts index a74db116..2a4c8d31 100644 --- a/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts +++ b/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts @@ -14,19 +14,12 @@ import { normalizeCityKey } from "./decision-utils"; const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v6"; const AI_CITY_FORECAST_CACHE_TTL_MS = 60 * 60 * 1000; -const AI_CITY_FORECAST_MAX_CONCURRENT_STREAMS = 2; const CITY_MARKET_SCAN_CACHE_PREFIX = "polyWeather_cityMarketScan_v3"; const CITY_MARKET_SCAN_CACHE_TTL_MS = 10 * 60 * 1000; -const pendingAiCityForecastRequests = new Map< - string, - Promise ->(); -const queuedAiCityForecastTasks: Array<() => void> = []; const aiCityForecastStateCache = new Map< string, { state: AiCityForecastState; updatedAt: number } >(); -let activeAiCityForecastStreams = 0; function getStorage() { if (typeof window === "undefined") return null; @@ -110,72 +103,6 @@ function writeCachedAiForecastState(key: string, state: AiCityForecastState) { }); } -function runQueuedAiCityForecastTask( - task: () => Promise, - onQueued?: () => void, -) { - return new Promise((resolve, reject) => { - const run = () => { - activeAiCityForecastStreams += 1; - task() - .then(resolve, reject) - .finally(() => { - activeAiCityForecastStreams = Math.max( - 0, - activeAiCityForecastStreams - 1, - ); - const next = queuedAiCityForecastTasks.shift(); - if (next) next(); - }); - }; - if (activeAiCityForecastStreams < AI_CITY_FORECAST_MAX_CONCURRENT_STREAMS) { - run(); - } else { - onQueued?.(); - queuedAiCityForecastTasks.push(run); - } - }); -} - -function requestAiCityForecast({ - city, - forceRefresh, - locale, - onProgress, - requestKey, -}: { - city: string; - forceRefresh: boolean; - locale: string; - onProgress?: (progress: AiCityStreamProgress) => void; - requestKey: string; -}) { - const pending = pendingAiCityForecastRequests.get(requestKey); - if (pending) return pending; - - const request = runQueuedAiCityForecastTask(async () => { - return scanTerminalClient.streamAiCityRead({ - city, - forceRefresh, - locale, - onProgress, - }); - }, () => { - onProgress?.({ - stage: "queued", - message_en: - "AI observation read is queued behind the cities already streaming...", - message_zh: "AI 观测解读已排队,正在等待前面的城市完成流式生成…", - }); - }) - .finally(() => { - pendingAiCityForecastRequests.delete(requestKey); - }); - - pendingAiCityForecastRequests.set(requestKey, request); - return request; -} - function getAiCityStreamProgressText(progress: AiCityStreamProgress, isEn: boolean) { const localizedMessage = String( (isEn ? progress.message_en : progress.message_zh) || @@ -379,7 +306,7 @@ export function useAiCityForecast({ }; writeCachedAiForecastState(cacheKey, loadingState); setAiForecast(loadingState); - void requestAiCityForecast({ + void scanTerminalClient.streamAiCityRead({ city: detailCityName, forceRefresh: aiRefreshToken > 0, locale,