diff --git a/frontend/app/api/assistant/chat/route.ts b/frontend/app/api/assistant/chat/route.ts index 26b4ccad..2c37ea63 100644 --- a/frontend/app/api/assistant/chat/route.ts +++ b/frontend/app/api/assistant/chat/route.ts @@ -23,6 +23,8 @@ type AssistantOpportunityContext = { local_time?: string | null; current_temperature?: number | null; deb_prediction?: number | null; + temp_symbol?: string | null; + today_high?: number | null; market_question?: string | null; market_label?: string | null; selected_date?: string | null; @@ -142,6 +144,12 @@ function sanitizeContext(input?: AssistantContextPayload | null) { deb_prediction: Number.isFinite(Number(item.deb_prediction)) ? Number(item.deb_prediction) : null, + temp_symbol: item.temp_symbol + ? String(item.temp_symbol).slice(0, 4) + : null, + today_high: Number.isFinite(Number(item.today_high)) + ? Number(item.today_high) + : null, market_question: item.market_question ? String(item.market_question).slice(0, 240) : null, @@ -206,19 +214,19 @@ function buildSuggestions( ) { if (locale === "en-US") { return [ + selectedCity?.city_display_name + ? `What is today's forecast high for ${selectedCity.city_display_name}?` + : "What is today's forecast high for the focus city?", "Which market is worth buying now?", "Rank current opportunities by edge", - selectedCity?.city_display_name - ? `Why is ${selectedCity.city_display_name} not recommended?` - : "Explain what edge means", ]; } return [ + selectedCity?.city_display_name + ? `${selectedCity.city_display_name} 今天预测最高温是多少?` + : "当前焦点城市今天预测最高温是多少?", "当前有哪些值得参与的市场?", "按 edge 排序", - selectedCity?.city_display_name - ? `为什么 ${selectedCity.city_display_name} 不建议参与?` - : "解释一下 edge 是什么", ]; } @@ -241,6 +249,27 @@ function findMentionedCity( ); } +function normalizeTempSymbol(value?: string | null) { + return String(value || "").toUpperCase().includes("F") ? "°F" : "°C"; +} + +function formatContextTemperature( + value: number | null | undefined, + symbol?: string | null, +) { + if (!Number.isFinite(Number(value))) return "--"; + const numeric = Number(value); + const rounded = + Math.abs(numeric) >= 10 ? Math.round(numeric) : Number(numeric.toFixed(1)); + return `${rounded}${normalizeTempSymbol(symbol)}`; +} + +function isForecastQuestion(question: string) { + return /最高温|最高气温|预测最高|今天.*多少度|今日.*多少度|当前温度|当前气温|现在多少度|多少度|几度|today high|max temp|max temperature|forecast high|current temperature|temperature/i.test( + question, + ); +} + function buildUnsupportedAnswer(locale: string) { return locale === "en-US" ? "I can only answer questions about the current PolyWeather market snapshot, such as opportunities, city-level reasoning, rankings, and metric definitions." @@ -339,6 +368,15 @@ function buildCityAnswer( city.market_probability == null ? "--" : `${city.market_probability.toFixed(1)}%`; + const currentText = formatContextTemperature( + city.current_temperature, + city.temp_symbol, + ); + const debText = formatContextTemperature(city.deb_prediction, city.temp_symbol); + const highText = formatContextTemperature( + city.today_high ?? city.deb_prediction ?? null, + city.temp_symbol, + ); if (locale === "en-US") { return [ @@ -346,11 +384,7 @@ function buildCityAnswer( city.tradable ? `The active market is ${city.market_label || city.market_question || "unavailable"}, preferred side ${city.best_side || "unavailable"}, edge ${edgeText}, YES ${yesText}, NO ${noText}.` : `This city does not have a tradable market in the current snapshot.`, - `Model probability ${modelText}, market-implied probability ${marketText}, current temperature ${ - city.current_temperature ?? "--" - }, DEB ${ - city.deb_prediction ?? "--" - }.`, + `Model probability ${modelText}, market-implied probability ${marketText}, current temperature ${currentText}, forecast high ${highText}, DEB ${debText}.`, ].join(" "); } @@ -359,14 +393,28 @@ function buildCityAnswer( city.tradable ? `当前可交易市场是 ${city.market_label || city.market_question || "暂无"},系统倾向 ${city.best_side || "待定"},edge ${edgeText},YES ${yesText},NO ${noText}。` : "这个城市在当前快照里没有可交易市场。", - `模型概率 ${modelText},市场隐含概率 ${marketText},当前温度 ${ - city.current_temperature ?? "--" - },DEB ${ - city.deb_prediction ?? "--" - }。`, + `模型概率 ${modelText},市场隐含概率 ${marketText},当前温度 ${currentText},最高温预测 ${highText},DEB ${debText}。`, ].join(""); } +function buildForecastAnswer( + locale: string, + city: AssistantOpportunityContext, +) { + const currentText = formatContextTemperature( + city.current_temperature, + city.temp_symbol, + ); + const highText = formatContextTemperature( + city.today_high ?? city.deb_prediction ?? null, + city.temp_symbol, + ); + if (locale === "en-US") { + return `${city.city_display_name} currently reads ${currentText}. The best available forecast for today's high is ${highText}.`; + } + return `${city.city_display_name} 当前温度是 ${currentText},今天最高温预测是 ${highText}。`; +} + function detectUnsupported(question: string) { return /冷锋|台风百科|气象百科|why is the sky|what is a cold front|recipe|股票|crypto|足球|nba/i.test( question, @@ -386,6 +434,12 @@ function buildFallbackAnswer( } const mentionedCity = findMentionedCity(question, context); + if (mentionedCity && isForecastQuestion(question)) { + return { + answer: buildForecastAnswer(locale, mentionedCity), + refused: false, + }; + } if (mentionedCity) { return { answer: buildCityAnswer(locale, mentionedCity), @@ -400,6 +454,16 @@ function buildFallbackAnswer( }; } + if (isForecastQuestion(question)) { + const fallbackCity = context.selected_city || context.opportunities[0] || null; + if (fallbackCity) { + return { + answer: buildForecastAnswer(locale, fallbackCity), + refused: false, + }; + } + } + return { answer: buildOpportunityAnswer(question, locale, context), refused: false, @@ -439,8 +503,8 @@ async function generateWithGroq(params: { role: "system", content: params.locale === "en-US" - ? "You are the PolyWeather AI assistant. Answer only from the provided snapshot JSON. Do not invent cities, prices, probabilities, timing, or market status. If the snapshot lacks the needed data, say so directly. Refuse non-product or non-market questions." - : "你是 PolyWeather AI 助手。只能基于提供的快照 JSON 回答,不得编造城市、价格、概率、时间或市场状态。如果快照没有所需数据,要直接说明。对于非产品、非市场问题要拒答。", + ? "You are the PolyWeather AI assistant. Answer only from the provided snapshot JSON. Do not invent cities, prices, probabilities, timing, weather forecasts, or market status. You may answer current temperature, forecast high, market opportunities, edge, risk reasons, and metric explanations. If the snapshot lacks the needed data, say so directly. Refuse non-product, non-market, or unsupported general-knowledge questions." + : "你是 PolyWeather AI 助手。只能基于提供的快照 JSON 回答,不得编造城市、价格、概率、时间、天气预测或市场状态。你可以回答当前温度、今日最高温预测、市场机会、edge、风险原因和指标解释;如果快照没有所需数据,要直接说明。对于非产品、非市场或缺少数据支撑的问题要拒答。", }, { role: "user", diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index c2f9c58a..07335e94 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -1637,14 +1637,50 @@ .root :global(.home-intraday-chart) { position: relative; - padding: 6px 0 0; + padding: 6px 0 18px 34px; } .root :global(.home-intraday-chart svg) { - width: 100%; + width: calc(100% - 34px); + margin-left: 34px; height: 78px; } +.root :global(.home-intraday-y-axis) { + position: absolute; + inset: 0 auto 18px 0; + width: 30px; + pointer-events: none; +} + +.root :global(.home-intraday-y-label) { + position: absolute; + left: 0; + transform: translateY(-50%); + color: rgba(148, 163, 184, 0.72); + font-size: 10px; + font-weight: 700; +} + +.root :global(.home-intraday-x-axis) { + position: absolute; + left: 34px; + right: 10px; + bottom: 0; + height: 14px; + pointer-events: none; +} + +.root :global(.home-intraday-x-label) { + position: absolute; + bottom: 0; + transform: translateX(-50%); + color: rgba(148, 163, 184, 0.72); + font-size: 10px; + font-weight: 700; + white-space: nowrap; +} + .root :global(.home-intraday-chart line) { stroke: rgba(115, 137, 161, 0.14); stroke-width: 0.9; @@ -1845,16 +1881,6 @@ box-shadow: 0 0 10px rgba(34, 211, 238, 0.14); } -.root :global(.home-probability-row:nth-child(3) i), -.root :global(.home-probability-ladder-row:nth-child(3) i) { - background: linear-gradient(90deg, #f59e0b, #fbbf24); -} - -.root :global(.home-probability-row:nth-child(n + 4) i), -.root :global(.home-probability-ladder-row:nth-child(n + 4) i) { - background: linear-gradient(90deg, #ef4444, #fb7185); -} - .root :global(.home-probability-row strong), .root :global(.home-probability-ladder-row strong) { position: relative; @@ -7266,10 +7292,14 @@ .root :global(.home-ai-assistant) { position: fixed; - right: 408px; - bottom: 210px; + right: 24px; + bottom: 24px; z-index: 910; - width: min(380px, calc(100vw - var(--sidebar-width) - 72px)); + width: min(340px, calc(100vw - 48px)); +} + +.root :global(.home-ai-assistant.collapsed) { + width: 56px; } .root :global(.home-ai-assistant.dragging) { @@ -7295,11 +7325,12 @@ } .root :global(.home-ai-launcher) { - display: grid; - grid-template-columns: auto 1fr auto; + display: inline-flex; align-items: center; - gap: 12px; - padding: 14px 16px; + justify-content: center; + width: 56px; + height: 56px; + padding: 0; cursor: grab; touch-action: none; user-select: none; @@ -7309,6 +7340,13 @@ background 160ms ease; } +.root :global(.home-ai-launcher-icon) { + width: 22px; + height: 22px; + display: block; + object-fit: contain; +} + .root :global(.home-ai-launcher:hover) { transform: translateY(-1px); border-color: rgba(34, 211, 238, 0.32); @@ -7577,15 +7615,19 @@ .root :global(.home-ai-assistant) { right: 24px; bottom: 24px; - width: min(380px, calc(100vw - 48px)); + width: min(340px, calc(100vw - 48px)); } } @media (max-width: 960px) { .root :global(.home-ai-assistant) { - left: 16px; + left: auto; right: 16px; - width: auto; + width: min(340px, calc(100vw - 32px)); bottom: 16px; } + + .root :global(.home-ai-assistant.collapsed) { + width: 56px; + } } diff --git a/frontend/components/dashboard/PolyWeatherDashboard.tsx b/frontend/components/dashboard/PolyWeatherDashboard.tsx index c68cb651..04567918 100644 --- a/frontend/components/dashboard/PolyWeatherDashboard.tsx +++ b/frontend/components/dashboard/PolyWeatherDashboard.tsx @@ -1,6 +1,7 @@ "use client"; import clsx from "clsx"; import dynamic from "next/dynamic"; +import Image from "next/image"; import Link from "next/link"; import { useEffect, @@ -8,7 +9,6 @@ import { useRef, useState, type FormEvent, - type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, } from "react"; import styles from "./Dashboard.module.css"; @@ -167,14 +167,24 @@ function getProbabilityLabel( }, symbol: string, ) { - if (bucket.label) return bucket.label; - if (bucket.bucket) return bucket.bucket; + if (bucket.label) return normalizeTemperatureBucketLabel(bucket.label, symbol); + if (bucket.bucket) return normalizeTemperatureBucketLabel(bucket.bucket, symbol); if (Number.isFinite(Number(bucket.value))) { return `≥ ${Math.round(Number(bucket.value))}${symbol}`; } return "--"; } +function normalizeTemperatureBucketLabel(label: string, symbol: string) { + const normalizedSymbol = symbol.includes("F") ? "°F" : "°C"; + return String(label || "") + .trim() + .replace( + /(-?\d+(?:\.\d+)?)\s*°?\s*[CF]\b/gi, + (_, value) => `${Math.round(Number(value))}${normalizedSymbol}`, + ); +} + function parseBucketThreshold(bucket?: { label?: string | null; value?: number | null; @@ -352,7 +362,7 @@ type AssistantDockDragState = { }; const HOME_AI_DOCK_POSITION_STORAGE_KEY = - "polyweather_home_ai_dock_position_v1"; + "polyweather_home_ai_dock_position_v2"; function readAssistantDockPosition() { if (typeof window === "undefined") return null; @@ -392,21 +402,9 @@ function getDefaultAssistantDockPosition() { if (typeof window === "undefined" || window.innerWidth <= 960) { return null; } - const detailPanel = document.querySelector( - ".home-intelligence-panel.full", - ) as HTMLElement | null; - const opportunityStrip = document.querySelector( - ".home-opportunity-strip", - ) as HTMLElement | null; - const panelRect = detailPanel?.getBoundingClientRect(); - const stripRect = opportunityStrip?.getBoundingClientRect(); return { - right: panelRect - ? Math.max(24, window.innerWidth - panelRect.left + 18) - : 408, - bottom: stripRect - ? Math.max(24, window.innerHeight - stripRect.top + 18) - : 340, + right: 24, + bottom: 24, }; } @@ -418,7 +416,7 @@ function clampAssistantDockPosition( return null; } const rect = dockElement?.getBoundingClientRect(); - const width = rect?.width || 380; + const width = rect?.width || 340; const height = rect?.height || 88; const minOffset = 24; const maxRight = Math.max(minOffset, window.innerWidth - width - minOffset); @@ -500,6 +498,12 @@ function buildAssistantOpportunityContext( current_temperature: summary?.current?.temp ?? detail?.current?.temp ?? null, deb_prediction: summary?.deb?.prediction ?? detail?.deb?.prediction ?? null, + temp_symbol: symbol, + today_high: + detail?.forecast?.today_high ?? + detail?.current?.max_so_far ?? + detail?.airport_current?.max_so_far ?? + null, market_question: marketQuestion, market_label: marketBucket ? getProbabilityLabel(marketBucket, symbol) : null, selected_date: marketScan?.selected_date || detail?.local_date || null, @@ -609,12 +613,12 @@ function buildAssistantContextPayload( function buildAssistantGreeting(locale: string, selectedCityName?: string | null) { if (locale === "en-US") { return selectedCityName - ? `Ask about ${selectedCityName}, current opportunities, edge ranking, or how the metrics should be read.` - : "Ask about current opportunities, edge ranking, or how the metrics should be read."; + ? `Ask about ${selectedCityName}'s current temperature, today's forecast high, market edge, or live opportunities.` + : "Ask about current temperature, today's forecast high, market edge, or live opportunities."; } return selectedCityName - ? `可以直接问我 ${selectedCityName}、当前值得参与的市场、edge 排序,或者指标怎么解读。` - : "可以直接问我当前值得参与的市场、edge 排序,或者指标怎么解读。"; + ? `可以直接问我 ${selectedCityName} 的当前温度、今日最高温预测、市场 edge 或实时机会。` + : "可以直接问我当前温度、今日最高温预测、市场 edge 或实时机会。"; } type HomeWeatherIconKind = @@ -642,6 +646,8 @@ type HomeTrendChart = { label: string; temperatureText: string; }>; + yAxisLabels: Array<{ key: string; label: string; y: number }>; + xAxisLabels: Array<{ key: string; label: string; x: number }>; }; type HomeForecastDay = { @@ -926,12 +932,61 @@ function buildHomeTrendChart( temperatureText: formatTemperature(point.y, detail.temp_symbol || "°C"), })); + const yAxisLabels = [ + { + key: "max", + label: formatTemperature(chartData.max, detail.temp_symbol || "°C"), + y: 14, + }, + { + key: "mid", + label: formatTemperature( + (chartData.max + chartData.min) / 2, + detail.temp_symbol || "°C", + ), + y: 36, + }, + { + key: "min", + label: formatTemperature(chartData.min, detail.temp_symbol || "°C"), + y: 58, + }, + ]; + + const xAxisSource = + hoverSource.length <= 4 + ? hoverSource + : [ + hoverSource[0], + hoverSource[Math.floor((hoverSource.length - 1) / 3)], + hoverSource[Math.floor(((hoverSource.length - 1) * 2) / 3)], + hoverSource[hoverSource.length - 1], + ]; + + const xAxisLabels = xAxisSource.map((point, index) => { + const projected = projectHomeTrendPoint( + point.x, + point.y, + chartData.xMin, + chartData.xMax, + chartData.min, + chartData.max, + ); + return { + key: `axis-${point.labelTime}-${index}`, + label: point.labelTime, + x: Number(((projected.cx / 296) * 100).toFixed(2)), + }; + }); + return { forecastPath, legendText: chartData.legendText, observationDots, hourlyReports, hoverPoints, + yAxisLabels, + xAxisLabels, }; } @@ -1243,15 +1298,13 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {