From 5d0d1e2673dad2b55b2f46fe71888334fe92d350 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Mon, 25 May 2026 22:27:02 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=A0=E9=99=A4=20AI=20=E9=A2=84=E6=8A=A5?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E5=85=A8=E9=83=A8=E6=AD=BB=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=EF=BC=9A11=20=E4=B8=AA=E6=96=87=E4=BB=B6=EF=BC=8C=E6=89=AB?= =?UTF-8?q?=E6=8F=8F=E5=AE=A2=E6=88=B7=E7=AB=AF=20AI=20=E6=B5=81=E3=80=81?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86=E3=80=81=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/api/scan/terminal/ai-city/route.ts | 112 ----- .../api/scan/terminal/ai-city/stream/route.ts | 67 --- .../scan-terminal/AiCityTemperatureChart.tsx | 247 ---------- .../scan-terminal/AiEvidencePanel.tsx | 205 -------- .../aiCityForecastStreamState.test.ts | 169 ------- .../aiCityReadQueuePerformance.test.ts | 30 -- .../__tests__/aiCityReadSoftTimeout.test.ts | 33 -- .../__tests__/buildCityDecisionState.test.ts | 25 +- .../__tests__/buildStatusTags.test.ts | 6 - .../ai-city-forecast-stream-state.ts | 465 ------------------ .../dashboard/scan-terminal/ai-city-stream.ts | 146 ------ .../scan-terminal/city-card-decision-utils.ts | 36 +- .../scan-terminal/city-decision-state.ts | 73 +-- .../scan-terminal/scan-terminal-client.ts | 226 --------- .../dashboard/scan-terminal/types.ts | 44 -- .../scan-terminal/use-ai-city-forecast.ts | 156 ------ frontend/lib/dashboard-types.ts | 13 - 17 files changed, 20 insertions(+), 2033 deletions(-) delete mode 100644 frontend/app/api/scan/terminal/ai-city/route.ts delete mode 100644 frontend/app/api/scan/terminal/ai-city/stream/route.ts delete mode 100644 frontend/components/dashboard/scan-terminal/AiCityTemperatureChart.tsx delete mode 100644 frontend/components/dashboard/scan-terminal/AiEvidencePanel.tsx delete mode 100644 frontend/components/dashboard/scan-terminal/__tests__/aiCityForecastStreamState.test.ts delete mode 100644 frontend/components/dashboard/scan-terminal/__tests__/aiCityReadQueuePerformance.test.ts delete mode 100644 frontend/components/dashboard/scan-terminal/__tests__/aiCityReadSoftTimeout.test.ts delete mode 100644 frontend/components/dashboard/scan-terminal/ai-city-forecast-stream-state.ts delete mode 100644 frontend/components/dashboard/scan-terminal/ai-city-stream.ts delete mode 100644 frontend/components/dashboard/scan-terminal/use-ai-city-forecast.ts diff --git a/frontend/app/api/scan/terminal/ai-city/route.ts b/frontend/app/api/scan/terminal/ai-city/route.ts deleted file mode 100644 index f42e76b4..00000000 --- a/frontend/app/api/scan/terminal/ai-city/route.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { - applyAuthResponseCookies, - buildBackendRequestHeaders, -} from "@/lib/backend-auth"; -import { - buildProxyExceptionResponse, - buildUpstreamErrorResponse, -} from "@/lib/api-proxy"; - -const API_BASE = process.env.POLYWEATHER_API_BASE_URL; -const AI_CITY_GATEWAY_TIMEOUT_MS = Math.max( - 10_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 || - "55000", - ) || 55_000, -); - -export const dynamic = "force-dynamic"; -export const maxDuration = 60; - -export async function POST(req: NextRequest) { - if (!API_BASE) { - return NextResponse.json( - { error: "POLYWEATHER_API_BASE_URL is not configured" }, - { status: 500 }, - ); - } - - let body: unknown = {}; - try { - body = await req.json(); - } catch { - body = {}; - } - - let auth: Awaited> | null = null; - const controller = new AbortController(); - const startedAt = Date.now(); - const timeoutId = setTimeout(() => controller.abort(), AI_CITY_GATEWAY_TIMEOUT_MS); - const requestBody = body && typeof body === "object" ? body as Record : {}; - - try { - auth = await buildBackendRequestHeaders(req); - const headers = new Headers(auth.headers); - headers.set("Content-Type", "application/json"); - headers.set("Accept", "application/json"); - console.info("[scan-ai-city] gateway request", { - city: requestBody.city, - force_refresh: requestBody.force_refresh === true, - locale: requestBody.locale, - timeout_ms: AI_CITY_GATEWAY_TIMEOUT_MS, - }); - const res = await fetch(`${API_BASE}/api/scan/terminal/ai-city`, { - method: "POST", - headers, - cache: "no-store", - signal: controller.signal, - body: JSON.stringify(body || {}), - }); - if (!res.ok) { - const raw = await res.text(); - console.warn("[scan-ai-city] backend returned non-ok", { - status: res.status, - detail: raw.slice(0, 180), - }); - const response = buildUpstreamErrorResponse(res.status, raw); - return applyAuthResponseCookies(response, auth.response); - } - const data = await res.json(); - console.info("[scan-ai-city] gateway complete", { - status: data?.status, - city: data?.city, - model: data?.model, - cached: data?.cached === true, - elapsed_ms: Date.now() - startedAt, - }); - const response = NextResponse.json(data, { - headers: { - "Cache-Control": "no-store", - }, - }); - return applyAuthResponseCookies(response, auth.response); - } catch (error) { - const timedOut = controller.signal.aborted; - const elapsedMs = Date.now() - startedAt; - console.warn("[scan-ai-city] gateway failed", { - city: requestBody.city, - timed_out: timedOut, - elapsed_ms: elapsedMs, - timeout_ms: AI_CITY_GATEWAY_TIMEOUT_MS, - error: String(error), - }); - const response = buildProxyExceptionResponse(error, { - publicMessage: timedOut - ? "City AI gateway timed out before backend responded" - : "Failed to fetch city AI data", - status: timedOut ? 504 : 500, - extra: { - elapsed_ms: elapsedMs, - timeout_ms: AI_CITY_GATEWAY_TIMEOUT_MS, - city: requestBody.city, - }, - }); - return auth ? applyAuthResponseCookies(response, auth.response) : response; - } finally { - clearTimeout(timeoutId); - } -} diff --git a/frontend/app/api/scan/terminal/ai-city/stream/route.ts b/frontend/app/api/scan/terminal/ai-city/stream/route.ts deleted file mode 100644 index ebcba9b0..00000000 --- a/frontend/app/api/scan/terminal/ai-city/stream/route.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { - applyAuthResponseCookies, - buildBackendRequestHeaders, -} from "@/lib/backend-auth"; -import { - buildProxyExceptionResponse, - buildUpstreamErrorResponse, -} from "@/lib/api-proxy"; - -const API_BASE = process.env.POLYWEATHER_API_BASE_URL; - -export const dynamic = "force-dynamic"; -export const maxDuration = 90; - -export async function POST(req: NextRequest) { - if (!API_BASE) { - return NextResponse.json( - { error: "POLYWEATHER_API_BASE_URL is not configured" }, - { status: 500 }, - ); - } - - let body: unknown = {}; - try { - body = await req.json(); - } catch { - body = {}; - } - - const requestBody = - body && typeof body === "object" ? (body as Record) : {}; - const auth = await buildBackendRequestHeaders(req); - const headers = new Headers(auth.headers); - headers.set("Content-Type", "application/json"); - headers.set("Accept", "text/event-stream"); - - try { - const res = await fetch(`${API_BASE}/api/scan/terminal/ai-city/stream`, { - method: "POST", - headers, - cache: "no-store", - body: JSON.stringify(requestBody), - }); - if (!res.ok || !res.body) { - const raw = await res.text(); - const response = buildUpstreamErrorResponse(res.status, raw); - return applyAuthResponseCookies(response, auth.response); - } - - const response = new NextResponse(res.body, { - status: 200, - headers: { - "Content-Type": "text/event-stream; charset=utf-8", - "Cache-Control": "no-store, no-transform", - "X-Accel-Buffering": "no", - }, - }); - return applyAuthResponseCookies(response, auth.response); - } catch (error) { - const response = buildProxyExceptionResponse(error, { - publicMessage: "Failed to stream city AI data", - extra: { city: requestBody.city }, - }); - return applyAuthResponseCookies(response, auth.response); - } -} diff --git a/frontend/components/dashboard/scan-terminal/AiCityTemperatureChart.tsx b/frontend/components/dashboard/scan-terminal/AiCityTemperatureChart.tsx deleted file mode 100644 index 9667f6cd..00000000 --- a/frontend/components/dashboard/scan-terminal/AiCityTemperatureChart.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import type { ChartConfiguration } from "chart.js"; -import { memo, useMemo, useRef } from "react"; -import { BarChart3 } from "lucide-react"; -import type { CityDetail } from "@/lib/dashboard-types"; -import { useChart } from "@/hooks/useChart"; -import { useI18n } from "@/hooks/useI18n"; -import { getTemperatureChartData } from "@/lib/chart-utils"; - -type TemperatureChartData = NonNullable>; - -function compactSeries( - rows?: T[] | null, -) { - return (Array.isArray(rows) ? rows : []) - .map((row) => `${String(row?.time || "").trim()}=${Number(row?.temp)}`) - .join("|"); -} - -function buildTemperatureChartSignature(detail: CityDetail) { - const hourly = detail.hourly || {}; - const mgmHourly = Array.isArray(detail.mgm?.hourly) ? detail.mgm?.hourly || [] : []; - const tafMarkers = Array.isArray(detail.taf?.signal?.markers) - ? detail.taf?.signal?.markers || [] - : []; - return [ - detail.name, - detail.local_date, - detail.temp_symbol, - (hourly.times || []).join("|"), - (hourly.temps || []).map((value) => Number(value)).join("|"), - detail.forecast?.today_high ?? "", - detail.deb?.prediction ?? "", - detail.mgm?.temp ?? "", - detail.mgm?.time ?? "", - mgmHourly - .map((row) => `${String(row?.time || "").trim()}=${Number(row?.temp)}`) - .join("|"), - compactSeries(detail.metar_today_obs), - compactSeries(detail.settlement_today_obs), - compactSeries(detail.trend?.recent), - detail.current?.temp ?? "", - detail.current?.obs_time ?? "", - detail.airport_current?.temp ?? "", - detail.airport_current?.obs_time ?? "", - detail.peak?.first_h ?? "", - detail.peak?.last_h ?? "", - tafMarkers - .map((marker) => - [ - marker?.marker_type, - marker?.label_time, - marker?.start_local, - marker?.end_local, - marker?.summary_zh, - marker?.summary_en, - ] - .map((value) => String(value || "").trim()) - .join("="), - ) - .join("|"), - ].join("::"); -} - -export const AiCityTemperatureChart = memo(function AiCityTemperatureChart({ detail }: { detail: CityDetail }) { - const { locale } = useI18n(); - const sectionRef = useRef(null); - const cityKey = `${detail.name || detail.display_name || ""}:${detail.local_date || ""}`; - const chartSignature = useMemo( - () => buildTemperatureChartSignature(detail), - [detail], - ); - const computedChartData = useMemo( - () => getTemperatureChartData(detail, locale), - // eslint-disable-next-line react-hooks/exhaustive-deps - [chartSignature, locale], - ); - const lastChartDataRef = useRef<{ - cityKey: string; - data: TemperatureChartData; - } | null>(null); - // Use a memo so we never mutate refs during render (React anti-pattern). - // When cityKey changes, discard any stale cache; once computedChartData - // arrives, save it into the ref and use it. - const chartData = useMemo(() => { - if (lastChartDataRef.current && lastChartDataRef.current.cityKey !== cityKey) { - // City switched — clear stale cache so the old city's chart cannot - // bleed into the new city card while its detail is still loading. - lastChartDataRef.current = null; - } - if (computedChartData) { - lastChartDataRef.current = { cityKey, data: computedChartData }; - return computedChartData; - } - if (lastChartDataRef.current?.cityKey === cityKey) { - return lastChartDataRef.current.data; - } - return null; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cityKey, computedChartData]); - const forecastLabel = locale === "en-US" ? "DEB baseline" : "DEB 原始路径"; - const calibratedLabel = - locale === "en-US" - ? "DEB calibrated path" - : "DEB 修正路径"; - const observationLabel = - chartData?.observationLabel || - (locale === "en-US" ? "METAR obs" : "METAR 实况"); - const hasCalibratedPath = Boolean( - chartData?.datasets.calibratedFuture.some((value) => value != null), - ); - const canvasRef = useChart(() => { - if (!chartData) { - return { - data: { datasets: [], labels: [] }, - type: "line", - } satisfies ChartConfiguration<"line">; - } - const datasets: NonNullable< - ChartConfiguration<"line">["data"] - >["datasets"] = [ - { - borderColor: "rgba(100, 116, 139, 0.72)", - borderWidth: 1.6, - data: chartData.datasets.debPast.map( - (value, index) => value ?? chartData.datasets.debFuture[index], - ), - fill: false, - label: forecastLabel, - pointRadius: 0, - segment: { - borderDash: (ctx: { p0DataIndex: number }) => - chartData.currentIndex != null && ctx.p0DataIndex < chartData.currentIndex ? [] : [6, 4], - }, - spanGaps: true, - tension: 0.1, - }, - ]; - - if (hasCalibratedPath) { - datasets.push({ - borderColor: "#38bdf8", - borderWidth: 2.3, - data: chartData.datasets.calibratedFuture, - fill: false, - label: calibratedLabel, - pointHoverRadius: 5, - pointRadius: 0, - spanGaps: true, - tension: 0.12, - }); - } - - datasets.push({ - backgroundColor: "#22C55E", - borderColor: "#22C55E", - borderWidth: 0, - data: chartData.datasets.metarPoints, - fill: false, - label: observationLabel, - pointHoverRadius: 5, - pointRadius: 3.5, - showLine: false, - }); - - return { - data: { - datasets, - labels: chartData.tickLabels, - }, - options: { - animation: false, - interaction: { intersect: false, mode: "index" }, - layout: { padding: { bottom: 2, left: 0, right: 8, top: 8 } }, - maintainAspectRatio: false, - plugins: { - legend: { display: false }, - tooltip: { - backgroundColor: "rgba(11, 18, 32, 0.96)", - borderColor: "rgba(77, 163, 255, 0.38)", - borderWidth: 1, - }, - } as Record, - responsive: true, - scales: { - x: { - grid: { color: "rgba(159, 178, 199, 0.08)" }, - ticks: { - color: "#6B7A90", - font: { size: 10 }, - maxRotation: 0, - }, - }, - y: { - grid: { color: "rgba(159, 178, 199, 0.08)" }, - max: chartData.max, - min: chartData.min, - ticks: { - callback: (value) => - `${Number(value).toFixed(chartData.yTickStep < 1 ? 1 : 0)}${detail.temp_symbol || "°C"}`, - color: "#6B7A90", - font: { size: 10 }, - maxTicksLimit: 5, - stepSize: chartData.yTickStep, - }, - }, - }, - transitions: { - active: { animation: { duration: 0 } }, - hide: { animation: { duration: 0 } }, - resize: { animation: { duration: 0 } }, - show: { animation: { duration: 0 } }, - }, - }, - type: "line", - } satisfies ChartConfiguration<"line">; - }, [ - calibratedLabel, - chartData, - cityKey, - detail.temp_symbol, - forecastLabel, - hasCalibratedPath, - observationLabel, - ]); - - return ( -
-
- - {locale === "en-US" ? "Evidence · intraday path" : "证据 · 今日日内路径"} -
-
- -
- {chartData ? ( -
- {forecastLabel} - {hasCalibratedPath ? ( - {calibratedLabel} - ) : null} - {observationLabel} -
- ) : null} -
- ); -}); - diff --git a/frontend/components/dashboard/scan-terminal/AiEvidencePanel.tsx b/frontend/components/dashboard/scan-terminal/AiEvidencePanel.tsx deleted file mode 100644 index 8ba0b5fe..00000000 --- a/frontend/components/dashboard/scan-terminal/AiEvidencePanel.tsx +++ /dev/null @@ -1,205 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import type { - AiCityForecastPayload, - AiCityForecastState, -} from "@/components/dashboard/scan-terminal/types"; -import { formatTemperatureValue } from "@/lib/temperature-utils"; - -function confidenceBadge(confidence: string | null, isEn: boolean) { - const c = String(confidence || "").toLowerCase(); - if (c === "high") return { label: isEn ? "High" : "高", tone: "high" }; - if (c === "medium") return { label: isEn ? "Medium" : "中", tone: "medium" }; - return { label: isEn ? "Low" : "低", tone: "low" }; -} - -function useTransitionMarker(status: string, hasContent: boolean) { - const [showUpdated, setShowUpdated] = useState(false); - useEffect(() => { - if (status === "ready" && hasContent) { - setShowUpdated(true); - const id = setTimeout(() => setShowUpdated(false), 4000); - return () => clearTimeout(id); - } - }, [status, hasContent]); - return showUpdated; -} - -export function AiEvidencePanel({ - aiBullets, - aiCityForecast, - aiForecast, - aiPredictedMax, - aiRangeLow, - aiRangeHigh, - aiConfidence, - aiReadCompleteText, - aiReadInProgressText, - aiRuleEvidenceMode, - aiRuleEvidenceText, - debPrediction, - fallbackAiReason, - isEn, - isHkoObservation, - localModelSupportNote, - localizedFinalJudgment, - tempSymbol, -}: { - aiBullets: string[]; - aiCityForecast: AiCityForecastPayload["city_forecast"] | null; - aiForecast: AiCityForecastState; - aiPredictedMax: number | null; - aiRangeLow: number | null; - aiRangeHigh: number | null; - aiConfidence: string | null; - aiReadCompleteText: string; - aiReadInProgressText: string; - aiRuleEvidenceMode: boolean; - aiRuleEvidenceText: string; - debPrediction: number | null; - fallbackAiReason: string; - isEn: boolean; - isHkoObservation: boolean; - localModelSupportNote: string; - localizedFinalJudgment: string; - tempSymbol: string; -}) { - const aiConfidenceMeta = confidenceBadge(aiConfidence, isEn); - const hasAiPrediction = aiPredictedMax != null; - const hasDebPrediction = debPrediction != null; - const aiRangeText = - aiRangeLow != null && aiRangeHigh != null - ? `${formatTemperatureValue(aiRangeLow, tempSymbol, { digits: 0 })} ~ ${formatTemperatureValue(aiRangeHigh, tempSymbol, { digits: 0 })}` - : null; - - const showUpdatedBadge = useTransitionMarker(aiForecast.status, Boolean(aiCityForecast)); - - return ( -
-
- {isHkoObservation - ? isEn - ? "Evidence · AI HKO observation read" - : "证据 · AI 香港天文台观测解读" - : isEn - ? "Evidence · AI airport read" - : "证据 · AI 机场报文解读"} -
-
- {hasAiPrediction || hasDebPrediction ? ( -
- {hasAiPrediction ? ( -
- {isEn ? "AI predicted high" : "AI 预测最高温"} - {formatTemperatureValue(aiPredictedMax!, tempSymbol, { digits: 1 })} - - {aiConfidenceMeta.label} - - {aiRangeText ? {aiRangeText} : null} -
- ) : aiForecast.status === "loading" && hasDebPrediction ? ( -
- {isEn ? "AI predicted high" : "AI 预测最高温"} - {isEn ? "..." : "…"} - - {isEn ? "Predicting" : "预测中"} - -
- ) : null} - {hasDebPrediction ? ( -
- {isEn ? "DEB fusion" : "DEB 融合"} - {formatTemperatureValue(debPrediction!, tempSymbol, { digits: 1 })} - - {isEn ? "Reference" : "参考"} - -
- ) : null} -
- ) : hasDebPrediction ? ( -
-
- {isEn ? "DEB fusion" : "DEB 融合"} - {formatTemperatureValue(debPrediction!, tempSymbol, { digits: 1 })} - - {isEn ? "Reference" : "参考"} - -
-
- {isEn ? "AI predicted high" : "AI 预测最高温"} - {isEn ? "..." : "…"} - - {isEn ? "Predicting" : "预测中"} - -
-
- ) : null} - - {aiForecast.status === "loading" ? ( - <> -

{aiReadInProgressText}

- {localizedFinalJudgment || aiForecast.streamText ? ( -

- {localizedFinalJudgment || aiForecast.streamText} -

- ) : null} -

- {isEn - ? isHkoObservation - ? "Rule evidence is shown first; the full HKO AI read will merge automatically." - : "Rule evidence is shown first; the full airport AI read will merge automatically." - : isHkoObservation - ? "先展示规则证据,完整香港天文台 AI 解读返回后会自动合并。" - : "先展示规则证据,完整机场 AI 解读返回后会自动合并。"} -

- - ) : aiForecast.status === "ready" && aiCityForecast ? ( - <> - {showUpdatedBadge ? ( - - {isEn ? "✓ Updated" : "✓ 已更新"} - - ) : null} -

- {aiRuleEvidenceMode ? aiRuleEvidenceText : aiReadCompleteText} -

-
    - {[localizedFinalJudgment, ...aiBullets] - .filter((line) => String(line || "").trim()) - .map((line, index) => ( -
  • {line}
  • - ))} -
- - ) : aiForecast.status === "ready" ? ( - <> -

{aiRuleEvidenceText}

-
    - {fallbackAiReason ?
  • {fallbackAiReason}
  • : null} -
  • {localModelSupportNote}
  • -
- - ) : aiForecast.status === "failed" ? ( - <> -

{aiRuleEvidenceText}

-
    - {aiForecast.error ?
  • {aiForecast.error}
  • : null} -
  • {localModelSupportNote}
  • -
- - ) : ( -

- {isEn - ? isHkoObservation - ? "Waiting for AI to read the latest HKO observation." - : "Waiting for AI to read the latest airport bulletin." - : isHkoObservation - ? "等待 AI 解读最新香港天文台观测。" - : "等待 AI 解读最新机场报文。"} -

- )} -
-
- ); -} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/aiCityForecastStreamState.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/aiCityForecastStreamState.test.ts deleted file mode 100644 index 06177d81..00000000 --- a/frontend/components/dashboard/scan-terminal/__tests__/aiCityForecastStreamState.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import assert from "node:assert/strict"; -import { - buildAiCityErrorForecastState, - buildAiCityForecastCacheKey, - buildAiCityForecastKey, - buildAiCityProgressForecastState, - buildAiCityReadyForecastState, - readReadyCachedAiForecastState, -} from "@/components/dashboard/scan-terminal/ai-city-forecast-stream-state"; -import { readCachedPayload, writeCachedPayload } from "@/components/dashboard/scan-terminal/scan-terminal-cache"; -import type { AiCityForecastPayload, AiCityForecastState } from "@/components/dashboard/scan-terminal/types"; -import type { CityDetail } from "@/lib/dashboard-types"; - -function installLocalStorageMock() { - const store = new Map(); - const localStorage = { - clear: () => store.clear(), - getItem: (key: string) => store.get(key) ?? null, - removeItem: (key: string) => { - store.delete(key); - }, - setItem: (key: string, value: string) => { - store.set(key, value); - }, - }; - Object.defineProperty(globalThis, "window", { - configurable: true, - value: { localStorage }, - }); - return localStorage; -} - -function cityDetail(extra: Partial = {}): CityDetail { - return { - airport_current: { - raw_metar: "METAR TEST 010000Z 34004KT CAVOK 21/10 Q1012", - temp: 21, - }, - current: { - temp: 21, - }, - local_date: "2026-04-28", - metar_status: { - last_observation_time: "2026-04-28T00:00:00Z", - stale_for_today: false, - }, - name: "Test City", - temp_symbol: "°C", - ...extra, - } as CityDetail; -} - -function readyPayload(extra: Partial = {}): AiCityForecastPayload { - return { - city_forecast: { - confidence: "medium", - final_judgment_en: "Centered near 25°C.", - final_judgment_zh: "预计最高温以 25°C 为中枢。", - metar_read_en: "Latest METAR supports the path.", - metar_read_zh: "最新 METAR 支撑当前路径。", - model_cluster_note_en: "Models are clustered.", - model_cluster_note_zh: "模型较集中。", - predicted_max: 25, - range_high: 26, - range_low: 24, - reasoning_en: "Evidence is aligned.", - reasoning_zh: "证据一致。", - risks_en: [], - risks_zh: [], - unit: "°C", - }, - status: "ready", - ...extra, - }; -} - -export function runTests() { - const storage = installLocalStorageMock(); - storage.clear(); - - const forecastKey = buildAiCityForecastKey({ - detail: cityDetail(), - detailCityName: "Test City", - locale: "zh-CN", - report: "METAR TEST 010000Z 34004KT CAVOK 21/10 Q1012", - }); - const cacheKey = buildAiCityForecastCacheKey(forecastKey); - const payload = readyPayload(); - writeCachedPayload(cacheKey, payload); - - const cachedReady = readReadyCachedAiForecastState(cacheKey, 0); - assert.equal(cachedReady?.status, "ready"); - assert.equal(cachedReady?.payload?.city_forecast?.predicted_max, 25); - - const degradedCacheKey = `${cacheKey}:degraded`; - writeCachedPayload(degradedCacheKey, readyPayload({ degraded: true })); - assert.equal(readReadyCachedAiForecastState(degradedCacheKey, 0), null); - assert.equal(readCachedPayload(degradedCacheKey, 60 * 60 * 1000), null); - - const currentLoading: AiCityForecastState = { - status: "loading", - streamText: "已有快速判断", - }; - const callingAiProgress = buildAiCityProgressForecastState({ - cacheKey: `${cacheKey}:progress`, - current: currentLoading, - isEn: false, - progress: { - message_zh: "DeepSeek 正在补充机场报文细节", - stage: "calling_ai", - }, - }); - assert.equal(callingAiProgress?.streamText, "已有快速判断"); - - const errorState = buildAiCityErrorForecastState({ - cacheKey: `${cacheKey}:error`, - detail: cityDetail(), - error: new Error("timeout"), - isEn: false, - report: "METAR TEST 010000Z 34004KT CAVOK 21/10 Q1012", - }); - assert.equal(errorState.status, "ready"); - assert.equal(errorState.payload?.status, "timeout_fallback"); - assert.match(errorState.payload?.reason_zh || "", /DeepSeek|DEB|METAR/); - - const modelFallbackState = buildAiCityErrorForecastState({ - cacheKey: `${cacheKey}:models`, - detail: cityDetail({ - deb: { prediction: 29 }, - multi_model: { ECMWF: 30, GFS: 32, ICON: 31 }, - } as unknown as Partial), - error: new Error("timeout"), - isEn: false, - report: "", - }); - assert.equal(modelFallbackState.payload?.city_forecast?.predicted_max, 31); - assert.equal(modelFallbackState.payload?.city_forecast?.range_low, 30); - assert.equal(modelFallbackState.payload?.city_forecast?.range_high, 32); - - const hkoState = buildAiCityErrorForecastState({ - cacheKey: `${cacheKey}:hko`, - detail: cityDetail({ - airport_current: null, - current: { - settlement_source: "hko", - temp: 30, - }, - settlement_station: { - settlement_source: "hko", - }, - } as unknown as Partial), - error: new Error("timeout"), - isEn: false, - report: "", - }); - const hkoRead = hkoState.payload?.city_forecast?.metar_read_zh || ""; - assert.doesNotMatch(hkoRead, /METAR|机场报文/); - assert.match(hkoRead, /官方观测|30\.0°C/); - - const degradedReadyState = buildAiCityReadyForecastState({ - cacheKey: `${cacheKey}:ready-degraded`, - detail: cityDetail(), - isEn: false, - payload: readyPayload({ degraded: true }), - report: "METAR TEST 010000Z 34004KT CAVOK 21/10 Q1012", - }); - assert.equal(degradedReadyState.status, "ready"); - assert.equal(readCachedPayload(`${cacheKey}:ready-degraded`, 60 * 60 * 1000), null); -} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/aiCityReadQueuePerformance.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/aiCityReadQueuePerformance.test.ts deleted file mode 100644 index 11515a7d..00000000 --- a/frontend/components/dashboard/scan-terminal/__tests__/aiCityReadQueuePerformance.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; - -function assert(condition: unknown, message: string) { - if (!condition) throw new Error(message); -} - -export function runTests() { - const projectRoot = process.cwd(); - const clientPath = path.join( - projectRoot, - "components", - "dashboard", - "scan-terminal", - "scan-terminal-client.ts", - ); - const source = fs.readFileSync(clientPath, "utf8"); - const concurrencyMatch = source.match(/AI_CITY_READ_MAX_CONCURRENT_STREAMS\s*=\s*(\d+)/); - const concurrency = Number(concurrencyMatch?.[1]); - - assert( - Number.isFinite(concurrency) && concurrency >= 4, - "AI airport-read streams should allow at least 4 concurrent cards to avoid slow decision-card evidence queues", - ); - assert( - source.includes("queuedAiCityReadTasks.unshift(run)") && - source.includes("queuedAiCityReadTasks.pop()"), - "newer AI airport-read requests should be prioritized instead of waiting behind older pinned cards", - ); -} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/aiCityReadSoftTimeout.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/aiCityReadSoftTimeout.test.ts deleted file mode 100644 index 716a08fb..00000000 --- a/frontend/components/dashboard/scan-terminal/__tests__/aiCityReadSoftTimeout.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; - -function assert(condition: unknown, message: string) { - if (!condition) throw new Error(message); -} - -export function runTests() { - const projectRoot = process.cwd(); - const hookPath = path.join( - projectRoot, - "components", - "dashboard", - "scan-terminal", - "use-ai-city-forecast.ts", - ); - const source = fs.readFileSync(hookPath, "utf8"); - - assert( - source.includes("AI_CITY_READ_SOFT_TIMEOUT_MS") && - source.includes("softTimeoutId"), - "AI city read hook must use a soft timeout so airport-read loading does not linger", - ); - assert( - source.includes("ai_soft_timeout_fallback") && - source.includes("buildAiCityErrorForecastState"), - "AI city read soft timeout must switch to the fast fallback state while allowing later merge", - ); - assert( - !source.includes("controller.abort()"), - "AI city read soft timeout should not abort the stream; late AI results should still merge", - ); -} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/buildCityDecisionState.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/buildCityDecisionState.test.ts index 28001006..8ddaeee4 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/buildCityDecisionState.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/buildCityDecisionState.test.ts @@ -1,7 +1,6 @@ import assert from "node:assert/strict"; import { buildCityDecisionState } from "@/components/dashboard/scan-terminal/city-decision-state"; import type { MarketDecisionView } from "@/components/dashboard/scan-terminal/city-card-decision-utils"; -import type { AiCityForecastState } from "@/components/dashboard/scan-terminal/types"; function market(status: MarketDecisionView["status"]): MarketDecisionView { return { @@ -18,15 +17,8 @@ function market(status: MarketDecisionView["status"]): MarketDecisionView { }; } -function ai(status: AiCityForecastState["status"], extra: Partial = {}): AiCityForecastState { - return { status, ...extra }; -} - export function runTests() { const breakout = buildCityDecisionState({ - aiCityForecast: null, - aiForecast: ai("ready"), - aiRuleEvidenceMode: false, isEn: false, isHkoObservation: false, modelHighlyConsistent: true, @@ -45,9 +37,6 @@ export function runTests() { assert.ok(breakout.badges.some((badge) => badge.label === "实测突破")); const marketUnavailable = buildCityDecisionState({ - aiCityForecast: null, - aiForecast: ai("ready"), - aiRuleEvidenceMode: false, isEn: false, isHkoObservation: false, modelHighlyConsistent: false, @@ -61,9 +50,6 @@ export function runTests() { const fallback = buildCityDecisionState({ - aiCityForecast: null, - aiForecast: ai("ready", { payload: { degraded: true } }), - aiRuleEvidenceMode: true, isEn: false, isHkoObservation: false, modelHighlyConsistent: false, @@ -75,14 +61,9 @@ export function runTests() { peakHasPassed: false, }); - assert.equal(fallback.aiStatus, "fallback"); - assert.equal(fallback.aiStatusLabel, "规则证据模式"); - assert.notEqual(fallback.aiStatusLabel, "AI 解读已完成"); + assert.equal(fallback.recommendation, "watch"); const partialStream = buildCityDecisionState({ - aiCityForecast: null, - aiForecast: ai("loading", { streamText: "partial" }), - aiRuleEvidenceMode: false, isEn: false, isHkoObservation: false, modelHighlyConsistent: false, @@ -94,6 +75,6 @@ export function runTests() { peakHasPassed: false, }); - assert.equal(partialStream.aiStatus, "deepseek-loading"); - assert.equal(partialStream.aiStatusLabel, "快速判断已完成"); + assert.equal(partialStream.urgency, "soon"); + assert.equal(partialStream.recommendation, "wait"); } diff --git a/frontend/components/dashboard/scan-terminal/__tests__/buildStatusTags.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/buildStatusTags.test.ts index 2eb82836..5059638a 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/buildStatusTags.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/buildStatusTags.test.ts @@ -17,9 +17,6 @@ const readyMarket: MarketDecisionView = { export function runTests() { const peakPassed = buildCityDecisionState({ - aiCityForecast: null, - aiForecast: { status: "ready" }, - aiRuleEvidenceMode: false, isEn: false, isHkoObservation: false, modelHighlyConsistent: true, @@ -41,9 +38,6 @@ export function runTests() { ); const staleMetar = buildCityDecisionState({ - aiCityForecast: null, - aiForecast: { status: "ready" }, - aiRuleEvidenceMode: false, isEn: false, isHkoObservation: false, modelHighlyConsistent: false, diff --git a/frontend/components/dashboard/scan-terminal/ai-city-forecast-stream-state.ts b/frontend/components/dashboard/scan-terminal/ai-city-forecast-stream-state.ts deleted file mode 100644 index 52408696..00000000 --- a/frontend/components/dashboard/scan-terminal/ai-city-forecast-stream-state.ts +++ /dev/null @@ -1,465 +0,0 @@ -import type { AiCityStreamProgress } from "@/components/dashboard/scan-terminal/scan-terminal-client"; -import { - buildStorageKey, - readCachedPayload, - removeCachedPayload, - writeCachedPayload, -} from "@/components/dashboard/scan-terminal/scan-terminal-cache"; -import type { - AiCityForecastPayload, - AiCityForecastState, -} from "@/components/dashboard/scan-terminal/types"; -import { getDisplayAirportPrimary } from "@/lib/airport-observation-display"; -import type { CityDetail } 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 aiCityForecastStateCache = new Map< - string, - { state: AiCityForecastState; updatedAt: number } ->(); -const MAX_AI_FORECAST_CACHE_SIZE = 40; - -function trimAiForecastCache() { - if (aiCityForecastStateCache.size <= MAX_AI_FORECAST_CACHE_SIZE) return; - const excess = aiCityForecastStateCache.size - MAX_AI_FORECAST_CACHE_SIZE; - const keys = Array.from(aiCityForecastStateCache.keys()); - for (let i = 0; i < excess && i < keys.length; i++) { - aiCityForecastStateCache.delete(keys[i]); - } -} - -function isHkoObservationCity(detail?: CityDetail | null) { - const source = String( - detail?.current?.settlement_source || - detail?.settlement_station?.settlement_source || - "", - ) - .trim() - .toLowerCase(); - return source === "hko"; -} - -export function buildAiCityForecastKey({ - detail, - detailCityName, - locale, - report, -}: { - detail: CityDetail | null; - detailCityName: string; - locale: string; - report: string; -}) { - 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(":"); -} - -export function buildAiCityForecastCacheKey(aiForecastKey: string) { - return buildStorageKey(AI_CITY_FORECAST_CACHE_PREFIX, [aiForecastKey]); -} - -export function buildAiCityForecastRequestKey(cacheKey: string, refreshToken: number) { - return `${cacheKey}:${refreshToken > 0 ? `refresh:${refreshToken}` : "normal"}`; -} - -export function readCachedAiForecastState(key: string) { - const cached = aiCityForecastStateCache.get(key); - if (!cached) return null; - if (Date.now() - cached.updatedAt > AI_CITY_FORECAST_CACHE_TTL_MS) { - aiCityForecastStateCache.delete(key); - return null; - } - return cached.state; -} - -export function writeCachedAiForecastState( - key: string, - state: AiCityForecastState, -) { - if (!key || state.status === "idle") return; - aiCityForecastStateCache.set(key, { - state, - updatedAt: Date.now(), - }); - trimAiForecastCache(); -} - -export function readReadyCachedAiForecastState(cacheKey: string, refreshToken: number) { - if (refreshToken > 0) return null; - const cachedPayload = readCachedPayload( - cacheKey, - AI_CITY_FORECAST_CACHE_TTL_MS, - ); - if (cachedPayload) { - if ( - cachedPayload.status === "ready" && - !cachedPayload.degraded && - cachedPayload.city_forecast - ) { - const readyState: AiCityForecastState = { - payload: cachedPayload, - status: "ready", - }; - writeCachedAiForecastState(cacheKey, readyState); - return readyState; - } - removeCachedPayload(cacheKey); - } - const cachedState = readCachedAiForecastState(cacheKey); - return cachedState?.status === "ready" ? cachedState : null; -} - -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 computeFallbackPredictedMax(detail: CityDetail | null): number | null { - if (!detail) return null; - const multiModel = detail.multi_model ?? {}; - const entries = Object.entries(multiModel).filter( - ([, v]) => v != null && Number.isFinite(v), - ) as [string, number][]; - const values = entries.map(([, v]) => v); - const debValue = detail.deb?.prediction ?? null; - const nonDebValues = entries - .filter(([name]) => !name.toLowerCase().includes("deb")) - .map(([, v]) => v); - const sorted = [...nonDebValues].sort((a, b) => a - b); - const clusterMedian = - sorted.length > 0 ? sorted[Math.floor(sorted.length / 2)] : null; - let predicted = clusterMedian; - if (predicted == null && debValue != null && Number.isFinite(debValue)) - predicted = debValue; - if (predicted == null && values.length > 0) - predicted = values.reduce((a, b) => a + b, 0) / values.length; - if (predicted == null) { - const isHkoObservation = isHkoObservationCity(detail); - const displayAirportPrimary = getDisplayAirportPrimary(detail); - const currentTemp = - (isHkoObservation - ? detail?.current?.temp - : detail?.airport_current?.temp ?? - displayAirportPrimary?.temp ?? - detail?.current?.temp) ?? null; - predicted = currentTemp != null && Number.isFinite(currentTemp) - ? currentTemp - : null; - } - return predicted; -} - -export 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 displayAirportPrimary = getDisplayAirportPrimary(detail); - const currentTemp = - (isHkoObservation - ? detail?.current?.temp - : detail?.airport_current?.temp ?? - displayAirportPrimary?.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.`; - - const fallbackPredictedMax = computeFallbackPredictedMax(detail); - const multiModel = detail?.multi_model ?? {}; - const modelValues = Object.values(multiModel).filter( - (v): v is number => v != null && Number.isFinite(v), - ); - - 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: fallbackPredictedMax, - range_high: modelValues.length > 0 ? Math.max(...modelValues) : null, - range_low: modelValues.length > 0 ? Math.min(...modelValues) : 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 buildAiCityLoadingForecastState({ - cacheKey, - detail, - isEn, - report, -}: { - cacheKey: string; - detail: CityDetail | null; - isEn: boolean; - report: string; -}) { - const cachedState = readCachedAiForecastState(cacheKey); - 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); - return loadingState; -} - -function hasPreviewQuantFields(progress: AiCityStreamProgress) { - return ( - progress.predicted_max != null || - progress.range_low != null || - progress.range_high != null - ); -} - -function mergePreviewQuantPayload( - current: AiCityForecastState, - progress: AiCityStreamProgress, -): AiCityForecastPayload { - const prev = current.payload ?? {}; - const prevCf = prev.city_forecast ?? {}; - return { - ...prev, - status: prev.status ?? "ready", - city_forecast: { - ...prevCf, - ...(progress.predicted_max != null - ? { predicted_max: progress.predicted_max } - : {}), - ...(progress.range_low != null - ? { range_low: progress.range_low } - : {}), - ...(progress.range_high != null - ? { range_high: progress.range_high } - : {}), - ...(progress.confidence != null - ? { confidence: progress.confidence } - : {}), - ...(progress.unit != null ? { unit: progress.unit } : {}), - ...(progress.metar_read_zh != null - ? { metar_read_zh: progress.metar_read_zh } - : {}), - ...(progress.metar_read_en != null - ? { metar_read_en: progress.metar_read_en } - : {}), - ...(progress.final_judgment_zh != null - ? { final_judgment_zh: progress.final_judgment_zh } - : {}), - ...(progress.final_judgment_en != null - ? { final_judgment_en: progress.final_judgment_en } - : {}), - ...(progress.model_cluster_note_zh != null - ? { model_cluster_note_zh: progress.model_cluster_note_zh } - : {}), - ...(progress.model_cluster_note_en != null - ? { model_cluster_note_en: progress.model_cluster_note_en } - : {}), - }, - }; -} - -export function buildAiCityProgressForecastState({ - cacheKey, - current, - isEn, - progress, -}: { - cacheKey: string; - current: AiCityForecastState; - isEn: boolean; - progress: AiCityStreamProgress; -}) { - const progressText = getAiCityStreamProgressText(progress, isEn); - const hasQuant = hasPreviewQuantFields(progress); - if (!progressText && !hasQuant) return null; - const cachedProgressState = readCachedAiForecastState(cacheKey); - const nextStreamText = - progress.stage === "calling_ai" && cachedProgressState?.streamText - ? cachedProgressState.streamText - : progressText || cachedProgressState?.streamText || ""; - const nextPayload = hasQuant - ? mergePreviewQuantPayload( - cachedProgressState ?? current, - progress, - ) - : cachedProgressState?.payload ?? current.payload; - const cachedNextState: AiCityForecastState = { - ...cachedProgressState, - status: "loading", - streamText: nextStreamText, - payload: nextPayload, - }; - writeCachedAiForecastState(cacheKey, cachedNextState); - return { - ...current, - status: "loading" as const, - streamText: - progress.stage === "calling_ai" && current.streamText - ? current.streamText - : progressText || current.streamText || "", - payload: hasQuant - ? mergePreviewQuantPayload(current, progress) - : current.payload, - } satisfies AiCityForecastState; -} - -export function buildAiCityReadyForecastState({ - cacheKey, - detail, - isEn, - payload, - report, -}: { - cacheKey: string; - detail: CityDetail | null; - isEn: boolean; - payload: AiCityForecastPayload; - report: string; -}) { - 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); - } - const readyState: AiCityForecastState = { - payload: usablePayload, - status: "ready", - }; - writeCachedAiForecastState(cacheKey, readyState); - return readyState; -} - -export function buildAiCityErrorForecastState({ - cacheKey, - detail, - error, - isEn, - report, -}: { - cacheKey: string; - detail: CityDetail | null; - error: unknown; - isEn: boolean; - report: string; -}) { - const fallbackPayload = buildAiCityFallbackPayload({ - detail, - error, - isEn, - report, - }); - const readyState: AiCityForecastState = { - payload: fallbackPayload, - status: "ready", - }; - writeCachedAiForecastState(cacheKey, readyState); - return readyState; -} diff --git a/frontend/components/dashboard/scan-terminal/ai-city-stream.ts b/frontend/components/dashboard/scan-terminal/ai-city-stream.ts deleted file mode 100644 index a9013444..00000000 --- a/frontend/components/dashboard/scan-terminal/ai-city-stream.ts +++ /dev/null @@ -1,146 +0,0 @@ -const AI_CITY_MAX_CONCURRENT = 2; -let aiCityActiveCount = 0; -const aiCityPendingQueue: Array<() => void> = []; - -function createAiCityAbortError() { - return new DOMException("The AI city request was aborted.", "AbortError"); -} - -function drainAiCityFetchQueue() { - while (aiCityActiveCount < AI_CITY_MAX_CONCURRENT && aiCityPendingQueue.length) { - const next = aiCityPendingQueue.shift(); - next?.(); - } -} - -export function enqueueAiCityFetch( - task: () => Promise, - signal: AbortSignal, - callbacks?: { - onQueued?: () => void; - onStart?: () => void; - }, -): Promise { - return new Promise((resolve, reject) => { - let started = false; - let queuedStart: (() => void) | null = null; - - const cleanup = () => { - signal.removeEventListener("abort", handleAbort); - }; - const removeQueuedStart = () => { - if (!queuedStart) return; - const index = aiCityPendingQueue.indexOf(queuedStart); - if (index >= 0) { - aiCityPendingQueue.splice(index, 1); - } - queuedStart = null; - }; - const finishActive = () => { - aiCityActiveCount = Math.max(0, aiCityActiveCount - 1); - cleanup(); - drainAiCityFetchQueue(); - }; - const handleAbort = () => { - if (started) return; - removeQueuedStart(); - cleanup(); - reject(createAiCityAbortError()); - drainAiCityFetchQueue(); - }; - const start = () => { - queuedStart = null; - if (signal.aborted) { - cleanup(); - reject(createAiCityAbortError()); - drainAiCityFetchQueue(); - return; - } - started = true; - aiCityActiveCount += 1; - callbacks?.onStart?.(); - task() - .then(resolve, reject) - .finally(finishActive); - }; - - signal.addEventListener("abort", handleAbort, { once: true }); - queuedStart = start; - if (aiCityActiveCount < AI_CITY_MAX_CONCURRENT) { - start(); - } else { - callbacks?.onQueued?.(); - aiCityPendingQueue.push(start); - } - }); -} - -export function parseSseBlock(block: string): { event: string; data: unknown } | null { - const lines = block.split(/\r?\n/); - let event = "message"; - const dataLines: string[] = []; - for (const line of lines) { - if (line.startsWith("event:")) { - event = line.slice("event:".length).trim() || "message"; - } else if (line.startsWith("data:")) { - dataLines.push(line.slice("data:".length).trimStart()); - } - } - if (!dataLines.length) return null; - const raw = dataLines.join("\n"); - try { - return { event, data: JSON.parse(raw) }; - } catch { - return { event, data: raw }; - } -} - -function decodeJsonStringFragment(fragment: string) { - const safe = fragment.replace(/\\$/g, ""); - try { - return JSON.parse(`"${safe.replace(/"/g, '\\"')}"`) as string; - } catch { - return safe - .replace(/\\"/g, '"') - .replace(/\\n/g, "\n") - .replace(/\\r/g, "\r") - .replace(/\\t/g, "\t") - .replace(/\\\\/g, "\\"); - } -} - -function extractStreamingJsonField(raw: string, field: string) { - const keyIndex = raw.indexOf(`"${field}"`); - if (keyIndex < 0) return ""; - const colonIndex = raw.indexOf(":", keyIndex); - if (colonIndex < 0) return ""; - const quoteIndex = raw.indexOf('"', colonIndex + 1); - if (quoteIndex < 0) return ""; - let end = raw.length; - let escaped = false; - for (let i = quoteIndex + 1; i < raw.length; i += 1) { - const char = raw[i]; - if (escaped) { - escaped = false; - continue; - } - if (char === "\\") { - escaped = true; - continue; - } - if (char === '"') { - end = i; - break; - } - } - return decodeJsonStringFragment(raw.slice(quoteIndex + 1, end)).trim(); -} - -export function extractStreamingAirportRead(raw: string, locale: string) { - const primaryField = locale === "en-US" ? "metar_read_en" : "metar_read_zh"; - const fallbackField = locale === "en-US" ? "metar_read_zh" : "metar_read_en"; - return ( - extractStreamingJsonField(raw, primaryField) || - extractStreamingJsonField(raw, fallbackField) - ); -} diff --git a/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts b/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts index fe658ac4..67450caa 100644 --- a/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts +++ b/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts @@ -4,7 +4,6 @@ import { formatTemperatureValue, normalizeTemperatureLabel, } from "@/lib/temperature-utils"; -import type { AiCityForecastPayload } from "@/components/dashboard/scan-terminal/types"; export type WeatherDecisionView = { action: string; @@ -476,7 +475,6 @@ export function buildMarketDecisionView({ } export function buildWeatherDecisionView({ - aiCityForecast, currentTemp, deb, isEn, @@ -489,7 +487,6 @@ export function buildWeatherDecisionView({ peakWindow, tempSymbol, }: { - aiCityForecast: AiCityForecastPayload["city_forecast"] | null; currentTemp: number | null; deb: number | null; isEn: boolean; @@ -503,35 +500,26 @@ export function buildWeatherDecisionView({ tempSymbol: string; }): WeatherDecisionView { const center = resolveExpectedHighCandidate({ - aiPredictedMax: aiCityForecast?.predicted_max, currentTemp, deb, modelMax, modelMin, paceAdjustedHigh: paceView?.paceAdjustedHigh ?? null, }); - const aiLow = toFiniteMarketNumber(aiCityForecast?.range_low); - const aiHigh = toFiniteMarketNumber(aiCityForecast?.range_high); - const low = aiLow != null - ? aiLow - : modelMin != null - ? modelMin - : center != null - ? center - 1 - : null; - const high = aiHigh != null - ? aiHigh - : modelMax != null - ? modelMax - : center != null - ? center + 1 - : null; + const low = modelMin != null + ? modelMin + : center != null + ? center - 1 + : null; + const high = modelMax != null + ? modelMax + : center != null + ? center + 1 + : null; const spread = modelMax != null && modelMin != null ? modelMax - modelMin : null; const modelCount = modelEntries.length; - const aiConfidence = String(aiCityForecast?.confidence || "").trim(); const confidence = - aiConfidence || - (modelCount >= 4 && spread != null && spread <= 2 + modelCount >= 4 && spread != null && spread <= 2 ? isEn ? "High" : "高" @@ -541,7 +529,7 @@ export function buildWeatherDecisionView({ : "中" : isEn ? "Low" - : "低"); + : "低"; const tone = modelCount <= 1 ? "watch" diff --git a/frontend/components/dashboard/scan-terminal/city-decision-state.ts b/frontend/components/dashboard/scan-terminal/city-decision-state.ts index bcf0c5c7..d4edbc68 100644 --- a/frontend/components/dashboard/scan-terminal/city-decision-state.ts +++ b/frontend/components/dashboard/scan-terminal/city-decision-state.ts @@ -1,16 +1,9 @@ -import type { - AiCityForecastPayload, - AiCityForecastState, -} from "@/components/dashboard/scan-terminal/types"; - export type CityDecisionUrgency = "now" | "soon" | "later" | "past"; export type CityDecisionRecommendation = "watch" | "wait" | "avoid" | "background"; export type CityDecisionEvidenceQuality = "fresh" | "mixed" | "stale"; export type CityDecisionAiStatus = | "fast-ready" - | "deepseek-loading" - | "complete" - | "fallback"; + | "ready"; export type StatusBadgeTone = "green" | "blue" | "amber" | "red" | "muted"; export type StatusBadge = { @@ -38,50 +31,7 @@ function uniqueStatusBadges(badges: Array) { }); } -function resolveAiStatus({ - aiCityForecast, - aiForecast, - aiRuleEvidenceMode, -}: { - aiCityForecast: AiCityForecastPayload["city_forecast"] | null; - aiForecast: AiCityForecastState; - aiRuleEvidenceMode: boolean; -}): CityDecisionAiStatus { - if (aiRuleEvidenceMode || aiForecast.status === "failed") return "fallback"; - if (aiForecast.status === "loading") return "deepseek-loading"; - if (aiForecast.status === "ready" && aiCityForecast) return "complete"; - return "fast-ready"; -} - -function resolveAiStatusView(aiStatus: CityDecisionAiStatus, isEn: boolean) { - if (aiStatus === "deepseek-loading") { - return { - label: isEn ? "Fast read ready" : "快速判断已完成", - tone: "blue" as const, - }; - } - if (aiStatus === "complete") { - return { - label: isEn ? "AI read complete" : "AI 解读已完成", - tone: "green" as const, - }; - } - if (aiStatus === "fallback") { - return { - label: isEn ? "Rule evidence" : "规则证据模式", - tone: "amber" as const, - }; - } - return { - label: isEn ? "AI pending" : "AI 待返回", - tone: "muted" as const, - }; -} - export function buildCityDecisionState({ - aiCityForecast, - aiForecast, - aiRuleEvidenceMode, isEn, isHkoObservation, modelHighlyConsistent, @@ -92,9 +42,6 @@ export function buildCityDecisionState({ observedLowLag, peakHasPassed, }: { - aiCityForecast: AiCityForecastPayload["city_forecast"] | null; - aiForecast: AiCityForecastState; - aiRuleEvidenceMode: boolean; isEn: boolean; isHkoObservation: boolean; modelHighlyConsistent: boolean; @@ -105,13 +52,9 @@ export function buildCityDecisionState({ observedLowLag: boolean; peakHasPassed: boolean; }): CityDecisionState { - const aiStatus = resolveAiStatus({ aiCityForecast, aiForecast, aiRuleEvidenceMode }); - const aiStatusView = resolveAiStatusView(aiStatus, isEn); const evidenceQuality: CityDecisionEvidenceQuality = observationStale ? "stale" - : aiStatus === "fallback" - ? "mixed" - : "fresh"; + : "fresh"; const urgency: CityDecisionUrgency = peakHasPassed ? "past" : observedHighBreak @@ -181,12 +124,6 @@ export function buildCityDecisionState({ tone: "blue", } : null, - aiStatus === "deepseek-loading" - ? { - label: isEn ? "Fast read ready" : "快速判断已完成", - tone: aiStatusView.tone, - } - : null, modelHighlyConsistent ? { label: isEn ? "Models agree" : "模型高度一致", @@ -205,9 +142,9 @@ export function buildCityDecisionState({ urgency, recommendation, evidenceQuality, - aiStatus, - aiStatusLabel: aiStatusView.label, - aiStatusTone: aiStatusView.tone, + aiStatus: "ready", + aiStatusLabel: isEn ? "AI ready" : "AI 就绪", + aiStatusTone: "blue", badges, primaryReason, }; diff --git a/frontend/components/dashboard/scan-terminal/scan-terminal-client.ts b/frontend/components/dashboard/scan-terminal/scan-terminal-client.ts index 4a1c5e07..ca95fe6e 100644 --- a/frontend/components/dashboard/scan-terminal/scan-terminal-client.ts +++ b/frontend/components/dashboard/scan-terminal/scan-terminal-client.ts @@ -1,7 +1,5 @@ "use client"; -import { extractStreamingAirportRead } from "@/components/dashboard/scan-terminal/ai-city-stream"; -import type { AiCityForecastPayload } from "@/components/dashboard/scan-terminal/types"; import { buildBrowserBackendHeaders, fetchBackendApi, @@ -18,34 +16,11 @@ export type RemoteData = | { status: "success"; data: T; freshAt: number } | { status: "error"; error: string; previous?: T }; -export type AiCityStreamProgress = { - stage?: string | null; - message_en?: string | null; - message_zh?: string | null; - final_judgment_en?: string | null; - final_judgment_zh?: string | null; - metar_read_en?: string | null; - metar_read_zh?: string | null; - model_cluster_note_en?: string | null; - model_cluster_note_zh?: string | null; - raw_length?: number | null; - predicted_max?: number | null; - range_low?: number | null; - range_high?: number | null; - confidence?: string | null; - unit?: string | null; -}; - export const scanTerminalQueryPolicy = { autoRefreshMs: 10 * 60_000, manualForceRefreshCooldownMs: 2 * 60_000, } as const; -type AiCityStreamEvent = { - data: Record; - event: string; -}; - type TerminalQueryOptions = { forceRefresh?: boolean; signal?: AbortSignal; @@ -61,20 +36,6 @@ type CityDetailQueryOptions = { targetDate?: string | null; }; -type AiCityReadOptions = { - city: string; - forceRefresh?: boolean; - locale: string; - requestKey?: string; - onProgress?: (progress: AiCityStreamProgress) => void; - signal?: AbortSignal; -}; - -const AI_CITY_READ_MAX_CONCURRENT_STREAMS = 4; -const pendingAiCityReadRequests = new Map>(); -const queuedAiCityReadTasks: Array<() => void> = []; -let activeAiCityReadStreams = 0; - function getRemoteError(error: unknown) { return error instanceof Error ? error.message : String(error); } @@ -156,94 +117,6 @@ async function readJsonOrThrow(path: string, init?: RequestInit): Promise throw new Error(message); } -function parseAiCityStreamBlock(block: string): AiCityStreamEvent | null { - const eventLines = block - .split(/\r?\n/) - .map((line) => line.trimEnd()) - .filter(Boolean); - let event = "message"; - const dataLines: string[] = []; - eventLines.forEach((line) => { - if (line.startsWith("event:")) { - event = line.slice("event:".length).trim() || event; - } else if (line.startsWith("data:")) { - dataLines.push(line.slice("data:".length).trimStart()); - } - }); - if (!dataLines.length) return null; - try { - const data = JSON.parse(dataLines.join("\n")) as Record; - return { data, event }; - } catch { - return null; - } -} - -async function readAiCityForecastStream( - response: Response, - locale: string, - onProgress?: (progress: AiCityStreamProgress) => void, -) { - if (!response.body) { - return response.json() as Promise; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - let accumulatedRaw = ""; - let finalPayload: AiCityForecastPayload | null = null; - - const consumeBlock = (block: string) => { - const parsed = parseAiCityStreamBlock(block); - if (!parsed) return; - const { data, event } = parsed; - if (event === "final") { - finalPayload = data as AiCityForecastPayload; - return; - } - if (event === "progress" || event === "preview") { - onProgress?.(data as AiCityStreamProgress); - return; - } - if (event !== "delta") return; - - const content = String(data.content || ""); - const rawLength = Number(data.raw_length); - if (content) { - accumulatedRaw += content; - } - const streamingAirportRead = extractStreamingAirportRead(accumulatedRaw, locale); - const progress: AiCityStreamProgress = { - raw_length: Number.isFinite(rawLength) ? rawLength : null, - }; - if (streamingAirportRead) { - if (locale === "en-US") { - progress.metar_read_en = streamingAirportRead; - } else { - progress.metar_read_zh = streamingAirportRead; - } - } - onProgress?.(progress); - }; - - for (;;) { - const { done, value } = await reader.read(); - buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); - const blocks = buffer.split(/\r?\n\r?\n/); - buffer = blocks.pop() || ""; - blocks.forEach(consumeBlock); - if (done) break; - } - if (buffer.trim()) { - consumeBlock(buffer); - } - if (!finalPayload) { - throw new Error("AI stream ended before final payload"); - } - return finalPayload; -} - async function getTerminal({ forceRefresh = false, signal, @@ -299,106 +172,7 @@ async function getCityDetail(city: string, options: CityDetailQueryOptions = {}) ); } -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.pop(); - if (next) next(); - }); - }; - if (activeAiCityReadStreams < AI_CITY_READ_MAX_CONCURRENT_STREAMS) { - run(); - } else { - onQueued?.(); - queuedAiCityReadTasks.unshift(run); - } - }); -} - -async function streamAiCityReadRequest({ - city, - forceRefresh = false, - locale, - onProgress, - signal, -}: Omit) { - const headers = await buildBrowserBackendHeaders({ - Accept: "text/event-stream", - "Content-Type": "application/json", - }); - const response = await fetchBackendApi("/api/scan/terminal/ai-city/stream", { - method: "POST", - headers, - cache: "no-store", - body: JSON.stringify({ - city, - force_refresh: forceRefresh, - locale, - }), - signal, - }); - if (!response.ok) { - let detailMessage = ""; - try { - const raw = await response.text(); - const errorPayload = JSON.parse(raw); - const message = String(errorPayload?.error || "").trim(); - const rawDetail = String(errorPayload?.detail || "").trim(); - const elapsed = Number(errorPayload?.elapsed_ms); - const timeout = Number(errorPayload?.timeout_ms); - detailMessage = [ - message, - rawDetail, - Number.isFinite(elapsed) && Number.isFinite(timeout) - ? `elapsed ${Math.round(elapsed / 1000)}s / timeout ${Math.round(timeout / 1000)}s` - : "", - ] - .filter(Boolean) - .join(" · "); - } catch { - detailMessage = ""; - } - throw new Error( - detailMessage - ? `HTTP ${response.status} · ${detailMessage}` - : `HTTP ${response.status}`, - ); - } - 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, getTerminal, - streamAiCityRead, }; diff --git a/frontend/components/dashboard/scan-terminal/types.ts b/frontend/components/dashboard/scan-terminal/types.ts index 418d122c..097eff30 100644 --- a/frontend/components/dashboard/scan-terminal/types.ts +++ b/frontend/components/dashboard/scan-terminal/types.ts @@ -3,47 +3,3 @@ export type AiPinnedCity = { displayName?: string | null; addedAt: number; }; - -export type AiCityForecastMeta = { - fallback?: boolean | null; - deterministic_guard_fields?: string[] | null; - deterministic_guard_reason?: Record | null; - [key: string]: unknown; -}; - -export type AiCityForecastPayload = { - status?: string | null; - reason?: string | null; - reason_zh?: string | null; - reason_en?: string | null; - raw_reason?: string | null; - degraded?: boolean | null; - cached?: boolean | null; - model?: string | null; - provider?: string | null; - city_forecast?: { - predicted_max?: number | string | null; - range_low?: number | string | null; - range_high?: number | string | null; - unit?: string | null; - confidence?: string | null; - final_judgment_zh?: string | null; - final_judgment_en?: string | null; - metar_read_zh?: string | null; - metar_read_en?: string | null; - reasoning_zh?: string | null; - reasoning_en?: string | null; - risks_zh?: string[] | null; - risks_en?: string[] | null; - model_cluster_note_zh?: string | null; - model_cluster_note_en?: string | null; - _polyweather_meta?: AiCityForecastMeta | null; - } | null; -}; -export type AiCityForecastState = { - status: "idle" | "loading" | "ready" | "failed"; - payload?: AiCityForecastPayload | null; - error?: string | null; - streamText?: string | null; - streamRaw?: string | null; -}; diff --git a/frontend/components/dashboard/scan-terminal/use-ai-city-forecast.ts b/frontend/components/dashboard/scan-terminal/use-ai-city-forecast.ts deleted file mode 100644 index a9bdc635..00000000 --- a/frontend/components/dashboard/scan-terminal/use-ai-city-forecast.ts +++ /dev/null @@ -1,156 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { scanTerminalClient } from "@/components/dashboard/scan-terminal/scan-terminal-client"; -import type { AiCityForecastState } from "@/components/dashboard/scan-terminal/types"; -import type { CityDetail } from "@/lib/dashboard-types"; -import { - buildAiCityErrorForecastState, - buildAiCityForecastCacheKey, - buildAiCityForecastKey, - buildAiCityForecastRequestKey, - buildAiCityLoadingForecastState, - buildAiCityProgressForecastState, - buildAiCityReadyForecastState, - readReadyCachedAiForecastState, -} from "./ai-city-forecast-stream-state"; - -const AI_CITY_READ_SOFT_TIMEOUT_MS = 4_500; - -export function useAiCityForecast({ - detail, - detailCityName, - isEn, - locale, - report, - enabled = true, -}: { - detail: CityDetail | null; - detailCityName: string; - enabled?: boolean; - isEn: boolean; - locale: string; - report: string; -}) { - const [aiRefreshToken, setAiRefreshToken] = useState(0); - // Keep refs to the latest detail/report so the effect closure always reads - // current values without needing to list unstable object refs as deps. - const detailRef = useRef(detail); - const reportRef = useRef(report); - detailRef.current = detail; - reportRef.current = report; - const aiForecastKey = useMemo( - () => buildAiCityForecastKey({ detail, detailCityName, locale, report }), - [detail, detailCityName, locale, report], - ); - - const [aiForecast, setAiForecast] = useState(() => { - if (!enabled || !aiForecastKey) return { status: "idle" }; - const cacheKey = buildAiCityForecastCacheKey(aiForecastKey); - const cached = readReadyCachedAiForecastState(cacheKey, 0); - if (cached) return cached; - return { status: "idle" }; - }); - - useEffect(() => { - if (!enabled || !aiForecastKey) { - setAiForecast({ status: "idle" }); - return; - } - let cancelled = false; - let resolved = false; - const cacheKey = buildAiCityForecastCacheKey(aiForecastKey); - const requestKey = buildAiCityForecastRequestKey(cacheKey, aiRefreshToken); - - // If cache was loaded into initial state and no force refresh, skip - if (aiRefreshToken === 0) { - const cached = readReadyCachedAiForecastState(cacheKey, 0); - if (cached) { - setAiForecast(cached); - return () => { cancelled = true; }; - } - } - - const loadingState = buildAiCityLoadingForecastState({ - cacheKey, - detail: detailRef.current, - isEn, - report: reportRef.current, - }); - setAiForecast(loadingState); - const softTimeoutId = window.setTimeout(() => { - if (cancelled || resolved) return; - const fallbackState = buildAiCityErrorForecastState({ - cacheKey, - detail: detailRef.current, - error: "ai_soft_timeout_fallback", - isEn, - report: reportRef.current, - }); - setAiForecast(fallbackState); - }, AI_CITY_READ_SOFT_TIMEOUT_MS); - void scanTerminalClient.streamAiCityRead({ - city: detailCityName, - forceRefresh: aiRefreshToken > 0, - locale, - onProgress: (progress) => { - if (cancelled) return; - setAiForecast((current) => - buildAiCityProgressForecastState({ - cacheKey, - current, - isEn, - progress, - }) ?? current, - ); - }, - requestKey, - }) - .then((payload) => { - if (!payload) return; - resolved = true; - window.clearTimeout(softTimeoutId); - const readyState = buildAiCityReadyForecastState({ - cacheKey, - detail: detailRef.current, - isEn, - payload, - report: reportRef.current, - }); - if (!cancelled) { - setAiForecast(readyState); - } - }) - .catch((error) => { - resolved = true; - window.clearTimeout(softTimeoutId); - const errorState = buildAiCityErrorForecastState({ - cacheKey, - detail: detailRef.current, - error, - isEn, - report: reportRef.current, - }); - if (!cancelled) { - setAiForecast(errorState); - } - }); - return () => { - cancelled = true; - window.clearTimeout(softTimeoutId); - }; - }, [ - aiForecastKey, - aiRefreshToken, - detailCityName, - enabled, - isEn, - locale, - ]); - - const refreshAiForecast = useCallback(() => { - setAiRefreshToken((current) => current + 1); - }, []); - - return { aiForecast, refreshAiForecast }; -} diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index 41299a5e..450c131a 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -649,18 +649,6 @@ export interface ScanOpportunityRow { export interface PrimarySignal extends ScanOpportunityRow {} -export interface ScanAiCityThesis { - city: string; - thesis_zh?: string | null; - thesis_en?: string | null; - summary_zh?: string | null; - summary_en?: string | null; - model_cluster_note?: string | null; - confidence?: string | null; - recommended_row_ids?: string[] | null; - vetoed_row_ids?: string[] | null; -} - export interface ScanAiWatchlistItem { row_id: string; reason?: string | null; @@ -695,7 +683,6 @@ export interface ScanAiReview { reason?: string | null; summary_zh?: string | null; summary_en?: string | null; - city_theses?: ScanAiCityThesis[] | null; watchlist?: ScanAiWatchlistItem[] | null; recommended_count?: number | null; vetoed_count?: number | null;