feat(hub): enrich AI coach with fund history, closed trades, and chat uploads
- Add 15-day fund snapshot store and /api/hub/account on all instances - Summary includes yesterday/today trades, fund columns, and section 5 操作建议 - Chat context distinguishes empty positions from local monitors - Support image/document attachments in AI chat Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2978,9 +2978,16 @@
|
||||
out = out.replace(/\*\*2\.\s*(?:👥\s*)?分户明细\*\*/g, "## 2. 👥 分户明细");
|
||||
out = out.replace(/\*\*3\.\s*(?:⚠️\s*)?需关注\*\*/g, "## 3. ⚠️ 需关注");
|
||||
out = out.replace(/\*\*4\.\s*(?:ℹ️\s*)?数据说明\*\*/g, "## 4. ℹ️ 数据说明");
|
||||
out = out.replace(/\*\*5\.\s*(?:💡\s*)?操作建议\*\*/g, "## 5. 💡 操作建议");
|
||||
return out;
|
||||
}
|
||||
|
||||
function aiFmtFund(v) {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return "—";
|
||||
return `${fmt(n, 2)}U`;
|
||||
}
|
||||
|
||||
function aiPnlCellHtml(v, digits) {
|
||||
const cls = aiPnlClass(v);
|
||||
const valCls = cls ? ` ai-stat-val ${cls}` : " ai-stat-val";
|
||||
@@ -3002,7 +3009,7 @@
|
||||
if (!rows.length) return "";
|
||||
const head =
|
||||
"<thead><tr>" +
|
||||
"<th>账户</th><th>状态</th><th>平仓盈亏</th><th>笔数</th><th>浮盈亏</th><th>备注</th>" +
|
||||
"<th>账户</th><th>状态</th><th>资金账户</th><th>交易账户</th><th>今日盈亏</th><th>笔数</th><th>浮盈亏</th><th>备注</th>" +
|
||||
"</tr></thead>";
|
||||
const body = rows
|
||||
.map((ac) => {
|
||||
@@ -3012,12 +3019,15 @@
|
||||
ac.remark ||
|
||||
(Array.isArray(ac.issues) && ac.issues.length ? ac.issues.join(";") : "无");
|
||||
const statusCls = aiAccountStatusClass(ac.status);
|
||||
const countLabel = `${Number(ac.closed_count) || 0}${Number(ac.closed_count_yesterday) ? ` / 昨${Number(ac.closed_count_yesterday)}` : ""}`;
|
||||
return (
|
||||
"<tr>" +
|
||||
`<td class="ai-ac-name">${esc(ac.name || "—")}</td>` +
|
||||
`<td class="${statusCls}">${esc(ac.status || "—")}</td>` +
|
||||
`<td>${aiFmtFund(ac.funding_usdt)}</td>` +
|
||||
`<td>${aiFmtFund(ac.trading_usdt)}</td>` +
|
||||
`<td>${aiPnlCellHtml(closedPnl, 2)}</td>` +
|
||||
`<td>${Number(ac.closed_count) || 0}</td>` +
|
||||
`<td>${countLabel}</td>` +
|
||||
`<td>${aiPnlCellHtml(floatPnl, 2)}</td>` +
|
||||
`<td class="ai-ac-remark">${esc(remark)}</td>` +
|
||||
"</tr>"
|
||||
@@ -3027,6 +3037,35 @@
|
||||
return `<div class="ai-ac-table-wrap"><table class="ai-ac-table">${head}<tbody>${body}</tbody></table></div>`;
|
||||
}
|
||||
|
||||
function renderAiClosedTradesBlock(snapshot) {
|
||||
const rows = (snapshot && snapshot.closed_trades) || [];
|
||||
if (!rows.length) return "";
|
||||
const head =
|
||||
"<thead><tr><th>交易日</th><th>账户</th><th>合约</th><th>方向</th><th>结果</th><th>盈亏</th><th>时间</th></tr></thead>";
|
||||
const body = rows
|
||||
.map((t) => {
|
||||
const pnl = Number(t.pnl_amount);
|
||||
return (
|
||||
"<tr>" +
|
||||
`<td>${esc(t.trading_day || "—")}</td>` +
|
||||
`<td>${esc(t.account_name || "—")}</td>` +
|
||||
`<td>${esc(t.symbol || "—")}</td>` +
|
||||
`<td>${esc(t.direction || "—")}</td>` +
|
||||
`<td>${esc(t.result || "—")}</td>` +
|
||||
`<td>${aiPnlCellHtml(pnl, 2)}</td>` +
|
||||
`<td class="ai-ac-remark">${esc(t.closed_at || "—")}</td>` +
|
||||
"</tr>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
return (
|
||||
`<div class="ai-closed-trades-wrap">` +
|
||||
`<h4 class="ai-closed-trades-title">平仓明细(昨日 + 今日)</h4>` +
|
||||
`<div class="ai-ac-table-wrap"><table class="ai-ac-table ai-closed-trades-table">${head}<tbody>${body}</tbody></table></div>` +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
|
||||
function renderAiSummaryBody(contentMd, snapshot) {
|
||||
const md = enhanceHubSummaryMarkdown(contentMd);
|
||||
const sec2 = /##\s*2\.\s*👥\s*分户明细/;
|
||||
@@ -3034,13 +3073,14 @@
|
||||
const i2 = md.search(sec2);
|
||||
const i3 = md.search(sec3);
|
||||
const tableHtml = renderAiAccountTable(snapshot);
|
||||
const closedHtml = renderAiClosedTradesBlock(snapshot);
|
||||
if (i2 >= 0 && i3 > i2 && tableHtml) {
|
||||
const headEnd = i2 + md.slice(i2).match(sec2)[0].length;
|
||||
const part1 = md.slice(0, headEnd);
|
||||
const part2 = md.slice(i3);
|
||||
return renderHubMarkdown(part1) + tableHtml + renderHubMarkdown(part2);
|
||||
return renderHubMarkdown(part1) + tableHtml + closedHtml + renderHubMarkdown(part2);
|
||||
}
|
||||
return renderHubMarkdown(md) + (tableHtml ? tableHtml : "");
|
||||
return renderHubMarkdown(md) + (tableHtml ? tableHtml + closedHtml : "");
|
||||
}
|
||||
|
||||
function setAiSummaryMarkdown(body, contentMd, snapshot) {
|
||||
@@ -3075,7 +3115,7 @@
|
||||
].join("");
|
||||
}
|
||||
|
||||
function renderAiChatRow(role, content, extraClass) {
|
||||
function renderAiChatRow(role, content, extraClass, attachments) {
|
||||
const isUser = role === "user";
|
||||
const label = isUser ? "主人" : "AI教练";
|
||||
const rowCls = isUser ? "ai-msg-row-user" : "ai-msg-row-coach";
|
||||
@@ -3083,9 +3123,16 @@
|
||||
const isThinking = extraClass && String(extraClass).includes("ai-bubble-thinking");
|
||||
const bubbleInner = isUser || isThinking ? esc(content || "") : renderHubMarkdown(content || "");
|
||||
const mdCls = !isUser && !isThinking ? " ai-result-md" : "";
|
||||
const attList = Array.isArray(attachments) ? attachments : [];
|
||||
const attHtml = attList.length
|
||||
? `<div class="ai-msg-attachments">${attList
|
||||
.map((a) => `<span class="ai-attach-chip">${esc(a.name || "附件")}</span>`)
|
||||
.join("")}</div>`
|
||||
: "";
|
||||
return (
|
||||
`<div class="ai-msg-row ${rowCls}">` +
|
||||
`<span class="ai-msg-role">${label}</span>` +
|
||||
`${attHtml}` +
|
||||
`<div class="ai-bubble ${bubbleCls}${mdCls}${extraClass ? " " + extraClass : ""}">${bubbleInner}</div>` +
|
||||
`</div>`
|
||||
);
|
||||
@@ -3104,14 +3151,21 @@
|
||||
!msgs.length && !options.pendingUser && !options.thinking;
|
||||
if (showPlaceholder) {
|
||||
box.innerHTML =
|
||||
'<p class="ai-placeholder">主人发消息会立刻出现在右侧;AI教练 会先显示「正在思考…」再回复。</p>';
|
||||
'<p class="ai-placeholder">主人发消息会立刻出现在右侧;AI教练 会先显示「正在思考…」再回复。可点「附件」上传图片或文档。</p>';
|
||||
return;
|
||||
}
|
||||
let html = msgs
|
||||
.map((m) => renderAiChatRow(m.role === "user" ? "user" : "assistant", m.content || ""))
|
||||
.map((m) =>
|
||||
renderAiChatRow(
|
||||
m.role === "user" ? "user" : "assistant",
|
||||
m.content || "",
|
||||
null,
|
||||
m.attachments
|
||||
)
|
||||
)
|
||||
.join("");
|
||||
if (options.pendingUser) {
|
||||
html += renderAiChatRow("user", options.pendingUser);
|
||||
html += renderAiChatRow("user", options.pendingUser, null, options.pendingAttachments);
|
||||
}
|
||||
if (options.thinking) {
|
||||
html += renderAiChatRow("assistant", "正在思考…", "ai-bubble-thinking");
|
||||
@@ -3206,21 +3260,33 @@
|
||||
if (ev) ev.preventDefault();
|
||||
if (aiChatLoading) return;
|
||||
const input = document.getElementById("ai-chat-input");
|
||||
const fileInput = document.getElementById("ai-chat-files");
|
||||
const fileLabel = document.getElementById("ai-chat-files-label");
|
||||
const text = (input && input.value || "").trim();
|
||||
if (!text) return;
|
||||
const files = fileInput && fileInput.files ? Array.from(fileInput.files) : [];
|
||||
if (!text && !files.length) return;
|
||||
const pendingAttachments = files.map((f) => ({ name: f.name, kind: f.type.startsWith("image/") ? "image" : "text" }));
|
||||
if (input) input.value = "";
|
||||
setAiChatBusy(true);
|
||||
renderAiChatMessages(aiChatSessionCache, { pendingUser: text, thinking: true });
|
||||
renderAiChatMessages(aiChatSessionCache, {
|
||||
pendingUser: text || (files.length ? `(上传 ${files.length} 个附件)` : ""),
|
||||
pendingAttachments,
|
||||
thinking: true,
|
||||
});
|
||||
try {
|
||||
const r = await apiFetch("/api/ai/chat/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: text }),
|
||||
});
|
||||
const fd = new FormData();
|
||||
fd.append("message", text);
|
||||
files.forEach((f) => fd.append("files", f, f.name));
|
||||
const r = await apiFetch("/api/ai/chat/send", { method: "POST", body: fd });
|
||||
const j = await r.json();
|
||||
if (!r.ok) throw new Error(j.detail || j.msg || "发送失败");
|
||||
aiChatSessionCache = j.session || null;
|
||||
renderAiChatMessages(aiChatSessionCache);
|
||||
if (fileInput) fileInput.value = "";
|
||||
if (fileLabel) fileLabel.textContent = "";
|
||||
if (j.attachment_warnings && j.attachment_warnings.length) {
|
||||
showToast(j.attachment_warnings.join(";"), true);
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(String(e), true);
|
||||
renderAiChatMessages(aiChatSessionCache);
|
||||
@@ -3229,6 +3295,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
const aiChatFiles = document.getElementById("ai-chat-files");
|
||||
const aiChatFilesLabel = document.getElementById("ai-chat-files-label");
|
||||
if (aiChatFiles && aiChatFilesLabel) {
|
||||
aiChatFiles.addEventListener("change", () => {
|
||||
const names = aiChatFiles.files ? Array.from(aiChatFiles.files).map((f) => f.name) : [];
|
||||
aiChatFilesLabel.textContent = names.length ? names.join("、") : "";
|
||||
});
|
||||
}
|
||||
|
||||
const aiSummaryBtn = document.getElementById("btn-ai-summary");
|
||||
if (aiSummaryBtn) aiSummaryBtn.onclick = () => generateAiSummary();
|
||||
const aiChatNewBtn = document.getElementById("btn-ai-chat-new");
|
||||
|
||||
Reference in New Issue
Block a user