feat: implement AI-driven METAR summary service and dashboard UI components

This commit is contained in:
2569718930@qq.com
2026-04-26 14:03:13 +08:00
parent f9ad34d7c0
commit ef8ef833b9
7 changed files with 614 additions and 36 deletions
@@ -16,6 +16,7 @@ import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSigna
import type { AiPinnedCity } from "@/components/dashboard/scan-terminal/types";
import {
useAiCityForecast,
useAiMetarSummary,
useCityMarketScan,
} from "@/components/dashboard/scan-terminal/use-ai-city-card-data";
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
@@ -152,6 +153,20 @@ function AiPinnedCityCard({
detail?.airport_primary?.station_code ||
"";
const detailCityName = detail?.name || item.cityName;
const debText =
debNumber != null
? formatTemperatureValue(debNumber, tempSymbol, { digits: 1 })
: "";
const { metarSummary } = useAiMetarSummary({
airport: airportStation,
city: displayName,
deb: debText,
enabled: Boolean(detail && report),
isEn,
locale,
metar: report,
modelRange,
});
const { aiForecast, refreshAiForecast } = useAiCityForecast({
detail,
detailCityName,
@@ -249,7 +264,6 @@ function AiPinnedCityCard({
? [String(localizedRisksRaw)]
: [];
const aiBullets = [
localizedMetarRead,
localizedReasoning !== localizedFinalJudgment ? localizedReasoning : "",
localizedModelNote || localModelSupportNote,
...localizedRisks,
@@ -420,21 +434,66 @@ function AiPinnedCityCard({
<div className="scan-ai-city-section-title">
{isEn ? "Evidence · AI airport read" : "证据 · AI 机场报文解读"}
</div>
{metarSummary.status === "loading" ? (
<>
<p className="scan-ai-weather-summary">
{metarSummary.streamText ||
(isEn
? "Streaming the lightweight METAR read..."
: "轻量 METAR 解读正在流式输出…")}
</p>
<p className="scan-ai-city-muted">
{isEn
? "This read is independent from the full city review below."
: "这一步已和下方完整城市分析解耦,不等待完整 JSON。"}
</p>
</>
) : metarSummary.status === "ready" && metarSummary.streamText ? (
<p className="scan-ai-weather-summary">{metarSummary.streamText}</p>
) : metarSummary.status === "failed" ? (
<p>
{localizedMetarRead ||
(isEn
? "Lightweight AI METAR read is unavailable; raw METAR remains below."
: "轻量 AI METAR 解读暂不可用;下方保留原始 METAR。")}
</p>
) : (
<p>
{report
? isEn
? "Waiting for lightweight AI to read the latest METAR."
: "等待轻量 AI 解读最新 METAR。"
: isEn
? "Raw METAR is unavailable."
: "暂无原始 METAR。"}
</p>
)}
<p className="scan-ai-raw-metar">
{report
? `${isEn ? "Raw METAR" : "原始 METAR"}${`${airportStation} ${report}`.trim()}`
: isEn
? "Raw METAR: unavailable."
: "原始 METAR:暂无。"}
</p>
</section>
<section className="scan-ai-city-section">
<div className="scan-ai-city-section-title">
{isEn ? "Complete city AI review" : "完整城市 AI 判断"}
</div>
{aiForecast.status === "loading" ? (
<>
<p className={aiForecast.streamText ? "scan-ai-weather-summary" : undefined}>
{aiForecast.streamText ||
{localizedFinalJudgment ||
aiForecast.streamText ||
(isEn
? "DeepSeek is reading the latest airport bulletin..."
: "DeepSeek 正在解读最新机场报文...")}
? "Generating the full city review in the background..."
: "后台正在生成完整城市分析…")}
</p>
<p className="scan-ai-city-muted">
{isEn
? "This heavier JSON review can finish after the airport read."
: "这部分是较重的 JSON 分析,可以晚于机场报文解读完成。"}
</p>
{!aiForecast.streamText ? (
<p className="scan-ai-city-muted">
{isEn
? "The final airport read will appear here shortly."
: "机场报文解读稍后将在这里显示。"}
</p>
) : null}
</>
) : aiForecast.status === "ready" && aiCityForecast ? (
<>
@@ -447,13 +506,6 @@ function AiPinnedCityCard({
<li key={`${line}-${index}`}>{line}</li>
))}
</ul>
<p className="scan-ai-raw-metar">
{report
? `${isEn ? "Raw METAR" : "原始 METAR"}${`${airportStation} ${report}`.trim()}`
: isEn
? "Raw METAR: unavailable."
: "原始 METAR:暂无。"}
</p>
</>
) : aiForecast.status === "ready" ? (
<>
@@ -469,13 +521,6 @@ function AiPinnedCityCard({
</p>
<ul className="scan-ai-weather-bullets">
<li>{localModelSupportNote}</li>
<li>
{report
? `${isEn ? "Raw METAR" : "原始 METAR"}${`${airportStation} ${report}`.trim()}`
: isEn
? "Raw METAR is unavailable."
: "暂无原始 METAR。"}
</li>
</ul>
</>
) : aiForecast.status === "failed" ? (
@@ -488,20 +533,13 @@ function AiPinnedCityCard({
</p>
<ul className="scan-ai-weather-bullets">
<li>{localModelSupportNote}</li>
<li>
{report
? `${isEn ? "Raw METAR" : "原始 METAR"}${`${airportStation} ${report}`.trim()}`
: isEn
? "Raw METAR is unavailable."
: "暂无原始 METAR。"}
</li>
</ul>
</>
) : (
<p>
{isEn
? "Waiting for AI to read the latest airport bulletin."
: "等待 AI 解读最新机场报文。"}
? "The complete city review is queued in the background."
: "完整城市分析正在后台排队。"}
</p>
)}
</section>
@@ -39,3 +39,20 @@ export type AiCityForecastState = {
streamRaw?: string | null;
};
export type AiMetarSummaryPayload = {
status?: string | null;
summary?: string | null;
reason?: string | null;
degraded?: boolean | null;
duration_ms?: number | null;
model?: string | null;
provider?: string | null;
};
export type AiMetarSummaryState = {
status: "idle" | "loading" | "ready" | "failed";
payload?: AiMetarSummaryPayload | null;
error?: string | null;
streamText?: string | null;
};
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import type {
AiCityForecastPayload,
AiCityForecastState,
AiMetarSummaryPayload,
AiMetarSummaryState,
} from "@/components/dashboard/scan-terminal/types";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import {
@@ -22,6 +24,10 @@ const pendingAiCityForecastRequests = new Map<
string,
Promise<AiCityForecastPayload>
>();
const pendingMetarSummaryRequests = new Map<
string,
Promise<AiMetarSummaryPayload>
>();
type AiCityStreamProgress = {
stage?: string | null;
@@ -39,6 +45,13 @@ type AiCityStreamEvent = {
event: string;
};
type AiMetarSummaryProgress = {
message_en?: string | null;
message_zh?: string | null;
content?: string | null;
raw_length?: number | null;
};
function getStorage() {
if (typeof window === "undefined") return null;
try {
@@ -270,6 +283,118 @@ function getAiCityStreamProgressText(progress: AiCityStreamProgress, isEn: boole
return "";
}
async function readMetarSummaryStream(
response: Response,
onProgress?: (progress: AiMetarSummaryProgress) => void,
) {
if (!response.body) {
return response.json() as Promise<AiMetarSummaryPayload>;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let accumulated = "";
let finalPayload: AiMetarSummaryPayload | null = null;
const consumeBlock = (block: string) => {
const parsed = parseAiCityStreamBlock(block);
if (!parsed) return;
const { data, event } = parsed;
if (event === "final") {
finalPayload = data as AiMetarSummaryPayload;
return;
}
if (event === "progress") {
onProgress?.(data as AiMetarSummaryProgress);
return;
}
if (event === "delta") {
const content = String(data.content || "");
if (content) {
accumulated += content;
}
onProgress?.({
content: accumulated,
raw_length: Number(data.raw_length),
});
}
};
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);
}
return (
finalPayload || {
status: accumulated ? "ready" : "failed",
summary: accumulated,
}
);
}
function requestMetarSummary({
airport,
city,
deb,
locale,
metar,
modelRange,
onProgress,
requestKey,
}: {
airport: string;
city: string;
deb: string;
locale: string;
metar: string;
modelRange: string;
onProgress?: (progress: AiMetarSummaryProgress) => void;
requestKey: string;
}) {
const pending = pendingMetarSummaryRequests.get(requestKey);
if (pending) return pending;
const request = buildBrowserBackendHeaders({
Accept: "text/event-stream",
"Content-Type": "application/json",
})
.then((headers) =>
fetchBackendApi("/api/ai/metar-summary", {
method: "POST",
headers,
cache: "no-store",
body: JSON.stringify({
airport,
city,
deb,
locale,
metar,
model_range: modelRange,
}),
}),
)
.then(async (response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return readMetarSummaryStream(response, onProgress);
})
.finally(() => {
pendingMetarSummaryRequests.delete(requestKey);
});
pendingMetarSummaryRequests.set(requestKey, request);
return request;
}
function buildAiCityFallbackPayload({
detail,
error,
@@ -337,6 +462,113 @@ function buildAiCityFallbackPayload({
};
}
export function useAiMetarSummary({
airport,
city,
deb,
enabled = true,
isEn,
locale,
metar,
modelRange,
}: {
airport: string;
city: string;
deb: string;
enabled?: boolean;
isEn: boolean;
locale: string;
metar: string;
modelRange: string;
}) {
const [metarSummary, setMetarSummary] = useState<AiMetarSummaryState>({
status: "idle",
});
const requestKey = useMemo(
() =>
metar
? [
"metar-summary",
normalizeCityKey(city) || city,
airport,
locale,
deb,
modelRange,
metar,
].join(":")
: "",
[airport, city, deb, locale, metar, modelRange],
);
useEffect(() => {
if (!enabled || !requestKey || !metar) {
setMetarSummary({ status: "idle" });
return;
}
let cancelled = false;
setMetarSummary({
status: "loading",
streamText: isEn
? "Reading current METAR with a lightweight AI pass..."
: "正在用轻量 AI 快速解读当前 METAR…",
});
void requestMetarSummary({
airport,
city,
deb,
locale,
metar,
modelRange,
onProgress: (progress) => {
if (cancelled) return;
const text = String(
progress.content ||
(isEn ? progress.message_en : progress.message_zh) ||
"",
).trim();
if (!text) return;
setMetarSummary((current) => ({
...current,
status: "loading",
streamText: text,
}));
},
requestKey,
})
.then((payload) => {
if (cancelled) return;
const summary = String(payload?.summary || "").trim();
if (summary) {
setMetarSummary({
payload,
status: "ready",
streamText: summary,
});
} else {
setMetarSummary({
payload,
status: "failed",
error: String(payload?.reason || payload?.status || "empty response"),
});
}
})
.catch((error) => {
if (!cancelled) {
setMetarSummary({
status: "failed",
error: String(error),
});
}
});
return () => {
cancelled = true;
};
}, [airport, city, deb, enabled, isEn, locale, metar, modelRange, requestKey]);
return { metarSummary };
}
export function useAiCityForecast({
detail,
detailCityName,