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:
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Optional
|
||||
|
||||
@@ -188,6 +189,179 @@ def _format_monitor_sections(hub_mon: Optional[dict]) -> dict[str, list[str]]:
|
||||
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]:
|
||||
name = ex.get("name") or ex.get("key") or ex.get("id")
|
||||
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)
|
||||
|
||||
hub_mon = None
|
||||
price_snap = None
|
||||
prev_day = previous_trading_day(trading_day)
|
||||
if flask_url:
|
||||
try:
|
||||
@@ -322,6 +497,23 @@ def _fetch_account_bundle(client: httpx.Client, ex: dict, trading_day: str) -> d
|
||||
if "成交接口" not in str(base["issues"]):
|
||||
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"]:
|
||||
base["status"] = "连接异常"
|
||||
elif base["issues"]:
|
||||
@@ -498,16 +690,13 @@ def format_context_text(payload: dict) -> str:
|
||||
for row in mon["orders"][:8]:
|
||||
lines.append(f" - {row}")
|
||||
positions = ac.get("positions") or []
|
||||
hub_mon = ac.get("hub_monitor")
|
||||
if positions:
|
||||
lines.append("持仓明细(交易所实盘):")
|
||||
lines.append("持仓明细(交易所实盘,含止盈止损若已挂):")
|
||||
for p in positions[:8]:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
sym = p.get("symbol") or "?"
|
||||
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(_format_position_detail_line(p, hub_mon))
|
||||
lines.append(
|
||||
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]:
|
||||
lines.append(f" - {row}")
|
||||
positions = ac.get("positions") or []
|
||||
hub_mon = ac.get("hub_monitor")
|
||||
if positions:
|
||||
lines.append("持仓明细(交易所实盘):")
|
||||
lines.append("持仓明细(交易所实盘,含止盈止损若已挂):")
|
||||
for p in positions[:8]:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
sym = p.get("symbol") or "?"
|
||||
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(_format_position_detail_line(p, hub_mon))
|
||||
lines.append(
|
||||
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 = [
|
||||
head,
|
||||
"【区分】只有带「持仓明细/交易所实盘」字样的才是已开仓;趋势回调、关键位、下单监控、顺势加仓是本地计划/监控,不算持仓。",
|
||||
"【区分】只有带「持仓明细/交易所实盘」字样的才是已开仓;趋势回调、关键位、下单监控、顺势加仓是本地计划/监控,不算持仓。持仓明细若含止损/止盈价,表示已挂条件单或监控计划中有价位。",
|
||||
]
|
||||
for ac in payload.get("accounts") or []:
|
||||
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"笔数 {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",
|
||||
"【说明】持仓=交易所实盘;趋势/关键位/监控单=本地计划,不等于已开仓。",
|
||||
"【说明】持仓=交易所实盘;趋势/关键位/监控单=本地计划,不等于已开仓。持仓行内「止损/止盈」= 交易所条件单或监控计划价(与监控页一致)。",
|
||||
]
|
||||
for ac in payload.get("accounts") or []:
|
||||
if ac.get("status") == "未监控":
|
||||
@@ -780,13 +966,11 @@ def format_chat_context_slim(payload: dict) -> str:
|
||||
if len(trades) > 4:
|
||||
lines.append(f" · …共{len(trades)}笔今日平仓")
|
||||
positions = ac.get("positions") or []
|
||||
hub_mon = ac.get("hub_monitor")
|
||||
for p in positions[:4]:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
sym = p.get("symbol") or "?"
|
||||
side = p.get("side") or "?"
|
||||
upnl = _position_float_pnl(p)
|
||||
lines.append(f" · 持仓 {sym} {side} 浮盈亏{upnl:.4f}U")
|
||||
lines.append(f" · {_format_position_detail_line(p, hub_mon).lstrip(' - ')}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ CHAT_SYSTEM = """
|
||||
- 若用户上传图片,可结合图中可见信息讨论,看不清的明确说看不清。
|
||||
- **优先接住【用户现在说】和【对话核心摘要】**:用户聊心态、悔单、某笔操作时,先顺着这个话题回应,不要每句都复述账户资金数字。
|
||||
- **接续对话**:有【对话核心摘要】时须接着聊,不要重复开场白;整段回复必须写完,以句号/问号/感叹号收尾,不得停在半句话;编号列表每条单独一行。
|
||||
- **止盈止损**:持仓明细若出现「止损xxx / 止盈xxx」,表示交易所条件单或监控计划里已有价位,勿再暗示用户「没挂止损/没设止盈」。仅当明细写「止损=未检测到」且无对应监控 SL 时,才可讨论补止损。趋势持仓「止盈=程序监控」表示由程序盯止盈,不是没止盈。
|
||||
- 快照里的盈亏/资金仅在需要核对事实时引用;用户口述与快照冲突时,以快照为准并口语说明。
|
||||
""".strip()
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user