feat: implement AiPinnedForecastView component with associated decision utilities and data hooks
This commit is contained in:
@@ -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]);
|
||||
|
||||
|
||||
Generated
+366
-7224
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user