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
@@ -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;
};