feat: implement AI-powered pinned city forecast dashboard with real-time METAR and market analysis integration
This commit is contained in:
+3
-6
@@ -140,18 +140,15 @@ POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800
|
|||||||
POLYWEATHER_SCAN_AI_ENABLED=false
|
POLYWEATHER_SCAN_AI_ENABLED=false
|
||||||
POLYWEATHER_DEEPSEEK_API_KEY=
|
POLYWEATHER_DEEPSEEK_API_KEY=
|
||||||
POLYWEATHER_DEEPSEEK_BASE_URL=https://api.deepseek.com
|
POLYWEATHER_DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||||
POLYWEATHER_SCAN_AI_MODEL=deepseek-v4-pro
|
POLYWEATHER_SCAN_AI_MODEL=deepseek-v4-flash
|
||||||
POLYWEATHER_SCAN_CITY_AI_MODEL=deepseek-v4-flash
|
POLYWEATHER_SCAN_CITY_AI_MODEL=deepseek-v4-flash
|
||||||
POLYWEATHER_METAR_SUMMARY_AI_MODEL=deepseek-v4-flash
|
POLYWEATHER_SCAN_AI_TIMEOUT_SEC=18
|
||||||
POLYWEATHER_SCAN_AI_TIMEOUT_SEC=40
|
POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=30
|
||||||
POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=18
|
|
||||||
POLYWEATHER_METAR_SUMMARY_AI_TIMEOUT_SEC=8
|
|
||||||
POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=false
|
POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=false
|
||||||
POLYWEATHER_SCAN_AI_CACHE_TTL_SEC=1800
|
POLYWEATHER_SCAN_AI_CACHE_TTL_SEC=1800
|
||||||
POLYWEATHER_SCAN_AI_MAX_ROWS=40
|
POLYWEATHER_SCAN_AI_MAX_ROWS=40
|
||||||
POLYWEATHER_SCAN_AI_MAX_TOKENS=3200
|
POLYWEATHER_SCAN_AI_MAX_TOKENS=3200
|
||||||
POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=900
|
POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=900
|
||||||
POLYWEATHER_METAR_SUMMARY_AI_MAX_TOKENS=160
|
|
||||||
POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS=55000
|
POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS=55000
|
||||||
POLYWEATHER_PREWARM_CITIES=ankara,istanbul,shanghai,beijing,shenzhen,guangzhou,wuhan,chengdu,chongqing,hong kong,taipei,singapore,tokyo,seoul,busan,london,paris,madrid
|
POLYWEATHER_PREWARM_CITIES=ankara,istanbul,shanghai,beijing,shenzhen,guangzhou,wuhan,chengdu,chongqing,hong kong,taipei,singapore,tokyo,seoul,busan,london,paris,madrid
|
||||||
POLYWEATHER_CITY_SUMMARY_CACHE_TTL_SEC=1800
|
POLYWEATHER_CITY_SUMMARY_CACHE_TTL_SEC=1800
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import {
|
|
||||||
applyAuthResponseCookies,
|
|
||||||
buildBackendRequestHeaders,
|
|
||||||
} from "@/lib/backend-auth";
|
|
||||||
|
|
||||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
export const maxDuration = 30;
|
|
||||||
|
|
||||||
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/ai/metar-summary`, {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
cache: "no-store",
|
|
||||||
body: JSON.stringify(requestBody),
|
|
||||||
});
|
|
||||||
if (!res.ok || !res.body) {
|
|
||||||
const raw = await res.text();
|
|
||||||
const response = NextResponse.json(
|
|
||||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
|
||||||
{ status: res.status === 402 || res.status === 403 ? res.status : 502 },
|
|
||||||
);
|
|
||||||
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 = NextResponse.json(
|
|
||||||
{
|
|
||||||
error: "Failed to stream METAR summary",
|
|
||||||
detail: String(error),
|
|
||||||
},
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
return applyAuthResponseCookies(response, auth.response);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -412,6 +412,17 @@ function ScanTerminalScreen() {
|
|||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const refreshAiPinnedCityDetail = useCallback(
|
||||||
|
async (cityName: string) => {
|
||||||
|
const key = normalizeCityKey(cityName);
|
||||||
|
if (key) {
|
||||||
|
aiFullHydrationRef.current.delete(key);
|
||||||
|
}
|
||||||
|
await store.ensureCityDetail(cityName, true, "full");
|
||||||
|
},
|
||||||
|
[store.ensureCityDetail],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
aiPinnedCities.forEach((item) => {
|
aiPinnedCities.forEach((item) => {
|
||||||
const key = normalizeCityKey(item.cityName);
|
const key = normalizeCityKey(item.cityName);
|
||||||
@@ -527,6 +538,7 @@ function ScanTerminalScreen() {
|
|||||||
rows={timeSortedRows}
|
rows={timeSortedRows}
|
||||||
detailsByName={store.cityDetailsByName}
|
detailsByName={store.cityDetailsByName}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
|
onRefreshCityDetail={refreshAiPinnedCityDetail}
|
||||||
onRemoveCity={removeAiPinnedCity}
|
onRemoveCity={removeAiPinnedCity}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSigna
|
|||||||
import type { AiPinnedCity } from "@/components/dashboard/scan-terminal/types";
|
import type { AiPinnedCity } from "@/components/dashboard/scan-terminal/types";
|
||||||
import {
|
import {
|
||||||
useAiCityForecast,
|
useAiCityForecast,
|
||||||
useAiMetarSummary,
|
|
||||||
useCityMarketScan,
|
useCityMarketScan,
|
||||||
} from "@/components/dashboard/scan-terminal/use-ai-city-card-data";
|
} from "@/components/dashboard/scan-terminal/use-ai-city-card-data";
|
||||||
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
|
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||||
@@ -91,6 +90,7 @@ function AiPinnedCityCard({
|
|||||||
locale,
|
locale,
|
||||||
collapsed,
|
collapsed,
|
||||||
removing,
|
removing,
|
||||||
|
onRefreshCityDetail,
|
||||||
onRemove,
|
onRemove,
|
||||||
onToggleCollapsed,
|
onToggleCollapsed,
|
||||||
}: {
|
}: {
|
||||||
@@ -100,6 +100,7 @@ function AiPinnedCityCard({
|
|||||||
locale: string;
|
locale: string;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
removing?: boolean;
|
removing?: boolean;
|
||||||
|
onRefreshCityDetail: (cityName: string) => Promise<void>;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onToggleCollapsed: () => void;
|
onToggleCollapsed: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -153,20 +154,7 @@ function AiPinnedCityCard({
|
|||||||
detail?.airport_primary?.station_code ||
|
detail?.airport_primary?.station_code ||
|
||||||
"";
|
"";
|
||||||
const detailCityName = detail?.name || item.cityName;
|
const detailCityName = detail?.name || item.cityName;
|
||||||
const debText =
|
const [refreshingDetail, setRefreshingDetail] = useState(false);
|
||||||
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({
|
const { aiForecast, refreshAiForecast } = useAiCityForecast({
|
||||||
detail,
|
detail,
|
||||||
detailCityName,
|
detailCityName,
|
||||||
@@ -180,6 +168,7 @@ function AiPinnedCityCard({
|
|||||||
detailCityName,
|
detailCityName,
|
||||||
enabled: Boolean(detail),
|
enabled: Boolean(detail),
|
||||||
});
|
});
|
||||||
|
const isRefreshing = refreshingDetail || aiForecast.status === "loading";
|
||||||
|
|
||||||
const aiCityForecast = aiForecast.payload?.city_forecast || null;
|
const aiCityForecast = aiForecast.payload?.city_forecast || null;
|
||||||
const localizedFinalJudgmentRaw =
|
const localizedFinalJudgmentRaw =
|
||||||
@@ -264,6 +253,7 @@ function AiPinnedCityCard({
|
|||||||
? [String(localizedRisksRaw)]
|
? [String(localizedRisksRaw)]
|
||||||
: [];
|
: [];
|
||||||
const aiBullets = [
|
const aiBullets = [
|
||||||
|
localizedMetarRead,
|
||||||
localizedReasoning !== localizedFinalJudgment ? localizedReasoning : "",
|
localizedReasoning !== localizedFinalJudgment ? localizedReasoning : "",
|
||||||
localizedModelNote || localModelSupportNote,
|
localizedModelNote || localModelSupportNote,
|
||||||
...localizedRisks,
|
...localizedRisks,
|
||||||
@@ -309,13 +299,24 @@ function AiPinnedCityCard({
|
|||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
refreshAiForecast();
|
if (refreshingDetail) return;
|
||||||
|
setRefreshingDetail(true);
|
||||||
|
void onRefreshCityDetail(detailCityName)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
refreshAiForecast();
|
||||||
|
setRefreshingDetail(false);
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
aria-label={isEn ? `Refresh ${displayName} analysis` : `刷新 ${displayName} 深度分析`}
|
aria-label={isEn ? `Refresh ${displayName} analysis` : `刷新 ${displayName} 深度分析`}
|
||||||
title={isEn ? "Refresh analysis" : "刷新深度分析"}
|
title={
|
||||||
disabled={aiForecast.status === "loading"}
|
isEn
|
||||||
|
? "Refresh city data, chart and AI analysis"
|
||||||
|
: "刷新城市数据、温度走势图和 AI 分析"
|
||||||
|
}
|
||||||
|
disabled={isRefreshing}
|
||||||
>
|
>
|
||||||
<RefreshCw size={15} className={aiForecast.status === "loading" ? "spin" : undefined} />
|
<RefreshCw size={15} className={isRefreshing ? "spin" : undefined} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -434,65 +435,19 @@ function AiPinnedCityCard({
|
|||||||
<div className="scan-ai-city-section-title">
|
<div className="scan-ai-city-section-title">
|
||||||
{isEn ? "Evidence · AI airport read" : "证据 · AI 机场报文解读"}
|
{isEn ? "Evidence · AI airport read" : "证据 · AI 机场报文解读"}
|
||||||
</div>
|
</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" ? (
|
{aiForecast.status === "loading" ? (
|
||||||
<>
|
<>
|
||||||
<p className={aiForecast.streamText ? "scan-ai-weather-summary" : undefined}>
|
<p className={aiForecast.streamText ? "scan-ai-weather-summary" : undefined}>
|
||||||
{localizedFinalJudgment ||
|
{localizedFinalJudgment ||
|
||||||
aiForecast.streamText ||
|
aiForecast.streamText ||
|
||||||
(isEn
|
(isEn
|
||||||
? "Generating the full city review in the background..."
|
? "DeepSeek is reading the airport bulletin and city context..."
|
||||||
: "后台正在生成完整城市分析…")}
|
: "DeepSeek 正在统一解读机场报文和城市上下文…")}
|
||||||
</p>
|
</p>
|
||||||
<p className="scan-ai-city-muted">
|
<p className="scan-ai-city-muted">
|
||||||
{isEn
|
{isEn
|
||||||
? "This heavier JSON review can finish after the airport read."
|
? "One v4-flash stream now drives both the airport read and city judgment."
|
||||||
: "这部分是较重的 JSON 分析,可以晚于机场报文解读完成。"}
|
: "现在由 v4-flash 一条流同时生成机场报文解读和城市判断。"}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : aiForecast.status === "ready" && aiCityForecast ? (
|
) : aiForecast.status === "ready" && aiCityForecast ? (
|
||||||
@@ -506,6 +461,13 @@ function AiPinnedCityCard({
|
|||||||
<li key={`${line}-${index}`}>{line}</li>
|
<li key={`${line}-${index}`}>{line}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
<p className="scan-ai-raw-metar">
|
||||||
|
{report
|
||||||
|
? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${airportStation} ${report}`.trim()}`
|
||||||
|
: isEn
|
||||||
|
? "Raw METAR: unavailable."
|
||||||
|
: "原始 METAR:暂无。"}
|
||||||
|
</p>
|
||||||
</>
|
</>
|
||||||
) : aiForecast.status === "ready" ? (
|
) : aiForecast.status === "ready" ? (
|
||||||
<>
|
<>
|
||||||
@@ -521,6 +483,13 @@ function AiPinnedCityCard({
|
|||||||
</p>
|
</p>
|
||||||
<ul className="scan-ai-weather-bullets">
|
<ul className="scan-ai-weather-bullets">
|
||||||
<li>{localModelSupportNote}</li>
|
<li>{localModelSupportNote}</li>
|
||||||
|
<li>
|
||||||
|
{report
|
||||||
|
? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${airportStation} ${report}`.trim()}`
|
||||||
|
: isEn
|
||||||
|
? "Raw METAR is unavailable."
|
||||||
|
: "暂无原始 METAR。"}
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</>
|
</>
|
||||||
) : aiForecast.status === "failed" ? (
|
) : aiForecast.status === "failed" ? (
|
||||||
@@ -533,13 +502,20 @@ function AiPinnedCityCard({
|
|||||||
</p>
|
</p>
|
||||||
<ul className="scan-ai-weather-bullets">
|
<ul className="scan-ai-weather-bullets">
|
||||||
<li>{localModelSupportNote}</li>
|
<li>{localModelSupportNote}</li>
|
||||||
|
<li>
|
||||||
|
{report
|
||||||
|
? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${airportStation} ${report}`.trim()}`
|
||||||
|
: isEn
|
||||||
|
? "Raw METAR is unavailable."
|
||||||
|
: "暂无原始 METAR。"}
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p>
|
<p>
|
||||||
{isEn
|
{isEn
|
||||||
? "The complete city review is queued in the background."
|
? "Waiting for AI to read the latest airport bulletin."
|
||||||
: "完整城市分析正在后台排队。"}
|
: "等待 AI 解读最新机场报文。"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -575,12 +551,14 @@ export function AiPinnedForecastView({
|
|||||||
rows,
|
rows,
|
||||||
detailsByName,
|
detailsByName,
|
||||||
locale,
|
locale,
|
||||||
|
onRefreshCityDetail,
|
||||||
onRemoveCity,
|
onRemoveCity,
|
||||||
}: {
|
}: {
|
||||||
items: AiPinnedCity[];
|
items: AiPinnedCity[];
|
||||||
rows: ScanOpportunityRow[];
|
rows: ScanOpportunityRow[];
|
||||||
detailsByName: Record<string, CityDetail>;
|
detailsByName: Record<string, CityDetail>;
|
||||||
locale: string;
|
locale: string;
|
||||||
|
onRefreshCityDetail: (cityName: string) => Promise<void>;
|
||||||
onRemoveCity: (cityName: string) => void;
|
onRemoveCity: (cityName: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const isEn = locale === "en-US";
|
const isEn = locale === "en-US";
|
||||||
@@ -697,6 +675,7 @@ export function AiPinnedForecastView({
|
|||||||
locale={locale}
|
locale={locale}
|
||||||
collapsed={!isKnownCity || collapsedCities.has(stableKey)}
|
collapsed={!isKnownCity || collapsedCities.has(stableKey)}
|
||||||
removing={removingCities.has(stableKey)}
|
removing={removingCities.has(stableKey)}
|
||||||
|
onRefreshCityDetail={onRefreshCityDetail}
|
||||||
onRemove={() => removeCityWithMotion(item, stableKey)}
|
onRemove={() => removeCityWithMotion(item, stableKey)}
|
||||||
onToggleCollapsed={() => {
|
onToggleCollapsed={() => {
|
||||||
setCollapsedCities((current) => {
|
setCollapsedCities((current) => {
|
||||||
|
|||||||
@@ -38,21 +38,3 @@ export type AiCityForecastState = {
|
|||||||
streamText?: string | null;
|
streamText?: string | null;
|
||||||
streamRaw?: string | null;
|
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,8 +4,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import type {
|
import type {
|
||||||
AiCityForecastPayload,
|
AiCityForecastPayload,
|
||||||
AiCityForecastState,
|
AiCityForecastState,
|
||||||
AiMetarSummaryPayload,
|
|
||||||
AiMetarSummaryState,
|
|
||||||
} from "@/components/dashboard/scan-terminal/types";
|
} from "@/components/dashboard/scan-terminal/types";
|
||||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||||
import {
|
import {
|
||||||
@@ -24,10 +22,6 @@ const pendingAiCityForecastRequests = new Map<
|
|||||||
string,
|
string,
|
||||||
Promise<AiCityForecastPayload>
|
Promise<AiCityForecastPayload>
|
||||||
>();
|
>();
|
||||||
const pendingMetarSummaryRequests = new Map<
|
|
||||||
string,
|
|
||||||
Promise<AiMetarSummaryPayload>
|
|
||||||
>();
|
|
||||||
|
|
||||||
type AiCityStreamProgress = {
|
type AiCityStreamProgress = {
|
||||||
stage?: string | null;
|
stage?: string | null;
|
||||||
@@ -45,13 +39,6 @@ type AiCityStreamEvent = {
|
|||||||
event: string;
|
event: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AiMetarSummaryProgress = {
|
|
||||||
message_en?: string | null;
|
|
||||||
message_zh?: string | null;
|
|
||||||
content?: string | null;
|
|
||||||
raw_length?: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getStorage() {
|
function getStorage() {
|
||||||
if (typeof window === "undefined") return null;
|
if (typeof window === "undefined") return null;
|
||||||
try {
|
try {
|
||||||
@@ -283,118 +270,6 @@ function getAiCityStreamProgressText(progress: AiCityStreamProgress, isEn: boole
|
|||||||
return "";
|
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({
|
function buildAiCityFallbackPayload({
|
||||||
detail,
|
detail,
|
||||||
error,
|
error,
|
||||||
@@ -462,113 +337,6 @@ 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({
|
export function useAiCityForecast({
|
||||||
detail,
|
detail,
|
||||||
detailCityName,
|
detailCityName,
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ from web.scan_terminal_service import (
|
|||||||
build_scan_city_ai_forecast_payload,
|
build_scan_city_ai_forecast_payload,
|
||||||
build_scan_terminal_ai_payload,
|
build_scan_terminal_ai_payload,
|
||||||
build_scan_terminal_payload,
|
build_scan_terminal_payload,
|
||||||
stream_metar_summary_payload,
|
|
||||||
stream_scan_city_ai_forecast_payload,
|
stream_scan_city_ai_forecast_payload,
|
||||||
)
|
)
|
||||||
from web.core import (
|
from web.core import (
|
||||||
@@ -1811,22 +1810,3 @@ async def scan_terminal_ai_city_stream(request: Request):
|
|||||||
"X-Accel-Buffering": "no",
|
"X-Accel-Buffering": "no",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/ai/metar-summary")
|
|
||||||
async def ai_metar_summary_stream(request: Request):
|
|
||||||
_assert_entitlement(request)
|
|
||||||
try:
|
|
||||||
body = await request.json()
|
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
if not isinstance(body, dict):
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
|
||||||
return StreamingResponse(
|
|
||||||
stream_metar_summary_payload(body),
|
|
||||||
media_type="text/event-stream",
|
|
||||||
headers={
|
|
||||||
"Cache-Control": "no-store",
|
|
||||||
"X-Accel-Buffering": "no",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ SCAN_TERMINAL_BUILD_TIMEOUT_SEC = max(
|
|||||||
int(os.getenv("POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC", "22")),
|
int(os.getenv("POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC", "22")),
|
||||||
)
|
)
|
||||||
SCAN_AI_MODEL = str(
|
SCAN_AI_MODEL = str(
|
||||||
os.getenv("POLYWEATHER_SCAN_AI_MODEL") or "deepseek-v4-pro"
|
os.getenv("POLYWEATHER_SCAN_AI_MODEL") or "deepseek-v4-flash"
|
||||||
).strip()
|
).strip()
|
||||||
SCAN_CITY_AI_MODEL = str(
|
SCAN_CITY_AI_MODEL = str(
|
||||||
os.getenv("POLYWEATHER_SCAN_CITY_AI_MODEL")
|
os.getenv("POLYWEATHER_SCAN_CITY_AI_MODEL")
|
||||||
@@ -98,24 +98,6 @@ SCAN_CITY_AI_MAX_TOKENS = _env_int(
|
|||||||
min_value=800,
|
min_value=800,
|
||||||
max_value=64000,
|
max_value=64000,
|
||||||
)
|
)
|
||||||
METAR_SUMMARY_AI_MODEL = str(
|
|
||||||
os.getenv("POLYWEATHER_METAR_SUMMARY_AI_MODEL")
|
|
||||||
or os.getenv("POLYWEATHER_SCAN_CITY_AI_MODEL")
|
|
||||||
or os.getenv("POLYWEATHER_SCAN_AI_MODEL")
|
|
||||||
or "deepseek-v4-flash"
|
|
||||||
).strip()
|
|
||||||
METAR_SUMMARY_AI_TIMEOUT_SEC = _env_int(
|
|
||||||
"POLYWEATHER_METAR_SUMMARY_AI_TIMEOUT_SEC",
|
|
||||||
8,
|
|
||||||
min_value=3,
|
|
||||||
max_value=30,
|
|
||||||
)
|
|
||||||
METAR_SUMMARY_AI_MAX_TOKENS = _env_int(
|
|
||||||
"POLYWEATHER_METAR_SUMMARY_AI_MAX_TOKENS",
|
|
||||||
160,
|
|
||||||
min_value=80,
|
|
||||||
max_value=1000,
|
|
||||||
)
|
|
||||||
SCAN_CITY_AI_PROMPT_VERSION = "city-airport-read-v3"
|
SCAN_CITY_AI_PROMPT_VERSION = "city-airport-read-v3"
|
||||||
|
|
||||||
CITY_AI_REQUIRED_FIELDS = [
|
CITY_AI_REQUIRED_FIELDS = [
|
||||||
@@ -1779,177 +1761,6 @@ def _build_city_ai_stream_request(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def stream_metar_summary_payload(body: Dict[str, Any]) -> Iterator[str]:
|
|
||||||
"""Stream a tiny METAR-only AI read.
|
|
||||||
|
|
||||||
This intentionally does not share the full city-review prompt. The goal is
|
|
||||||
first-token speed for the "AI airport read" section, while the heavier city
|
|
||||||
JSON review continues separately.
|
|
||||||
"""
|
|
||||||
|
|
||||||
started_at = time.time()
|
|
||||||
normalized_locale = _normalize_locale(str(body.get("locale") or "zh-CN"))
|
|
||||||
city = str(body.get("city") or "").strip()
|
|
||||||
airport = str(body.get("airport") or body.get("station") or "").strip()
|
|
||||||
metar = str(body.get("metar") or "").strip()
|
|
||||||
model_range = str(body.get("model_range") or "").strip()
|
|
||||||
deb = str(body.get("deb") or "").strip()
|
|
||||||
|
|
||||||
if not metar:
|
|
||||||
yield _sse_event(
|
|
||||||
"final",
|
|
||||||
{
|
|
||||||
"status": "failed",
|
|
||||||
"model": METAR_SUMMARY_AI_MODEL,
|
|
||||||
"provider": "deepseek",
|
|
||||||
"summary": "",
|
|
||||||
"reason": "metar is required",
|
|
||||||
"duration_ms": int((time.time() - started_at) * 1000),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
yield _sse_event(
|
|
||||||
"progress",
|
|
||||||
{
|
|
||||||
"stage": "calling_ai",
|
|
||||||
"message_zh": "DeepSeek 正在快速解读当前 METAR…",
|
|
||||||
"message_en": "DeepSeek is quickly reading the current METAR…",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if not SCAN_AI_ENABLED:
|
|
||||||
yield _sse_event(
|
|
||||||
"final",
|
|
||||||
{
|
|
||||||
"status": "disabled",
|
|
||||||
"model": METAR_SUMMARY_AI_MODEL,
|
|
||||||
"provider": "deepseek",
|
|
||||||
"summary": "",
|
|
||||||
"reason": "POLYWEATHER_SCAN_AI_ENABLED is not enabled",
|
|
||||||
"duration_ms": int((time.time() - started_at) * 1000),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if not str(os.getenv("POLYWEATHER_DEEPSEEK_API_KEY") or "").strip():
|
|
||||||
yield _sse_event(
|
|
||||||
"final",
|
|
||||||
{
|
|
||||||
"status": "missing_key",
|
|
||||||
"model": METAR_SUMMARY_AI_MODEL,
|
|
||||||
"provider": "deepseek",
|
|
||||||
"summary": "",
|
|
||||||
"reason": "POLYWEATHER_DEEPSEEK_API_KEY is not configured",
|
|
||||||
"duration_ms": int((time.time() - started_at) * 1000),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
is_en = normalized_locale == "en-US"
|
|
||||||
system_prompt = (
|
|
||||||
"You are PolyWeather's fast airport-bulletin module. "
|
|
||||||
"Use only the current METAR, DEB and model range. "
|
|
||||||
"Do not output JSON. Do not repeat the full METAR. Do not predict market prices. "
|
|
||||||
"Keep the answer within 80 Chinese characters or 45 English words."
|
|
||||||
if is_en
|
|
||||||
else "你是 PolyWeather 的机场报文快速解读模块。"
|
|
||||||
"请用中文用1到2句话解读当前 METAR 对今日最高温判断的影响。"
|
|
||||||
"要求:只基于当前 METAR、DEB 和模型区间;不输出 JSON;不要复述完整报文;"
|
|
||||||
"不要预测市场价格;不超过80个中文字。"
|
|
||||||
)
|
|
||||||
user_prompt = (
|
|
||||||
f"City: {city or 'unknown'}\n"
|
|
||||||
f"Airport: {airport or 'unknown'}\n"
|
|
||||||
f"METAR: {metar}\n"
|
|
||||||
f"DEB: {deb or 'unknown'}\n"
|
|
||||||
f"Model range: {model_range or 'unknown'}"
|
|
||||||
)
|
|
||||||
request_json = {
|
|
||||||
"model": METAR_SUMMARY_AI_MODEL,
|
|
||||||
"temperature": 0.15,
|
|
||||||
"max_tokens": METAR_SUMMARY_AI_MAX_TOKENS,
|
|
||||||
"stream": True,
|
|
||||||
"messages": [
|
|
||||||
{"role": "system", "content": system_prompt},
|
|
||||||
{"role": "user", "content": user_prompt},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
timeout = httpx.Timeout(
|
|
||||||
timeout=float(METAR_SUMMARY_AI_TIMEOUT_SEC),
|
|
||||||
connect=min(5.0, float(METAR_SUMMARY_AI_TIMEOUT_SEC)),
|
|
||||||
read=float(METAR_SUMMARY_AI_TIMEOUT_SEC),
|
|
||||||
write=5.0,
|
|
||||||
pool=3.0,
|
|
||||||
)
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Bearer {os.getenv('POLYWEATHER_DEEPSEEK_API_KEY')}",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
accumulated = ""
|
|
||||||
try:
|
|
||||||
logger.info(
|
|
||||||
"metar summary stream request city={} airport={} model={} timeout_sec={}",
|
|
||||||
city,
|
|
||||||
airport,
|
|
||||||
METAR_SUMMARY_AI_MODEL,
|
|
||||||
METAR_SUMMARY_AI_TIMEOUT_SEC,
|
|
||||||
)
|
|
||||||
with httpx.Client(timeout=timeout) as client:
|
|
||||||
with client.stream(
|
|
||||||
"POST",
|
|
||||||
f"{SCAN_AI_BASE_URL}/chat/completions",
|
|
||||||
headers=headers,
|
|
||||||
json=request_json,
|
|
||||||
) as response:
|
|
||||||
response.raise_for_status()
|
|
||||||
for line in response.iter_lines():
|
|
||||||
text = str(line or "").strip()
|
|
||||||
if not text or not text.startswith("data:"):
|
|
||||||
continue
|
|
||||||
payload_text = text[5:].strip()
|
|
||||||
if payload_text == "[DONE]":
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
chunk = json.loads(payload_text)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
delta = _extract_provider_stream_delta(chunk)
|
|
||||||
if delta:
|
|
||||||
accumulated += delta
|
|
||||||
yield _sse_event(
|
|
||||||
"delta",
|
|
||||||
{
|
|
||||||
"content": delta,
|
|
||||||
"raw_length": len(accumulated),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
summary = _truncate_ai_text(accumulated, 260)
|
|
||||||
yield _sse_event(
|
|
||||||
"final",
|
|
||||||
{
|
|
||||||
"status": "ready" if summary else "empty",
|
|
||||||
"model": METAR_SUMMARY_AI_MODEL,
|
|
||||||
"provider": "deepseek",
|
|
||||||
"summary": summary,
|
|
||||||
"duration_ms": int((time.time() - started_at) * 1000),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
summary = _truncate_ai_text(accumulated, 260)
|
|
||||||
yield _sse_event(
|
|
||||||
"final",
|
|
||||||
{
|
|
||||||
"status": "ready" if summary else "failed",
|
|
||||||
"degraded": bool(summary),
|
|
||||||
"model": METAR_SUMMARY_AI_MODEL,
|
|
||||||
"provider": "deepseek",
|
|
||||||
"summary": summary,
|
|
||||||
"reason": str(exc),
|
|
||||||
"duration_ms": int((time.time() - started_at) * 1000),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _cache_city_ai_payload(
|
def _cache_city_ai_payload(
|
||||||
cache_key: str,
|
cache_key: str,
|
||||||
*,
|
*,
|
||||||
|
|||||||
Reference in New Issue
Block a user