From d46e0c2e810a3266a2e0391f947234f3a2f876c8 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Mon, 27 Apr 2026 00:19:52 +0800 Subject: [PATCH] feat: implement AI-powered pinned city forecast dashboard with real-time METAR and market analysis integration --- .env.example | 9 +- frontend/app/api/ai/metar-summary/route.ts | 69 ------ .../dashboard/ScanTerminalDashboard.tsx | 12 + .../scan-terminal/AiPinnedForecastView.tsx | 121 ++++----- .../dashboard/scan-terminal/types.ts | 18 -- .../scan-terminal/use-ai-city-card-data.ts | 232 ------------------ web/routes.py | 20 -- web/scan_terminal_service.py | 191 +------------- 8 files changed, 66 insertions(+), 606 deletions(-) delete mode 100644 frontend/app/api/ai/metar-summary/route.ts diff --git a/.env.example b/.env.example index fa11a761..f1ca17c8 100644 --- a/.env.example +++ b/.env.example @@ -140,18 +140,15 @@ POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800 POLYWEATHER_SCAN_AI_ENABLED=false POLYWEATHER_DEEPSEEK_API_KEY= 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_METAR_SUMMARY_AI_MODEL=deepseek-v4-flash -POLYWEATHER_SCAN_AI_TIMEOUT_SEC=40 -POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=18 -POLYWEATHER_METAR_SUMMARY_AI_TIMEOUT_SEC=8 +POLYWEATHER_SCAN_AI_TIMEOUT_SEC=18 +POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=30 POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=false POLYWEATHER_SCAN_AI_CACHE_TTL_SEC=1800 POLYWEATHER_SCAN_AI_MAX_ROWS=40 POLYWEATHER_SCAN_AI_MAX_TOKENS=3200 POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=900 -POLYWEATHER_METAR_SUMMARY_AI_MAX_TOKENS=160 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_CITY_SUMMARY_CACHE_TTL_SEC=1800 diff --git a/frontend/app/api/ai/metar-summary/route.ts b/frontend/app/api/ai/metar-summary/route.ts deleted file mode 100644 index a6bf714f..00000000 --- a/frontend/app/api/ai/metar-summary/route.ts +++ /dev/null @@ -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) : {}; - 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); - } -} diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 50cd0ed8..32bc954b 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -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(() => { aiPinnedCities.forEach((item) => { const key = normalizeCityKey(item.cityName); @@ -527,6 +538,7 @@ function ScanTerminalScreen() { rows={timeSortedRows} detailsByName={store.cityDetailsByName} locale={locale} + onRefreshCityDetail={refreshAiPinnedCityDetail} onRemoveCity={removeAiPinnedCity} /> ); diff --git a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx index caddb80b..b3b39cff 100644 --- a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx +++ b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx @@ -16,7 +16,6 @@ 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"; @@ -91,6 +90,7 @@ function AiPinnedCityCard({ locale, collapsed, removing, + onRefreshCityDetail, onRemove, onToggleCollapsed, }: { @@ -100,6 +100,7 @@ function AiPinnedCityCard({ locale: string; collapsed: boolean; removing?: boolean; + onRefreshCityDetail: (cityName: string) => Promise; onRemove: () => void; onToggleCollapsed: () => void; }) { @@ -153,20 +154,7 @@ 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 [refreshingDetail, setRefreshingDetail] = useState(false); const { aiForecast, refreshAiForecast } = useAiCityForecast({ detail, detailCityName, @@ -180,6 +168,7 @@ function AiPinnedCityCard({ detailCityName, enabled: Boolean(detail), }); + const isRefreshing = refreshingDetail || aiForecast.status === "loading"; const aiCityForecast = aiForecast.payload?.city_forecast || null; const localizedFinalJudgmentRaw = @@ -264,6 +253,7 @@ function AiPinnedCityCard({ ? [String(localizedRisksRaw)] : []; const aiBullets = [ + localizedMetarRead, localizedReasoning !== localizedFinalJudgment ? localizedReasoning : "", localizedModelNote || localModelSupportNote, ...localizedRisks, @@ -309,13 +299,24 @@ function AiPinnedCityCard({ onClick={(event) => { event.preventDefault(); event.stopPropagation(); - refreshAiForecast(); + if (refreshingDetail) return; + setRefreshingDetail(true); + void onRefreshCityDetail(detailCityName) + .catch(() => {}) + .finally(() => { + refreshAiForecast(); + setRefreshingDetail(false); + }); }} aria-label={isEn ? `Refresh ${displayName} analysis` : `刷新 ${displayName} 深度分析`} - title={isEn ? "Refresh analysis" : "刷新深度分析"} - disabled={aiForecast.status === "loading"} + title={ + isEn + ? "Refresh city data, chart and AI analysis" + : "刷新城市数据、温度走势图和 AI 分析" + } + disabled={isRefreshing} > - +