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:
dekun
2026-06-07 08:54:20 +08:00
parent 51c59b073b
commit 62e48dab92
19 changed files with 947 additions and 106 deletions
+62 -4
View File
@@ -3719,15 +3719,73 @@ body.hub-page-ai #page-ai {
opacity: 1;
}
}
.ai-closed-trades-wrap {
margin: 0 0 12px;
}
.ai-closed-trades-title {
margin: 0 0 6px;
font-size: 0.82rem;
font-weight: 600;
color: var(--accent-2, var(--accent));
}
.ai-msg-attachments {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 4px;
}
.ai-attach-chip {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.72rem;
color: var(--muted);
background: var(--inset-surface);
border: 1px solid var(--border-soft);
}
.ai-chat-form {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
align-items: end;
flex-shrink: 0;
padding-top: 4px;
border-top: 1px solid var(--border-soft);
}
.ai-chat-compose {
display: flex;
flex-direction: column;
gap: 8px;
}
.ai-chat-compose-actions {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
}
.ai-chat-upload-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 36px;
padding: 0 12px;
border-radius: 8px;
border: 1px solid var(--border-soft);
background: var(--inset-surface);
color: var(--text);
font-size: 0.82rem;
cursor: pointer;
}
.ai-chat-upload-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.ai-chat-files-label {
flex: 1;
min-width: 0;
font-size: 0.72rem;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-chat-form textarea {
width: 100%;
resize: none;
+90 -15
View File
@@ -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");
+12 -3
View File
@@ -231,8 +231,17 @@
</div>
<div id="ai-chat-messages" class="ai-panel-scroll ai-chat-messages" aria-live="polite"></div>
<form id="ai-chat-form" class="ai-chat-form">
<textarea id="ai-chat-input" rows="2" placeholder="聊聊行情、心态、纪律、执行…" autocomplete="off"></textarea>
<button type="submit" id="btn-ai-chat-send" class="primary">发送</button>
<div class="ai-chat-compose">
<textarea id="ai-chat-input" rows="2" placeholder="聊聊行情、心态、纪律、执行…" autocomplete="off"></textarea>
<div class="ai-chat-compose-actions">
<label class="ai-chat-upload-btn" title="上传图片或 txt/md/json 文档">
<input type="file" id="ai-chat-files" accept="image/*,.txt,.md,.markdown,.json" multiple hidden />
附件
</label>
<span id="ai-chat-files-label" class="ai-chat-files-label"></span>
<button type="submit" id="btn-ai-chat-send" class="primary">发送</button>
</div>
</div>
</form>
</section>
</div>
@@ -286,6 +295,6 @@
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="/assets/chart.js?v=20260604-upnl-contracts"></script>
<script src="/assets/ai_review_render.js?v=2"></script>
<script src="/assets/app.js?v=20260606-hub-ai-ui"></script>
<script src="/assets/app.js?v=20260607-hub-ai-v2"></script>
</body>
</html>