feat: implement dashboard store and API routes for city market scanning and analysis
This commit is contained in:
@@ -470,6 +470,18 @@ function buildFallbackAnswer(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldUseRuleAnswer(
|
||||||
|
question: string,
|
||||||
|
context: ReturnType<typeof sanitizeContext>,
|
||||||
|
) {
|
||||||
|
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: {
|
async function generateWithGroq(params: {
|
||||||
question: string;
|
question: string;
|
||||||
locale: string;
|
locale: string;
|
||||||
@@ -636,7 +648,8 @@ export async function POST(request: NextRequest) {
|
|||||||
model: "rule-fallback",
|
model: "rule-fallback",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!fallback.refused) {
|
const useRuleAnswer = shouldUseRuleAnswer(question, context);
|
||||||
|
if (!fallback.refused && !useRuleAnswer) {
|
||||||
try {
|
try {
|
||||||
const generated = await generateWithGroq({
|
const generated = await generateWithGroq({
|
||||||
question,
|
question,
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ import {
|
|||||||
|
|
||||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
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(
|
export async function GET(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
context: { params: Promise<{ name: string }> },
|
context: { params: Promise<{ name: string }> },
|
||||||
@@ -49,7 +57,11 @@ export async function GET(
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const raw = await res.text();
|
const raw = await res.text();
|
||||||
const response = NextResponse.json(
|
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 },
|
{ status: 502 },
|
||||||
);
|
);
|
||||||
return applyAuthResponseCookies(response, auth.response);
|
return applyAuthResponseCookies(response, auth.response);
|
||||||
@@ -63,8 +75,11 @@ export async function GET(
|
|||||||
return applyAuthResponseCookies(response, auth.response);
|
return applyAuthResponseCookies(response, auth.response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const response = NextResponse.json(
|
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;
|
return response;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,10 +220,12 @@ export const OpportunityTable = React.memo(function OpportunityTable({
|
|||||||
const { locale } = useI18n();
|
const { locale } = useI18n();
|
||||||
const isEn = locale === "en-US";
|
const isEn = locale === "en-US";
|
||||||
const hasRows = rows.length > 0;
|
const hasRows = rows.length > 0;
|
||||||
|
const scanInProgress =
|
||||||
|
loading || status === "partial" || status === "scanning";
|
||||||
|
|
||||||
if (!hasRows) {
|
if (!hasRows) {
|
||||||
const title =
|
const title =
|
||||||
loading
|
scanInProgress
|
||||||
? isEn
|
? isEn
|
||||||
? "Scanning markets"
|
? "Scanning markets"
|
||||||
: "正在扫描市场"
|
: "正在扫描市场"
|
||||||
@@ -235,7 +237,7 @@ export const OpportunityTable = React.memo(function OpportunityTable({
|
|||||||
? "No tradable market right now"
|
? "No tradable market right now"
|
||||||
: "当前暂无可交易市场";
|
: "当前暂无可交易市场";
|
||||||
const copy =
|
const copy =
|
||||||
loading
|
scanInProgress
|
||||||
? isEn
|
? isEn
|
||||||
? "Waiting for the latest market snapshot. Existing data will stay on screen when available."
|
? "Waiting for the latest market snapshot. Existing data will stay on screen when available."
|
||||||
: "正在等待最新市场快照;如果有旧数据,会继续保留在页面上。"
|
: "正在等待最新市场快照;如果有旧数据,会继续保留在页面上。"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
useDeferredValue,
|
useDeferredValue,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import styles from "./Dashboard.module.css";
|
import styles from "./Dashboard.module.css";
|
||||||
@@ -33,7 +34,11 @@ import {
|
|||||||
useDashboardStore,
|
useDashboardStore,
|
||||||
} from "@/hooks/useDashboardStore";
|
} from "@/hooks/useDashboardStore";
|
||||||
import { I18nProvider, useI18n } from "@/hooks/useI18n";
|
import { I18nProvider, useI18n } from "@/hooks/useI18n";
|
||||||
import { dashboardClient } from "@/lib/dashboard-client";
|
import {
|
||||||
|
dashboardClient,
|
||||||
|
type AssistantContextPayload,
|
||||||
|
type AssistantOpportunityContext,
|
||||||
|
} from "@/lib/dashboard-client";
|
||||||
import type {
|
import type {
|
||||||
DistributionPreviewPoint,
|
DistributionPreviewPoint,
|
||||||
MarketScan,
|
MarketScan,
|
||||||
@@ -64,6 +69,11 @@ const DEFAULT_FILTERS: FilterState = {
|
|||||||
|
|
||||||
type ContentView = "list" | "map" | "calendar";
|
type ContentView = "list" | "map" | "calendar";
|
||||||
type ThemeMode = "dark" | "light";
|
type ThemeMode = "dark" | "light";
|
||||||
|
type AssistantMessage = {
|
||||||
|
id: string;
|
||||||
|
role: "assistant" | "user";
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
function formatPercent(value?: number | null, signed = false) {
|
function formatPercent(value?: number | null, signed = false) {
|
||||||
if (value == null || Number.isNaN(Number(value))) return "--";
|
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({
|
function DetailPanel({
|
||||||
row,
|
row,
|
||||||
marketScan,
|
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<string | null>(null);
|
||||||
|
const [messages, setMessages] = useState<AssistantMessage[]>(() => [
|
||||||
|
{
|
||||||
|
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<HTMLElement>) => {
|
||||||
|
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<HTMLElement>) => {
|
||||||
|
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<HTMLElement>) => {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={clsx("home-ai-assistant", !open && "collapsed", dragging && "dragging")}
|
||||||
|
style={{
|
||||||
|
left: position.x,
|
||||||
|
top: position.y,
|
||||||
|
right: "auto",
|
||||||
|
bottom: "auto",
|
||||||
|
width: open ? "min(380px, calc(100vw - 32px))" : 56,
|
||||||
|
}}
|
||||||
|
onPointerMove={updateDrag}
|
||||||
|
onPointerUp={endDrag}
|
||||||
|
onPointerCancel={endDrag}
|
||||||
|
>
|
||||||
|
{!open ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="home-ai-launcher"
|
||||||
|
aria-label={isEn ? "Open AI assistant" : "打开 AI 对话助手"}
|
||||||
|
title={isEn ? "Open AI assistant" : "打开 AI 对话助手"}
|
||||||
|
onPointerDown={startDrag}
|
||||||
|
onClick={handleLauncherClick}
|
||||||
|
>
|
||||||
|
<img src="/favicon-32x32.png" alt="" className="home-ai-launcher-icon" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<section className="home-ai-panel" aria-label={isEn ? "AI assistant" : "AI 对话助手"}>
|
||||||
|
<div className="home-ai-header">
|
||||||
|
<div>
|
||||||
|
<strong>{isEn ? "AI Assistant" : "AI 对话助手"}</strong>
|
||||||
|
<span>
|
||||||
|
{isEn ? "Current market snapshot only" : "仅基于当前市场快照"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="home-ai-header-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="home-ai-drag-handle"
|
||||||
|
aria-label={isEn ? "Move assistant" : "移动助手"}
|
||||||
|
title={isEn ? "Move assistant" : "移动助手"}
|
||||||
|
onPointerDown={startDrag}
|
||||||
|
>
|
||||||
|
⋮⋮
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="home-ai-close"
|
||||||
|
aria-label={isEn ? "Collapse assistant" : "收起助手"}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="home-ai-disclaimer">
|
||||||
|
{isEn
|
||||||
|
? "Uses only scan data already provided by PolyWeather; no invented prices, probabilities, or cities."
|
||||||
|
: "只使用 PolyWeather 当前扫描数据,不编造价格、概率或城市。"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="home-ai-messages" aria-live="polite">
|
||||||
|
{messages.map((message) => (
|
||||||
|
<div key={message.id} className={clsx("home-ai-message", message.role)}>
|
||||||
|
<p>{message.content}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{submitting ? (
|
||||||
|
<div className="home-ai-message assistant loading">
|
||||||
|
<p>{isEn ? "Reading the current scan..." : "正在读取当前扫描数据..."}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="home-ai-starters">
|
||||||
|
{starterQuestions.map((starter) => (
|
||||||
|
<button
|
||||||
|
key={starter}
|
||||||
|
type="button"
|
||||||
|
className="home-ai-starter"
|
||||||
|
onClick={() => void submitQuestion(starter)}
|
||||||
|
>
|
||||||
|
{starter}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="home-ai-composer">
|
||||||
|
<textarea
|
||||||
|
className="home-ai-input"
|
||||||
|
value={question}
|
||||||
|
onChange={(event) => setQuestion(event.target.value)}
|
||||||
|
placeholder={
|
||||||
|
isEn
|
||||||
|
? "Ask about current opportunities, one city, or edge..."
|
||||||
|
: "询问当前机会、某个城市、edge 排序..."
|
||||||
|
}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
|
||||||
|
event.preventDefault();
|
||||||
|
void submitQuestion();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="home-ai-composer-actions">
|
||||||
|
<span className="home-ai-error">{error}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="home-ai-send"
|
||||||
|
disabled={!question.trim() || submitting}
|
||||||
|
onClick={() => void submitQuestion()}
|
||||||
|
>
|
||||||
|
{isEn ? "Send" : "发送"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ScanTerminalScreen() {
|
function ScanTerminalScreen() {
|
||||||
const store = useDashboardStore();
|
const store = useDashboardStore();
|
||||||
const { locale, toggleLocale } = useI18n();
|
const { locale, toggleLocale } = useI18n();
|
||||||
@@ -974,6 +1501,13 @@ function ScanTerminalScreen() {
|
|||||||
marketScan={selectedDetail}
|
marketScan={selectedDetail}
|
||||||
loading={detailLoadingId === activeDetailRow?.id}
|
loading={detailLoadingId === activeDetailRow?.id}
|
||||||
/>
|
/>
|
||||||
|
<AssistantWidget
|
||||||
|
terminalData={terminalData}
|
||||||
|
rows={timeSortedRows}
|
||||||
|
selectedRow={activeDetailRow}
|
||||||
|
locale={locale}
|
||||||
|
totalCities={store.cities.length}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -626,7 +626,8 @@ export function DashboardStoreProvider({
|
|||||||
lite: options?.lite === true,
|
lite: options?.lite === true,
|
||||||
marketSlug: options?.marketSlug || null,
|
marketSlug: options?.marketSlug || null,
|
||||||
targetDate:
|
targetDate:
|
||||||
options?.targetDate || cached?.local_date || selectedForecastDate || null,
|
options?.targetDate ||
|
||||||
|
(options?.marketSlug ? cached?.local_date || selectedForecastDate || null : null),
|
||||||
});
|
});
|
||||||
if (!payload.market_scan) return null;
|
if (!payload.market_scan) return null;
|
||||||
setCityDetailsByName((current) => {
|
setCityDetailsByName((current) => {
|
||||||
|
|||||||
@@ -112,6 +112,19 @@ async function fetchJson<T>(url: string): Promise<T> {
|
|||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isStaleMarketSlugResponse(payload: {
|
||||||
|
market_scan?: MarketScan | null;
|
||||||
|
} | null | undefined) {
|
||||||
|
const scan = payload?.market_scan;
|
||||||
|
const reason = String(scan?.reason || "").toLowerCase();
|
||||||
|
return (
|
||||||
|
scan?.available === false &&
|
||||||
|
(reason.includes("market_slug not found") ||
|
||||||
|
reason.includes("specified market_slug not found") ||
|
||||||
|
reason.includes("slug not found"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isClient() {
|
function isClient() {
|
||||||
return typeof window !== "undefined";
|
return typeof window !== "undefined";
|
||||||
}
|
}
|
||||||
@@ -338,15 +351,31 @@ export const dashboardClient = {
|
|||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const request = fetchJson<{
|
type MarketScanPayload = {
|
||||||
fetched_at?: string | null;
|
fetched_at?: string | null;
|
||||||
market_scan?: MarketScan | null;
|
market_scan?: MarketScan | null;
|
||||||
selected_date?: string | null;
|
selected_date?: string | null;
|
||||||
}>(`/api/city/${normalizeCityName(cityName)}/market-scan?${params.toString()}`).finally(
|
};
|
||||||
() => {
|
const request = (async () => {
|
||||||
pendingCityMarketScanRequests.delete(requestKey);
|
const payload = await fetchJson<MarketScanPayload>(
|
||||||
},
|
`/api/city/${normalizeCityName(cityName)}/market-scan?${params.toString()}`,
|
||||||
);
|
);
|
||||||
|
if (!force && options?.marketSlug && isStaleMarketSlugResponse(payload)) {
|
||||||
|
const fallbackParams = new URLSearchParams({
|
||||||
|
force_refresh: "false",
|
||||||
|
_ts: String(Date.now()),
|
||||||
|
});
|
||||||
|
if (options?.lite) {
|
||||||
|
fallbackParams.set("lite", "true");
|
||||||
|
}
|
||||||
|
return fetchJson<MarketScanPayload>(
|
||||||
|
`/api/city/${normalizeCityName(cityName)}/market-scan?${fallbackParams.toString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
})().finally(() => {
|
||||||
|
pendingCityMarketScanRequests.delete(requestKey);
|
||||||
|
});
|
||||||
if (!force) {
|
if (!force) {
|
||||||
pendingCityMarketScanRequests.set(requestKey, request);
|
pendingCityMarketScanRequests.set(requestKey, request);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -546,6 +546,9 @@ export interface ScanTerminalResponse {
|
|||||||
tradable_market_count: number;
|
tradable_market_count: number;
|
||||||
total_volume: number;
|
total_volume: number;
|
||||||
resolved_market_type?: string | null;
|
resolved_market_type?: string | null;
|
||||||
|
total_city_count?: number | null;
|
||||||
|
scanned_city_count?: number | null;
|
||||||
|
failed_city_count?: number | null;
|
||||||
};
|
};
|
||||||
top_signal?: PrimarySignal | null;
|
top_signal?: PrimarySignal | null;
|
||||||
rows: ScanOpportunityRow[];
|
rows: ScanOpportunityRow[];
|
||||||
|
|||||||
+112
-27
@@ -5,6 +5,7 @@ import os
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import hashlib
|
import hashlib
|
||||||
|
from concurrent.futures import TimeoutError as FutureTimeoutError
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
@@ -16,10 +17,15 @@ from web.core import CITIES
|
|||||||
|
|
||||||
_SCAN_TERMINAL_CACHE_LOCK = threading.Lock()
|
_SCAN_TERMINAL_CACHE_LOCK = threading.Lock()
|
||||||
_SCAN_TERMINAL_CACHE: Dict[str, Dict[str, Any]] = {}
|
_SCAN_TERMINAL_CACHE: Dict[str, Dict[str, Any]] = {}
|
||||||
|
_SCAN_TERMINAL_REFRESHING: set[str] = set()
|
||||||
SCAN_TERMINAL_PAYLOAD_TTL_SEC = max(
|
SCAN_TERMINAL_PAYLOAD_TTL_SEC = max(
|
||||||
5,
|
5,
|
||||||
int(os.getenv("POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC", "30")),
|
int(os.getenv("POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC", "30")),
|
||||||
)
|
)
|
||||||
|
SCAN_TERMINAL_BUILD_TIMEOUT_SEC = max(
|
||||||
|
8,
|
||||||
|
int(os.getenv("POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC", "22")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _safe_float(value: Any) -> Optional[float]:
|
def _safe_float(value: Any) -> Optional[float]:
|
||||||
@@ -172,6 +178,31 @@ def _set_scan_terminal_failure_state(
|
|||||||
_SCAN_TERMINAL_CACHE[cache_key] = existing
|
_SCAN_TERMINAL_CACHE[cache_key] = existing
|
||||||
|
|
||||||
|
|
||||||
|
def _start_scan_terminal_background_refresh(filters: Dict[str, Any]) -> bool:
|
||||||
|
cache_key = _scan_terminal_cache_key(filters)
|
||||||
|
with _SCAN_TERMINAL_CACHE_LOCK:
|
||||||
|
if cache_key in _SCAN_TERMINAL_REFRESHING:
|
||||||
|
return False
|
||||||
|
_SCAN_TERMINAL_REFRESHING.add(cache_key)
|
||||||
|
|
||||||
|
def _runner() -> None:
|
||||||
|
try:
|
||||||
|
_build_scan_terminal_payload_uncached(filters, force_refresh=True)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive background guard
|
||||||
|
logger.warning("scan terminal background refresh failed: {}", exc)
|
||||||
|
finally:
|
||||||
|
with _SCAN_TERMINAL_CACHE_LOCK:
|
||||||
|
_SCAN_TERMINAL_REFRESHING.discard(cache_key)
|
||||||
|
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=_runner,
|
||||||
|
name="polyweather-scan-terminal-refresh",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _build_stale_scan_terminal_payload(
|
def _build_stale_scan_terminal_payload(
|
||||||
*,
|
*,
|
||||||
filters: Dict[str, Any],
|
filters: Dict[str, Any],
|
||||||
@@ -363,16 +394,11 @@ def _scan_city_terminal_rows(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_scan_terminal_payload(
|
def _build_scan_terminal_payload_uncached(
|
||||||
raw_filters: Optional[Dict[str, Any]] = None,
|
filters: Dict[str, Any],
|
||||||
*,
|
*,
|
||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
filters = _normalize_scan_terminal_filters(raw_filters)
|
|
||||||
if not force_refresh:
|
|
||||||
cached = _get_cached_scan_terminal_payload(filters)
|
|
||||||
if cached is not None:
|
|
||||||
return cached
|
|
||||||
cached_entry = _get_scan_terminal_cache_entry(filters) or {}
|
cached_entry = _get_scan_terminal_cache_entry(filters) or {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -382,24 +408,51 @@ def build_scan_terminal_payload(
|
|||||||
failed_cities: List[str] = []
|
failed_cities: List[str] = []
|
||||||
failed_reasons: List[str] = []
|
failed_reasons: List[str] = []
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
timed_out = False
|
||||||
future_map = {
|
timeout_message: Optional[str] = None
|
||||||
executor.submit(
|
executor = ThreadPoolExecutor(max_workers=max_workers)
|
||||||
_scan_city_terminal_rows,
|
future_map = {
|
||||||
city_name,
|
executor.submit(
|
||||||
filters,
|
_scan_city_terminal_rows,
|
||||||
force_refresh=force_refresh,
|
city_name,
|
||||||
): city_name
|
filters,
|
||||||
for city_name in city_names
|
force_refresh=force_refresh,
|
||||||
}
|
): city_name
|
||||||
for future in as_completed(future_map):
|
for city_name in city_names
|
||||||
city_name = future_map[future]
|
}
|
||||||
try:
|
try:
|
||||||
city_results.append(future.result())
|
try:
|
||||||
except Exception as exc:
|
completed = as_completed(
|
||||||
failed_cities.append(city_name)
|
future_map,
|
||||||
failed_reasons.append(str(exc))
|
timeout=float(SCAN_TERMINAL_BUILD_TIMEOUT_SEC),
|
||||||
logger.warning("scan terminal city failed city={}: {}", city_name, exc)
|
)
|
||||||
|
for future in completed:
|
||||||
|
city_name = future_map[future]
|
||||||
|
try:
|
||||||
|
city_results.append(future.result())
|
||||||
|
except Exception as exc:
|
||||||
|
failed_cities.append(city_name)
|
||||||
|
failed_reasons.append(str(exc))
|
||||||
|
logger.warning("scan terminal city failed city={}: {}", city_name, exc)
|
||||||
|
except FutureTimeoutError:
|
||||||
|
timed_out = True
|
||||||
|
timeout_message = (
|
||||||
|
f"scan terminal build timed out after "
|
||||||
|
f"{SCAN_TERMINAL_BUILD_TIMEOUT_SEC}s"
|
||||||
|
)
|
||||||
|
failed_reasons.append(timeout_message)
|
||||||
|
for future, city_name in future_map.items():
|
||||||
|
if not future.done():
|
||||||
|
future.cancel()
|
||||||
|
failed_cities.append(city_name)
|
||||||
|
logger.warning(
|
||||||
|
"{}; completed={}/{}",
|
||||||
|
timeout_message,
|
||||||
|
len(city_results),
|
||||||
|
len(city_names),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
executor.shutdown(wait=False)
|
||||||
|
|
||||||
if city_names and len(failed_cities) >= len(city_names):
|
if city_names and len(failed_cities) >= len(city_names):
|
||||||
error_message = failed_reasons[0] if failed_reasons else "all city market scans failed"
|
error_message = failed_reasons[0] if failed_reasons else "all city market scans failed"
|
||||||
@@ -480,6 +533,9 @@ def build_scan_terminal_payload(
|
|||||||
"tradable_market_count": len(unique_market_volume),
|
"tradable_market_count": len(unique_market_volume),
|
||||||
"total_volume": sum(unique_market_volume.values()),
|
"total_volume": sum(unique_market_volume.values()),
|
||||||
"resolved_market_type": "maxtemp",
|
"resolved_market_type": "maxtemp",
|
||||||
|
"total_city_count": len(city_names),
|
||||||
|
"scanned_city_count": len(city_results),
|
||||||
|
"failed_city_count": len(failed_cities),
|
||||||
}
|
}
|
||||||
payload = {
|
payload = {
|
||||||
"generated_at": datetime.utcnow().isoformat() + "Z",
|
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||||||
@@ -487,9 +543,9 @@ def build_scan_terminal_payload(
|
|||||||
"summary": summary,
|
"summary": summary,
|
||||||
"top_signal": top_signal,
|
"top_signal": top_signal,
|
||||||
"rows": ranked_rows,
|
"rows": ranked_rows,
|
||||||
"status": "ready",
|
"status": "partial" if timed_out else "ready",
|
||||||
"stale": False,
|
"stale": False,
|
||||||
"stale_reason": None,
|
"stale_reason": timeout_message,
|
||||||
"last_success_at": None,
|
"last_success_at": None,
|
||||||
"last_failed_at": None,
|
"last_failed_at": None,
|
||||||
}
|
}
|
||||||
@@ -520,3 +576,32 @@ def build_scan_terminal_payload(
|
|||||||
error_message=error_message,
|
error_message=error_message,
|
||||||
failed_at=failed_at,
|
failed_at=failed_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_scan_terminal_payload(
|
||||||
|
raw_filters: Optional[Dict[str, Any]] = None,
|
||||||
|
*,
|
||||||
|
force_refresh: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
filters = _normalize_scan_terminal_filters(raw_filters)
|
||||||
|
if not force_refresh:
|
||||||
|
cached = _get_cached_scan_terminal_payload(filters)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
cached_entry = _get_scan_terminal_cache_entry(filters) or {}
|
||||||
|
success_payload = cached_entry.get("success_payload")
|
||||||
|
if isinstance(success_payload, dict) and success_payload:
|
||||||
|
started = _start_scan_terminal_background_refresh(filters)
|
||||||
|
return _build_stale_scan_terminal_payload(
|
||||||
|
filters=filters,
|
||||||
|
success_payload=success_payload,
|
||||||
|
error_message=(
|
||||||
|
"正在后台刷新市场扫描快照"
|
||||||
|
if started
|
||||||
|
else "市场扫描快照正在刷新中"
|
||||||
|
),
|
||||||
|
failed_at=cached_entry.get("last_failed_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return _build_scan_terminal_payload_uncached(filters, force_refresh=force_refresh)
|
||||||
|
|||||||
Reference in New Issue
Block a user