From 467f736602184d41c96cd84a7b204406473f9428 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Fri, 24 Apr 2026 12:46:44 +0800 Subject: [PATCH] feat: implement dashboard store and API routes for city market scanning and analysis --- frontend/app/api/assistant/chat/route.ts | 15 +- .../app/api/city/[name]/market-scan/route.ts | 21 +- .../components/dashboard/OpportunityTable.tsx | 6 +- .../dashboard/ScanTerminalDashboard.tsx | 536 +++++++++++++++++- frontend/hooks/useDashboardStore.tsx | 3 +- frontend/lib/dashboard-client.ts | 41 +- frontend/lib/dashboard-types.ts | 3 + web/scan_terminal_service.py | 139 ++++- 8 files changed, 723 insertions(+), 41 deletions(-) diff --git a/frontend/app/api/assistant/chat/route.ts b/frontend/app/api/assistant/chat/route.ts index 2c37ea63..33e2722d 100644 --- a/frontend/app/api/assistant/chat/route.ts +++ b/frontend/app/api/assistant/chat/route.ts @@ -470,6 +470,18 @@ function buildFallbackAnswer( }; } +function shouldUseRuleAnswer( + question: string, + context: ReturnType, +) { + if (detectUnsupported(question)) return true; + if (findMentionedCity(question, context)) return true; + if (isForecastQuestion(question)) return true; + return /当前|市场|机会|值得|参与|买|卖|yes|no|edge|排序|风险|概率|probability|emos|deb|价格|盘口|高风险|低风险|model|forecast|temperature|rank|buy|sell/i.test( + question, + ); +} + async function generateWithGroq(params: { question: string; locale: string; @@ -636,7 +648,8 @@ export async function POST(request: NextRequest) { model: "rule-fallback", }; - if (!fallback.refused) { + const useRuleAnswer = shouldUseRuleAnswer(question, context); + if (!fallback.refused && !useRuleAnswer) { try { const generated = await generateWithGroq({ question, diff --git a/frontend/app/api/city/[name]/market-scan/route.ts b/frontend/app/api/city/[name]/market-scan/route.ts index 394d20b3..165e419b 100644 --- a/frontend/app/api/city/[name]/market-scan/route.ts +++ b/frontend/app/api/city/[name]/market-scan/route.ts @@ -6,6 +6,14 @@ import { const API_BASE = process.env.POLYWEATHER_API_BASE_URL; +function parseBackendError(raw: string) { + try { + return JSON.parse(raw) as unknown; + } catch { + return raw.slice(0, 800); + } +} + export async function GET( req: NextRequest, context: { params: Promise<{ name: string }> }, @@ -49,7 +57,11 @@ export async function GET( if (!res.ok) { const raw = await res.text(); const response = NextResponse.json( - { error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) }, + { + error: "Backend city market scan failed", + upstream_status: res.status, + detail: parseBackendError(raw), + }, { status: 502 }, ); return applyAuthResponseCookies(response, auth.response); @@ -63,8 +75,11 @@ export async function GET( return applyAuthResponseCookies(response, auth.response); } catch (error) { const response = NextResponse.json( - { error: "Failed to fetch city market scan", detail: String(error) }, - { status: 500 }, + { + error: "Failed to fetch city market scan", + detail: String(error), + }, + { status: 502 }, ); return response; } diff --git a/frontend/components/dashboard/OpportunityTable.tsx b/frontend/components/dashboard/OpportunityTable.tsx index beb69801..d0cb6719 100644 --- a/frontend/components/dashboard/OpportunityTable.tsx +++ b/frontend/components/dashboard/OpportunityTable.tsx @@ -220,10 +220,12 @@ export const OpportunityTable = React.memo(function OpportunityTable({ const { locale } = useI18n(); const isEn = locale === "en-US"; const hasRows = rows.length > 0; + const scanInProgress = + loading || status === "partial" || status === "scanning"; if (!hasRows) { const title = - loading + scanInProgress ? isEn ? "Scanning markets" : "正在扫描市场" @@ -235,7 +237,7 @@ export const OpportunityTable = React.memo(function OpportunityTable({ ? "No tradable market right now" : "当前暂无可交易市场"; const copy = - loading + scanInProgress ? isEn ? "Waiting for the latest market snapshot. Existing data will stay on screen when available." : "正在等待最新市场快照;如果有旧数据,会继续保留在页面上。" diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 00c364c6..de1d0de7 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -14,6 +14,7 @@ import { useDeferredValue, useEffect, useMemo, + useRef, useState, } from "react"; import styles from "./Dashboard.module.css"; @@ -33,7 +34,11 @@ import { useDashboardStore, } from "@/hooks/useDashboardStore"; import { I18nProvider, useI18n } from "@/hooks/useI18n"; -import { dashboardClient } from "@/lib/dashboard-client"; +import { + dashboardClient, + type AssistantContextPayload, + type AssistantOpportunityContext, +} from "@/lib/dashboard-client"; import type { DistributionPreviewPoint, MarketScan, @@ -64,6 +69,11 @@ const DEFAULT_FILTERS: FilterState = { type ContentView = "list" | "map" | "calendar"; type ThemeMode = "dark" | "light"; +type AssistantMessage = { + id: string; + role: "assistant" | "user"; + content: string; +}; function formatPercent(value?: number | null, signed = false) { if (value == null || Number.isNaN(Number(value))) return "--"; @@ -274,6 +284,165 @@ function buildComparisonBuckets( ]; } +function hashClientText(value: string) { + let hash = 0; + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) | 0; + } + return Math.abs(hash).toString(16).padStart(8, "0").slice(0, 8); +} + +function normalizeAssistantProbability(value?: number | null) { + if (!Number.isFinite(Number(value))) return null; + const numeric = Number(value); + return Math.abs(numeric) <= 1 ? numeric : numeric / 100; +} + +function getAssistantSidePrice(row: ScanOpportunityRow, side: "yes" | "no") { + if (side === "yes") { + return row.yes_ask ?? (String(row.side || "").toLowerCase() === "yes" ? row.ask : null); + } + return row.no_ask ?? (String(row.side || "").toLowerCase() === "no" ? row.ask : null); +} + +function buildAssistantOpportunity( + row: ScanOpportunityRow, + locale: string, +): AssistantOpportunityContext { + const tempSymbol = row.temp_symbol || "°C"; + const marketLabel = + normalizeTemperatureLabel(row.target_label, tempSymbol) || + row.market_question || + row.target_label || + null; + const bestSide = String(row.side || "").toUpperCase(); + + return { + city_name: row.city, + city_display_name: getLocalizedCityName( + row.city, + row.city_display_name || row.display_name || row.city, + locale, + ), + airport: getLocalizedAirportName(row.city, row.airport || "", locale), + risk_level: row.risk_level || "low", + tradable: + row.tradable === true && + row.closed !== true && + row.active !== false && + row.accepting_orders !== false, + local_time: row.local_time || null, + current_temperature: Number.isFinite(Number(row.current_temp)) + ? Number(row.current_temp) + : null, + deb_prediction: Number.isFinite(Number(row.deb_prediction)) + ? Number(row.deb_prediction) + : null, + temp_symbol: tempSymbol, + today_high: Number.isFinite(Number(row.current_max_so_far)) + ? Number(row.current_max_so_far) + : Number.isFinite(Number(row.deb_prediction)) + ? Number(row.deb_prediction) + : null, + market_question: row.market_question || null, + market_label: marketLabel, + selected_date: row.selected_date || row.local_date || null, + best_side: bestSide === "YES" || bestSide === "NO" ? bestSide : null, + yes_price: getAssistantSidePrice(row, "yes"), + no_price: getAssistantSidePrice(row, "no"), + edge_percent: Number.isFinite(Number(row.edge_percent)) + ? Number(row.edge_percent) + : null, + market_probability: normalizeAssistantProbability( + row.market_event_probability ?? row.market_probability, + ), + model_probability: normalizeAssistantProbability( + row.model_event_probability ?? row.model_probability, + ), + status: row.closed ? "closed" : row.tradable ? "tradable" : "watch", + }; +} + +function buildAssistantContext(params: { + terminalData: ScanTerminalResponse | null; + rows: ScanOpportunityRow[]; + selectedRow: ScanOpportunityRow | null; + locale: string; + totalCities: number; +}): AssistantContextPayload { + const opportunities = params.rows + .slice(0, 52) + .map((row) => buildAssistantOpportunity(row, params.locale)); + const selectedCity = params.selectedRow + ? buildAssistantOpportunity(params.selectedRow, params.locale) + : opportunities[0] || null; + const source = JSON.stringify({ + generated_at: params.terminalData?.generated_at || "", + rows: params.rows.slice(0, 20).map((row) => [ + row.id, + row.city, + row.market_slug, + row.side, + row.edge_percent, + row.yes_ask, + row.no_ask, + ]), + }); + + return { + snapshot_id: + params.terminalData?.snapshot_id || + `home-${hashClientText(source)}`, + locale: params.locale, + generated_at: params.terminalData?.generated_at || new Date().toISOString(), + totals: { + cities: params.totalCities || params.rows.length, + tradable_markets: + params.terminalData?.summary?.tradable_market_count ?? + opportunities.filter((item) => item.tradable).length, + high_risk: params.rows.filter((row) => row.risk_level === "high").length, + medium_risk: params.rows.filter((row) => row.risk_level === "medium").length, + low_risk: params.rows.filter((row) => row.risk_level === "low").length, + }, + selected_city: selectedCity, + opportunities, + glossary: + params.locale === "en-US" + ? [ + { + term: "edge", + meaning: + "Edge is model probability minus market-implied probability. Positive edge means the model is more favorable than the market price.", + }, + { + term: "EMOS", + meaning: + "EMOS is the calibrated probability distribution for max-temperature buckets.", + }, + { + term: "DEB", + meaning: + "DEB is PolyWeather's forecast anchor for the 24-hour maximum temperature.", + }, + ] + : [ + { + term: "edge", + meaning: + "edge 是模型概率减去市场隐含概率。正 edge 表示模型判断比市场价格更有利。", + }, + { + term: "EMOS", + meaning: "EMOS 是最高温分桶的校准概率分布。", + }, + { + term: "DEB", + meaning: "DEB 是 PolyWeather 对 24 小时最高温的预测锚点。", + }, + ], + }; +} + function DetailPanel({ row, marketScan, @@ -646,6 +815,364 @@ function OverviewMapView({ locale }: { locale: string }) { ); } +function AssistantWidget({ + terminalData, + rows, + selectedRow, + locale, + totalCities, +}: { + terminalData: ScanTerminalResponse | null; + rows: ScanOpportunityRow[]; + selectedRow: ScanOpportunityRow | null; + locale: string; + totalCities: number; +}) { + const isEn = locale === "en-US"; + const [open, setOpen] = useState(false); + const [question, setQuestion] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [messages, setMessages] = useState(() => [ + { + id: "initial", + role: "assistant", + content: isEn + ? "Ask about current opportunities, edge ranking, one city, or the forecast high. I only use the current scan data." + : "可以问当前机会、edge 排序、某个城市是否值得参与,或今天预测最高温。我只使用当前扫描数据。", + }, + ]); + const [position, setPosition] = useState<{ x: number; y: number } | null>(null); + const dragStateRef = useRef<{ + pointerId: number; + startX: number; + startY: number; + originX: number; + originY: number; + moved: boolean; + } | null>(null); + const suppressClickRef = useRef(false); + const [dragging, setDragging] = useState(false); + + const context = useMemo( + () => + buildAssistantContext({ + terminalData, + rows, + selectedRow, + locale, + totalCities, + }), + [locale, rows, selectedRow, terminalData, totalCities], + ); + + useEffect(() => { + setMessages((current) => + current[0]?.id === "initial" + ? [ + { + id: "initial", + role: "assistant", + content: isEn + ? "Ask about current opportunities, edge ranking, one city, or the forecast high. I only use the current scan data." + : "可以问当前机会、edge 排序、某个城市是否值得参与,或今天预测最高温。我只使用当前扫描数据。", + }, + ...current.slice(1), + ] + : current, + ); + }, [isEn]); + + useEffect(() => { + const stored = window.localStorage.getItem("polyweather_ai_position_v1"); + if (stored) { + try { + const parsed = JSON.parse(stored) as { x?: number; y?: number }; + if (Number.isFinite(parsed.x) && Number.isFinite(parsed.y)) { + setPosition({ + x: Math.min(Math.max(Number(parsed.x), 12), window.innerWidth - 72), + y: Math.min(Math.max(Number(parsed.y), 76), window.innerHeight - 72), + }); + return; + } + } catch { + // Ignore invalid localStorage state. + } + } + setPosition({ + x: Math.max(16, window.innerWidth - 88), + y: Math.max(88, window.innerHeight - 96), + }); + }, []); + + useEffect(() => { + if (!position) return; + window.localStorage.setItem( + "polyweather_ai_position_v1", + JSON.stringify(position), + ); + }, [position]); + + const clampPosition = useCallback((nextX: number, nextY: number, isPanel: boolean) => { + const width = isPanel ? Math.min(380, window.innerWidth - 32) : 56; + const height = isPanel ? Math.min(520, window.innerHeight - 96) : 56; + return { + x: Math.min(Math.max(nextX, 12), Math.max(12, window.innerWidth - width - 12)), + y: Math.min(Math.max(nextY, 76), Math.max(76, window.innerHeight - height - 12)), + }; + }, []); + + const startDrag = useCallback( + (event: React.PointerEvent) => { + if (!position) return; + dragStateRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + originX: position.x, + originY: position.y, + moved: false, + }; + setDragging(true); + event.currentTarget.setPointerCapture?.(event.pointerId); + }, + [position], + ); + + const updateDrag = useCallback( + (event: React.PointerEvent) => { + const dragState = dragStateRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) return; + const deltaX = event.clientX - dragState.startX; + const deltaY = event.clientY - dragState.startY; + if (Math.abs(deltaX) + Math.abs(deltaY) > 4) { + dragState.moved = true; + } + setPosition( + clampPosition(dragState.originX + deltaX, dragState.originY + deltaY, open), + ); + }, + [clampPosition, open], + ); + + const endDrag = useCallback((event: React.PointerEvent) => { + const dragState = dragStateRef.current; + if (dragState?.pointerId === event.pointerId) { + event.currentTarget.releasePointerCapture?.(event.pointerId); + setDragging(false); + if (dragState.moved) { + suppressClickRef.current = true; + window.setTimeout(() => { + suppressClickRef.current = false; + }, 160); + } + window.setTimeout(() => { + dragStateRef.current = null; + }, 0); + } + }, []); + + const starterQuestions = useMemo(() => { + const cityName = + context.selected_city?.city_display_name || + (isEn ? "the focus city" : "当前焦点城市"); + return isEn + ? [ + "Which market is worth buying now?", + "Rank opportunities by edge", + `What is today's forecast high for ${cityName}?`, + ] + : [ + "当前有哪些值得参与的市场?", + "按 edge 排序", + `${cityName} 今天预测最高温是多少?`, + ]; + }, [context.selected_city?.city_display_name, isEn]); + + const submitQuestion = useCallback( + async (rawQuestion?: string) => { + const nextQuestion = String(rawQuestion ?? question).trim(); + if (!nextQuestion || submitting) return; + const userMessage: AssistantMessage = { + id: `user-${Date.now()}`, + role: "user", + content: nextQuestion, + }; + setMessages((current) => [...current, userMessage]); + setQuestion(""); + setError(null); + setSubmitting(true); + try { + const response = await dashboardClient.askAssistant({ + question: nextQuestion, + locale, + snapshotId: context.snapshot_id, + context, + }); + setMessages((current) => [ + ...current, + { + id: `assistant-${Date.now()}`, + role: "assistant", + content: response.answer, + }, + ]); + } catch (submitError) { + const message = String(submitError); + const paywall = + message.includes("402") || message.includes("assistant_requires_pro"); + setError( + paywall + ? isEn + ? "PolyWeather AI assistant is a Pro feature." + : "AI 对话助手属于 Pro 功能,请先开通后使用。" + : isEn + ? "Assistant request failed. Try again after the market scan refreshes." + : "AI 请求失败。请等市场扫描刷新后再试。", + ); + } finally { + setSubmitting(false); + } + }, + [context, isEn, locale, question, submitting], + ); + + const handleLauncherClick = useCallback(() => { + if (suppressClickRef.current || dragStateRef.current?.moved) return; + setOpen(true); + setPosition((current) => { + const next = current || { + x: window.innerWidth - 420, + y: window.innerHeight - 520, + }; + return clampPosition(next.x, next.y, true); + }); + }, [clampPosition]); + + if (!position) return null; + + return ( +
+ {!open ? ( + + ) : ( +
+
+
+ {isEn ? "AI Assistant" : "AI 对话助手"} + + {isEn ? "Current market snapshot only" : "仅基于当前市场快照"} + +
+
+ + +
+
+ +
+ {isEn + ? "Uses only scan data already provided by PolyWeather; no invented prices, probabilities, or cities." + : "只使用 PolyWeather 当前扫描数据,不编造价格、概率或城市。"} +
+ +
+ {messages.map((message) => ( +
+

{message.content}

+
+ ))} + {submitting ? ( +
+

{isEn ? "Reading the current scan..." : "正在读取当前扫描数据..."}

+
+ ) : null} +
+ +
+ {starterQuestions.map((starter) => ( + + ))} +
+ +
+