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