删除 AI 预报系统全部死代码:11 个文件,扫描客户端 AI 流、状态管理、类型定义
This commit is contained in:
@@ -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<ReturnType<typeof buildBackendRequestHeaders>> | 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<string, unknown> : {};
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<string, unknown>) : {};
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<ReturnType<typeof getTemperatureChartData>>;
|
|
||||||
|
|
||||||
function compactSeries<T extends { time?: string | null; temp?: number | null }>(
|
|
||||||
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<HTMLElement | null>(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<string, unknown>,
|
|
||||||
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 (
|
|
||||||
<section className="scan-ai-city-section chart" ref={sectionRef}>
|
|
||||||
<div className="scan-ai-city-section-title">
|
|
||||||
<BarChart3 size={15} />
|
|
||||||
<span>{locale === "en-US" ? "Evidence · intraday path" : "证据 · 今日日内路径"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="scan-ai-city-chart">
|
|
||||||
<canvas ref={canvasRef} />
|
|
||||||
</div>
|
|
||||||
{chartData ? (
|
|
||||||
<div className="scan-ai-city-chart-legend">
|
|
||||||
<span><i className="forecast" />{forecastLabel}</span>
|
|
||||||
{hasCalibratedPath ? (
|
|
||||||
<span><i className="calibrated" />{calibratedLabel}</span>
|
|
||||||
) : null}
|
|
||||||
<span><i className="observation" />{observationLabel}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -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 (
|
|
||||||
<section className="scan-ai-city-section scan-ai-city-ai-read">
|
|
||||||
<div className="scan-ai-city-section-title">
|
|
||||||
{isHkoObservation
|
|
||||||
? isEn
|
|
||||||
? "Evidence · AI HKO observation read"
|
|
||||||
: "证据 · AI 香港天文台观测解读"
|
|
||||||
: isEn
|
|
||||||
? "Evidence · AI airport read"
|
|
||||||
: "证据 · AI 机场报文解读"}
|
|
||||||
</div>
|
|
||||||
<div className="scan-ai-city-section-body">
|
|
||||||
{hasAiPrediction || hasDebPrediction ? (
|
|
||||||
<div className="scan-ai-prediction-dual">
|
|
||||||
{hasAiPrediction ? (
|
|
||||||
<div className="scan-ai-prediction-card ai">
|
|
||||||
<small>{isEn ? "AI predicted high" : "AI 预测最高温"}</small>
|
|
||||||
<strong>{formatTemperatureValue(aiPredictedMax!, tempSymbol, { digits: 1 })}</strong>
|
|
||||||
<span className={`scan-ai-confidence ${aiConfidenceMeta.tone}`}>
|
|
||||||
{aiConfidenceMeta.label}
|
|
||||||
</span>
|
|
||||||
{aiRangeText ? <em>{aiRangeText}</em> : null}
|
|
||||||
</div>
|
|
||||||
) : aiForecast.status === "loading" && hasDebPrediction ? (
|
|
||||||
<div className="scan-ai-prediction-card ai pending">
|
|
||||||
<small>{isEn ? "AI predicted high" : "AI 预测最高温"}</small>
|
|
||||||
<strong>{isEn ? "..." : "…"}</strong>
|
|
||||||
<span className="scan-ai-confidence low">
|
|
||||||
{isEn ? "Predicting" : "预测中"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{hasDebPrediction ? (
|
|
||||||
<div className="scan-ai-prediction-card deb">
|
|
||||||
<small>{isEn ? "DEB fusion" : "DEB 融合"}</small>
|
|
||||||
<strong>{formatTemperatureValue(debPrediction!, tempSymbol, { digits: 1 })}</strong>
|
|
||||||
<span className="scan-ai-confidence neutral">
|
|
||||||
{isEn ? "Reference" : "参考"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : hasDebPrediction ? (
|
|
||||||
<div className="scan-ai-prediction-dual">
|
|
||||||
<div className="scan-ai-prediction-card deb">
|
|
||||||
<small>{isEn ? "DEB fusion" : "DEB 融合"}</small>
|
|
||||||
<strong>{formatTemperatureValue(debPrediction!, tempSymbol, { digits: 1 })}</strong>
|
|
||||||
<span className="scan-ai-confidence neutral">
|
|
||||||
{isEn ? "Reference" : "参考"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="scan-ai-prediction-card ai pending">
|
|
||||||
<small>{isEn ? "AI predicted high" : "AI 预测最高温"}</small>
|
|
||||||
<strong>{isEn ? "..." : "…"}</strong>
|
|
||||||
<span className="scan-ai-confidence low">
|
|
||||||
{isEn ? "Predicting" : "预测中"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{aiForecast.status === "loading" ? (
|
|
||||||
<>
|
|
||||||
<p className="scan-ai-weather-summary">{aiReadInProgressText}</p>
|
|
||||||
{localizedFinalJudgment || aiForecast.streamText ? (
|
|
||||||
<p className="scan-ai-city-muted">
|
|
||||||
{localizedFinalJudgment || aiForecast.streamText}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<p className="scan-ai-city-muted">
|
|
||||||
{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 解读返回后会自动合并。"}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
) : aiForecast.status === "ready" && aiCityForecast ? (
|
|
||||||
<>
|
|
||||||
{showUpdatedBadge ? (
|
|
||||||
<span className="scan-ai-updated-badge">
|
|
||||||
{isEn ? "✓ Updated" : "✓ 已更新"}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<p className="scan-ai-weather-summary">
|
|
||||||
{aiRuleEvidenceMode ? aiRuleEvidenceText : aiReadCompleteText}
|
|
||||||
</p>
|
|
||||||
<ul className="scan-ai-weather-bullets">
|
|
||||||
{[localizedFinalJudgment, ...aiBullets]
|
|
||||||
.filter((line) => String(line || "").trim())
|
|
||||||
.map((line, index) => (
|
|
||||||
<li key={`${line}-${index}`}>{line}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</>
|
|
||||||
) : aiForecast.status === "ready" ? (
|
|
||||||
<>
|
|
||||||
<p className="scan-ai-weather-summary">{aiRuleEvidenceText}</p>
|
|
||||||
<ul className="scan-ai-weather-bullets">
|
|
||||||
{fallbackAiReason ? <li>{fallbackAiReason}</li> : null}
|
|
||||||
<li>{localModelSupportNote}</li>
|
|
||||||
</ul>
|
|
||||||
</>
|
|
||||||
) : aiForecast.status === "failed" ? (
|
|
||||||
<>
|
|
||||||
<p className="scan-ai-weather-summary">{aiRuleEvidenceText}</p>
|
|
||||||
<ul className="scan-ai-weather-bullets">
|
|
||||||
{aiForecast.error ? <li>{aiForecast.error}</li> : null}
|
|
||||||
<li>{localModelSupportNote}</li>
|
|
||||||
</ul>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p>
|
|
||||||
{isEn
|
|
||||||
? isHkoObservation
|
|
||||||
? "Waiting for AI to read the latest HKO observation."
|
|
||||||
: "Waiting for AI to read the latest airport bulletin."
|
|
||||||
: isHkoObservation
|
|
||||||
? "等待 AI 解读最新香港天文台观测。"
|
|
||||||
: "等待 AI 解读最新机场报文。"}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-169
@@ -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<string, string>();
|
|
||||||
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> = {}): 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> = {}): 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<CityDetail>),
|
|
||||||
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<CityDetail>),
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
-30
@@ -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",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+3
-22
@@ -1,7 +1,6 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { buildCityDecisionState } from "@/components/dashboard/scan-terminal/city-decision-state";
|
import { buildCityDecisionState } from "@/components/dashboard/scan-terminal/city-decision-state";
|
||||||
import type { MarketDecisionView } from "@/components/dashboard/scan-terminal/city-card-decision-utils";
|
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 {
|
function market(status: MarketDecisionView["status"]): MarketDecisionView {
|
||||||
return {
|
return {
|
||||||
@@ -18,15 +17,8 @@ function market(status: MarketDecisionView["status"]): MarketDecisionView {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function ai(status: AiCityForecastState["status"], extra: Partial<AiCityForecastState> = {}): AiCityForecastState {
|
|
||||||
return { status, ...extra };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runTests() {
|
export function runTests() {
|
||||||
const breakout = buildCityDecisionState({
|
const breakout = buildCityDecisionState({
|
||||||
aiCityForecast: null,
|
|
||||||
aiForecast: ai("ready"),
|
|
||||||
aiRuleEvidenceMode: false,
|
|
||||||
isEn: false,
|
isEn: false,
|
||||||
isHkoObservation: false,
|
isHkoObservation: false,
|
||||||
modelHighlyConsistent: true,
|
modelHighlyConsistent: true,
|
||||||
@@ -45,9 +37,6 @@ export function runTests() {
|
|||||||
assert.ok(breakout.badges.some((badge) => badge.label === "实测突破"));
|
assert.ok(breakout.badges.some((badge) => badge.label === "实测突破"));
|
||||||
|
|
||||||
const marketUnavailable = buildCityDecisionState({
|
const marketUnavailable = buildCityDecisionState({
|
||||||
aiCityForecast: null,
|
|
||||||
aiForecast: ai("ready"),
|
|
||||||
aiRuleEvidenceMode: false,
|
|
||||||
isEn: false,
|
isEn: false,
|
||||||
isHkoObservation: false,
|
isHkoObservation: false,
|
||||||
modelHighlyConsistent: false,
|
modelHighlyConsistent: false,
|
||||||
@@ -61,9 +50,6 @@ export function runTests() {
|
|||||||
|
|
||||||
|
|
||||||
const fallback = buildCityDecisionState({
|
const fallback = buildCityDecisionState({
|
||||||
aiCityForecast: null,
|
|
||||||
aiForecast: ai("ready", { payload: { degraded: true } }),
|
|
||||||
aiRuleEvidenceMode: true,
|
|
||||||
isEn: false,
|
isEn: false,
|
||||||
isHkoObservation: false,
|
isHkoObservation: false,
|
||||||
modelHighlyConsistent: false,
|
modelHighlyConsistent: false,
|
||||||
@@ -75,14 +61,9 @@ export function runTests() {
|
|||||||
peakHasPassed: false,
|
peakHasPassed: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(fallback.aiStatus, "fallback");
|
assert.equal(fallback.recommendation, "watch");
|
||||||
assert.equal(fallback.aiStatusLabel, "规则证据模式");
|
|
||||||
assert.notEqual(fallback.aiStatusLabel, "AI 解读已完成");
|
|
||||||
|
|
||||||
const partialStream = buildCityDecisionState({
|
const partialStream = buildCityDecisionState({
|
||||||
aiCityForecast: null,
|
|
||||||
aiForecast: ai("loading", { streamText: "partial" }),
|
|
||||||
aiRuleEvidenceMode: false,
|
|
||||||
isEn: false,
|
isEn: false,
|
||||||
isHkoObservation: false,
|
isHkoObservation: false,
|
||||||
modelHighlyConsistent: false,
|
modelHighlyConsistent: false,
|
||||||
@@ -94,6 +75,6 @@ export function runTests() {
|
|||||||
peakHasPassed: false,
|
peakHasPassed: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(partialStream.aiStatus, "deepseek-loading");
|
assert.equal(partialStream.urgency, "soon");
|
||||||
assert.equal(partialStream.aiStatusLabel, "快速判断已完成");
|
assert.equal(partialStream.recommendation, "wait");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,6 @@ const readyMarket: MarketDecisionView = {
|
|||||||
|
|
||||||
export function runTests() {
|
export function runTests() {
|
||||||
const peakPassed = buildCityDecisionState({
|
const peakPassed = buildCityDecisionState({
|
||||||
aiCityForecast: null,
|
|
||||||
aiForecast: { status: "ready" },
|
|
||||||
aiRuleEvidenceMode: false,
|
|
||||||
isEn: false,
|
isEn: false,
|
||||||
isHkoObservation: false,
|
isHkoObservation: false,
|
||||||
modelHighlyConsistent: true,
|
modelHighlyConsistent: true,
|
||||||
@@ -41,9 +38,6 @@ export function runTests() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const staleMetar = buildCityDecisionState({
|
const staleMetar = buildCityDecisionState({
|
||||||
aiCityForecast: null,
|
|
||||||
aiForecast: { status: "ready" },
|
|
||||||
aiRuleEvidenceMode: false,
|
|
||||||
isEn: false,
|
isEn: false,
|
||||||
isHkoObservation: false,
|
isHkoObservation: false,
|
||||||
modelHighlyConsistent: false,
|
modelHighlyConsistent: false,
|
||||||
|
|||||||
@@ -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<AiCityForecastPayload>(
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -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<T>(
|
|
||||||
task: () => Promise<T>,
|
|
||||||
signal: AbortSignal,
|
|
||||||
callbacks?: {
|
|
||||||
onQueued?: () => void;
|
|
||||||
onStart?: () => void;
|
|
||||||
},
|
|
||||||
): Promise<T> {
|
|
||||||
return new Promise<T>((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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
formatTemperatureValue,
|
formatTemperatureValue,
|
||||||
normalizeTemperatureLabel,
|
normalizeTemperatureLabel,
|
||||||
} from "@/lib/temperature-utils";
|
} from "@/lib/temperature-utils";
|
||||||
import type { AiCityForecastPayload } from "@/components/dashboard/scan-terminal/types";
|
|
||||||
|
|
||||||
export type WeatherDecisionView = {
|
export type WeatherDecisionView = {
|
||||||
action: string;
|
action: string;
|
||||||
@@ -476,7 +475,6 @@ export function buildMarketDecisionView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildWeatherDecisionView({
|
export function buildWeatherDecisionView({
|
||||||
aiCityForecast,
|
|
||||||
currentTemp,
|
currentTemp,
|
||||||
deb,
|
deb,
|
||||||
isEn,
|
isEn,
|
||||||
@@ -489,7 +487,6 @@ export function buildWeatherDecisionView({
|
|||||||
peakWindow,
|
peakWindow,
|
||||||
tempSymbol,
|
tempSymbol,
|
||||||
}: {
|
}: {
|
||||||
aiCityForecast: AiCityForecastPayload["city_forecast"] | null;
|
|
||||||
currentTemp: number | null;
|
currentTemp: number | null;
|
||||||
deb: number | null;
|
deb: number | null;
|
||||||
isEn: boolean;
|
isEn: boolean;
|
||||||
@@ -503,35 +500,26 @@ export function buildWeatherDecisionView({
|
|||||||
tempSymbol: string;
|
tempSymbol: string;
|
||||||
}): WeatherDecisionView {
|
}): WeatherDecisionView {
|
||||||
const center = resolveExpectedHighCandidate({
|
const center = resolveExpectedHighCandidate({
|
||||||
aiPredictedMax: aiCityForecast?.predicted_max,
|
|
||||||
currentTemp,
|
currentTemp,
|
||||||
deb,
|
deb,
|
||||||
modelMax,
|
modelMax,
|
||||||
modelMin,
|
modelMin,
|
||||||
paceAdjustedHigh: paceView?.paceAdjustedHigh ?? null,
|
paceAdjustedHigh: paceView?.paceAdjustedHigh ?? null,
|
||||||
});
|
});
|
||||||
const aiLow = toFiniteMarketNumber(aiCityForecast?.range_low);
|
const low = modelMin != null
|
||||||
const aiHigh = toFiniteMarketNumber(aiCityForecast?.range_high);
|
? modelMin
|
||||||
const low = aiLow != null
|
: center != null
|
||||||
? aiLow
|
? center - 1
|
||||||
: modelMin != null
|
: null;
|
||||||
? modelMin
|
const high = modelMax != null
|
||||||
: center != null
|
? modelMax
|
||||||
? center - 1
|
: center != null
|
||||||
: null;
|
? center + 1
|
||||||
const high = aiHigh != null
|
: null;
|
||||||
? aiHigh
|
|
||||||
: modelMax != null
|
|
||||||
? modelMax
|
|
||||||
: center != null
|
|
||||||
? center + 1
|
|
||||||
: null;
|
|
||||||
const spread = modelMax != null && modelMin != null ? modelMax - modelMin : null;
|
const spread = modelMax != null && modelMin != null ? modelMax - modelMin : null;
|
||||||
const modelCount = modelEntries.length;
|
const modelCount = modelEntries.length;
|
||||||
const aiConfidence = String(aiCityForecast?.confidence || "").trim();
|
|
||||||
const confidence =
|
const confidence =
|
||||||
aiConfidence ||
|
modelCount >= 4 && spread != null && spread <= 2
|
||||||
(modelCount >= 4 && spread != null && spread <= 2
|
|
||||||
? isEn
|
? isEn
|
||||||
? "High"
|
? "High"
|
||||||
: "高"
|
: "高"
|
||||||
@@ -541,7 +529,7 @@ export function buildWeatherDecisionView({
|
|||||||
: "中"
|
: "中"
|
||||||
: isEn
|
: isEn
|
||||||
? "Low"
|
? "Low"
|
||||||
: "低");
|
: "低";
|
||||||
const tone =
|
const tone =
|
||||||
modelCount <= 1
|
modelCount <= 1
|
||||||
? "watch"
|
? "watch"
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
import type {
|
|
||||||
AiCityForecastPayload,
|
|
||||||
AiCityForecastState,
|
|
||||||
} from "@/components/dashboard/scan-terminal/types";
|
|
||||||
|
|
||||||
export type CityDecisionUrgency = "now" | "soon" | "later" | "past";
|
export type CityDecisionUrgency = "now" | "soon" | "later" | "past";
|
||||||
export type CityDecisionRecommendation = "watch" | "wait" | "avoid" | "background";
|
export type CityDecisionRecommendation = "watch" | "wait" | "avoid" | "background";
|
||||||
export type CityDecisionEvidenceQuality = "fresh" | "mixed" | "stale";
|
export type CityDecisionEvidenceQuality = "fresh" | "mixed" | "stale";
|
||||||
export type CityDecisionAiStatus =
|
export type CityDecisionAiStatus =
|
||||||
| "fast-ready"
|
| "fast-ready"
|
||||||
| "deepseek-loading"
|
| "ready";
|
||||||
| "complete"
|
|
||||||
| "fallback";
|
|
||||||
export type StatusBadgeTone = "green" | "blue" | "amber" | "red" | "muted";
|
export type StatusBadgeTone = "green" | "blue" | "amber" | "red" | "muted";
|
||||||
|
|
||||||
export type StatusBadge = {
|
export type StatusBadge = {
|
||||||
@@ -38,50 +31,7 @@ function uniqueStatusBadges(badges: Array<StatusBadge | null | undefined>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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({
|
export function buildCityDecisionState({
|
||||||
aiCityForecast,
|
|
||||||
aiForecast,
|
|
||||||
aiRuleEvidenceMode,
|
|
||||||
isEn,
|
isEn,
|
||||||
isHkoObservation,
|
isHkoObservation,
|
||||||
modelHighlyConsistent,
|
modelHighlyConsistent,
|
||||||
@@ -92,9 +42,6 @@ export function buildCityDecisionState({
|
|||||||
observedLowLag,
|
observedLowLag,
|
||||||
peakHasPassed,
|
peakHasPassed,
|
||||||
}: {
|
}: {
|
||||||
aiCityForecast: AiCityForecastPayload["city_forecast"] | null;
|
|
||||||
aiForecast: AiCityForecastState;
|
|
||||||
aiRuleEvidenceMode: boolean;
|
|
||||||
isEn: boolean;
|
isEn: boolean;
|
||||||
isHkoObservation: boolean;
|
isHkoObservation: boolean;
|
||||||
modelHighlyConsistent: boolean;
|
modelHighlyConsistent: boolean;
|
||||||
@@ -105,13 +52,9 @@ export function buildCityDecisionState({
|
|||||||
observedLowLag: boolean;
|
observedLowLag: boolean;
|
||||||
peakHasPassed: boolean;
|
peakHasPassed: boolean;
|
||||||
}): CityDecisionState {
|
}): CityDecisionState {
|
||||||
const aiStatus = resolveAiStatus({ aiCityForecast, aiForecast, aiRuleEvidenceMode });
|
|
||||||
const aiStatusView = resolveAiStatusView(aiStatus, isEn);
|
|
||||||
const evidenceQuality: CityDecisionEvidenceQuality = observationStale
|
const evidenceQuality: CityDecisionEvidenceQuality = observationStale
|
||||||
? "stale"
|
? "stale"
|
||||||
: aiStatus === "fallback"
|
: "fresh";
|
||||||
? "mixed"
|
|
||||||
: "fresh";
|
|
||||||
const urgency: CityDecisionUrgency = peakHasPassed
|
const urgency: CityDecisionUrgency = peakHasPassed
|
||||||
? "past"
|
? "past"
|
||||||
: observedHighBreak
|
: observedHighBreak
|
||||||
@@ -181,12 +124,6 @@ export function buildCityDecisionState({
|
|||||||
tone: "blue",
|
tone: "blue",
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
aiStatus === "deepseek-loading"
|
|
||||||
? {
|
|
||||||
label: isEn ? "Fast read ready" : "快速判断已完成",
|
|
||||||
tone: aiStatusView.tone,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
modelHighlyConsistent
|
modelHighlyConsistent
|
||||||
? {
|
? {
|
||||||
label: isEn ? "Models agree" : "模型高度一致",
|
label: isEn ? "Models agree" : "模型高度一致",
|
||||||
@@ -205,9 +142,9 @@ export function buildCityDecisionState({
|
|||||||
urgency,
|
urgency,
|
||||||
recommendation,
|
recommendation,
|
||||||
evidenceQuality,
|
evidenceQuality,
|
||||||
aiStatus,
|
aiStatus: "ready",
|
||||||
aiStatusLabel: aiStatusView.label,
|
aiStatusLabel: isEn ? "AI ready" : "AI 就绪",
|
||||||
aiStatusTone: aiStatusView.tone,
|
aiStatusTone: "blue",
|
||||||
badges,
|
badges,
|
||||||
primaryReason,
|
primaryReason,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { extractStreamingAirportRead } from "@/components/dashboard/scan-terminal/ai-city-stream";
|
|
||||||
import type { AiCityForecastPayload } from "@/components/dashboard/scan-terminal/types";
|
|
||||||
import {
|
import {
|
||||||
buildBrowserBackendHeaders,
|
buildBrowserBackendHeaders,
|
||||||
fetchBackendApi,
|
fetchBackendApi,
|
||||||
@@ -18,34 +16,11 @@ export type RemoteData<T> =
|
|||||||
| { status: "success"; data: T; freshAt: number }
|
| { status: "success"; data: T; freshAt: number }
|
||||||
| { status: "error"; error: string; previous?: T };
|
| { 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 = {
|
export const scanTerminalQueryPolicy = {
|
||||||
autoRefreshMs: 10 * 60_000,
|
autoRefreshMs: 10 * 60_000,
|
||||||
manualForceRefreshCooldownMs: 2 * 60_000,
|
manualForceRefreshCooldownMs: 2 * 60_000,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type AiCityStreamEvent = {
|
|
||||||
data: Record<string, unknown>;
|
|
||||||
event: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TerminalQueryOptions = {
|
type TerminalQueryOptions = {
|
||||||
forceRefresh?: boolean;
|
forceRefresh?: boolean;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
@@ -61,20 +36,6 @@ type CityDetailQueryOptions = {
|
|||||||
targetDate?: string | null;
|
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<string, Promise<AiCityForecastPayload>>();
|
|
||||||
const queuedAiCityReadTasks: Array<() => void> = [];
|
|
||||||
let activeAiCityReadStreams = 0;
|
|
||||||
|
|
||||||
function getRemoteError(error: unknown) {
|
function getRemoteError(error: unknown) {
|
||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
@@ -156,94 +117,6 @@ async function readJsonOrThrow<T>(path: string, init?: RequestInit): Promise<T>
|
|||||||
throw new Error(message);
|
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<string, unknown>;
|
|
||||||
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<AiCityForecastPayload>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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({
|
async function getTerminal({
|
||||||
forceRefresh = false,
|
forceRefresh = false,
|
||||||
signal,
|
signal,
|
||||||
@@ -299,106 +172,7 @@ async function getCityDetail(city: string, options: CityDetailQueryOptions = {})
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function runQueuedAiCityReadTask<T>(task: () => Promise<T>, onQueued?: () => void) {
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
|
||||||
const run = () => {
|
|
||||||
activeAiCityReadStreams += 1;
|
|
||||||
task()
|
|
||||||
.then(resolve, reject)
|
|
||||||
.finally(() => {
|
|
||||||
activeAiCityReadStreams = Math.max(0, activeAiCityReadStreams - 1);
|
|
||||||
const next = queuedAiCityReadTasks.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<AiCityReadOptions, "requestKey">) {
|
|
||||||
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 = {
|
export const scanTerminalClient = {
|
||||||
getCityDetail,
|
getCityDetail,
|
||||||
getTerminal,
|
getTerminal,
|
||||||
streamAiCityRead,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,47 +3,3 @@ export type AiPinnedCity = {
|
|||||||
displayName?: string | null;
|
displayName?: string | null;
|
||||||
addedAt: number;
|
addedAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiCityForecastMeta = {
|
|
||||||
fallback?: boolean | null;
|
|
||||||
deterministic_guard_fields?: string[] | null;
|
|
||||||
deterministic_guard_reason?: Record<string, unknown> | 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;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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<CityDetail | null>(detail);
|
|
||||||
const reportRef = useRef<string>(report);
|
|
||||||
detailRef.current = detail;
|
|
||||||
reportRef.current = report;
|
|
||||||
const aiForecastKey = useMemo(
|
|
||||||
() => buildAiCityForecastKey({ detail, detailCityName, locale, report }),
|
|
||||||
[detail, detailCityName, locale, report],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [aiForecast, setAiForecast] = useState<AiCityForecastState>(() => {
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -649,18 +649,6 @@ export interface ScanOpportunityRow {
|
|||||||
|
|
||||||
export interface PrimarySignal extends 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 {
|
export interface ScanAiWatchlistItem {
|
||||||
row_id: string;
|
row_id: string;
|
||||||
reason?: string | null;
|
reason?: string | null;
|
||||||
@@ -695,7 +683,6 @@ export interface ScanAiReview {
|
|||||||
reason?: string | null;
|
reason?: string | null;
|
||||||
summary_zh?: string | null;
|
summary_zh?: string | null;
|
||||||
summary_en?: string | null;
|
summary_en?: string | null;
|
||||||
city_theses?: ScanAiCityThesis[] | null;
|
|
||||||
watchlist?: ScanAiWatchlistItem[] | null;
|
watchlist?: ScanAiWatchlistItem[] | null;
|
||||||
recommended_count?: number | null;
|
recommended_count?: number | null;
|
||||||
vetoed_count?: number | null;
|
vetoed_count?: number | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user