Files
PolyWeather/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts
T
2569718930@qq.com 6fe0e5dfc5 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.
2026-04-28 10:37:04 +08:00

482 lines
15 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import type {
AiCityForecastPayload,
AiCityForecastState,
} from "@/components/dashboard/scan-terminal/types";
import {
scanTerminalClient,
type AiCityStreamProgress,
} from "@/components/dashboard/scan-terminal/scan-terminal-client";
import type { CityDetail, MarketScan } from "@/lib/dashboard-types";
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 CITY_MARKET_SCAN_CACHE_PREFIX = "polyWeather_cityMarketScan_v3";
const CITY_MARKET_SCAN_CACHE_TTL_MS = 10 * 60 * 1000;
const aiCityForecastStateCache = new Map<
string,
{ state: AiCityForecastState; updatedAt: number }
>();
function getStorage() {
if (typeof window === "undefined") return null;
try {
return window.localStorage;
} catch {
return null;
}
}
function buildStorageKey(prefix: string, parts: Array<string | null | undefined>) {
return `${prefix}:${parts
.map((part) => encodeURIComponent(String(part || "").trim()))
.join(":")}`;
}
function isHkoObservationCity(detail?: CityDetail | null) {
const source = String(
detail?.current?.settlement_source ||
detail?.settlement_station?.settlement_source ||
"",
)
.trim()
.toLowerCase();
return source === "hko";
}
function readCachedPayload<T>(key: string, ttlMs: number): T | null {
const storage = getStorage();
if (!storage) return null;
try {
const raw = storage.getItem(key);
if (!raw) return null;
const parsed = JSON.parse(raw) as { cachedAt?: number; payload?: T };
if (!parsed?.payload) return null;
if (Date.now() - Number(parsed.cachedAt || 0) > ttlMs) {
storage.removeItem(key);
return null;
}
return parsed.payload;
} catch {
return null;
}
}
function writeCachedPayload<T>(key: string, payload: T) {
const storage = getStorage();
if (!storage) return;
try {
storage.setItem(key, JSON.stringify({ cachedAt: Date.now(), payload }));
} catch {
// Ignore quota/privacy-mode failures; network fallbacks still work.
}
}
function removeCachedPayload(key: string) {
const storage = getStorage();
if (!storage) return;
try {
storage.removeItem(key);
} catch {
// Ignore privacy-mode failures; the next network request can still proceed.
}
}
function readCachedAiForecastState(key: string, ttlMs: number) {
const cached = aiCityForecastStateCache.get(key);
if (!cached) return null;
if (Date.now() - cached.updatedAt > ttlMs) {
aiCityForecastStateCache.delete(key);
return null;
}
return cached.state;
}
function writeCachedAiForecastState(key: string, state: AiCityForecastState) {
if (!key || state.status === "idle") return;
aiCityForecastStateCache.set(key, {
state,
updatedAt: Date.now(),
});
}
function getAiCityStreamProgressText(progress: AiCityStreamProgress, isEn: boolean) {
const localizedMessage = String(
(isEn ? progress.message_en : progress.message_zh) ||
(isEn ? progress.final_judgment_en : progress.final_judgment_zh) ||
(isEn ? progress.metar_read_en : progress.metar_read_zh) ||
"",
).trim();
if (localizedMessage) return localizedMessage;
const rawLength = Number(progress.raw_length);
if (Number.isFinite(rawLength) && rawLength > 0) {
return isEn
? `DeepSeek is streaming the observation enhancement... ${Math.round(rawLength)} chars received.`
: `DeepSeek 正在流式增强观测解读... 已收到 ${Math.round(rawLength)} 字符。`;
}
return "";
}
function buildAiCityFallbackPayload({
detail,
error,
isEn,
report,
}: {
detail: CityDetail | null;
error?: unknown;
isEn: boolean;
report: string;
}): AiCityForecastPayload {
const tempSymbol = detail?.temp_symbol || "°C";
const isHkoObservation = isHkoObservationCity(detail);
const currentTemp =
(isHkoObservation
? detail?.current?.temp
: detail?.airport_current?.temp ??
detail?.airport_primary?.temp ??
detail?.current?.temp) ?? null;
const currentText =
currentTemp != null && Number.isFinite(Number(currentTemp))
? `${Number(currentTemp).toFixed(1)}${tempSymbol}`
: isEn
? "the latest observed temperature"
: "最新实测温度";
const timeoutLike = /timeout|timed out|504|aborted|超时/i.test(String(error || ""));
const rawMetar = isHkoObservation
? ""
: String(report || detail?.airport_current?.raw_metar || detail?.current?.raw_metar || "").trim();
const sourceZh = isHkoObservation ? "香港天文台观测" : "METAR";
const sourceEn = isHkoObservation ? "Hong Kong Observatory observation" : "METAR";
const bulletinZh = isHkoObservation ? "官方观测" : "机场报文";
const bulletinEn = isHkoObservation ? "official observation" : "airport bulletin";
const finalZh = timeoutLike
? `DeepSeek 增强暂未返回;当前先以多模型集中度和最新${sourceZh}快速判断。`
: `当前先以多模型集中度和最新${sourceZh}快速判断。`;
const finalEn = timeoutLike
? `DeepSeek enhancement is not back yet; use the model cluster and latest ${sourceEn} as the fast working read.`
: `Use the model cluster and latest ${sourceEn} as the fast working read.`;
const metarZh = rawMetar
? `最新 METAR 显示 ${currentText};当前先作为实况锚点,并结合后续报文确认温度路径。`
: `当前可先参考 ${currentText} 与多模型路径,等待下一次${bulletinZh}更新。`;
const metarEn = rawMetar
? `Latest METAR shows ${currentText}; use it as the live anchor while later reports confirm the path.`
: `Use ${currentText} and the model path for now while waiting for the next ${bulletinEn}.`;
const reasonZh = `DEB、多模型集合和最新${sourceZh}已足够给出当前方向判断;页面会在 DeepSeek 返回后合并完整机场报文解读。`;
const reasonEn = `DEB, the model cluster and latest ${sourceEn} are enough for the current directional read; the page will merge the full airport-bulletin read when DeepSeek returns.`;
return {
city_forecast: {
confidence: "low",
final_judgment_en: finalEn,
final_judgment_zh: finalZh,
metar_read_en: metarEn,
metar_read_zh: metarZh,
model_cluster_note_en: "",
model_cluster_note_zh: "",
predicted_max: null,
range_high: null,
range_low: null,
reasoning_en: reasonEn,
reasoning_zh: reasonZh,
risks_en: [],
risks_zh: [],
unit: tempSymbol,
},
raw_reason: timeoutLike ? "ai_timeout_fallback" : "ai_unavailable_fallback",
reason: isEn ? reasonEn : reasonZh,
reason_en: reasonEn,
reason_zh: reasonZh,
status: timeoutLike ? "timeout_fallback" : "fallback",
};
}
export function useAiCityForecast({
detail,
detailCityName,
isEn,
locale,
report,
enabled = true,
}: {
detail: CityDetail | null;
detailCityName: string;
enabled?: boolean;
isEn: boolean;
locale: string;
report: string;
}) {
const [aiForecast, setAiForecast] = useState<AiCityForecastState>({
status: "idle",
});
const [aiRefreshToken, setAiRefreshToken] = useState(0);
const aiForecastKey = useMemo(
() => {
if (!detail) return "";
const isHkoObservation = isHkoObservationCity(detail);
const observationSource = isHkoObservation ? "hko" : "metar";
const observationCurrent = isHkoObservation
? detail.current || {}
: detail.airport_current || detail.current || {};
const observationSignature =
(!isHkoObservation ? String(report || "").trim() : "") ||
[
observationSource,
observationCurrent.report_time,
observationCurrent.obs_time_epoch,
observationCurrent.obs_time,
observationCurrent.receipt_time,
observationCurrent.temp,
observationCurrent.max_so_far,
observationCurrent.station_code,
detail.metar_status?.stale_for_today,
detail.metar_status?.last_observation_time,
]
.filter((part) => part != null && part !== "")
.join("|");
return [
normalizeCityKey(detailCityName),
detail.local_date || "",
locale,
observationSignature,
].join(":");
},
[detail, detailCityName, locale, report],
);
useEffect(() => {
if (!enabled || !aiForecastKey) {
setAiForecast({ status: "idle" });
return;
}
let cancelled = false;
const cacheKey = buildStorageKey(AI_CITY_FORECAST_CACHE_PREFIX, [aiForecastKey]);
const requestKey = `${cacheKey}:${aiRefreshToken > 0 ? `refresh:${aiRefreshToken}` : "normal"}`;
const cachedPayload =
aiRefreshToken <= 0
? readCachedPayload<AiCityForecastPayload>(
cacheKey,
AI_CITY_FORECAST_CACHE_TTL_MS,
)
: null;
if (cachedPayload) {
if (
cachedPayload.status === "ready" &&
!cachedPayload.degraded &&
cachedPayload.city_forecast
) {
const readyState: AiCityForecastState = {
payload: cachedPayload,
status: "ready",
};
writeCachedAiForecastState(cacheKey, readyState);
setAiForecast(readyState);
return () => {
cancelled = true;
};
}
removeCachedPayload(cacheKey);
}
const cachedState =
aiRefreshToken <= 0
? readCachedAiForecastState(cacheKey, AI_CITY_FORECAST_CACHE_TTL_MS)
: null;
if (cachedState?.status === "ready") {
setAiForecast(cachedState);
return () => {
cancelled = true;
};
}
const initialFallback = buildAiCityFallbackPayload({ detail, isEn, report });
const loadingState: AiCityForecastState =
cachedState?.status === "loading"
? cachedState
: {
status: "loading",
streamText:
(isEn
? initialFallback.city_forecast?.metar_read_en
: initialFallback.city_forecast?.metar_read_zh) ||
(isEn
? "Reading the latest observation with model fallback ready..."
: "已先用最新观测给出兜底解读,正在等待 DeepSeek 补充…"),
};
writeCachedAiForecastState(cacheKey, loadingState);
setAiForecast(loadingState);
void scanTerminalClient.streamAiCityRead({
city: detailCityName,
forceRefresh: aiRefreshToken > 0,
locale,
onProgress: (progress) => {
const progressText = getAiCityStreamProgressText(progress, isEn);
if (!progressText) return;
const cachedProgressState = readCachedAiForecastState(
cacheKey,
AI_CITY_FORECAST_CACHE_TTL_MS,
);
const nextStreamText =
progress.stage === "calling_ai" && cachedProgressState?.streamText
? cachedProgressState.streamText
: progressText;
writeCachedAiForecastState(cacheKey, {
...cachedProgressState,
status: "loading",
streamText: nextStreamText,
});
if (cancelled) return;
setAiForecast((current) => ({
...current,
status: "loading",
streamText:
progress.stage === "calling_ai" && current.streamText
? current.streamText
: progressText,
}));
},
requestKey,
})
.then((payload) => {
if (!payload) return;
const usablePayload =
payload?.city_forecast
? payload
: buildAiCityFallbackPayload({
detail,
error: payload?.reason || payload?.raw_reason || payload?.status,
isEn,
report,
});
if (usablePayload.status === "ready" && !usablePayload.degraded) {
writeCachedPayload(cacheKey, usablePayload);
}
writeCachedAiForecastState(cacheKey, {
payload: usablePayload,
status: "ready",
});
if (!cancelled) {
setAiForecast({ payload: usablePayload, status: "ready" });
}
})
.catch((error) => {
const fallbackPayload = buildAiCityFallbackPayload({
detail,
error,
isEn,
report,
});
writeCachedAiForecastState(cacheKey, {
payload: fallbackPayload,
status: "ready",
});
if (!cancelled) {
setAiForecast({ payload: fallbackPayload, status: "ready" });
}
});
return () => {
cancelled = true;
};
}, [aiForecastKey, aiRefreshToken, detail, detailCityName, enabled, isEn, locale, report]);
const refreshAiForecast = useCallback(() => {
setAiRefreshToken((current) => current + 1);
}, []);
return { aiForecast, refreshAiForecast };
}
export function useCityMarketScan({
detail,
detailCityName,
enabled = true,
}: {
detail: CityDetail | null;
detailCityName: string;
enabled?: boolean;
}) {
const [marketScan, setMarketScan] = useState<MarketScan | null>(
detail?.market_scan || null,
);
const [marketStatus, setMarketStatus] = useState<
"idle" | "loading" | "ready" | "failed"
>(detail?.market_scan ? "ready" : "idle");
useEffect(() => {
if (!detail) {
setMarketScan(null);
setMarketStatus("idle");
return;
}
const cacheKey = buildStorageKey(CITY_MARKET_SCAN_CACHE_PREFIX, [
normalizeCityKey(detailCityName),
detail.local_date || "",
"full",
]);
let cancelled = false;
if (detail.market_scan) {
setMarketScan(detail.market_scan);
setMarketStatus("ready");
writeCachedPayload(cacheKey, detail.market_scan);
return () => {
cancelled = true;
};
}
if (!enabled) {
const cached = readCachedPayload<MarketScan>(
cacheKey,
CITY_MARKET_SCAN_CACHE_TTL_MS,
);
if (cached) {
setMarketScan(cached);
setMarketStatus("ready");
} else {
setMarketScan(null);
setMarketStatus("idle");
}
return () => {
cancelled = true;
};
}
const cached = readCachedPayload<MarketScan>(
cacheKey,
CITY_MARKET_SCAN_CACHE_TTL_MS,
);
if (cached) {
setMarketScan(cached);
setMarketStatus("ready");
return () => {
cancelled = true;
};
} else {
setMarketStatus("loading");
}
const controller = new AbortController();
void scanTerminalClient.getMarketScan(detailCityName, {
lite: false,
signal: controller.signal,
targetDate: detail.local_date || null,
})
.then((payload) => {
if (cancelled) return;
if (payload) {
writeCachedPayload(cacheKey, payload);
}
setMarketScan(payload || detail.market_scan || null);
setMarketStatus("ready");
})
.catch(() => {
if (cancelled) return;
setMarketScan(detail.market_scan || null);
setMarketStatus(detail.market_scan ? "ready" : "failed");
});
return () => {
cancelled = true;
controller.abort();
};
}, [detail, detailCityName, enabled]);
return { marketScan, marketStatus };
}