Move AI city stream concurrency into the scan client

The AI city forecast hook still owned stream queueing and in-flight request dedupe after the first request-client pass. Moving that policy into scanTerminalClient keeps network concurrency, queued progress, and requestKey reuse in the request layer while leaving the hook responsible only for cached UI state and progress rendering.

Constraint: Preserve existing two-stream concurrency limit and queued user-facing progress copy.

Rejected: Move localStorage cache at the same time | cache policy should be separated from stream transport policy to keep this refactor reviewable.

Confidence: high

Scope-risk: narrow

Reversibility: clean

Tested: npm run build

Not-tested: Live multi-city SSE under production latency.
This commit is contained in:
2569718930@qq.com
2026-04-28 10:37:04 +08:00
parent 9ca3ee5a8e
commit 6fe0e5dfc5
2 changed files with 55 additions and 76 deletions
@@ -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<string, Promise<AiCityForecastPayload>>();
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<T>(task: () => Promise<T>, onQueued?: () => void) {
return new Promise<T>((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<AiCityReadOptions, "requestKey">) {
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,