feat: implement PolyWeather dashboard core components, state management, and API integration
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[] }) {
|
||||
<div className={clsx("home-pro-card", isPro && "active")}>
|
||||
<div>
|
||||
<span>{proLabel}</span>
|
||||
<strong>
|
||||
{isPro
|
||||
? locale === "en-US"
|
||||
? "Today intraday analysis is the primary paid workflow."
|
||||
: "今日日内分析是当前主要付费工作流。"
|
||||
: locale === "en-US"
|
||||
{!isPro ? (
|
||||
<strong>
|
||||
{locale === "en-US"
|
||||
? "History review and future dates stay paid."
|
||||
: "历史复盘和未来日期保持付费。"}
|
||||
</strong>
|
||||
</strong>
|
||||
) : null}
|
||||
</div>
|
||||
{isPro ? (
|
||||
<button type="button" onClick={() => void store.openTodayModal()}>
|
||||
@@ -1319,10 +1372,6 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
{locale === "en-US" ? "Observed now" : "当前实况"}
|
||||
</span>
|
||||
<strong>{formatTemperature(currentTemp, symbol)}</strong>
|
||||
<span className="home-weather-sub">
|
||||
{locale === "en-US" ? "Feels near" : "体感接近"}{" "}
|
||||
{formatTemperature(currentTemp, symbol)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="home-weather-side">
|
||||
<div
|
||||
@@ -1366,6 +1415,17 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
<small>{locale === "en-US" ? "1h cadence" : "1h 级别"}</small>
|
||||
</h3>
|
||||
<div className="home-intraday-chart">
|
||||
<div className="home-intraday-y-axis" aria-hidden="true">
|
||||
{trendChart.yAxisLabels.map((axisLabel) => (
|
||||
<span
|
||||
key={axisLabel.key}
|
||||
className="home-intraday-y-label"
|
||||
style={{ top: `${axisLabel.y}px` }}
|
||||
>
|
||||
{axisLabel.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<svg viewBox="0 0 296 78" aria-hidden="true">
|
||||
<line x1="10" y1="14" x2="286" y2="14" />
|
||||
<line x1="10" y1="36" x2="286" y2="36" />
|
||||
@@ -1377,6 +1437,17 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
<circle key={point.key} cx={point.cx} cy={point.cy} r="3.2" />
|
||||
))}
|
||||
</svg>
|
||||
<div className="home-intraday-x-axis" aria-hidden="true">
|
||||
{trendChart.xAxisLabels.map((axisLabel) => (
|
||||
<span
|
||||
key={axisLabel.key}
|
||||
className="home-intraday-x-label"
|
||||
style={{ left: `${axisLabel.x}%` }}
|
||||
>
|
||||
{axisLabel.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{trendChart.hoverPoints.map((point) => (
|
||||
<button
|
||||
key={point.key}
|
||||
@@ -1952,17 +2023,19 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
const starterPrompts = useMemo(() => {
|
||||
if (locale === "en-US") {
|
||||
return [
|
||||
"Which market is worth buying now?",
|
||||
"Rank the current markets by edge",
|
||||
selectedCityName
|
||||
? `Why is ${selectedCityName} not recommended?`
|
||||
: "Explain what edge means",
|
||||
? `What is today's forecast high for ${selectedCityName}?`
|
||||
: "What is today's forecast high for the focus city?",
|
||||
"Which market is worth buying now?",
|
||||
"Rank current opportunities by edge",
|
||||
];
|
||||
}
|
||||
return [
|
||||
selectedCityName
|
||||
? `${selectedCityName} 今天预测最高温是多少?`
|
||||
: "当前焦点城市今天预测最高温是多少?",
|
||||
"当前有哪些值得参与的市场?",
|
||||
"按 edge 排序",
|
||||
selectedCityName ? `为什么 ${selectedCityName} 不建议参与?` : "解释一下 edge 是什么",
|
||||
];
|
||||
}, [locale, selectedCityName]);
|
||||
|
||||
@@ -2099,14 +2172,6 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleLauncherKeyDown = (
|
||||
event: ReactKeyboardEvent<HTMLDivElement>,
|
||||
) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
openAssistant();
|
||||
};
|
||||
|
||||
const sendQuestion = async (rawQuestion?: string) => {
|
||||
const question = String(rawQuestion ?? input).trim();
|
||||
if (!question || loading) return;
|
||||
@@ -2169,7 +2234,11 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
<>
|
||||
<div
|
||||
ref={dockRef}
|
||||
className={clsx("home-ai-assistant", isDragging && "dragging")}
|
||||
className={clsx(
|
||||
"home-ai-assistant",
|
||||
!isOpen && "collapsed",
|
||||
isDragging && "dragging",
|
||||
)}
|
||||
style={
|
||||
dockPosition
|
||||
? {
|
||||
@@ -2180,40 +2249,24 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
}
|
||||
>
|
||||
{!isOpen ? (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className="home-ai-launcher"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={openAssistant}
|
||||
onKeyDown={handleLauncherKeyDown}
|
||||
onPointerDown={beginDockDrag}
|
||||
onPointerMove={updateDockDrag}
|
||||
onPointerUp={endDockDrag}
|
||||
onPointerCancel={endDockDrag}
|
||||
aria-label={locale === "en-US" ? "Open AI assistant" : "打开 AI 助手"}
|
||||
>
|
||||
<span className="home-ai-launcher-badge">AI</span>
|
||||
<div className="home-ai-launcher-copy">
|
||||
<strong>
|
||||
{locale === "en-US" ? "Market Assistant" : "AI 对话助手"}
|
||||
</strong>
|
||||
<span>
|
||||
{store.proAccess.subscriptionActive
|
||||
? locale === "en-US"
|
||||
? "Ask using the current market snapshot"
|
||||
: "基于当前市场快照直接提问"
|
||||
: locale === "en-US"
|
||||
? "Pro only"
|
||||
: "Pro 专属"}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="home-ai-drag-handle"
|
||||
title={locale === "en-US" ? "Move assistant" : "拖动助手"}
|
||||
aria-hidden="true"
|
||||
>
|
||||
⋮⋮
|
||||
</span>
|
||||
</div>
|
||||
<Image
|
||||
src="/favicon-32x32.png"
|
||||
alt=""
|
||||
width={22}
|
||||
height={22}
|
||||
className="home-ai-launcher-icon"
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<section
|
||||
className="home-ai-panel"
|
||||
@@ -2224,8 +2277,8 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
<strong>{locale === "en-US" ? "AI assistant" : "AI 对话助手"}</strong>
|
||||
<span>
|
||||
{locale === "en-US"
|
||||
? `Snapshot ${assistantContext.snapshot_id}`
|
||||
: `快照 ${assistantContext.snapshot_id}`}
|
||||
? "Ask about cities, forecast highs, edge, and live opportunities"
|
||||
: "可直接问城市、最高温预测、edge 和实时市场机会"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="home-ai-header-actions">
|
||||
@@ -2253,8 +2306,8 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
|
||||
<div className="home-ai-disclaimer">
|
||||
{locale === "en-US"
|
||||
? "Only explains the current system snapshot. It does not scan markets or calculate probabilities."
|
||||
: "只解释当前系统快照,不参与市场扫描、概率计算或核心决策。"}
|
||||
? "You can ask about current temperature, today's forecast high, market opportunities, edge, and risk reasons."
|
||||
: "可直接问当前温度、今日最高温、市场机会、edge 和风险原因。"}
|
||||
</div>
|
||||
|
||||
<div className="home-ai-messages">
|
||||
@@ -2301,8 +2354,8 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
placeholder={
|
||||
locale === "en-US"
|
||||
? "Ask about current opportunities, a city, or edge..."
|
||||
: "问当前机会、某个城市,或者 edge 怎么看..."
|
||||
? "Ask about a city's current temperature, today's forecast high, or market edge..."
|
||||
: "可以问某个城市当前温度、今日最高温,或者市场 edge..."
|
||||
}
|
||||
/>
|
||||
<div className="home-ai-composer-actions">
|
||||
@@ -2461,10 +2514,9 @@ function DashboardScreen() {
|
||||
[cityName]: "pending",
|
||||
}));
|
||||
try {
|
||||
const detail = await store.ensureCityDetail(cityName, false, "panel");
|
||||
if (cancelled) return;
|
||||
const existingDetail = store.cityDetailsByName[cityName];
|
||||
const marketScan =
|
||||
detail.market_scan ||
|
||||
existingDetail?.market_scan ||
|
||||
(await store.ensureCityMarketScan(cityName, false));
|
||||
if (cancelled) return;
|
||||
setMarketScanStatusByCity((current) => ({
|
||||
@@ -2484,7 +2536,7 @@ function DashboardScreen() {
|
||||
};
|
||||
|
||||
void Promise.allSettled(
|
||||
Array.from({ length: Math.min(4, queue.length) }, () => runWorker()),
|
||||
Array.from({ length: Math.min(2, queue.length) }, () => runWorker()),
|
||||
);
|
||||
|
||||
return () => {
|
||||
@@ -2494,7 +2546,10 @@ function DashboardScreen() {
|
||||
marketScanStatusByCity,
|
||||
marketScanTargetNames,
|
||||
showHomepageChrome,
|
||||
store,
|
||||
store.cityDetailsByName,
|
||||
store.ensureCityMarketScan,
|
||||
store.proAccess.authenticated,
|
||||
store.proAccess.loading,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -570,15 +570,18 @@ export function DashboardStoreProvider({
|
||||
};
|
||||
|
||||
const ensureCityMarketScan = async (cityName: string, force = false) => {
|
||||
const cached = cityDetailsByName[cityName];
|
||||
let cached = cityDetailsByName[cityName];
|
||||
try {
|
||||
if (!cached) {
|
||||
cached = await ensureCityDetail(cityName, false, "panel");
|
||||
}
|
||||
const payload = await dashboardClient.getCityMarketScan(cityName, {
|
||||
force,
|
||||
targetDate: cached?.local_date || selectedForecastDate || null,
|
||||
});
|
||||
if (!payload.market_scan) return null;
|
||||
setCityDetailsByName((current) => {
|
||||
const detail = current[cityName];
|
||||
const detail = current[cityName] || cached;
|
||||
if (!detail) return current;
|
||||
return {
|
||||
...current,
|
||||
|
||||
@@ -17,6 +17,8 @@ export 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;
|
||||
|
||||
@@ -54,7 +54,8 @@ function isPublicApi(pathname: string) {
|
||||
pathname === "/api/cities" ||
|
||||
pathname === "/api/vitals" ||
|
||||
/^\/api\/city\/[^/]+$/i.test(pathname) ||
|
||||
/^\/api\/city\/[^/]+\/summary$/i.test(pathname)
|
||||
/^\/api\/city\/[^/]+\/summary$/i.test(pathname) ||
|
||||
/^\/api\/city\/[^/]+\/market-scan$/i.test(pathname)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user