Add pro AI assistant to homepage

This commit is contained in:
2569718930@qq.com
2026-04-23 08:44:10 +08:00
parent ecba404e88
commit b65bdd010d
6 changed files with 1459 additions and 22 deletions
+609
View File
@@ -0,0 +1,609 @@
import { createHash } from "crypto";
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
const GROQ_API_URL = "https://api.groq.com/openai/v1/chat/completions";
const GROQ_MODEL = process.env.GROQ_MODEL || "llama-3.3-70b-versatile";
const AI_CACHE_TTL_MS =
Number(process.env.POLYWEATHER_AI_ANSWER_CACHE_TTL_SEC || "240") * 1000;
type AssistantOpportunityContext = {
city_name: string;
city_display_name: string;
airport?: string | null;
risk_level: string;
tradable: boolean;
local_time?: string | null;
current_temperature?: number | null;
deb_prediction?: number | null;
market_question?: string | null;
market_label?: string | null;
selected_date?: string | null;
best_side?: string | null;
yes_price?: number | null;
no_price?: number | null;
edge_percent?: number | null;
market_probability?: number | null;
model_probability?: number | null;
status?: string | null;
};
type AssistantContextPayload = {
snapshot_id?: string;
locale?: string;
generated_at?: string;
totals?: {
cities?: number;
tradable_markets?: number;
high_risk?: number;
medium_risk?: number;
low_risk?: number;
};
selected_city?: AssistantOpportunityContext | null;
opportunities?: AssistantOpportunityContext[];
glossary?: Array<{
term: string;
meaning: string;
}>;
};
type AssistantChatRequest = {
question?: string;
locale?: string;
snapshot_id?: string;
context?: AssistantContextPayload;
};
type AssistantChatResponse = {
answer: string;
cached?: boolean;
model?: string;
refused?: boolean;
snapshot_id?: string;
suggestions?: string[];
};
type CachedAssistantReply = {
expiresAt: number;
payload: AssistantChatResponse;
};
const assistantAnswerCache = new Map<string, CachedAssistantReply>();
function normalizeQuestion(value: string) {
return value.trim().replace(/\s+/g, " ").slice(0, 500);
}
function normalizeLocale(value?: string | null) {
return value === "en-US" ? "en-US" : "zh-CN";
}
function hashText(value: string) {
return createHash("sha1").update(value).digest("hex").slice(0, 16);
}
function normalizeRiskLevel(value: string | undefined, locale: string) {
const risk = String(value || "").toLowerCase();
if (risk === "high") return locale === "en-US" ? "high risk" : "高风险";
if (risk === "medium") return locale === "en-US" ? "watch list" : "重点观察";
if (risk === "low") return locale === "en-US" ? "low risk" : "低波动";
return locale === "en-US" ? "unrated" : "待评级";
}
function normalizePercent(value: number | null | undefined) {
if (!Number.isFinite(Number(value))) return null;
const numeric = Number(value);
return Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
}
function normalizeProbability(value: number | null | undefined) {
const percent = normalizePercent(value);
return percent == null ? null : Number(percent.toFixed(1));
}
function normalizeCents(value: number | null | undefined) {
if (!Number.isFinite(Number(value))) return null;
const numeric = Number(value);
return Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
}
function buildSnapshotId(context: AssistantContextPayload) {
const source = JSON.stringify({
totals: context.totals || {},
selected_city: context.selected_city || null,
opportunities: Array.isArray(context.opportunities)
? context.opportunities.slice(0, 52)
: [],
});
return `snapshot-${hashText(source)}`;
}
function sanitizeContext(input?: AssistantContextPayload | null) {
const opportunities = Array.isArray(input?.opportunities)
? input!.opportunities
.slice(0, 52)
.map((item) => ({
city_name: String(item.city_name || "").trim(),
city_display_name: String(item.city_display_name || "").trim(),
airport: item.airport ? String(item.airport) : null,
risk_level: String(item.risk_level || "").trim(),
tradable: item.tradable === true,
local_time: item.local_time ? String(item.local_time) : null,
current_temperature: Number.isFinite(Number(item.current_temperature))
? Number(item.current_temperature)
: null,
deb_prediction: Number.isFinite(Number(item.deb_prediction))
? Number(item.deb_prediction)
: null,
market_question: item.market_question
? String(item.market_question).slice(0, 240)
: null,
market_label: item.market_label
? String(item.market_label).slice(0, 120)
: null,
selected_date: item.selected_date ? String(item.selected_date) : null,
best_side: item.best_side ? String(item.best_side).toUpperCase() : null,
yes_price: normalizeCents(item.yes_price),
no_price: normalizeCents(item.no_price),
edge_percent: normalizePercent(item.edge_percent),
market_probability: normalizeProbability(item.market_probability),
model_probability: normalizeProbability(item.model_probability),
status: item.status ? String(item.status) : null,
}))
.filter((item) => item.city_name || item.city_display_name)
: [];
const selectedCity = input?.selected_city
? opportunities.find(
(item) =>
item.city_name === input.selected_city?.city_name ||
item.city_display_name === input.selected_city?.city_display_name,
) || null
: null;
return {
snapshot_id: String(input?.snapshot_id || "").trim() || buildSnapshotId(input || {}),
locale: normalizeLocale(input?.locale),
generated_at: input?.generated_at
? String(input.generated_at)
: new Date().toISOString(),
totals: {
cities: Number(input?.totals?.cities || opportunities.length || 0),
tradable_markets: Number(
input?.totals?.tradable_markets ||
opportunities.filter((item) => item.tradable).length ||
0,
),
high_risk: Number(input?.totals?.high_risk || 0),
medium_risk: Number(input?.totals?.medium_risk || 0),
low_risk: Number(input?.totals?.low_risk || 0),
},
selected_city: selectedCity,
opportunities,
glossary: Array.isArray(input?.glossary)
? input!.glossary.slice(0, 8).map((item) => ({
term: String(item.term || "").slice(0, 64),
meaning: String(item.meaning || "").slice(0, 220),
}))
: [],
};
}
function buildCacheKey(question: string, locale: string, snapshotId: string) {
return `${locale}::${snapshotId}::${question.toLowerCase()}`;
}
function buildSuggestions(
locale: string,
selectedCity?: AssistantOpportunityContext | null,
) {
if (locale === "en-US") {
return [
"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 [
"当前有哪些值得参与的市场?",
"按 edge 排序",
selectedCity?.city_display_name
? `为什么 ${selectedCity.city_display_name} 不建议参与?`
: "解释一下 edge 是什么",
];
}
function findMentionedCity(
question: string,
context: ReturnType<typeof sanitizeContext>,
) {
const normalizedQuestion = question.toLowerCase();
return (
context.opportunities.find((item) => {
const candidates = [
item.city_name,
item.city_display_name,
item.airport || "",
]
.map((value) => value.toLowerCase())
.filter(Boolean);
return candidates.some((candidate) => normalizedQuestion.includes(candidate));
}) || null
);
}
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."
: "我只能回答当前 PolyWeather 市场快照里的问题,例如当前机会、单城市分析、排序筛选和指标解释。";
}
function buildExplainAnswer(
question: string,
locale: string,
context: ReturnType<typeof sanitizeContext>,
) {
const normalized = question.toLowerCase();
const glossaryMatch = context.glossary.find((item) =>
normalized.includes(String(item.term || "").toLowerCase()),
);
if (glossaryMatch) {
return glossaryMatch.meaning;
}
if (normalized.includes("edge")) {
return locale === "en-US"
? "Edge is the gap between model probability and market-implied probability. Positive edge means the model is more bullish than the market."
: "edge 是模型概率和市场隐含概率之间的差值。正 edge 表示模型比市场更乐观。";
}
if (normalized.includes("emos")) {
return locale === "en-US"
? "EMOS is the calibrated probability ladder used here for the 24h max-temperature market buckets."
: "EMOS 是这里用于 24 小时最高温市场分桶的校准概率分布。";
}
if (normalized.includes("deb")) {
return locale === "en-US"
? "DEB is the system forecast anchor for the day's max temperature. It is one of the inputs used by the product, not the final trading decision."
: "DEB 是系统对当日最高温的核心预测锚点之一,它是产品输入,不是最终交易决策本身。";
}
return buildUnsupportedAnswer(locale);
}
function buildOpportunityAnswer(
question: string,
locale: string,
context: ReturnType<typeof sanitizeContext>,
) {
const ranked = [...context.opportunities]
.filter((item) => item.tradable)
.sort((left, right) => (right.edge_percent || -999) - (left.edge_percent || -999));
if (!ranked.length) {
return locale === "en-US"
? "There is no tradable market in the current snapshot. The scan finished, but no live market passes the current filters."
: "当前快照里没有可交易市场。也就是说扫描结果已经出来了,但没有市场通过当前可交易筛选。";
}
const wantsYes = /(^|\s)yes(\s|$)|买\s*yes|做多|buy yes/i.test(question);
const wantsNo = /(^|\s)no(\s|$)|买\s*no|做空|buy no/i.test(question);
const filtered = ranked.filter((item) => {
if (wantsYes) return item.best_side === "YES";
if (wantsNo) return item.best_side === "NO";
return true;
});
const items = (filtered.length ? filtered : ranked).slice(0, 5);
const lines = items.map((item, index) => {
const sideText = item.best_side
? locale === "en-US"
? `preferred side ${item.best_side}`
: `倾向 ${item.best_side}`
: locale === "en-US"
? "side unavailable"
: "方向待定";
const edgeText =
item.edge_percent == null ? "--" : `${item.edge_percent.toFixed(1)}%`;
const yesText = item.yes_price == null ? "--" : `${Math.round(item.yes_price)}¢`;
const noText = item.no_price == null ? "--" : `${Math.round(item.no_price)}¢`;
return locale === "en-US"
? `${index + 1}. ${item.city_display_name}: ${item.market_label || item.market_question || "market unavailable"}, edge ${edgeText}, YES ${yesText}, NO ${noText}, ${sideText}.`
: `${index + 1}. ${item.city_display_name}${item.market_label || item.market_question || "市场信息缺失"}edge ${edgeText}YES ${yesText}NO ${noText}${sideText}`;
});
return locale === "en-US"
? [`Top tradable markets in the current snapshot:`, ...lines].join("\n")
: [`当前快照里最值得优先看的可交易市场如下:`, ...lines].join("\n");
}
function buildCityAnswer(
locale: string,
city: AssistantOpportunityContext,
) {
const edgeText =
city.edge_percent == null ? "--" : `${city.edge_percent.toFixed(1)}%`;
const yesText = city.yes_price == null ? "--" : `${Math.round(city.yes_price)}¢`;
const noText = city.no_price == null ? "--" : `${Math.round(city.no_price)}¢`;
const modelText =
city.model_probability == null
? "--"
: `${city.model_probability.toFixed(1)}%`;
const marketText =
city.market_probability == null
? "--"
: `${city.market_probability.toFixed(1)}%`;
if (locale === "en-US") {
return [
`${city.city_display_name} is currently marked as ${normalizeRiskLevel(city.risk_level, locale)}.`,
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 ?? "--"
}.`,
].join(" ");
}
return [
`${city.city_display_name} 当前被归类为${normalizeRiskLevel(city.risk_level, locale)}`,
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 ?? "--"
}`,
].join("");
}
function detectUnsupported(question: string) {
return /冷锋|台风百科|气象百科|why is the sky|what is a cold front|recipe|股票|crypto|足球|nba/i.test(
question,
);
}
function buildFallbackAnswer(
question: string,
locale: string,
context: ReturnType<typeof sanitizeContext>,
) {
if (detectUnsupported(question)) {
return {
answer: buildUnsupportedAnswer(locale),
refused: true,
};
}
const mentionedCity = findMentionedCity(question, context);
if (mentionedCity) {
return {
answer: buildCityAnswer(locale, mentionedCity),
refused: false,
};
}
if (/edge|概率|probability|模型|emos|deb/i.test(question)) {
return {
answer: buildExplainAnswer(question, locale, context),
refused: false,
};
}
return {
answer: buildOpportunityAnswer(question, locale, context),
refused: false,
};
}
async function generateWithGroq(params: {
question: string;
locale: string;
context: ReturnType<typeof sanitizeContext>;
fallbackAnswer: string;
}) {
const apiKey = String(process.env.GROQ_API_KEY || "").trim();
if (!apiKey) {
return {
answer: params.fallbackAnswer,
model: "rule-fallback",
};
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 12_000);
try {
const response = await fetch(GROQ_API_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
signal: controller.signal,
body: JSON.stringify({
model: GROQ_MODEL,
temperature: 0.1,
max_tokens: 700,
messages: [
{
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 回答,不得编造城市、价格、概率、时间或市场状态。如果快照没有所需数据,要直接说明。对于非产品、非市场问题要拒答。",
},
{
role: "user",
content: JSON.stringify(
{
question: params.question,
locale: params.locale,
snapshot: {
snapshot_id: params.context.snapshot_id,
generated_at: params.context.generated_at,
totals: params.context.totals,
selected_city: params.context.selected_city,
opportunities: params.context.opportunities.slice(0, 18),
glossary: params.context.glossary,
},
fallback_reference: params.fallbackAnswer,
},
null,
2,
),
},
],
}),
});
if (!response.ok) {
throw new Error(`Groq HTTP ${response.status}`);
}
const data = (await response.json()) as {
choices?: Array<{ message?: { content?: string | null } }>;
};
const answer = String(data.choices?.[0]?.message?.content || "").trim();
if (!answer) {
throw new Error("Groq returned empty content");
}
return {
answer,
model: GROQ_MODEL,
};
} finally {
clearTimeout(timeoutId);
}
}
async function ensureAssistantAccess(request: NextRequest) {
const auth = await buildBackendRequestHeaders(request);
if (process.env.POLYWEATHER_AI_ALLOW_FREE === "true") {
return { allowed: true, auth };
}
if (!API_BASE) {
return { allowed: false, auth, reason: "API base missing" };
}
try {
const response = await fetch(`${API_BASE}/api/auth/me`, {
headers: auth.headers,
cache: "no-store",
});
if (!response.ok) {
return { allowed: false, auth, reason: `auth ${response.status}` };
}
const profile = (await response.json()) as {
subscription_active?: boolean | null;
};
return {
allowed: profile.subscription_active === true,
auth,
reason: profile.subscription_active === true ? null : "pro_required",
};
} catch (error) {
return { allowed: false, auth, reason: String(error) };
}
}
export async function POST(request: NextRequest) {
let parsedBody: AssistantChatRequest | null = null;
try {
parsedBody = (await request.json()) as AssistantChatRequest;
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const question = normalizeQuestion(String(parsedBody?.question || ""));
if (!question) {
return NextResponse.json({ error: "Question is required" }, { status: 400 });
}
const locale = normalizeLocale(parsedBody?.locale);
const context = sanitizeContext(parsedBody?.context);
const snapshotId =
String(parsedBody?.snapshot_id || context.snapshot_id || "").trim() ||
buildSnapshotId(context);
const access = await ensureAssistantAccess(request);
if (!access.allowed) {
const response = NextResponse.json(
{
error: "assistant_requires_pro",
detail:
locale === "en-US"
? "PolyWeather AI assistant is a Pro feature."
: "PolyWeather AI 对话助手属于 Pro 功能。",
},
{ status: 402 },
);
return applyAuthResponseCookies(response, access.auth.response);
}
const cacheKey = buildCacheKey(question, locale, snapshotId);
const cached = assistantAnswerCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
const response = NextResponse.json({
...cached.payload,
cached: true,
snapshot_id: snapshotId,
});
return applyAuthResponseCookies(response, access.auth.response);
}
const fallback = buildFallbackAnswer(question, locale, context);
let answerPayload: AssistantChatResponse = {
answer: fallback.answer,
cached: false,
refused: fallback.refused,
snapshot_id: snapshotId,
suggestions: buildSuggestions(locale, context.selected_city),
model: "rule-fallback",
};
if (!fallback.refused) {
try {
const generated = await generateWithGroq({
question,
locale,
context,
fallbackAnswer: fallback.answer,
});
answerPayload = {
...answerPayload,
answer: generated.answer,
model: generated.model,
};
} catch {
answerPayload = {
...answerPayload,
answer: fallback.answer,
model: "rule-fallback",
};
}
}
assistantAnswerCache.set(cacheKey, {
expiresAt: Date.now() + AI_CACHE_TTL_MS,
payload: answerPayload,
});
const response = NextResponse.json(answerPayload, {
headers: {
"Cache-Control": "no-store",
"X-PolyWeather-Snapshot-Id": snapshotId,
},
});
return applyAuthResponseCookies(response, access.auth.response);
}
@@ -7187,3 +7187,284 @@
border-color: rgba(34, 211, 238, 0.4);
color: #fff;
}
.root :global(.home-ai-assistant) {
position: fixed;
right: 408px;
bottom: 210px;
z-index: 910;
width: min(380px, calc(100vw - var(--sidebar-width) - 72px));
}
.root :global(.home-ai-launcher),
.root :global(.home-ai-panel) {
width: 100%;
border: 1px solid rgba(34, 211, 238, 0.18);
border-radius: 20px;
background:
radial-gradient(
circle at 100% 0%,
rgba(30, 64, 175, 0.18),
transparent 38%
),
linear-gradient(180deg, rgba(5, 14, 28, 0.96), rgba(6, 16, 30, 0.92));
box-shadow:
0 24px 48px rgba(2, 6, 23, 0.32),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
backdrop-filter: blur(18px);
}
.root :global(.home-ai-launcher) {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 12px;
padding: 14px 16px;
cursor: pointer;
transition:
transform 160ms ease,
border-color 160ms ease,
background 160ms ease;
}
.root :global(.home-ai-launcher:hover) {
transform: translateY(-1px);
border-color: rgba(34, 211, 238, 0.32);
}
.root :global(.home-ai-launcher-badge) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 12px;
background: linear-gradient(135deg, rgba(34, 211, 238, 0.9), rgba(59, 130, 246, 0.86));
color: #04111d;
font-size: 13px;
font-weight: 900;
letter-spacing: 0.08em;
}
.root :global(.home-ai-launcher strong),
.root :global(.home-ai-header strong) {
display: block;
color: #f8fafc;
font-size: 14px;
font-weight: 850;
}
.root :global(.home-ai-launcher span),
.root :global(.home-ai-header span),
.root :global(.home-ai-disclaimer),
.root :global(.home-ai-error) {
color: rgba(148, 163, 184, 0.88);
font-size: 11px;
line-height: 1.5;
}
.root :global(.home-ai-panel) {
display: grid;
gap: 12px;
padding: 14px;
}
.root :global(.home-ai-header) {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.root :global(.home-ai-close) {
border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 999px;
width: 28px;
height: 28px;
background: rgba(15, 23, 42, 0.4);
color: rgba(226, 232, 240, 0.9);
cursor: pointer;
}
.root :global(.home-ai-disclaimer) {
padding: 10px 12px;
border: 1px solid rgba(34, 211, 238, 0.12);
border-radius: 14px;
background: rgba(6, 13, 24, 0.58);
}
.root :global(.home-ai-messages) {
display: grid;
gap: 10px;
max-height: 248px;
overflow-y: auto;
padding-right: 4px;
}
.root :global(.home-ai-messages::-webkit-scrollbar) {
width: 4px;
}
.root :global(.home-ai-messages::-webkit-scrollbar-thumb) {
background: rgba(34, 211, 238, 0.22);
border-radius: 999px;
}
.root :global(.home-ai-message) {
max-width: calc(100% - 18px);
padding: 11px 12px;
border-radius: 16px;
border: 1px solid rgba(148, 163, 184, 0.12);
background: rgba(8, 15, 28, 0.8);
}
.root :global(.home-ai-message.user) {
margin-left: auto;
border-color: rgba(34, 211, 238, 0.22);
background: rgba(6, 18, 34, 0.92);
}
.root :global(.home-ai-message.assistant) {
margin-right: auto;
}
.root :global(.home-ai-message.loading) {
opacity: 0.8;
}
.root :global(.home-ai-message p) {
color: #e2e8f0;
font-size: 12px;
line-height: 1.7;
white-space: pre-wrap;
}
.root :global(.home-ai-message small) {
display: inline-block;
margin-top: 6px;
color: #67e8f9;
font-size: 10px;
font-weight: 800;
}
.root :global(.home-ai-starters) {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.root :global(.home-ai-starter) {
border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 999px;
padding: 7px 10px;
background: rgba(9, 16, 30, 0.7);
color: #cbd5e1;
cursor: pointer;
font-size: 11px;
font-weight: 700;
}
.root :global(.home-ai-starter:hover) {
border-color: rgba(34, 211, 238, 0.28);
color: #f8fafc;
}
.root :global(.home-ai-composer) {
display: grid;
gap: 8px;
}
.root :global(.home-ai-input) {
resize: none;
width: 100%;
min-height: 84px;
border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 14px;
padding: 10px 12px;
background: rgba(4, 10, 20, 0.84);
color: #f8fafc;
font: inherit;
font-size: 12px;
line-height: 1.6;
}
.root :global(.home-ai-input:focus) {
outline: none;
border-color: rgba(34, 211, 238, 0.42);
box-shadow: 0 0 0 1px rgba(34, 211, 238, 0.16);
}
.root :global(.home-ai-composer-actions) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.root :global(.home-ai-send) {
border: 1px solid rgba(34, 211, 238, 0.22);
border-radius: 12px;
padding: 9px 14px;
background: linear-gradient(135deg, rgba(8, 145, 178, 0.24), rgba(37, 99, 235, 0.24));
color: #e0f2fe;
cursor: pointer;
font-size: 12px;
font-weight: 800;
}
.root :global(.home-ai-send:disabled) {
cursor: not-allowed;
opacity: 0.52;
}
.root :global(.home-ai-paywall-backdrop) {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
background: rgba(2, 6, 23, 0.68);
backdrop-filter: blur(10px);
}
.root :global(.home-ai-paywall-shell) {
width: min(820px, calc(100vw - 32px));
}
:global(html.light) .root :global(.home-ai-launcher),
:global(html.light) .root :global(.home-ai-panel) {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(240, 248, 255, 0.94));
border-color: rgba(48, 77, 112, 0.16);
}
:global(html.light) .root :global(.home-ai-message),
:global(html.light) .root :global(.home-ai-disclaimer),
:global(html.light) .root :global(.home-ai-input),
:global(html.light) .root :global(.home-ai-starter) {
background: rgba(255, 255, 255, 0.88);
border-color: rgba(48, 77, 112, 0.14);
}
:global(html.light) .root :global(.home-ai-message p),
:global(html.light) .root :global(.home-ai-launcher strong),
:global(html.light) .root :global(.home-ai-header strong) {
color: #0b1726;
}
@media (max-width: 1400px) {
.root :global(.home-ai-assistant) {
right: 24px;
bottom: 24px;
width: min(380px, calc(100vw - 48px));
}
}
@media (max-width: 960px) {
.root :global(.home-ai-assistant) {
left: 16px;
right: 16px;
width: auto;
bottom: 16px;
}
}
@@ -51,26 +51,6 @@ export function HeaderBar({
label: locale === "en-US" ? "Dashboard" : "总览",
active: pathname === "/",
},
{
href: "/docs/intraday-signal",
label: locale === "en-US" ? "Markets" : "市场",
active: pathname?.startsWith("/docs/intraday-signal"),
},
{
href: "/docs/model-stack-deb",
label: locale === "en-US" ? "Analytics" : "分析",
active: pathname?.startsWith("/docs/model-stack-deb"),
},
{
href: "/docs/history-review",
label: locale === "en-US" ? "History" : "历史",
active: pathname?.startsWith("/docs/history-review"),
},
{
href: "/docs/alert-playbook",
label: locale === "en-US" ? "Alerts" : "预警",
active: pathname?.startsWith("/docs/alert-playbook"),
},
];
const isRefreshing = refreshSpinning ?? store.loadingState.refresh;
const handleRefresh = () => {
@@ -2,7 +2,7 @@
import clsx from "clsx";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import styles from "./Dashboard.module.css";
import detailChromeStyles from "./DetailPanelChrome.module.css";
import modalChromeStyles from "./ModalChrome.module.css";
@@ -13,6 +13,7 @@ import {
import { I18nProvider, useI18n } from "@/hooks/useI18n";
import { CitySidebar } from "@/components/dashboard/CitySidebar";
import { HeaderBar } from "@/components/dashboard/HeaderBar";
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
import type {
CityDetail,
CityListItem,
@@ -22,6 +23,11 @@ import type {
ProbabilityBucket,
RiskLevel,
} from "@/lib/dashboard-types";
import type {
AssistantContextPayload,
AssistantOpportunityContext,
} from "@/lib/dashboard-client";
import { dashboardClient, getCityRevision } from "@/lib/dashboard-client";
import {
getLocalizedAirportDisplay,
getLocalizedCityDisplay,
@@ -283,6 +289,196 @@ function buildSparklinePoints(values: number[] | undefined) {
.join(" ");
}
type AssistantMessage = {
id: string;
role: "assistant" | "user";
content: string;
cached?: boolean;
};
function hashSnapshotText(source: string) {
let hash = 5381;
for (let index = 0; index < source.length; index += 1) {
hash = (hash * 33) ^ source.charCodeAt(index);
}
return Math.abs(hash >>> 0).toString(16);
}
function normalizeAssistantPercent(value: number | null | undefined) {
if (!Number.isFinite(Number(value))) return null;
const numeric = Number(value);
return Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
}
function normalizeAssistantCents(value: number | null | undefined) {
if (!Number.isFinite(Number(value))) return null;
const numeric = Number(value);
return Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
}
function buildAssistantOpportunityContext(
snapshot: CitySnapshot,
locale: string,
): AssistantOpportunityContext {
const { city, detail, summary, tradableOpportunity } = snapshot;
const symbol = getTempSymbol(city, summary, detail);
const marketScan = detail?.market_scan;
const marketBucket =
marketScan?.temperature_bucket || marketScan?.top_buckets?.[0] || null;
const marketQuestion =
marketScan?.primary_market?.question ||
(marketBucket
? `${getProbabilityLabel(marketBucket, symbol)} ${
marketScan?.selected_date || detail?.local_date || ""
}`
: null);
const localizedCityName = getLocalizedCityDisplay(
city,
locale,
summary,
detail,
);
const localizedAirport = getLocalizedAirportDisplay(city, locale, detail);
const marketProbability =
marketScan?.market_price ??
readNumericField(marketBucket, "market_price") ??
readNumericField(marketBucket, "yes_buy") ??
marketScan?.yes_buy;
const modelProbability =
marketScan?.model_probability ?? marketBucket?.probability ?? null;
return {
city_name: city.name,
city_display_name: localizedCityName,
airport: localizedAirport,
risk_level: String(
city.deb_recent_tier ||
city.risk_level ||
summary?.risk?.level ||
detail?.risk?.level ||
"",
),
tradable: tradableOpportunity,
local_time: summary?.local_time || detail?.local_time || null,
current_temperature:
summary?.current?.temp ?? detail?.current?.temp ?? null,
deb_prediction: summary?.deb?.prediction ?? detail?.deb?.prediction ?? null,
market_question: marketQuestion,
market_label: marketBucket ? getProbabilityLabel(marketBucket, symbol) : null,
selected_date: marketScan?.selected_date || detail?.local_date || null,
best_side: marketScan?.price_analysis?.best_side || null,
yes_price: normalizeAssistantCents(
marketScan?.yes_buy ??
readNumericField(marketBucket, "yes_buy") ??
marketScan?.yes_token?.buy_price ??
marketScan?.yes_token?.midpoint,
),
no_price: normalizeAssistantCents(
marketScan?.no_buy ??
readNumericField(marketBucket, "no_buy") ??
marketScan?.no_token?.buy_price ??
marketScan?.no_token?.midpoint,
),
edge_percent: normalizeAssistantPercent(getMarketEdgeValue(detail)),
market_probability: normalizeAssistantPercent(marketProbability),
model_probability: normalizeAssistantPercent(modelProbability),
status: tradableOpportunity
? "tradable"
: marketScan
? "inactive"
: "market_pending",
};
}
function buildAssistantContextPayload(
snapshots: CitySnapshot[],
selectedCity: string | null,
locale: string,
): AssistantContextPayload {
const opportunities = snapshots.map((snapshot) =>
buildAssistantOpportunityContext(snapshot, locale),
);
const revisionSeed = snapshots
.map((snapshot) => {
const revision =
getCityRevision(snapshot.detail) || getCityRevision(snapshot.summary);
return `${snapshot.city.name}:${revision}:${snapshot.tradableOpportunity ? 1 : 0}`;
})
.join("|");
const snapshotId = `home-${hashSnapshotText(revisionSeed || String(Date.now()))}`;
const selected =
opportunities.find((item) => item.city_name === selectedCity) || null;
return {
snapshot_id: snapshotId,
locale,
generated_at: new Date().toISOString(),
totals: {
cities: snapshots.length,
tradable_markets: opportunities.filter((item) => item.tradable).length,
high_risk: opportunities.filter((item) => item.risk_level === "high").length,
medium_risk: opportunities.filter((item) => item.risk_level === "medium")
.length,
low_risk: opportunities.filter((item) => item.risk_level === "low").length,
},
selected_city: selected,
opportunities,
glossary:
locale === "en-US"
? [
{
term: "edge",
meaning:
"Edge is the gap between model probability and market-implied probability. Positive edge means the model is more optimistic than the market.",
},
{
term: "market probability",
meaning:
"Market probability is the implied probability backed out from the live YES/NO price.",
},
{
term: "DEB",
meaning:
"DEB is the internal forecast anchor for the day-max settlement temperature.",
},
{
term: "EMOS",
meaning:
"EMOS is the calibrated probability ladder for the 24-hour max-temperature outcome buckets.",
},
]
: [
{
term: "edge",
meaning:
"edge 是模型概率和市场隐含概率之间的差值。正值表示模型比市场更乐观。",
},
{
term: "市场概率",
meaning: "市场概率来自 YES/NO 实时价格的隐含概率,而不是模型输出。",
},
{
term: "DEB",
meaning: "DEB 是系统对当日结算最高温的核心预测锚点之一。",
},
{
term: "EMOS",
meaning: "EMOS 是 24 小时最高温结果分桶使用的校准概率分布。",
},
],
};
}
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.";
}
return selectedCityName
? `可以直接问我 ${selectedCityName}、当前值得参与的市场、edge 排序,或者指标怎么解读。`
: "可以直接问我当前值得参与的市场、edge 排序,或者指标怎么解读。";
}
type HomeWeatherIconKind =
| "clear"
| "partly"
@@ -1359,6 +1555,273 @@ function OpportunityStrip({ snapshots }: { snapshots: CitySnapshot[] }) {
);
}
function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
const store = useDashboardStore();
const { locale } = useI18n();
const [isOpen, setIsOpen] = useState(false);
const [showPaywall, setShowPaywall] = useState(false);
const [loading, setLoading] = useState(false);
const [input, setInput] = useState("");
const [messages, setMessages] = useState<AssistantMessage[]>([]);
const [error, setError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement | null>(null);
const selectedSnapshot = useMemo(
() =>
store.selectedCity
? snapshots.find((snapshot) => snapshot.city.name === store.selectedCity) ||
null
: null,
[snapshots, store.selectedCity],
);
const selectedCityName = selectedSnapshot
? getLocalizedCityDisplay(
selectedSnapshot.city,
locale,
selectedSnapshot.summary,
selectedSnapshot.detail,
)
: null;
const assistantContext = useMemo(
() => buildAssistantContextPayload(snapshots, store.selectedCity, locale),
[locale, snapshots, store.selectedCity],
);
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",
];
}
return [
"当前有哪些值得参与的市场?",
"按 edge 排序",
selectedCityName ? `为什么 ${selectedCityName} 不建议参与?` : "解释一下 edge 是什么",
];
}, [locale, selectedCityName]);
useEffect(() => {
if (messages.length) return;
setMessages([
{
id: "assistant-greeting",
role: "assistant",
content: buildAssistantGreeting(locale, selectedCityName),
},
]);
}, [locale, messages.length, selectedCityName]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({
behavior: loading ? "auto" : "smooth",
block: "end",
});
}, [loading, messages]);
const openAssistant = () => {
if (!store.proAccess.subscriptionActive) {
setShowPaywall(true);
return;
}
setIsOpen(true);
};
const sendQuestion = async (rawQuestion?: string) => {
const question = String(rawQuestion ?? input).trim();
if (!question || loading) return;
if (!store.proAccess.subscriptionActive) {
setShowPaywall(true);
return;
}
setIsOpen(true);
setError(null);
setLoading(true);
setMessages((current) => [
...current,
{
id: `user-${Date.now()}`,
role: "user",
content: question,
},
]);
setInput("");
try {
const reply = await dashboardClient.askAssistant({
question,
locale,
snapshotId: assistantContext.snapshot_id,
context: assistantContext,
});
setMessages((current) => [
...current,
{
id: `assistant-${Date.now()}`,
role: "assistant",
content: reply.answer,
cached: reply.cached,
},
]);
} catch (submitError) {
const message = String(submitError);
if (message.includes("HTTP 402")) {
setShowPaywall(true);
} else {
setError(
locale === "en-US"
? "Assistant is temporarily unavailable."
: "AI 助手暂时不可用。",
);
}
} finally {
setLoading(false);
}
};
const submitForm = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
await sendQuestion();
};
return (
<>
<div className="home-ai-assistant">
{!isOpen ? (
<button
type="button"
className="home-ai-launcher"
onClick={openAssistant}
>
<span className="home-ai-launcher-badge">AI</span>
<div>
<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>
</button>
) : (
<section
className="home-ai-panel"
aria-label={locale === "en-US" ? "AI assistant" : "AI 助手"}
>
<div className="home-ai-header">
<div>
<strong>{locale === "en-US" ? "AI assistant" : "AI 对话助手"}</strong>
<span>
{locale === "en-US"
? `Snapshot ${assistantContext.snapshot_id}`
: `快照 ${assistantContext.snapshot_id}`}
</span>
</div>
<button
type="button"
className="home-ai-close"
onClick={() => setIsOpen(false)}
aria-label={locale === "en-US" ? "Close assistant" : "关闭 AI 助手"}
>
×
</button>
</div>
<div className="home-ai-disclaimer">
{locale === "en-US"
? "Only explains the current system snapshot. It does not scan markets or calculate probabilities."
: "只解释当前系统快照,不参与市场扫描、概率计算或核心决策。"}
</div>
<div className="home-ai-messages">
{messages.map((message) => (
<div
key={message.id}
className={clsx(
"home-ai-message",
message.role === "user" ? "user" : "assistant",
)}
>
<p>{message.content}</p>
{message.cached ? (
<small>{locale === "en-US" ? "Cache hit" : "命中缓存"}</small>
) : null}
</div>
))}
{loading ? (
<div className="home-ai-message assistant loading">
<p>{locale === "en-US" ? "Thinking..." : "正在整理答案..."}</p>
</div>
) : null}
<div ref={messagesEndRef} />
</div>
<div className="home-ai-starters">
{starterPrompts.map((prompt) => (
<button
key={prompt}
type="button"
className="home-ai-starter"
onClick={() => void sendQuestion(prompt)}
>
{prompt}
</button>
))}
</div>
<form className="home-ai-composer" onSubmit={submitForm}>
<textarea
className="home-ai-input"
rows={3}
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder={
locale === "en-US"
? "Ask about current opportunities, a city, or edge..."
: "问当前机会、某个城市,或者 edge 怎么看..."
}
/>
<div className="home-ai-composer-actions">
{error ? <span className="home-ai-error">{error}</span> : <span />}
<button
type="submit"
className="home-ai-send"
disabled={loading || !input.trim()}
>
{locale === "en-US" ? "Ask" : "发送"}
</button>
</div>
</form>
</section>
)}
</div>
{showPaywall ? (
<div className="home-ai-paywall-backdrop" onClick={() => setShowPaywall(false)}>
<div
className="home-ai-paywall-shell"
onClick={(event) => event.stopPropagation()}
>
<ProFeaturePaywall
feature="assistant"
onClose={() => setShowPaywall(false)}
/>
</div>
</div>
) : null}
</>
);
}
function DashboardScreen() {
const store = useDashboardStore();
const { t } = useI18n();
@@ -1464,6 +1927,7 @@ function DashboardScreen() {
<>
<HomeIntelligencePanel snapshots={homepageSnapshots} />
<OpportunityStrip snapshots={homepageSnapshots} />
<HomeAssistantDock snapshots={homepageSnapshots} />
</>
) : null}
{showCitySyncToast ? (
@@ -14,7 +14,7 @@ const TELEGRAM_GROUP_URL = String(
const SUBSCRIPTION_HELP_HREF = "/subscription-help";
type ProFeaturePaywallProps = {
feature: "today" | "history" | "future";
feature: "today" | "history" | "future" | "assistant";
onClose?: () => void;
};
+103
View File
@@ -8,6 +8,55 @@ import {
MarketScan,
} from "@/lib/dashboard-types";
export type AssistantOpportunityContext = {
city_name: string;
city_display_name: string;
airport?: string | null;
risk_level: string;
tradable: boolean;
local_time?: string | null;
current_temperature?: number | null;
deb_prediction?: number | null;
market_question?: string | null;
market_label?: string | null;
selected_date?: string | null;
best_side?: string | null;
yes_price?: number | null;
no_price?: number | null;
edge_percent?: number | null;
market_probability?: number | null;
model_probability?: number | null;
status?: string | null;
};
export type AssistantContextPayload = {
snapshot_id: string;
locale: string;
generated_at: string;
totals: {
cities: number;
tradable_markets: number;
high_risk: number;
medium_risk: number;
low_risk: number;
};
selected_city?: AssistantOpportunityContext | null;
opportunities: AssistantOpportunityContext[];
glossary: Array<{
term: string;
meaning: string;
}>;
};
export type AssistantChatResponse = {
answer: string;
cached?: boolean;
model?: string;
refused?: boolean;
snapshot_id?: string;
suggestions?: string[];
};
const CACHE_KEY = "polyWeather_v1";
const CACHE_TTL_MS = 5 * 60 * 1000;
const pendingCityDetailRequests = new Map<string, Promise<CityDetail>>();
@@ -21,6 +70,7 @@ const pendingCityMarketScanRequests = new Map<
selected_date?: string | null;
}>
>();
const pendingAssistantRequests = new Map<string, Promise<AssistantChatResponse>>();
const PRIORITY_WARM_SESSION_KEY = "polyWeather_priority_warm_v1";
type CityCacheMeta = {
@@ -331,6 +381,59 @@ export const dashboardClient = {
return request;
},
async askAssistant(payload: {
question: string;
locale: string;
snapshotId: string;
context: AssistantContextPayload;
}) {
const question = String(payload.question || "").trim();
const snapshotId = String(payload.snapshotId || "").trim();
if (!question) {
throw new Error("Question is required");
}
if (!snapshotId) {
throw new Error("Snapshot id is required");
}
const requestKey = [
snapshotId,
payload.locale,
question.toLowerCase(),
].join("::");
const existing = pendingAssistantRequests.get(requestKey);
if (existing) {
return existing;
}
const request = fetch("/api/assistant/chat", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
cache: "no-store",
body: JSON.stringify({
question,
locale: payload.locale,
snapshot_id: snapshotId,
context: payload.context,
}),
})
.then(async (response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json() as Promise<AssistantChatResponse>;
})
.finally(() => {
pendingAssistantRequests.delete(requestKey);
});
pendingAssistantRequests.set(requestKey, request);
return request;
},
isCityDetailFresh(meta?: CityCacheMeta | null) {
return isFresh(meta);
},