feat(hub-ai): paste screenshots in chat and include position TP/SL in coach context

Let users paste images into AI chat with removable pending attachments, and feed exchange/monitor stop-loss and take-profit into trading coach snapshots so replies reflect actual protection on open positions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-14 01:53:24 +08:00
parent 42c06c0f38
commit 28a23008f3
5 changed files with 389 additions and 45 deletions
+48 -11
View File
@@ -4467,14 +4467,8 @@ body.hub-page-ai #page-ai {
width: 100%;
}
body.hub-page-ai .ai-chat-files-label {
flex: 1;
min-width: 0;
font-size: 0.68rem;
}
body.hub-page-ai .ai-chat-files-label:empty {
display: none;
body.hub-page-ai .ai-chat-pending-list {
width: 100%;
}
body.hub-page-ai .ai-chat-upload-btn,
@@ -5140,15 +5134,58 @@ body.hub-page-ai #page-ai {
border-color: var(--accent);
color: var(--accent);
}
.ai-chat-files-label {
flex: 1;
min-width: 0;
.ai-chat-pending-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.ai-chat-pending-list[hidden] {
display: none;
}
.ai-chat-pending-chip {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
padding: 2px 4px 2px 8px;
border-radius: 999px;
font-size: 0.72rem;
color: var(--text);
background: var(--inset-surface);
border: 1px solid var(--border-soft);
}
.ai-chat-pending-kind {
flex-shrink: 0;
font-size: 0.65rem;
color: var(--muted);
}
.ai-chat-pending-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-chat-pending-del {
flex-shrink: 0;
min-width: 22px;
min-height: 22px;
padding: 0;
border: none;
border-radius: 999px;
background: transparent;
color: var(--muted);
font-size: 0.95rem;
line-height: 1;
cursor: pointer;
}
.ai-chat-pending-del:hover {
color: var(--red);
background: color-mix(in srgb, var(--red) 12%, transparent);
}
.ai-chat-pending-del:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.ai-chat-form textarea {
width: 100%;
resize: none;
+136 -14
View File
@@ -3517,6 +3517,111 @@
let aiChatSessionCache = null;
let aiChatSessionsCache = [];
let aiSelectedBotMode = "trading";
const AI_CHAT_MAX_ATTACHMENTS = 3;
let aiChatPendingFiles = [];
function aiChatFileKind(file) {
return file && file.type && file.type.startsWith("image/") ? "image" : "text";
}
function isValidAiChatFile(file) {
if (!file) return false;
if (file.type && file.type.startsWith("image/")) return true;
const mime = (file.type || "").toLowerCase();
if (["text/plain", "text/markdown", "application/json"].includes(mime)) return true;
const name = (file.name || "").toLowerCase();
return (
name.endsWith(".txt") ||
name.endsWith(".md") ||
name.endsWith(".markdown") ||
name.endsWith(".json")
);
}
function syncAiChatFileInput() {
const fileInput = document.getElementById("ai-chat-files");
if (!fileInput || typeof DataTransfer === "undefined") return;
const dt = new DataTransfer();
aiChatPendingFiles.forEach((f) => dt.items.add(f));
fileInput.files = dt.files;
}
function renderAiChatPendingAttachments() {
const box = document.getElementById("ai-chat-pending");
if (!box) return;
if (!aiChatPendingFiles.length) {
box.innerHTML = "";
box.hidden = true;
return;
}
box.hidden = false;
box.innerHTML = aiChatPendingFiles
.map((f, idx) => {
const kind = aiChatFileKind(f);
const icon = kind === "image" ? "图" : "文";
return (
`<span class="ai-chat-pending-chip" data-pending-idx="${idx}">` +
`<span class="ai-chat-pending-kind">${icon}</span>` +
`<span class="ai-chat-pending-name" title="${esc(f.name || "附件")}">${esc(f.name || "附件")}</span>` +
`<button type="button" class="ai-chat-pending-del" data-pending-del="${idx}" title="移除" aria-label="移除附件">×</button>` +
`</span>`
);
})
.join("");
}
function addAiChatPendingFiles(files) {
const incoming = Array.isArray(files) ? files : [];
if (!incoming.length) return;
let added = 0;
for (const file of incoming) {
if (aiChatPendingFiles.length >= AI_CHAT_MAX_ATTACHMENTS) {
showToast(`最多 ${AI_CHAT_MAX_ATTACHMENTS} 个附件`, true);
break;
}
if (!isValidAiChatFile(file)) {
showToast(`${file.name || "文件"}: 不支持的类型(仅图片或 txt/md/json`, true);
continue;
}
aiChatPendingFiles.push(file);
added += 1;
}
if (!added) return;
syncAiChatFileInput();
renderAiChatPendingAttachments();
}
function removeAiChatPendingFile(index) {
if (index < 0 || index >= aiChatPendingFiles.length) return;
aiChatPendingFiles.splice(index, 1);
syncAiChatFileInput();
renderAiChatPendingAttachments();
}
function clearAiChatPendingFiles() {
aiChatPendingFiles = [];
syncAiChatFileInput();
renderAiChatPendingAttachments();
}
function handleAiChatPaste(ev) {
if (aiChatLoading) return;
const clipboard = ev.clipboardData;
if (!clipboard || !clipboard.items) return;
const imageFiles = [];
for (const item of clipboard.items) {
if (!item.type || !item.type.startsWith("image/")) continue;
const blob = item.getAsFile();
if (!blob) continue;
const sub = (item.type.split("/")[1] || "png").toLowerCase();
const ext = sub === "jpeg" ? "jpg" : sub;
const name = `screenshot-${Date.now()}.${ext}`;
imageFiles.push(new File([blob], name, { type: item.type }));
}
if (!imageFiles.length) return;
ev.preventDefault();
addAiChatPendingFiles(imageFiles);
}
function renderHubMarkdown(text) {
const raw = String(text || "");
@@ -3558,8 +3663,8 @@
if (input) {
input.placeholder =
m === "general"
? "随便聊点什么,不绑交易数据…"
: "聊聊行情、心态、纪律、执行…";
? "随便聊点什么,不绑交易数据…可直接 Ctrl+V 粘贴截图"
: "聊聊行情、心态、纪律、执行…;可直接 Ctrl+V 粘贴截图";
}
}
@@ -3658,8 +3763,8 @@
if (showPlaceholder) {
const hint =
botMode === "general"
? "普通聊天不注入交易快照;发消息后可点气泡下方「复制」。"
: "交易教练会结合四户监控数据陪聊;发消息后可点气泡下方「复制」。可点「附件」上传图片文档。";
? "普通聊天不注入交易快照;发消息后可点气泡下方「复制」。可粘贴截图或上传附件。"
: "交易教练会结合四户监控数据陪聊;发消息后可点气泡下方「复制」。可粘贴截图或点「附件」上传图片/文档。";
box.innerHTML = `<p class="ai-placeholder">${hint}</p>`;
return;
}
@@ -3690,6 +3795,9 @@
const input = document.getElementById("ai-chat-input");
if (btn) btn.disabled = busy;
if (input) input.disabled = busy;
document.querySelectorAll(".ai-chat-pending-del").forEach((el) => {
el.disabled = busy;
});
}
async function loadAiChatSession() {
@@ -3857,12 +3965,13 @@
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();
const files = fileInput && fileInput.files ? Array.from(fileInput.files) : [];
const files = aiChatPendingFiles.slice();
if (!text && !files.length) return;
const pendingAttachments = files.map((f) => ({ name: f.name, kind: f.type.startsWith("image/") ? "image" : "text" }));
const pendingAttachments = files.map((f) => ({
name: f.name,
kind: aiChatFileKind(f),
}));
const savedText = text;
if (input) input.value = "";
setAiChatBusy(true);
@@ -3882,8 +3991,7 @@
aiChatSessionsCache = j.sessions || aiChatSessionsCache;
renderAiChatMessages(aiChatSessionCache);
renderAiChatHistory(aiChatSessionsCache);
if (fileInput) fileInput.value = "";
if (fileLabel) fileLabel.textContent = "";
clearAiChatPendingFiles();
if (j.attachment_warnings && j.attachment_warnings.length) {
showToast(j.attachment_warnings.join(""), true);
}
@@ -3901,11 +4009,25 @@
}
const aiChatFiles = document.getElementById("ai-chat-files");
const aiChatFilesLabel = document.getElementById("ai-chat-files-label");
if (aiChatFiles && aiChatFilesLabel) {
if (aiChatFiles) {
aiChatFiles.addEventListener("change", () => {
const names = aiChatFiles.files ? Array.from(aiChatFiles.files).map((f) => f.name) : [];
aiChatFilesLabel.textContent = names.length ? names.join("、") : "";
const picked = aiChatFiles.files ? Array.from(aiChatFiles.files) : [];
addAiChatPendingFiles(picked);
aiChatFiles.value = "";
});
}
const aiChatInput = document.getElementById("ai-chat-input");
if (aiChatInput) {
aiChatInput.addEventListener("paste", handleAiChatPaste);
}
const aiChatPending = document.getElementById("ai-chat-pending");
if (aiChatPending) {
aiChatPending.addEventListener("click", (ev) => {
const btn = ev.target.closest("[data-pending-del]");
if (!btn || aiChatLoading) return;
ev.preventDefault();
const idx = Number(btn.getAttribute("data-pending-del"));
if (!Number.isNaN(idx)) removeAiChatPendingFile(idx);
});
}
+2 -2
View File
@@ -561,13 +561,13 @@
<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">
<div class="ai-chat-compose">
<textarea id="ai-chat-input" rows="2" placeholder="聊聊行情、心态、纪律、执行…" autocomplete="off"></textarea>
<textarea id="ai-chat-input" rows="2" placeholder="聊聊行情、心态、纪律、执行…;可直接 Ctrl+V 粘贴截图" autocomplete="off"></textarea>
<div id="ai-chat-pending" class="ai-chat-pending-list" aria-live="polite"></div>
<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>