feat(hub): add data dashboard and AI chat with session history

Add /dashboard with daily PnL overview and loss alerts. Extend AI coach chat with history sidebar, delete/switch sessions, message copy, and trading vs general bot modes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-11 10:42:33 +08:00
parent a45a3b18e2
commit 582ada7e60
11 changed files with 1479 additions and 55 deletions
+193 -11
View File
@@ -644,6 +644,7 @@
const p = window.location.pathname.replace(/\/$/, "") || "/monitor";
if (p.includes("settings")) return "settings";
if (p.includes("archive")) return "archive";
if (p.includes("dashboard")) return "dashboard";
if (p.includes("funds")) return "funds";
if (p.includes("market")) return "market";
if (p.includes("/ai")) return "ai";
@@ -653,6 +654,7 @@
function pageElementId(page) {
if (page === "settings") return "page-settings";
if (page === "archive") return "page-archive";
if (page === "dashboard") return "page-dashboard";
if (page === "funds") return "page-funds";
if (page === "market") return "page-market";
if (page === "ai") return "page-ai";
@@ -674,9 +676,15 @@
});
document.body.classList.toggle("hub-page-ai", page === "ai");
document.body.classList.toggle("hub-page-funds", page === "funds");
document.body.classList.toggle("hub-page-dashboard", page === "dashboard");
syncHubAiMobileViewport();
if (page === "monitor") startMonitorPoll();
else stopMonitorPoll();
if (page === "dashboard" && window.hubDashboardPage) {
window.hubDashboardPage.init();
} else if (window.hubDashboardPage && window.hubDashboardPage.destroy) {
window.hubDashboardPage.destroy();
}
if (page === "settings") loadSettingsUI();
if (page === "ai") loadAiPage();
if (page === "archive" && window.hubArchivePage) {
@@ -1010,6 +1018,10 @@
btn.setAttribute("aria-selected", on ? "true" : "false");
});
if (mobile && active === "chat") scrollAiChatToEnd();
if (mobile && active === "history") {
const hist = document.getElementById("ai-chat-history-list");
if (hist) hist.scrollTop = 0;
}
}
function initAiMobileTabs() {
@@ -3133,6 +3145,8 @@
let aiSummaryLoading = false;
let aiChatLoading = false;
let aiChatSessionCache = null;
let aiChatSessionsCache = [];
let aiSelectedBotMode = "trading";
function aiPnlClass(v) {
const n = Number(v);
@@ -3324,9 +3338,64 @@
requestAnimationFrame(() => requestAnimationFrame(run));
}
function renderAiChatRow(role, content, extraClass, attachments) {
function updateAiBotTabs(mode) {
const m = mode === "general" ? "general" : "trading";
aiSelectedBotMode = m;
document.querySelectorAll(".ai-bot-tab").forEach((btn) => {
const on = (btn.dataset.bot || "trading") === m;
btn.classList.toggle("is-active", on);
btn.setAttribute("aria-selected", on ? "true" : "false");
});
const input = document.getElementById("ai-chat-input");
if (input) {
input.placeholder =
m === "general"
? "随便聊点什么,不绑交易数据…"
: "聊聊行情、心态、纪律、执行…";
}
}
function renderAiChatHistory(sessions) {
const list = document.getElementById("ai-chat-history-list");
if (!list) return;
const items = Array.isArray(sessions) ? sessions : [];
if (!items.length) {
list.innerHTML = '<p class="ai-placeholder">暂无历史,发送消息后会出现在这里。</p>';
return;
}
list.innerHTML = items
.map((s) => {
const mode = s.bot_mode === "general" ? "general" : "trading";
const badge = mode === "general" ? "普通" : "交易";
const badgeCls = mode === "general" ? "" : " trading";
const active = s.is_active ? " is-active" : "";
const time = esc((s.updated_at || s.created_at || "").slice(0, 16));
const title = esc(s.title || "新对话");
const preview = esc(s.preview || "(空会话)");
const sid = esc(s.id || "");
return (
`<div class="ai-chat-history-item${active}" role="listitem" data-session-id="${sid}">` +
`<div class="ai-chat-history-item-main">` +
`<span class="ai-chat-history-item-title">${title}</span>` +
`<span class="ai-chat-history-item-preview">${preview}</span>` +
`<span class="ai-chat-history-item-meta">` +
`<span>${time}</span>` +
`<span class="ai-chat-history-badge${badgeCls}">${badge}</span>` +
`<span>${Number(s.message_count) || 0} 条</span>` +
`</span>` +
`</div>` +
`<button type="button" class="ai-chat-history-del" title="删除" data-delete-session="${sid}" aria-label="删除">×</button>` +
`</div>`
);
})
.join("");
}
function renderAiChatRow(role, content, extraClass, attachments, rowOpts) {
const opts = rowOpts || {};
const botMode = opts.botMode === "general" ? "general" : "trading";
const isUser = role === "user";
const label = isUser ? "主人" : "AI教练";
const label = isUser ? "主人" : botMode === "general" ? "助手" : "交易教练";
const rowCls = isUser ? "ai-msg-row-user" : "ai-msg-row-coach";
const bubbleCls = isUser ? "ai-bubble-user" : "ai-bubble-assistant";
const isThinking = extraClass && String(extraClass).includes("ai-bubble-thinking");
@@ -3342,11 +3411,16 @@
.map((a) => `<span class="ai-attach-chip">${esc(a.name || "附件")}</span>`)
.join("")}</div>`
: "";
const canCopy = !isThinking && String(content || "").trim();
const copyHtml = canCopy
? `<div class="ai-msg-actions"><button type="button" class="ai-msg-copy-btn" data-msg-idx="${opts.msgIdx != null ? opts.msgIdx : ""}">复制</button></div>`
: "";
return (
`<div class="ai-msg-row ${rowCls}">` +
`<span class="ai-msg-role">${label}</span>` +
`${attHtml}` +
`<div class="ai-bubble ${bubbleCls}${mdCls}${isError ? " ai-bubble-error" : ""}${extraClass ? " " + extraClass : ""}">${bubbleInner}</div>` +
`${copyHtml}` +
`</div>`
);
}
@@ -3357,23 +3431,30 @@
const title = document.getElementById("ai-chat-title");
if (!box) return;
const msgs = (session && session.messages) || [];
const botMode = (session && session.bot_mode) || aiSelectedBotMode || "trading";
if (title) {
title.textContent = session && session.title ? `聊天 · ${session.title}` : "聊天";
const modeLabel = botMode === "general" ? "普通聊天" : "交易教练";
title.textContent =
session && session.title ? `${modeLabel} · ${session.title}` : modeLabel;
}
const showPlaceholder =
!msgs.length && !options.pendingUser && !options.thinking;
if (showPlaceholder) {
box.innerHTML =
'<p class="ai-placeholder">主人发消息会立刻出现在右侧;AI教练 会先显示「正在思考…」再回复。可点「附件」上传图片或文档。</p>';
const hint =
botMode === "general"
? "普通聊天不注入交易快照;发消息后可点气泡下方「复制」。"
: "交易教练会结合四户监控数据陪聊;发消息后可点气泡下方「复制」。可点「附件」上传图片或文档。";
box.innerHTML = `<p class="ai-placeholder">${hint}</p>`;
return;
}
let html = msgs
.map((m) =>
.map((m, idx) =>
renderAiChatRow(
m.role === "user" ? "user" : "assistant",
m.content || "",
null,
m.attachments
m.attachments,
{ botMode, msgIdx: idx }
)
)
.join("");
@@ -3414,7 +3495,54 @@
const r = await apiFetch("/api/ai/chat/session");
const j = await r.json();
aiChatSessionCache = j.session || null;
aiChatSessionsCache = j.sessions || [];
renderAiChatMessages(aiChatSessionCache);
renderAiChatHistory(aiChatSessionsCache);
updateAiBotTabs((aiChatSessionCache && aiChatSessionCache.bot_mode) || aiSelectedBotMode);
}
async function switchAiChatSession(sessionId) {
if (!sessionId || aiChatLoading) return;
try {
const r = await apiFetch("/api/ai/chat/switch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.detail || j.msg || "切换失败");
aiChatSessionCache = j.session || null;
aiChatSessionsCache = j.sessions || [];
renderAiChatMessages(aiChatSessionCache);
renderAiChatHistory(aiChatSessionsCache);
updateAiBotTabs((aiChatSessionCache && aiChatSessionCache.bot_mode) || "trading");
applyAiMobileTab("chat");
scrollAiChatToEnd();
} catch (e) {
showToast(String(e), true);
}
}
async function deleteAiChatSession(sessionId) {
if (!sessionId) return;
if (!confirm("确定删除这条聊天历史?")) return;
try {
const r = await apiFetch(`/api/ai/chat/session/${encodeURIComponent(sessionId)}`, {
method: "DELETE",
});
const j = await r.json();
if (!r.ok) throw new Error(j.detail || j.msg || "删除失败");
aiChatSessionCache = j.session || null;
aiChatSessionsCache = j.sessions || [];
renderAiChatMessages(aiChatSessionCache);
renderAiChatHistory(aiChatSessionsCache);
updateAiBotTabs(
(aiChatSessionCache && aiChatSessionCache.bot_mode) || aiSelectedBotMode || "trading"
);
showToast("已删除");
} catch (e) {
showToast(String(e), true);
}
}
async function loadAiPage() {
@@ -3460,17 +3588,22 @@
}
}
async function newAiChat() {
async function newAiChat(botMode) {
const mode = botMode === "general" ? "general" : "trading";
try {
const r = await apiFetch("/api/ai/chat/new", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
body: JSON.stringify({ bot_mode: mode }),
});
const j = await r.json();
aiChatSessionCache = j.session || null;
aiChatSessionsCache = j.sessions || [];
renderAiChatMessages(aiChatSessionCache);
showToast("已开始新对话");
renderAiChatHistory(aiChatSessionsCache);
updateAiBotTabs(mode);
applyAiMobileTab("chat");
showToast(mode === "general" ? "已开始普通聊天" : "已开始交易教练对话");
} catch (e) {
showToast(String(e), true);
}
@@ -3501,7 +3634,9 @@
const j = await r.json();
if (!r.ok) throw new Error(j.detail || j.msg || "发送失败");
aiChatSessionCache = j.session || null;
aiChatSessionsCache = j.sessions || aiChatSessionsCache;
renderAiChatMessages(aiChatSessionCache);
renderAiChatHistory(aiChatSessionsCache);
if (fileInput) fileInput.value = "";
if (fileLabel) fileLabel.textContent = "";
if (j.attachment_warnings && j.attachment_warnings.length) {
@@ -3531,10 +3666,57 @@
const aiSummaryBtn = document.getElementById("btn-ai-summary");
if (aiSummaryBtn) aiSummaryBtn.onclick = () => generateAiSummary();
const aiChatNewBtn = document.getElementById("btn-ai-chat-new");
if (aiChatNewBtn) aiChatNewBtn.onclick = () => newAiChat();
if (aiChatNewBtn) aiChatNewBtn.onclick = () => newAiChat(aiSelectedBotMode);
const aiChatForm = document.getElementById("ai-chat-form");
if (aiChatForm) aiChatForm.addEventListener("submit", sendAiChat);
function initAiChatInteractions() {
const hist = document.getElementById("ai-chat-history-list");
if (hist && !hist._aiBound) {
hist._aiBound = true;
hist.addEventListener("click", (ev) => {
const delBtn = ev.target.closest(".ai-chat-history-del");
if (delBtn) {
ev.stopPropagation();
const sid = delBtn.getAttribute("data-delete-session");
if (sid) deleteAiChatSession(sid);
return;
}
const item = ev.target.closest(".ai-chat-history-item");
if (!item) return;
const sid = item.getAttribute("data-session-id");
if (sid) switchAiChatSession(sid);
});
}
const box = document.getElementById("ai-chat-messages");
if (box && !box._aiCopyBound) {
box._aiCopyBound = true;
box.addEventListener("click", async (ev) => {
const btn = ev.target.closest(".ai-msg-copy-btn");
if (!btn) return;
const idx = Number(btn.getAttribute("data-msg-idx"));
const msgs = (aiChatSessionCache && aiChatSessionCache.messages) || [];
const text = msgs[idx] && msgs[idx].content ? String(msgs[idx].content) : "";
if (!text) return;
try {
await navigator.clipboard.writeText(text);
showToast("已复制");
} catch (_) {
showToast("复制失败", true);
}
});
}
document.querySelectorAll(".ai-bot-tab").forEach((btn) => {
if (btn._aiBotBound) return;
btn._aiBotBound = true;
btn.addEventListener("click", () => {
const mode = btn.getAttribute("data-bot") || "trading";
newAiChat(mode);
});
});
}
initAiChatInteractions();
initTpslModal();
initInstanceFrame();
initFullscreen();