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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user