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
+202 -18
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import os import os
import re
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Optional from typing import Any, Optional
@@ -188,6 +189,179 @@ def _format_monitor_sections(hub_mon: Optional[dict]) -> dict[str, list[str]]:
return out return out
_SL_TP_COMBO_RE = re.compile(r"SL=([\d.eE+-]+).*TP=([\d.eE+-]+)", re.I)
def _norm_symbol(sym: str) -> str:
s = (sym or "").strip().upper()
if "/" in s:
s = s.split(":")[0].split("/")[0]
return s
def _symbols_match(a: str, b: str) -> bool:
na, nb = _norm_symbol(a), _norm_symbol(b)
return bool(na and nb and na == nb)
def _pick_tpsl_from_cond(cond: list) -> tuple[Optional[float], Optional[float]]:
sl = tp = None
if not cond:
return sl, tp
sl_o = tp_o = combo = None
for o in cond:
if not isinstance(o, dict):
continue
lbl = str(o.get("label") or "")
if "止盈止损" in lbl:
combo = o
elif lbl.startswith("止损"):
sl_o = o
elif lbl.startswith("止盈"):
tp_o = o
if combo:
lbl = str(combo.get("label") or "")
m = _SL_TP_COMBO_RE.search(lbl)
if m:
sl = _safe_float(m.group(1))
tp = _safe_float(m.group(2))
if sl_o and sl is None:
sl = _safe_float(sl_o.get("trigger_price"))
if tp_o and tp is None:
tp = _safe_float(tp_o.get("trigger_price"))
if sl is None:
for o in cond:
if not isinstance(o, dict):
continue
lbl = str(o.get("label") or "")
if "止损" in lbl and "止盈止损" not in lbl:
sl = _safe_float(o.get("trigger_price"))
if sl is not None:
break
if tp is None:
for o in cond:
if not isinstance(o, dict):
continue
lbl = str(o.get("label") or "")
if lbl.startswith("止盈") or ("止盈" in lbl and "止盈止损" not in lbl):
tp = _safe_float(o.get("trigger_price"))
if tp is not None:
break
return sl, tp
def _pick_tpsl_from_exchange_tpsl(et: Any) -> tuple[Optional[float], Optional[float]]:
if not isinstance(et, dict):
return None, None
sl = tp = None
slot_sl = et.get("sl")
slot_tp = et.get("tp")
if isinstance(slot_sl, dict):
sl = _safe_float(slot_sl.get("trigger_price"))
if isinstance(slot_tp, dict):
tp = _safe_float(slot_tp.get("trigger_price"))
return sl, tp
def _find_plan_tpsl_for_position(
symbol: str,
side: str,
hub_mon: Optional[dict],
) -> tuple[Optional[float], Optional[float], bool]:
"""匹配本地监控/趋势计划:sl, tp, tp_is_program_monitored。"""
if not isinstance(hub_mon, dict):
return None, None, False
side_l = (side or "").lower()
for o in hub_mon.get("orders") or []:
if not isinstance(o, dict):
continue
o_sym = o.get("exchange_symbol") or o.get("symbol") or ""
if not _symbols_match(symbol, o_sym):
continue
if (o.get("direction") or "").lower() != side_l:
continue
return (
_safe_float(o.get("stop_loss")),
_safe_float(o.get("take_profit")),
False,
)
for t in hub_mon.get("trends") or []:
if not isinstance(t, dict):
continue
if not _symbols_match(symbol, t.get("symbol") or ""):
continue
if (t.get("direction") or "").lower() != side_l:
continue
plan_tp = t.get("take_profit")
tp = _safe_float(plan_tp) if plan_tp not in (None, "") else None
return _safe_float(t.get("stop_loss")), tp, tp is None
return None, None, False
def _resolve_position_tpsl(pos: dict, hub_mon: Optional[dict]) -> dict[str, Any]:
cond = pos.get("conditional_orders") or []
cond_sl, cond_tp = _pick_tpsl_from_cond(cond)
et_sl, et_tp = _pick_tpsl_from_exchange_tpsl(pos.get("exchange_tpsl"))
plan_sl, plan_tp, tp_monitored = _find_plan_tpsl_for_position(
str(pos.get("symbol") or ""),
str(pos.get("side") or ""),
hub_mon,
)
sl = cond_sl if cond_sl is not None else et_sl if et_sl is not None else plan_sl
tp_note = ""
tp: Optional[float] = None
if tp_monitored and cond_tp is None and et_tp is None:
tp_note = "程序监控"
else:
tp = cond_tp if cond_tp is not None else et_tp if et_tp is not None else plan_tp
if sl is not None and tp is not None and sl == tp:
tp = None
return {"sl": sl, "tp": tp, "tp_note": tp_note}
def _format_position_detail_line(pos: dict, hub_mon: Optional[dict]) -> str:
sym = pos.get("symbol") or "?"
side = pos.get("side") or "?"
contracts = pos.get("contracts") or pos.get("size") or "?"
upnl = _position_float_pnl(pos)
entry = _safe_float(pos.get("entry_price"))
tpsl = _resolve_position_tpsl(pos, hub_mon)
parts = [f"{sym} {side} 张数{contracts}"]
if entry is not None:
parts.append(f"入场{entry:g}")
if tpsl["sl"] is not None:
parts.append(f"止损{tpsl['sl']:g}")
else:
parts.append("止损=未检测到")
if tpsl["tp_note"]:
parts.append(f"止盈={tpsl['tp_note']}")
elif tpsl["tp"] is not None:
parts.append(f"止盈{tpsl['tp']:g}")
else:
parts.append("止盈=未检测到")
parts.append(f"浮盈亏{upnl:.4f}U")
return " - " + " ".join(parts)
def _enrich_positions_exchange_tpsl(
positions: list,
price_snap: Optional[dict],
hub_mon: Optional[dict],
) -> None:
if not positions:
return
try:
from hub import _merge_flask_exchange_tpsl
_merge_flask_exchange_tpsl(
{"agent": {"positions": positions}},
price_snap if isinstance(price_snap, dict) else None,
hub_mon if isinstance(hub_mon, dict) else None,
)
except Exception:
pass
def _fetch_account_bundle(client: httpx.Client, ex: dict, trading_day: str) -> dict[str, Any]: def _fetch_account_bundle(client: httpx.Client, ex: dict, trading_day: str) -> dict[str, Any]:
name = ex.get("name") or ex.get("key") or ex.get("id") name = ex.get("name") or ex.get("key") or ex.get("id")
key = ex.get("key") or "" key = ex.get("key") or ""
@@ -251,6 +425,7 @@ def _fetch_account_bundle(client: httpx.Client, ex: dict, trading_day: str) -> d
base["float_pnl_u"] = round(sum(_position_float_pnl(p) for p in open_positions), 4) base["float_pnl_u"] = round(sum(_position_float_pnl(p) for p in open_positions), 4)
hub_mon = None hub_mon = None
price_snap = None
prev_day = previous_trading_day(trading_day) prev_day = previous_trading_day(trading_day)
if flask_url: if flask_url:
try: try:
@@ -322,6 +497,23 @@ def _fetch_account_bundle(client: httpx.Client, ex: dict, trading_day: str) -> d
if "成交接口" not in str(base["issues"]): if "成交接口" not in str(base["issues"]):
base["issues"].append(f"监控接口: {exc}") base["issues"].append(f"监控接口: {exc}")
try:
r = client.get(
f"{flask_url}/api/price_snapshot",
headers=_hub_headers(),
timeout=hub_flask_timeout(),
)
if r.status_code == 200:
body = r.json()
if isinstance(body, dict):
price_snap = body
base["flask_ok"] = True
except Exception:
pass
if base["positions"]:
_enrich_positions_exchange_tpsl(base["positions"], price_snap, hub_mon)
if monitored and not base["agent_ok"] and not base["flask_ok"]: if monitored and not base["agent_ok"] and not base["flask_ok"]:
base["status"] = "连接异常" base["status"] = "连接异常"
elif base["issues"]: elif base["issues"]:
@@ -498,16 +690,13 @@ def format_context_text(payload: dict) -> str:
for row in mon["orders"][:8]: for row in mon["orders"][:8]:
lines.append(f" - {row}") lines.append(f" - {row}")
positions = ac.get("positions") or [] positions = ac.get("positions") or []
hub_mon = ac.get("hub_monitor")
if positions: if positions:
lines.append("持仓明细(交易所实盘):") lines.append("持仓明细(交易所实盘,含止盈止损若已挂):")
for p in positions[:8]: for p in positions[:8]:
if not isinstance(p, dict): if not isinstance(p, dict):
continue continue
sym = p.get("symbol") or "?" lines.append(_format_position_detail_line(p, hub_mon))
side = p.get("side") or "?"
contracts = p.get("contracts") or p.get("size") or "?"
upnl = _position_float_pnl(p)
lines.append(f" - {sym} {side} 张数{contracts} 浮盈亏{upnl:.4f}U")
lines.append( lines.append(
f"Agent合约余额:{ac.get('balance_usdt') if ac.get('balance_usdt') is not None else '未知'} USDT" f"Agent合约余额:{ac.get('balance_usdt') if ac.get('balance_usdt') is not None else '未知'} USDT"
) )
@@ -589,16 +778,13 @@ def format_summary_context_text(payload: dict) -> str:
for row in mon["orders"][:8]: for row in mon["orders"][:8]:
lines.append(f" - {row}") lines.append(f" - {row}")
positions = ac.get("positions") or [] positions = ac.get("positions") or []
hub_mon = ac.get("hub_monitor")
if positions: if positions:
lines.append("持仓明细(交易所实盘):") lines.append("持仓明细(交易所实盘,含止盈止损若已挂):")
for p in positions[:8]: for p in positions[:8]:
if not isinstance(p, dict): if not isinstance(p, dict):
continue continue
sym = p.get("symbol") or "?" lines.append(_format_position_detail_line(p, hub_mon))
side = p.get("side") or "?"
contracts = p.get("contracts") or p.get("size") or "?"
upnl = _position_float_pnl(p)
lines.append(f" - {sym} {side} 张数{contracts} 浮盈亏{upnl:.4f}U")
lines.append( lines.append(
f"Agent合约余额:{ac.get('balance_usdt') if ac.get('balance_usdt') is not None else '未知'} USDT" f"Agent合约余额:{ac.get('balance_usdt') if ac.get('balance_usdt') is not None else '未知'} USDT"
) )
@@ -714,7 +900,7 @@ def format_chat_position_overview(payload: dict) -> str:
) )
lines = [ lines = [
head, head,
"【区分】只有带「持仓明细/交易所实盘」字样的才是已开仓;趋势回调、关键位、下单监控、顺势加仓是本地计划/监控,不算持仓。", "【区分】只有带「持仓明细/交易所实盘」字样的才是已开仓;趋势回调、关键位、下单监控、顺势加仓是本地计划/监控,不算持仓。持仓明细若含止损/止盈价,表示已挂条件单或监控计划中有价位。",
] ]
for ac in payload.get("accounts") or []: for ac in payload.get("accounts") or []:
if ac.get("status") == "未监控": if ac.get("status") == "未监控":
@@ -748,7 +934,7 @@ def format_chat_context_slim(payload: dict) -> str:
f"【今日合计 {day}】平仓盈亏 {totals.get('total_pnl_u')}U | " f"【今日合计 {day}】平仓盈亏 {totals.get('total_pnl_u')}U | "
f"笔数 {totals.get('closed_count')}(胜{totals.get('win_count')}/负{totals.get('loss_count')}| " f"笔数 {totals.get('closed_count')}(胜{totals.get('win_count')}/负{totals.get('loss_count')}| "
f"实盘持仓 {totals.get('open_position_count', 0)} 仓 | 浮盈亏 {totals.get('float_pnl_u')}U", f"实盘持仓 {totals.get('open_position_count', 0)} 仓 | 浮盈亏 {totals.get('float_pnl_u')}U",
"【说明】持仓=交易所实盘;趋势/关键位/监控单=本地计划,不等于已开仓。", "【说明】持仓=交易所实盘;趋势/关键位/监控单=本地计划,不等于已开仓。持仓行内「止损/止盈」= 交易所条件单或监控计划价(与监控页一致)。",
] ]
for ac in payload.get("accounts") or []: for ac in payload.get("accounts") or []:
if ac.get("status") == "未监控": if ac.get("status") == "未监控":
@@ -780,13 +966,11 @@ def format_chat_context_slim(payload: dict) -> str:
if len(trades) > 4: if len(trades) > 4:
lines.append(f" · …共{len(trades)}笔今日平仓") lines.append(f" · …共{len(trades)}笔今日平仓")
positions = ac.get("positions") or [] positions = ac.get("positions") or []
hub_mon = ac.get("hub_monitor")
for p in positions[:4]: for p in positions[:4]:
if not isinstance(p, dict): if not isinstance(p, dict):
continue continue
sym = p.get("symbol") or "?" lines.append(f" · {_format_position_detail_line(p, hub_mon).lstrip(' - ')}")
side = p.get("side") or "?"
upnl = _position_float_pnl(p)
lines.append(f" · 持仓 {sym} {side} 浮盈亏{upnl:.4f}U")
return "\n".join(lines) return "\n".join(lines)
+1
View File
@@ -51,6 +51,7 @@ CHAT_SYSTEM = """
- 若用户上传图片,可结合图中可见信息讨论,看不清的明确说看不清。 - 若用户上传图片,可结合图中可见信息讨论,看不清的明确说看不清。
- **优先接住【用户现在说】和【对话核心摘要】**:用户聊心态、悔单、某笔操作时,先顺着这个话题回应,不要每句都复述账户资金数字。 - **优先接住【用户现在说】和【对话核心摘要】**:用户聊心态、悔单、某笔操作时,先顺着这个话题回应,不要每句都复述账户资金数字。
- **接续对话**:有【对话核心摘要】时须接着聊,不要重复开场白;整段回复必须写完,以句号/问号/感叹号收尾,不得停在半句话;编号列表每条单独一行。 - **接续对话**:有【对话核心摘要】时须接着聊,不要重复开场白;整段回复必须写完,以句号/问号/感叹号收尾,不得停在半句话;编号列表每条单独一行。
- **止盈止损**:持仓明细若出现「止损xxx / 止盈xxx」,表示交易所条件单或监控计划里已有价位,勿再暗示用户「没挂止损/没设止盈」。仅当明细写「止损=未检测到」且无对应监控 SL 时,才可讨论补止损。趋势持仓「止盈=程序监控」表示由程序盯止盈,不是没止盈。
- 快照里的盈亏/资金仅在需要核对事实时引用;用户口述与快照冲突时,以快照为准并口语说明。 - 快照里的盈亏/资金仅在需要核对事实时引用;用户口述与快照冲突时,以快照为准并口语说明。
""".strip() """.strip()
+48 -11
View File
@@ -4467,14 +4467,8 @@ body.hub-page-ai #page-ai {
width: 100%; width: 100%;
} }
body.hub-page-ai .ai-chat-files-label { body.hub-page-ai .ai-chat-pending-list {
flex: 1; width: 100%;
min-width: 0;
font-size: 0.68rem;
}
body.hub-page-ai .ai-chat-files-label:empty {
display: none;
} }
body.hub-page-ai .ai-chat-upload-btn, body.hub-page-ai .ai-chat-upload-btn,
@@ -5140,15 +5134,58 @@ body.hub-page-ai #page-ai {
border-color: var(--accent); border-color: var(--accent);
color: var(--accent); color: var(--accent);
} }
.ai-chat-files-label { .ai-chat-pending-list {
flex: 1; display: flex;
min-width: 0; 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; 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); color: var(--muted);
}
.ai-chat-pending-name {
min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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 { .ai-chat-form textarea {
width: 100%; width: 100%;
resize: none; resize: none;
+136 -14
View File
@@ -3517,6 +3517,111 @@
let aiChatSessionCache = null; let aiChatSessionCache = null;
let aiChatSessionsCache = []; let aiChatSessionsCache = [];
let aiSelectedBotMode = "trading"; 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) { function renderHubMarkdown(text) {
const raw = String(text || ""); const raw = String(text || "");
@@ -3558,8 +3663,8 @@
if (input) { if (input) {
input.placeholder = input.placeholder =
m === "general" m === "general"
? "随便聊点什么,不绑交易数据…" ? "随便聊点什么,不绑交易数据…可直接 Ctrl+V 粘贴截图"
: "聊聊行情、心态、纪律、执行…"; : "聊聊行情、心态、纪律、执行…;可直接 Ctrl+V 粘贴截图";
} }
} }
@@ -3658,8 +3763,8 @@
if (showPlaceholder) { if (showPlaceholder) {
const hint = const hint =
botMode === "general" botMode === "general"
? "普通聊天不注入交易快照;发消息后可点气泡下方「复制」。" ? "普通聊天不注入交易快照;发消息后可点气泡下方「复制」。可粘贴截图或上传附件。"
: "交易教练会结合四户监控数据陪聊;发消息后可点气泡下方「复制」。可点「附件」上传图片文档。"; : "交易教练会结合四户监控数据陪聊;发消息后可点气泡下方「复制」。可粘贴截图或点「附件」上传图片/文档。";
box.innerHTML = `<p class="ai-placeholder">${hint}</p>`; box.innerHTML = `<p class="ai-placeholder">${hint}</p>`;
return; return;
} }
@@ -3690,6 +3795,9 @@
const input = document.getElementById("ai-chat-input"); const input = document.getElementById("ai-chat-input");
if (btn) btn.disabled = busy; if (btn) btn.disabled = busy;
if (input) input.disabled = busy; if (input) input.disabled = busy;
document.querySelectorAll(".ai-chat-pending-del").forEach((el) => {
el.disabled = busy;
});
} }
async function loadAiChatSession() { async function loadAiChatSession() {
@@ -3857,12 +3965,13 @@
if (ev) ev.preventDefault(); if (ev) ev.preventDefault();
if (aiChatLoading) return; if (aiChatLoading) return;
const input = document.getElementById("ai-chat-input"); 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 text = (input && input.value || "").trim();
const files = fileInput && fileInput.files ? Array.from(fileInput.files) : []; const files = aiChatPendingFiles.slice();
if (!text && !files.length) return; 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; const savedText = text;
if (input) input.value = ""; if (input) input.value = "";
setAiChatBusy(true); setAiChatBusy(true);
@@ -3882,8 +3991,7 @@
aiChatSessionsCache = j.sessions || aiChatSessionsCache; aiChatSessionsCache = j.sessions || aiChatSessionsCache;
renderAiChatMessages(aiChatSessionCache); renderAiChatMessages(aiChatSessionCache);
renderAiChatHistory(aiChatSessionsCache); renderAiChatHistory(aiChatSessionsCache);
if (fileInput) fileInput.value = ""; clearAiChatPendingFiles();
if (fileLabel) fileLabel.textContent = "";
if (j.attachment_warnings && j.attachment_warnings.length) { if (j.attachment_warnings && j.attachment_warnings.length) {
showToast(j.attachment_warnings.join(""), true); showToast(j.attachment_warnings.join(""), true);
} }
@@ -3901,11 +4009,25 @@
} }
const aiChatFiles = document.getElementById("ai-chat-files"); const aiChatFiles = document.getElementById("ai-chat-files");
const aiChatFilesLabel = document.getElementById("ai-chat-files-label"); if (aiChatFiles) {
if (aiChatFiles && aiChatFilesLabel) {
aiChatFiles.addEventListener("change", () => { aiChatFiles.addEventListener("change", () => {
const names = aiChatFiles.files ? Array.from(aiChatFiles.files).map((f) => f.name) : []; const picked = aiChatFiles.files ? Array.from(aiChatFiles.files) : [];
aiChatFilesLabel.textContent = names.length ? names.join("、") : ""; 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> <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"> <form id="ai-chat-form" class="ai-chat-form">
<div class="ai-chat-compose"> <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"> <div class="ai-chat-compose-actions">
<label class="ai-chat-upload-btn" title="上传图片或 txt/md/json 文档"> <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 /> <input type="file" id="ai-chat-files" accept="image/*,.txt,.md,.markdown,.json" multiple hidden />
附件 附件
</label> </label>
<span id="ai-chat-files-label" class="ai-chat-files-label"></span>
<button type="submit" id="btn-ai-chat-send" class="primary">发送</button> <button type="submit" id="btn-ai-chat-send" class="primary">发送</button>
</div> </div>
</div> </div>