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
@@ -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 ? (