feat: implement AiPinnedForecastView component with associated decision utilities and data hooks

This commit is contained in:
2569718930@qq.com
2026-04-26 12:39:16 +08:00
parent f39f59f47a
commit 03eca3f93b
4 changed files with 639 additions and 7298 deletions
@@ -5,7 +5,11 @@ import { ChevronDown, RefreshCw, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ModelForecast } from "@/components/dashboard/PanelSections";
import { AiCityTemperatureChart } from "@/components/dashboard/scan-terminal/AiCityTemperatureChart";
import { buildMarketDecisionView, buildWeatherDecisionView } from "@/components/dashboard/scan-terminal/city-card-decision-utils";
import {
buildMarketDecisionView,
buildWeatherDecisionView,
resolveExpectedHighCandidate,
} from "@/components/dashboard/scan-terminal/city-card-decision-utils";
import { findDetailForCity } from "@/components/dashboard/scan-terminal/city-detail-utils";
import { findRowForCity, getPeakWindowLabel, normalizeCityKey } from "@/components/dashboard/scan-terminal/decision-utils";
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
@@ -127,6 +131,8 @@ function AiPinnedCityCard({
detail?.current?.temp ??
row?.current_temp ??
null;
const debNumber = toFiniteDecisionNumber(deb);
const currentTempNumber = toFiniteDecisionNumber(currentTemp);
const modelRange =
modelMin != null && modelMax != null
? `${formatTemperatureValue(modelMin, tempSymbol, { digits: 1 })} ~ ${formatTemperatureValue(modelMax, tempSymbol, { digits: 1 })}`
@@ -206,15 +212,18 @@ function AiPinnedCityCard({
? "Model support is unavailable, so this city must rely on DEB path and METAR observations."
: "暂无可用多模型支撑,需要主要参考 DEB 路径和 METAR 实测。";
const aiPredictedMax = toFiniteDecisionNumber(aiCityForecast?.predicted_max);
const decisionExpectedHighNumber = aiPredictedMax != null
? aiPredictedMax
: paceView?.paceAdjustedHigh != null
? paceView.paceAdjustedHigh
: deb;
const decisionExpectedHighNumber = resolveExpectedHighCandidate({
aiPredictedMax,
currentTemp: currentTempNumber,
deb: debNumber,
modelMax,
modelMin,
paceAdjustedHigh: paceView?.paceAdjustedHigh ?? null,
});
const decisionView = buildWeatherDecisionView({
aiCityForecast,
currentTemp,
deb,
currentTemp: currentTempNumber,
deb: debNumber,
isEn,
localModelSupportNote,
modelEntries,
@@ -264,8 +273,8 @@ function AiPinnedCityCard({
<span>{detail?.local_time || row?.local_time || "--"}</span>
<span>
DEB{" "}
{deb != null
? formatTemperatureValue(deb, tempSymbol, { digits: 1 })
{debNumber != null
? formatTemperatureValue(debNumber, tempSymbol, { digits: 1 })
: "--"}
</span>
<span>{isEn ? "Model" : "模型"} {modelRange}</span>
@@ -275,11 +284,9 @@ function AiPinnedCityCard({
<div className="scan-ai-city-hero-side">
<span>{isEn ? "Expected high" : "预计最高温"}</span>
<strong>
{paceView?.paceAdjustedHigh != null
? formatTemperatureValue(paceView.paceAdjustedHigh, tempSymbol, { digits: 1 })
: deb != null
? formatTemperatureValue(deb, tempSymbol, { digits: 1 })
: "--"}
{decisionExpectedHighNumber != null
? formatTemperatureValue(decisionExpectedHighNumber, tempSymbol, { digits: 1 })
: "--"}
</strong>
<div className="scan-ai-city-actions">
<button
@@ -384,8 +391,8 @@ function AiPinnedCityCard({
<span>
{isEn ? "Observed" : "实测"}
<b>
{currentTemp != null
? formatTemperatureValue(currentTemp, tempSymbol, { digits: 1 })
{currentTempNumber != null
? formatTemperatureValue(currentTempNumber, tempSymbol, { digits: 1 })
: "--"}
</b>
</span>
@@ -47,6 +47,52 @@ function toFiniteMarketNumber(value: unknown) {
return Number.isFinite(numeric) ? numeric : null;
}
function hasReasonableTemperatureValue(value: number | null): value is number {
return value != null && Number.isFinite(value) && value > -130 && value < 150;
}
function isPlausibleExpectedHigh(
value: number | null,
references: Array<number | null>,
) {
if (!hasReasonableTemperatureValue(value)) return false;
const usableReferences = references.filter(hasReasonableTemperatureValue);
if (!usableReferences.length) return true;
const referenceMin = Math.min(...usableReferences);
const referenceMax = Math.max(...usableReferences);
return value >= referenceMin - 6 && value <= referenceMax + 6;
}
export function resolveExpectedHighCandidate({
aiPredictedMax,
currentTemp,
deb,
modelMax,
modelMin,
paceAdjustedHigh,
}: {
aiPredictedMax?: unknown;
currentTemp?: number | null;
deb?: number | null;
modelMax?: number | null;
modelMin?: number | null;
paceAdjustedHigh?: number | null;
}) {
const ai = toFiniteMarketNumber(aiPredictedMax);
const modelCenter =
modelMin != null && modelMax != null
? (modelMin + modelMax) / 2
: null;
const references = [deb ?? null, modelMin ?? null, modelMax ?? null, currentTemp ?? null];
const candidates = [ai, deb ?? null, paceAdjustedHigh ?? null, modelCenter, currentTemp ?? null];
for (const candidate of candidates) {
if (isPlausibleExpectedHigh(candidate, references)) {
return candidate;
}
}
return null;
}
export function formatMarketPercent(value: number | null, digits = 1) {
if (value == null || !Number.isFinite(value)) return "--";
return `${(value * 100).toFixed(digits)}%`;
@@ -388,12 +434,14 @@ export function buildWeatherDecisionView({
peakWindow: string;
tempSymbol: string;
}): WeatherDecisionView {
const aiPredictedMax = toFiniteMarketNumber(aiCityForecast?.predicted_max);
const center = aiPredictedMax != null
? aiPredictedMax
: paceView?.paceAdjustedHigh != null
? paceView.paceAdjustedHigh
: deb;
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
@@ -17,6 +17,25 @@ const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v2";
const AI_CITY_FORECAST_CACHE_TTL_MS = 60 * 60 * 1000;
const CITY_MARKET_SCAN_CACHE_PREFIX = "polyWeather_cityMarketScan_v2";
const CITY_MARKET_SCAN_CACHE_TTL_MS = 10 * 60 * 1000;
const pendingAiCityForecastRequests = new Map<
string,
Promise<AiCityForecastPayload>
>();
type AiCityStreamProgress = {
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;
raw_length?: number | null;
};
type AiCityStreamEvent = {
data: Record<string, unknown>;
event: string;
};
function getStorage() {
if (typeof window === "undefined") return null;
@@ -61,6 +80,166 @@ function writeCachedPayload<T>(key: string, payload: T) {
}
}
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,
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 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") {
const rawLength = Number(data.raw_length);
onProgress?.({
raw_length: Number.isFinite(rawLength) ? rawLength : null,
});
}
};
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;
}
function requestAiCityForecast({
city,
forceRefresh,
locale,
onProgress,
requestKey,
}: {
city: string;
forceRefresh: boolean;
locale: string;
onProgress?: (progress: AiCityStreamProgress) => void;
requestKey: string;
}) {
const pending = pendingAiCityForecastRequests.get(requestKey);
if (pending) return pending;
const request = buildBrowserBackendHeaders({
Accept: "text/event-stream",
"Content-Type": "application/json",
})
.then((headers) =>
fetchBackendApi("/api/scan/terminal/ai-city/stream", {
method: "POST",
headers,
cache: "no-store",
body: JSON.stringify({
city,
force_refresh: forceRefresh,
locale,
}),
}),
)
.then(async (response) => {
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, onProgress);
})
.finally(() => {
pendingAiCityForecastRequests.delete(requestKey);
});
pendingAiCityForecastRequests.set(requestKey, request);
return request;
}
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 V4-Pro is streaming the airport bulletin read... ${Math.round(rawLength)} chars received.`
: `DeepSeek V4-Pro 正在流式解读机场报文... 已收到 ${Math.round(rawLength)} 字符。`;
}
return "";
}
function buildAiCityFallbackPayload({
detail,
error,
@@ -164,8 +343,8 @@ export function useAiCityForecast({
return;
}
let cancelled = false;
const controller = new AbortController();
const cacheKey = buildStorageKey(AI_CITY_FORECAST_CACHE_PREFIX, [aiForecastKey]);
const requestKey = `${cacheKey}:${aiRefreshToken > 0 ? `refresh:${aiRefreshToken}` : "normal"}`;
const cachedPayload =
aiRefreshToken <= 0
? readCachedPayload<AiCityForecastPayload>(
@@ -177,7 +356,6 @@ export function useAiCityForecast({
setAiForecast({ payload: cachedPayload, status: "ready" });
return () => {
cancelled = true;
controller.abort();
};
}
setAiForecast({
@@ -186,53 +364,21 @@ export function useAiCityForecast({
? "DeepSeek V4-Pro is reading the latest airport bulletin..."
: "DeepSeek V4-Pro 正在解读最新机场报文...",
});
void buildBrowserBackendHeaders({
Accept: "application/json",
"Content-Type": "application/json",
})
.then((headers) => {
if (cancelled) return null;
return fetchBackendApi("/api/scan/terminal/ai-city", {
method: "POST",
headers,
cache: "no-store",
signal: controller.signal,
body: JSON.stringify({
city: detailCityName,
force_refresh: aiRefreshToken > 0,
locale,
}),
});
})
.then(async (response) => {
if (!response) return null;
if (!response.ok) {
let detailMessage = "";
try {
const errorPayload = await response.json();
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 response.json() as Promise<AiCityForecastPayload>;
void requestAiCityForecast({
city: detailCityName,
forceRefresh: aiRefreshToken > 0,
locale,
onProgress: (progress) => {
if (cancelled) return;
const progressText = getAiCityStreamProgressText(progress, isEn);
if (!progressText) return;
setAiForecast((current) => ({
...current,
status: "loading",
streamText: progressText,
}));
},
requestKey,
})
.then((payload) => {
if (!payload) return;
@@ -251,7 +397,6 @@ export function useAiCityForecast({
}
})
.catch((error) => {
if (controller.signal.aborted) return;
if (!cancelled) {
const fallbackPayload = buildAiCityFallbackPayload({
detail,
@@ -265,7 +410,6 @@ export function useAiCityForecast({
});
return () => {
cancelled = true;
controller.abort();
};
}, [aiForecastKey, aiRefreshToken, detail, detailCityName, enabled, isEn, locale, report]);
+366 -7224
View File
File diff suppressed because it is too large Load Diff