From e374ad4bf0a6887f20b52d28974767c0d8a05e22 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sun, 26 Apr 2026 10:43:14 +0800 Subject: [PATCH] feat: add AI city forecast terminal and integration components --- .../app/api/scan/terminal/ai-city/route.ts | 27 +++--- frontend/components/dashboard/DetailPanel.tsx | 26 ++++- .../scan-terminal/AiPinnedForecastView.tsx | 4 +- .../scan-terminal/use-ai-city-card-data.ts | 95 ++++++++++++++++++- frontend/hooks/useDashboardStore.tsx | 34 ++++--- frontend/lib/dashboard-client.ts | 3 + 6 files changed, 155 insertions(+), 34 deletions(-) diff --git a/frontend/app/api/scan/terminal/ai-city/route.ts b/frontend/app/api/scan/terminal/ai-city/route.ts index 9069b30e..3d7a0149 100644 --- a/frontend/app/api/scan/terminal/ai-city/route.ts +++ b/frontend/app/api/scan/terminal/ai-city/route.ts @@ -5,13 +5,18 @@ import { } from "@/lib/backend-auth"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; -const SCAN_AI_PROXY_TIMEOUT_MS = Math.max( +const AI_CITY_GATEWAY_TIMEOUT_MS = Math.max( 10_000, - Number(process.env.POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS || "55000") || 55_000, + Number( + process.env.POLYWEATHER_SCAN_AI_GATEWAY_TIMEOUT_MS || + process.env.POLYWEATHER_AI_CITY_GATEWAY_TIMEOUT_MS || + process.env.POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS || + "120000", + ) || 120_000, ); export const dynamic = "force-dynamic"; -export const maxDuration = 90; +export const maxDuration = 130; export async function POST(req: NextRequest) { if (!API_BASE) { @@ -31,7 +36,7 @@ export async function POST(req: NextRequest) { let auth: Awaited> | null = null; const controller = new AbortController(); const startedAt = Date.now(); - const timeoutId = setTimeout(() => controller.abort(), SCAN_AI_PROXY_TIMEOUT_MS); + const timeoutId = setTimeout(() => controller.abort(), AI_CITY_GATEWAY_TIMEOUT_MS); const requestBody = body && typeof body === "object" ? body as Record : {}; try { @@ -39,11 +44,11 @@ export async function POST(req: NextRequest) { const headers = new Headers(auth.headers); headers.set("Content-Type", "application/json"); headers.set("Accept", "application/json"); - console.info("[scan-ai-city] proxy request", { + console.info("[scan-ai-city] gateway request", { city: requestBody.city, force_refresh: requestBody.force_refresh === true, locale: requestBody.locale, - timeout_ms: SCAN_AI_PROXY_TIMEOUT_MS, + timeout_ms: AI_CITY_GATEWAY_TIMEOUT_MS, }); const res = await fetch(`${API_BASE}/api/scan/terminal/ai-city`, { method: "POST", @@ -65,7 +70,7 @@ export async function POST(req: NextRequest) { return applyAuthResponseCookies(response, auth.response); } const data = await res.json(); - console.info("[scan-ai-city] proxy complete", { + console.info("[scan-ai-city] gateway complete", { status: data?.status, city: data?.city, model: data?.model, @@ -81,21 +86,21 @@ export async function POST(req: NextRequest) { } catch (error) { const timedOut = controller.signal.aborted; const elapsedMs = Date.now() - startedAt; - console.warn("[scan-ai-city] proxy failed", { + console.warn("[scan-ai-city] gateway failed", { city: requestBody.city, timed_out: timedOut, elapsed_ms: elapsedMs, - timeout_ms: SCAN_AI_PROXY_TIMEOUT_MS, + timeout_ms: AI_CITY_GATEWAY_TIMEOUT_MS, error: String(error), }); const response = NextResponse.json( { error: timedOut - ? "City AI request timed out" + ? "City AI gateway timed out before backend responded" : "Failed to fetch city AI data", detail: String(error), elapsed_ms: elapsedMs, - timeout_ms: SCAN_AI_PROXY_TIMEOUT_MS, + timeout_ms: AI_CITY_GATEWAY_TIMEOUT_MS, city: requestBody.city, }, { status: timedOut ? 504 : 500 }, diff --git a/frontend/components/dashboard/DetailPanel.tsx b/frontend/components/dashboard/DetailPanel.tsx index 8291d84a..63ffbdc7 100644 --- a/frontend/components/dashboard/DetailPanel.tsx +++ b/frontend/components/dashboard/DetailPanel.tsx @@ -226,7 +226,9 @@ export function DetailPanel({ (detail.forecast?.daily?.length ?? 0) <= 1), ); const isPanelSyncing = store.loadingState.cityDetail; - const isShowingCachedDetailDuringSync = Boolean(detail && isPanelSyncing); + const [panelSyncTimedOut, setPanelSyncTimedOut] = useState(false); + const showPanelSyncing = isPanelSyncing && !panelSyncTimedOut; + const isShowingCachedDetailDuringSync = false; const blurActiveElement = () => { if (typeof document === "undefined") return; @@ -269,6 +271,22 @@ export function DetailPanel({ void store.openHistory(); }; + useEffect(() => { + if (!isPanelSyncing || !store.selectedCity) { + setPanelSyncTimedOut(false); + return; + } + + setPanelSyncTimedOut(false); + const timeoutId = window.setTimeout(() => { + setPanelSyncTimedOut(true); + }, 12_000); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [isPanelSyncing, store.selectedCity]); + useEffect(() => { const panel = panelRef.current; if (!panel) return; @@ -362,7 +380,7 @@ export function DetailPanel({

{panelDisplayName}

- {isPanelSyncing && ( + {showPanelSyncing && (
- {store.loadingState.cityDetail + {showPanelSyncing ? t("detail.loading") : t("detail.emptyHint")}
) : !detail ? (
- {store.loadingState.cityDetail + {showPanelSyncing ? locale === "en-US" ? "Loading city cards..." : "正在加载城市卡片..." diff --git a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx index 3ba51c13..6ad6d524 100644 --- a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx +++ b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx @@ -423,8 +423,8 @@ function AiPinnedCityCard({

{isEn - ? "Non-streaming mode is enabled to avoid truncated airport reads." - : "已停用流式输出,改用非流式 JSON 请求,避免报文解读被截断。"} + ? "The final airport read will appear here shortly." + : "机场报文解读稍后将在这里显示。"}

) : aiForecast.status === "ready" && aiCityForecast ? ( 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 be0a3d98..85f601c3 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 @@ -57,6 +57,77 @@ function writeCachedPayload(key: string, payload: T) { } } +function buildAiCityFallbackPayload({ + detail, + error, + isEn, + report, +}: { + detail: CityDetail | null; + error?: unknown; + isEn: boolean; + report: string; +}): AiCityForecastPayload { + const tempSymbol = detail?.temp_symbol || "°C"; + const currentTemp = + 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 = String(report || detail?.airport_current?.raw_metar || detail?.current?.raw_metar || "").trim(); + + const finalZh = timeoutLike + ? "AI 解读暂未返回;先以多模型集中度和最新 METAR 实况作为判断依据。" + : "AI 解读暂不可用;先以多模型集中度和最新 METAR 实况作为判断依据。"; + const finalEn = timeoutLike + ? "The AI read is not back yet; use the model cluster and latest METAR as the working read." + : "The AI read is temporarily unavailable; use the model cluster and latest METAR as the working read."; + const metarZh = rawMetar + ? `最新 METAR 显示 ${currentText};原始报文已保留,可继续结合后续报文确认温度路径。` + : `当前可先参考 ${currentText} 与多模型路径,等待下一次机场报文更新。`; + const metarEn = rawMetar + ? `Latest METAR shows ${currentText}; the raw bulletin is preserved and later reports should confirm the path.` + : `Use ${currentText} and the model path for now while waiting for the next airport bulletin.`; + const reasonZh = timeoutLike + ? "AI 服务响应较慢,本次未在页面等待窗口内完成;页面已自动降级为天气证据模式。" + : "AI 服务本次没有返回可用解读;页面已自动降级为天气证据模式。"; + const reasonEn = timeoutLike + ? "The AI service was slow and did not finish within the page wait window; the card fell back to weather evidence mode." + : "The AI service did not return a usable read; the card fell back to weather evidence mode."; + + 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, @@ -156,21 +227,37 @@ export function useAiCityForecast({ }) .then((payload) => { if (!cancelled) { - writeCachedPayload(cacheKey, payload); - setAiForecast({ payload, status: "ready" }); + const usablePayload = + payload?.city_forecast + ? payload + : buildAiCityFallbackPayload({ + detail, + error: payload?.reason || payload?.raw_reason || payload?.status, + isEn, + report, + }); + writeCachedPayload(cacheKey, usablePayload); + setAiForecast({ payload: usablePayload, status: "ready" }); } }) .catch((error) => { if (controller.signal.aborted) return; if (!cancelled) { - setAiForecast({ error: String(error), status: "failed" }); + const fallbackPayload = buildAiCityFallbackPayload({ + detail, + error, + isEn, + report, + }); + writeCachedPayload(cacheKey, fallbackPayload); + setAiForecast({ payload: fallbackPayload, status: "ready" }); } }); return () => { cancelled = true; controller.abort(); }; - }, [aiForecastKey, aiRefreshToken, detailCityName, enabled, isEn, locale]); + }, [aiForecastKey, aiRefreshToken, detail, detailCityName, enabled, isEn, locale, report]); const refreshAiForecast = useCallback(() => { setAiRefreshToken((current) => current + 1); diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx index bed93b31..cda1228d 100644 --- a/frontend/hooks/useDashboardStore.tsx +++ b/frontend/hooks/useDashboardStore.tsx @@ -599,6 +599,13 @@ export function DashboardStoreProvider({ const citiesRef = useRef([]); const citySummariesRef = useRef>({}); const selectedCityRef = useRef(null); + const setCityDetailLoading = (isLoading: boolean) => { + setLoadingState((current) => + current.cityDetail === isLoading + ? current + : { ...current, cityDetail: isLoading }, + ); + }; const selectedDetail = selectedCity ? findCachedCityDetail(cityDetailsByName, selectedCity) : null; @@ -790,7 +797,7 @@ export function DashboardStoreProvider({ if (findCachedCityDetail(cityDetailsByName, selectedCity)) return; let cancelled = false; - setLoadingState((current) => ({ ...current, cityDetail: true })); + setCityDetailLoading(true); void ensureCityDetail(selectedCity, false, "panel") .then((detail) => { if (cancelled) return; @@ -799,7 +806,7 @@ export function DashboardStoreProvider({ .catch(() => {}) .finally(() => { if (cancelled) return; - setLoadingState((current) => ({ ...current, cityDetail: false })); + setCityDetailLoading(false); }); return () => { @@ -807,7 +814,6 @@ export function DashboardStoreProvider({ }; }, [ cityDetailsByName, - ensureCityDetail, isPanelOpen, proAccess.authenticated, proAccess.loading, @@ -1080,7 +1086,7 @@ export function DashboardStoreProvider({ : Promise.resolve(citySummariesRef.current[cityName]); if (proAccessRef.current.loading) { - setLoadingState((current) => ({ ...current, cityDetail: true })); + setCityDetailLoading(true); const detailPromise = ensureCityDetail(cityName, false, "panel"); try { const [, detail] = await Promise.allSettled([summaryPromise, detailPromise]); @@ -1091,12 +1097,14 @@ export function DashboardStoreProvider({ } } catch { } finally { - setLoadingState((current) => ({ ...current, cityDetail: false })); + if (selectedCityRef.current === cityName) { + setCityDetailLoading(false); + } } return; } - setLoadingState((current) => ({ ...current, cityDetail: !cached })); + setCityDetailLoading(!cached); const detailPromise = ensureCityDetail(cityName, false, "panel"); void Promise.allSettled([summaryPromise, detailPromise]) .then(([, detail]) => { @@ -1107,7 +1115,7 @@ export function DashboardStoreProvider({ }) .finally(() => { if (selectedCityRef.current !== cityName) return; - setLoadingState((current) => ({ ...current, cityDetail: false })); + setCityDetailLoading(false); }); }; @@ -1119,7 +1127,7 @@ export function DashboardStoreProvider({ setSelectedForecastDate(null); setFutureModalDate(null); setForecastModalMode(null); - setLoadingState((current) => ({ ...current, cityDetail: !cached })); + setCityDetailLoading(!cached); void Promise.allSettled([ ensureCitySummary(cityName), ensureCityDetail(cityName, false, "panel"), @@ -1132,7 +1140,7 @@ export function DashboardStoreProvider({ }) .finally(() => { if (selectedCityRef.current !== cityName) return; - setLoadingState((current) => ({ ...current, cityDetail: false })); + setCityDetailLoading(false); }); }; @@ -1369,12 +1377,12 @@ export function DashboardStoreProvider({ selectedCityRef.current === cityName; let cachedDetail = findCachedCityDetail(cityDetailsByName, selectedCity); if (!cachedDetail) { - setLoadingState((current) => ({ ...current, cityDetail: true })); + setCityDetailLoading(true); try { cachedDetail = await ensureCityDetail(cityName, false, "panel"); } finally { if (isLatestModalRequest()) { - setLoadingState((current) => ({ ...current, cityDetail: false })); + setCityDetailLoading(false); } } } @@ -1423,12 +1431,12 @@ export function DashboardStoreProvider({ selectedCityRef.current === cityName; let cachedDetail = findCachedCityDetail(cityDetailsByName, cityName); if (!cachedDetail) { - setLoadingState((current) => ({ ...current, cityDetail: true })); + setCityDetailLoading(true); try { cachedDetail = await ensureCityDetail(cityName, false, "panel"); } finally { if (isLatestModalRequest()) { - setLoadingState((current) => ({ ...current, cityDetail: false })); + setCityDetailLoading(false); } } } diff --git a/frontend/lib/dashboard-client.ts b/frontend/lib/dashboard-client.ts index dcc96aad..4740121e 100644 --- a/frontend/lib/dashboard-client.ts +++ b/frontend/lib/dashboard-client.ts @@ -64,6 +64,7 @@ export type AssistantChatResponse = { const CACHE_KEY = "polyWeather_v1"; const CACHE_TTL_MS = 30 * 60 * 1000; const SCAN_TERMINAL_CLIENT_TIMEOUT_MS = 35_000; +const CITY_DETAIL_CLIENT_TIMEOUT_MS = 35_000; const pendingCityDetailRequests = new Map>(); const pendingHistoryRequests = new Map>(); const pendingCitySummaryRequests = new Map>(); @@ -320,6 +321,7 @@ export const dashboardClient = { const request = fetchJson( `/api/city/${normalizeCityName(cityName)}?force_refresh=false&depth=${depth}`, + { timeoutMs: CITY_DETAIL_CLIENT_TIMEOUT_MS }, ).finally(() => { pendingCityDetailRequests.delete(requestKey); }); @@ -335,6 +337,7 @@ export const dashboardClient = { }); return fetchJson( `/api/city/${normalizeCityName(cityName)}?${params.toString()}`, + { timeoutMs: CITY_DETAIL_CLIENT_TIMEOUT_MS }, ); },