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:
@@ -18,6 +18,7 @@
|
|||||||
manual_trading_hub/hub_settings.json
|
manual_trading_hub/hub_settings.json
|
||||||
manual_trading_hub/hub_ai_summaries.json
|
manual_trading_hub/hub_ai_summaries.json
|
||||||
manual_trading_hub/hub_ai_chat.json
|
manual_trading_hub/hub_ai_chat.json
|
||||||
|
manual_trading_hub/hub_ai_fund_history.json
|
||||||
manual_trading_hub/data/
|
manual_trading_hub/data/
|
||||||
|
|
||||||
# 数据库与上传(运行时生成)
|
# 数据库与上传(运行时生成)
|
||||||
|
|||||||
@@ -8145,6 +8145,19 @@ def _hub_meta_bundle():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _hub_account_bundle():
|
||||||
|
funding_capital, trading_capital = get_exchange_capitals(force=True)
|
||||||
|
funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None
|
||||||
|
trading_usdt = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else None
|
||||||
|
available = get_available_trading_usdt()
|
||||||
|
return {
|
||||||
|
"funding_usdt": funding_usdt,
|
||||||
|
"trading_usdt": trading_usdt,
|
||||||
|
"available_trading_usdt": round(available, FUNDS_DECIMALS) if available is not None else None,
|
||||||
|
"trading_day": get_trading_day(app_now()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500):
|
def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500):
|
||||||
from hub_ohlcv_lib import fetch_ohlcv_for_hub
|
from hub_ohlcv_lib import fetch_ohlcv_for_hub
|
||||||
|
|
||||||
@@ -8178,6 +8191,7 @@ try:
|
|||||||
get_db=get_db,
|
get_db=get_db,
|
||||||
row_to_dict=row_to_dict,
|
row_to_dict=row_to_dict,
|
||||||
meta_fn=_hub_meta_bundle,
|
meta_fn=_hub_meta_bundle,
|
||||||
|
account_fn=_hub_account_bundle,
|
||||||
views={"add_order": add_order, "add_key": add_key},
|
views={"add_order": add_order, "add_key": add_key},
|
||||||
ohlcv_fn=_hub_fetch_ohlcv,
|
ohlcv_fn=_hub_fetch_ohlcv,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8204,6 +8204,19 @@ def _hub_meta_bundle():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _hub_account_bundle():
|
||||||
|
funding_capital, trading_capital = get_exchange_capitals(force=True)
|
||||||
|
funding_usdt = round(funding_capital, 2) if funding_capital is not None else None
|
||||||
|
trading_usdt = round(trading_capital, 2) if trading_capital is not None else None
|
||||||
|
available = get_available_trading_usdt()
|
||||||
|
return {
|
||||||
|
"funding_usdt": funding_usdt,
|
||||||
|
"trading_usdt": trading_usdt,
|
||||||
|
"available_trading_usdt": round(available, 2) if available is not None else None,
|
||||||
|
"trading_day": get_trading_day(app_now()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500):
|
def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500):
|
||||||
from hub_ohlcv_lib import fetch_ohlcv_for_hub
|
from hub_ohlcv_lib import fetch_ohlcv_for_hub
|
||||||
|
|
||||||
@@ -8237,6 +8250,7 @@ try:
|
|||||||
get_db=get_db,
|
get_db=get_db,
|
||||||
row_to_dict=row_to_dict,
|
row_to_dict=row_to_dict,
|
||||||
meta_fn=_hub_meta_bundle,
|
meta_fn=_hub_meta_bundle,
|
||||||
|
account_fn=_hub_account_bundle,
|
||||||
views={"add_order": add_order, "add_key": add_key},
|
views={"add_order": add_order, "add_key": add_key},
|
||||||
ohlcv_fn=_hub_fetch_ohlcv,
|
ohlcv_fn=_hub_fetch_ohlcv,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8127,6 +8127,19 @@ def _hub_meta_bundle():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _hub_account_bundle():
|
||||||
|
funding_capital, trading_capital = get_exchange_capitals(force=True)
|
||||||
|
funding_usdt = round(funding_capital, 2) if funding_capital is not None else None
|
||||||
|
trading_usdt = round(trading_capital, 2) if trading_capital is not None else None
|
||||||
|
available = get_available_trading_usdt()
|
||||||
|
return {
|
||||||
|
"funding_usdt": funding_usdt,
|
||||||
|
"trading_usdt": trading_usdt,
|
||||||
|
"available_trading_usdt": round(available, 2) if available is not None else None,
|
||||||
|
"trading_day": get_trading_day(app_now()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500):
|
def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500):
|
||||||
from hub_ohlcv_lib import fetch_ohlcv_for_hub
|
from hub_ohlcv_lib import fetch_ohlcv_for_hub
|
||||||
|
|
||||||
@@ -8160,6 +8173,7 @@ try:
|
|||||||
get_db=get_db,
|
get_db=get_db,
|
||||||
row_to_dict=row_to_dict,
|
row_to_dict=row_to_dict,
|
||||||
meta_fn=_hub_meta_bundle,
|
meta_fn=_hub_meta_bundle,
|
||||||
|
account_fn=_hub_account_bundle,
|
||||||
views={
|
views={
|
||||||
"add_order": add_order,
|
"add_order": add_order,
|
||||||
"add_key": add_key,
|
"add_key": add_key,
|
||||||
|
|||||||
@@ -7828,6 +7828,19 @@ def _hub_meta_bundle():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _hub_account_bundle():
|
||||||
|
funding_capital, trading_capital = get_exchange_capitals(force=True)
|
||||||
|
funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None
|
||||||
|
trading_usdt = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else None
|
||||||
|
available = get_available_trading_usdt()
|
||||||
|
return {
|
||||||
|
"funding_usdt": funding_usdt,
|
||||||
|
"trading_usdt": trading_usdt,
|
||||||
|
"available_trading_usdt": round(available, FUNDS_DECIMALS) if available is not None else None,
|
||||||
|
"trading_day": get_trading_day(app_now()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500):
|
def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500):
|
||||||
from hub_ohlcv_lib import fetch_ohlcv_for_hub
|
from hub_ohlcv_lib import fetch_ohlcv_for_hub
|
||||||
|
|
||||||
@@ -7861,6 +7874,7 @@ try:
|
|||||||
get_db=get_db,
|
get_db=get_db,
|
||||||
row_to_dict=row_to_dict,
|
row_to_dict=row_to_dict,
|
||||||
meta_fn=_hub_meta_bundle,
|
meta_fn=_hub_meta_bundle,
|
||||||
|
account_fn=_hub_account_bundle,
|
||||||
views={"add_order": add_order, "add_key": add_key},
|
views={"add_order": add_order, "add_key": add_key},
|
||||||
ohlcv_fn=_hub_fetch_ohlcv,
|
ohlcv_fn=_hub_fetch_ohlcv,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ def install_on_app(
|
|||||||
meta_fn,
|
meta_fn,
|
||||||
views: dict,
|
views: dict,
|
||||||
ohlcv_fn=None,
|
ohlcv_fn=None,
|
||||||
|
account_fn=None,
|
||||||
):
|
):
|
||||||
app.config["HUB_CTX"] = {
|
app.config["HUB_CTX"] = {
|
||||||
"exchange": exchange,
|
"exchange": exchange,
|
||||||
@@ -213,6 +214,7 @@ def install_on_app(
|
|||||||
"get_db": get_db,
|
"get_db": get_db,
|
||||||
"row_to_dict": row_to_dict,
|
"row_to_dict": row_to_dict,
|
||||||
"meta_fn": meta_fn,
|
"meta_fn": meta_fn,
|
||||||
|
"account_fn": account_fn,
|
||||||
"views": views,
|
"views": views,
|
||||||
"ohlcv_fn": ohlcv_fn,
|
"ohlcv_fn": ohlcv_fn,
|
||||||
}
|
}
|
||||||
@@ -338,6 +340,21 @@ def register_hub_routes(app):
|
|||||||
meta = meta_fn() if callable(meta_fn) else {}
|
meta = meta_fn() if callable(meta_fn) else {}
|
||||||
return jsonify({"ok": True, "meta": meta})
|
return jsonify({"ok": True, "meta": meta})
|
||||||
|
|
||||||
|
@app.route("/api/hub/account")
|
||||||
|
@_hub_auth_required
|
||||||
|
def api_hub_account():
|
||||||
|
"""中控 AI:资金账户 / 交易账户余额(无需浏览器登录)。"""
|
||||||
|
fn = _ctx().get("account_fn")
|
||||||
|
if not callable(fn):
|
||||||
|
return jsonify({"ok": False, "msg": "未配置 account_fn"}), 501
|
||||||
|
try:
|
||||||
|
data = fn()
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {}
|
||||||
|
return jsonify({"ok": True, **data})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"ok": False, "msg": str(e)}), 500
|
||||||
|
|
||||||
@app.route("/api/hub/monitor")
|
@app.route("/api/hub/monitor")
|
||||||
@_hub_auth_required
|
@_hub_auth_required
|
||||||
def api_hub_monitor():
|
def api_hub_monitor():
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""中控 AI 聊天附件解析。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from hub_ai.config import (
|
||||||
|
CHAT_MAX_ATTACHMENTS,
|
||||||
|
CHAT_MAX_IMAGE_BYTES,
|
||||||
|
CHAT_MAX_TEXT_FILE_BYTES,
|
||||||
|
)
|
||||||
|
|
||||||
|
IMAGE_MIMES = {
|
||||||
|
"image/jpeg",
|
||||||
|
"image/jpg",
|
||||||
|
"image/png",
|
||||||
|
"image/webp",
|
||||||
|
"image/gif",
|
||||||
|
}
|
||||||
|
TEXT_MIMES = {
|
||||||
|
"text/plain",
|
||||||
|
"text/markdown",
|
||||||
|
"application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _guess_mime(filename: str, content_type: str) -> str:
|
||||||
|
ct = (content_type or "").split(";")[0].strip().lower()
|
||||||
|
if ct:
|
||||||
|
return ct
|
||||||
|
name = (filename or "").lower()
|
||||||
|
if name.endswith(".png"):
|
||||||
|
return "image/png"
|
||||||
|
if name.endswith((".jpg", ".jpeg")):
|
||||||
|
return "image/jpeg"
|
||||||
|
if name.endswith(".webp"):
|
||||||
|
return "image/webp"
|
||||||
|
if name.endswith(".gif"):
|
||||||
|
return "image/gif"
|
||||||
|
if name.endswith((".md", ".markdown")):
|
||||||
|
return "text/markdown"
|
||||||
|
if name.endswith(".txt"):
|
||||||
|
return "text/plain"
|
||||||
|
if name.endswith(".json"):
|
||||||
|
return "application/json"
|
||||||
|
return "application/octet-stream"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_chat_attachments(raw_files: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
raw_files: [{filename, content_type, data: bytes}]
|
||||||
|
返回 images_b64, attachment_note, attachment_meta, text_append
|
||||||
|
"""
|
||||||
|
images_b64: list[str] = []
|
||||||
|
meta: list[dict] = []
|
||||||
|
notes: list[str] = []
|
||||||
|
text_blocks: list[str] = []
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
for item in (raw_files or [])[:CHAT_MAX_ATTACHMENTS]:
|
||||||
|
name = str(item.get("filename") or "file")
|
||||||
|
data = item.get("data") or b""
|
||||||
|
if not isinstance(data, (bytes, bytearray)):
|
||||||
|
errors.append(f"{name}: 无效数据")
|
||||||
|
continue
|
||||||
|
mime = _guess_mime(name, str(item.get("content_type") or ""))
|
||||||
|
size = len(data)
|
||||||
|
if mime in IMAGE_MIMES:
|
||||||
|
if size > CHAT_MAX_IMAGE_BYTES:
|
||||||
|
errors.append(f"{name}: 图片超过 {CHAT_MAX_IMAGE_BYTES // 1024 // 1024}MB")
|
||||||
|
continue
|
||||||
|
images_b64.append(base64.b64encode(bytes(data)).decode("ascii"))
|
||||||
|
meta.append({"name": name, "kind": "image", "mime": mime, "size": size})
|
||||||
|
notes.append(f"图片 {name}")
|
||||||
|
continue
|
||||||
|
if mime in TEXT_MIMES or name.lower().endswith((".txt", ".md", ".markdown", ".json")):
|
||||||
|
if size > CHAT_MAX_TEXT_FILE_BYTES:
|
||||||
|
errors.append(f"{name}: 文本超过 {CHAT_MAX_TEXT_FILE_BYTES // 1024}KB")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
text = bytes(data).decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
errors.append(f"{name}: 非 UTF-8 文本")
|
||||||
|
continue
|
||||||
|
text_blocks.append(f"--- 附件 {name} ---\n{text.strip()}")
|
||||||
|
meta.append({"name": name, "kind": "text", "mime": mime, "size": size})
|
||||||
|
notes.append(f"文档 {name}")
|
||||||
|
continue
|
||||||
|
errors.append(f"{name}: 不支持的类型(仅图片或 txt/md/json)")
|
||||||
|
|
||||||
|
attachment_note = ";".join(notes) if notes else ""
|
||||||
|
if errors:
|
||||||
|
attachment_note = (attachment_note + ";" if attachment_note else "") + ";".join(errors)
|
||||||
|
text_append = "\n\n".join(text_blocks)
|
||||||
|
return {
|
||||||
|
"images_b64": images_b64,
|
||||||
|
"attachment_note": attachment_note,
|
||||||
|
"attachment_meta": meta,
|
||||||
|
"text_append": text_append,
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
"""中控 AI:单会话聊天(直到用户点击新开)。"""
|
"""中控 AI:单会话聊天(直到用户点击新开)。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from hub_ai.attachments import parse_chat_attachments
|
||||||
from hub_ai.client import generate_text, model_label
|
from hub_ai.client import generate_text, model_label
|
||||||
from hub_ai.config import CHAT_MAX_HISTORY_TURNS, CHAT_TEMPERATURE
|
from hub_ai.config import CHAT_MAX_HISTORY_TURNS, CHAT_TEMPERATURE
|
||||||
from hub_ai.context import build_daily_context, format_chat_context_brief
|
from hub_ai.context import build_daily_context, format_chat_context_for_chat
|
||||||
from hub_ai.prompts import CHAT_SYSTEM, build_chat_user_prompt
|
from hub_ai.prompts import CHAT_SYSTEM, build_chat_user_prompt
|
||||||
from hub_ai.store import (
|
from hub_ai.store import (
|
||||||
append_chat_message,
|
append_chat_message,
|
||||||
@@ -23,7 +24,12 @@ def _history_lines(messages: list[dict], max_turns: int = CHAT_MAX_HISTORY_TURNS
|
|||||||
lines = []
|
lines = []
|
||||||
for m in rows:
|
for m in rows:
|
||||||
role = "用户" if m.get("role") == "user" else "搭档"
|
role = "用户" if m.get("role") == "user" else "搭档"
|
||||||
lines.append(f"{role}:{m.get('content') or ''}")
|
content = m.get("content") or ""
|
||||||
|
att = m.get("attachments") or []
|
||||||
|
if att:
|
||||||
|
names = "、".join(str(a.get("name") or "附件") for a in att[:3])
|
||||||
|
content = f"{content} [附件: {names}]".strip()
|
||||||
|
lines.append(f"{role}:{content}")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
@@ -47,20 +53,35 @@ def send_chat_message(
|
|||||||
message: str,
|
message: str,
|
||||||
*,
|
*,
|
||||||
trading_day: str | None = None,
|
trading_day: str | None = None,
|
||||||
|
raw_attachments: Optional[list[dict]] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
text = (message or "").strip()
|
text = (message or "").strip()
|
||||||
if not text:
|
parsed = parse_chat_attachments(raw_attachments or [])
|
||||||
|
if parsed.get("errors") and not text and not parsed.get("images_b64"):
|
||||||
|
return {"ok": False, "msg": ";".join(parsed["errors"])}
|
||||||
|
if not text and not parsed.get("images_b64") and not parsed.get("text_append"):
|
||||||
return {"ok": False, "msg": "消息不能为空"}
|
return {"ok": False, "msg": "消息不能为空"}
|
||||||
|
|
||||||
|
user_visible = text
|
||||||
|
if parsed.get("text_append"):
|
||||||
|
user_visible = (user_visible + "\n\n" + parsed["text_append"]).strip()
|
||||||
|
if not user_visible and parsed.get("attachment_note"):
|
||||||
|
user_visible = f"(上传了 {parsed['attachment_note']})"
|
||||||
|
|
||||||
ctx = build_daily_context(exchanges, trading_day=trading_day)
|
ctx = build_daily_context(exchanges, trading_day=trading_day)
|
||||||
day = ctx["trading_day"]
|
day = ctx["trading_day"]
|
||||||
session = ensure_active_session(trading_day=day)
|
session = ensure_active_session(trading_day=day)
|
||||||
sid = session["id"]
|
sid = session["id"]
|
||||||
history = _history_lines(session.get("messages") or [])
|
history = _history_lines(session.get("messages") or [])
|
||||||
|
|
||||||
append_chat_message(sid, "user", text)
|
append_chat_message(
|
||||||
|
sid,
|
||||||
|
"user",
|
||||||
|
user_visible,
|
||||||
|
attachments=parsed.get("attachment_meta") or [],
|
||||||
|
)
|
||||||
|
|
||||||
brief_ctx = format_chat_context_brief(ctx)
|
brief_ctx = format_chat_context_for_chat(ctx)
|
||||||
excerpt = summary_excerpt_for_chat(day)
|
excerpt = summary_excerpt_for_chat(day)
|
||||||
|
|
||||||
user_prompt = build_chat_user_prompt(
|
user_prompt = build_chat_user_prompt(
|
||||||
@@ -68,12 +89,17 @@ def send_chat_message(
|
|||||||
trading_day=day,
|
trading_day=day,
|
||||||
summary_excerpt=excerpt,
|
summary_excerpt=excerpt,
|
||||||
history_lines=history,
|
history_lines=history,
|
||||||
user_message=text,
|
user_message=text or user_visible,
|
||||||
|
attachment_note=str(parsed.get("attachment_note") or ""),
|
||||||
)
|
)
|
||||||
|
if parsed.get("text_append"):
|
||||||
|
user_prompt += "\n\n【附件正文】\n" + parsed["text_append"]
|
||||||
|
|
||||||
reply = generate_text(
|
reply = generate_text(
|
||||||
system=CHAT_SYSTEM,
|
system=CHAT_SYSTEM,
|
||||||
user=user_prompt,
|
user=user_prompt,
|
||||||
temperature=CHAT_TEMPERATURE,
|
temperature=CHAT_TEMPERATURE,
|
||||||
|
images_b64=parsed.get("images_b64") or None,
|
||||||
)
|
)
|
||||||
if reply.startswith("AI 调用失败"):
|
if reply.startswith("AI 调用失败"):
|
||||||
return {"ok": False, "msg": reply, "session_id": sid}
|
return {"ok": False, "msg": reply, "session_id": sid}
|
||||||
@@ -85,4 +111,5 @@ def send_chat_message(
|
|||||||
"session": session,
|
"session": session,
|
||||||
"reply": reply,
|
"reply": reply,
|
||||||
"model": model_label(),
|
"model": model_label(),
|
||||||
|
"attachment_warnings": parsed.get("errors") or [],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional, Sequence
|
||||||
|
|
||||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
if str(_REPO_ROOT) not in sys.path:
|
if str(_REPO_ROOT) not in sys.path:
|
||||||
@@ -15,6 +16,12 @@ def model_label() -> str:
|
|||||||
return ai_provider_label()
|
return ai_provider_label()
|
||||||
|
|
||||||
|
|
||||||
def generate_text(*, system: str, user: str, temperature: float) -> str:
|
def generate_text(
|
||||||
|
*,
|
||||||
|
system: str,
|
||||||
|
user: str,
|
||||||
|
temperature: float,
|
||||||
|
images_b64: Optional[Sequence[str]] = None,
|
||||||
|
) -> str:
|
||||||
prompt = f"{system.strip()}\n\n---\n\n{user.strip()}"
|
prompt = f"{system.strip()}\n\n---\n\n{user.strip()}"
|
||||||
return ai_generate(prompt, temperature=temperature)
|
return ai_generate(prompt, temperature=temperature, images_b64=images_b64)
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ CHAT_TEMPERATURE = 0.5
|
|||||||
CHAT_MAX_HISTORY_TURNS = 20
|
CHAT_MAX_HISTORY_TURNS = 20
|
||||||
SUMMARY_RETENTION_DAYS = 90
|
SUMMARY_RETENTION_DAYS = 90
|
||||||
CHAT_SESSION_RETENTION_DAYS = 60
|
CHAT_SESSION_RETENTION_DAYS = 60
|
||||||
|
FUND_HISTORY_DAYS = 15
|
||||||
|
CHAT_MAX_ATTACHMENTS = 3
|
||||||
|
CHAT_MAX_IMAGE_BYTES = 4 * 1024 * 1024
|
||||||
|
CHAT_MAX_TEXT_FILE_BYTES = 200 * 1024
|
||||||
|
|
||||||
|
|
||||||
def trading_day_reset_hour() -> int:
|
def trading_day_reset_hour() -> int:
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from typing import Any, Callable, Optional
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from hub_ai.config import hub_agent_timeout, hub_flask_timeout, trading_day_reset_hour
|
from hub_ai.config import FUND_HISTORY_DAYS, hub_agent_timeout, hub_flask_timeout, trading_day_reset_hour
|
||||||
|
from hub_ai.fund_history import format_fund_history_text, get_fund_history, record_fund_snapshot
|
||||||
from hub_trades_lib import current_trading_day, summarize_trades
|
from hub_trades_lib import current_trading_day, summarize_trades
|
||||||
|
|
||||||
|
|
||||||
@@ -35,6 +37,42 @@ def _safe_float(v: Any) -> Optional[float]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _position_contracts(p: dict) -> float:
|
||||||
|
for key in ("contracts", "contracts_signed", "size"):
|
||||||
|
v = p.get(key)
|
||||||
|
try:
|
||||||
|
if v is not None and v != "":
|
||||||
|
return float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_open_positions(positions: list) -> list[dict]:
|
||||||
|
out: list[dict] = []
|
||||||
|
for p in positions or []:
|
||||||
|
if not isinstance(p, dict):
|
||||||
|
continue
|
||||||
|
if abs(_position_contracts(p)) < 1e-12:
|
||||||
|
continue
|
||||||
|
out.append(p)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _account_open_position_count(ac: dict) -> int:
|
||||||
|
return len(_filter_open_positions(ac.get("positions") or []))
|
||||||
|
|
||||||
|
|
||||||
|
def _monitor_counts(ac: dict) -> dict[str, int]:
|
||||||
|
mon = ac.get("monitor_lines") or {}
|
||||||
|
return {
|
||||||
|
"trends": len(mon.get("trends") or []),
|
||||||
|
"rolls": len(mon.get("rolls") or []),
|
||||||
|
"keys": len(mon.get("keys") or []),
|
||||||
|
"orders": len(mon.get("orders") or []),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _position_float_pnl(pos: dict) -> float:
|
def _position_float_pnl(pos: dict) -> float:
|
||||||
for key in ("unrealized_pnl", "unrealizedPnl", "upnl"):
|
for key in ("unrealized_pnl", "unrealizedPnl", "upnl"):
|
||||||
v = _safe_float(pos.get(key))
|
v = _safe_float(pos.get(key))
|
||||||
@@ -61,17 +99,89 @@ def _collect_open_issues(
|
|||||||
issues.append("Flask 监控连接异常")
|
issues.append("Flask 监控连接异常")
|
||||||
if day_pnl < -0.01:
|
if day_pnl < -0.01:
|
||||||
issues.append(f"当日平仓亏损 {day_pnl:.2f}U")
|
issues.append(f"当日平仓亏损 {day_pnl:.2f}U")
|
||||||
float_pnl = sum(_position_float_pnl(p) for p in positions if isinstance(p, dict))
|
open_positions = _filter_open_positions(positions)
|
||||||
|
float_pnl = sum(_position_float_pnl(p) for p in open_positions)
|
||||||
if float_pnl < -0.5:
|
if float_pnl < -0.5:
|
||||||
issues.append(f"当前浮亏 {float_pnl:.2f}U")
|
issues.append(f"当前浮亏 {float_pnl:.2f}U")
|
||||||
if isinstance(hub_mon, dict) and hub_mon.get("ok") is not False:
|
if isinstance(hub_mon, dict) and hub_mon.get("ok") is not False:
|
||||||
orders = hub_mon.get("orders") or []
|
orders = hub_mon.get("orders") or []
|
||||||
trends = hub_mon.get("trends") or []
|
trends = hub_mon.get("trends") or []
|
||||||
if positions and not orders and not trends:
|
if open_positions and not orders and not trends:
|
||||||
issues.append("交易所有持仓但无本地 active 监控/趋势计划")
|
issues.append("交易所有持仓但无本地 active 监控/趋势计划")
|
||||||
return issues
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def previous_trading_day(trading_day: str) -> str:
|
||||||
|
day = (trading_day or "").strip()[:10]
|
||||||
|
if not day:
|
||||||
|
return day
|
||||||
|
dt = datetime.strptime(day, "%Y-%m-%d")
|
||||||
|
return (dt - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_fund(v: Any) -> str:
|
||||||
|
n = _safe_float(v)
|
||||||
|
if n is None:
|
||||||
|
return "未知"
|
||||||
|
return f"{n:.2f}U"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_trade_line(t: dict, *, day_label: str = "") -> str:
|
||||||
|
prefix = f"[{day_label}] " if day_label else ""
|
||||||
|
return (
|
||||||
|
f"{prefix}{t.get('symbol')} {t.get('direction')} {t.get('result')} "
|
||||||
|
f"{t.get('pnl_amount')}U @ {t.get('closed_at') or '?'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _monitor_label(item: dict, default: str = "") -> str:
|
||||||
|
for key in ("monitor_type_label", "monitor_type", "entry_reason", "source_label"):
|
||||||
|
val = item.get(key)
|
||||||
|
if val:
|
||||||
|
return str(val)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _format_monitor_sections(hub_mon: Optional[dict]) -> dict[str, list[str]]:
|
||||||
|
out = {"trends": [], "orders": [], "keys": [], "rolls": []}
|
||||||
|
if not isinstance(hub_mon, dict) or hub_mon.get("ok") is False:
|
||||||
|
return out
|
||||||
|
for t in hub_mon.get("trends") or []:
|
||||||
|
if not isinstance(t, dict):
|
||||||
|
continue
|
||||||
|
out["trends"].append(
|
||||||
|
f"{t.get('symbol')} {t.get('direction')} "
|
||||||
|
f"SL={t.get('stop_loss')} TP={t.get('take_profit')} "
|
||||||
|
f"补仓区[{t.get('add_lower')}~{t.get('add_upper')}] "
|
||||||
|
f"状态={t.get('status')}"
|
||||||
|
)
|
||||||
|
for o in hub_mon.get("orders") or []:
|
||||||
|
if not isinstance(o, dict):
|
||||||
|
continue
|
||||||
|
label = _monitor_label(o, "下单监控")
|
||||||
|
out["orders"].append(
|
||||||
|
f"{label}: {o.get('symbol')} {o.get('direction')} "
|
||||||
|
f"触发={o.get('trigger_price')} SL={o.get('stop_loss')} TP={o.get('take_profit')} "
|
||||||
|
f"状态={o.get('status')}"
|
||||||
|
)
|
||||||
|
for k in hub_mon.get("keys") or []:
|
||||||
|
if not isinstance(k, dict):
|
||||||
|
continue
|
||||||
|
out["keys"].append(
|
||||||
|
f"关键位: {k.get('symbol')} {k.get('direction')} "
|
||||||
|
f"上={k.get('upper')} 下={k.get('lower')} 类型={k.get('monitor_type')}"
|
||||||
|
)
|
||||||
|
for r in hub_mon.get("rolls") or []:
|
||||||
|
if not isinstance(r, dict):
|
||||||
|
continue
|
||||||
|
out["rolls"].append(
|
||||||
|
f"顺势加仓: {r.get('symbol')} {r.get('direction')} "
|
||||||
|
f"腿数={r.get('leg_count')} SL={r.get('current_stop_loss') or r.get('initial_stop_loss')} "
|
||||||
|
f"状态={r.get('status')}"
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
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 ""
|
||||||
@@ -89,8 +199,15 @@ def _fetch_account_bundle(client: httpx.Client, ex: dict, trading_day: str) -> d
|
|||||||
"trades": [],
|
"trades": [],
|
||||||
"trade_stats": summarize_trades([]),
|
"trade_stats": summarize_trades([]),
|
||||||
"positions": [],
|
"positions": [],
|
||||||
|
"open_position_count": 0,
|
||||||
"float_pnl_u": 0.0,
|
"float_pnl_u": 0.0,
|
||||||
"balance_usdt": None,
|
"balance_usdt": None,
|
||||||
|
"funding_usdt": None,
|
||||||
|
"trading_usdt": None,
|
||||||
|
"available_trading_usdt": None,
|
||||||
|
"trades_yesterday": [],
|
||||||
|
"trade_stats_yesterday": summarize_trades([]),
|
||||||
|
"monitor_lines": {"trends": [], "orders": [], "keys": [], "rolls": []},
|
||||||
"issues": [],
|
"issues": [],
|
||||||
"agent_ok": False,
|
"agent_ok": False,
|
||||||
"flask_ok": False,
|
"flask_ok": False,
|
||||||
@@ -122,13 +239,30 @@ def _fetch_account_bundle(client: httpx.Client, ex: dict, trading_day: str) -> d
|
|||||||
base["balance_usdt"] = _safe_float(agent_body.get("balance_usdt"))
|
base["balance_usdt"] = _safe_float(agent_body.get("balance_usdt"))
|
||||||
positions = agent_body.get("positions") or []
|
positions = agent_body.get("positions") or []
|
||||||
if isinstance(positions, list):
|
if isinstance(positions, list):
|
||||||
base["positions"] = positions
|
open_positions = _filter_open_positions(positions)
|
||||||
base["float_pnl_u"] = round(
|
base["positions"] = open_positions
|
||||||
sum(_position_float_pnl(p) for p in positions if isinstance(p, dict)), 4
|
base["open_position_count"] = len(open_positions)
|
||||||
)
|
base["float_pnl_u"] = round(sum(_position_float_pnl(p) for p in open_positions), 4)
|
||||||
|
|
||||||
hub_mon = None
|
hub_mon = None
|
||||||
|
prev_day = previous_trading_day(trading_day)
|
||||||
if flask_url:
|
if flask_url:
|
||||||
|
try:
|
||||||
|
r = client.get(
|
||||||
|
f"{flask_url}/api/hub/account",
|
||||||
|
headers=_hub_headers(),
|
||||||
|
timeout=hub_flask_timeout(),
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
acct_body = r.json()
|
||||||
|
if isinstance(acct_body, dict) and acct_body.get("ok"):
|
||||||
|
base["funding_usdt"] = _safe_float(acct_body.get("funding_usdt"))
|
||||||
|
base["trading_usdt"] = _safe_float(acct_body.get("trading_usdt"))
|
||||||
|
base["available_trading_usdt"] = _safe_float(acct_body.get("available_trading_usdt"))
|
||||||
|
base["flask_ok"] = True
|
||||||
|
except Exception as exc:
|
||||||
|
base["issues"].append(f"资金接口: {exc}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
r = client.get(
|
r = client.get(
|
||||||
f"{flask_url}/api/hub/trades/today",
|
f"{flask_url}/api/hub/trades/today",
|
||||||
@@ -145,6 +279,25 @@ def _fetch_account_bundle(client: httpx.Client, ex: dict, trading_day: str) -> d
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
base["issues"].append(f"成交接口: {exc}")
|
base["issues"].append(f"成交接口: {exc}")
|
||||||
|
|
||||||
|
if prev_day:
|
||||||
|
try:
|
||||||
|
r = client.get(
|
||||||
|
f"{flask_url}/api/hub/trades/today",
|
||||||
|
headers=_hub_headers(),
|
||||||
|
params={"trading_day": prev_day},
|
||||||
|
timeout=hub_flask_timeout(),
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
y_body = r.json()
|
||||||
|
if isinstance(y_body, dict) and y_body.get("ok"):
|
||||||
|
base["trades_yesterday"] = y_body.get("trades") or []
|
||||||
|
base["trade_stats_yesterday"] = y_body.get("stats") or summarize_trades(
|
||||||
|
base["trades_yesterday"]
|
||||||
|
)
|
||||||
|
base["flask_ok"] = True
|
||||||
|
except Exception as exc:
|
||||||
|
base["issues"].append(f"昨日成交: {exc}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
r = client.get(
|
r = client.get(
|
||||||
f"{flask_url}/api/hub/monitor",
|
f"{flask_url}/api/hub/monitor",
|
||||||
@@ -158,6 +311,7 @@ def _fetch_account_bundle(client: httpx.Client, ex: dict, trading_day: str) -> d
|
|||||||
base["flask_ok"] = True
|
base["flask_ok"] = True
|
||||||
base["active_orders"] = len(hub_mon.get("orders") or [])
|
base["active_orders"] = len(hub_mon.get("orders") or [])
|
||||||
base["active_trends"] = len(hub_mon.get("trends") or [])
|
base["active_trends"] = len(hub_mon.get("trends") or [])
|
||||||
|
base["monitor_lines"] = _format_monitor_sections(hub_mon)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if "成交接口" not in str(base["issues"]):
|
if "成交接口" not in str(base["issues"]):
|
||||||
base["issues"].append(f"监控接口: {exc}")
|
base["issues"].append(f"监控接口: {exc}")
|
||||||
@@ -198,6 +352,10 @@ def build_daily_context(
|
|||||||
total_closed_pnl = 0.0
|
total_closed_pnl = 0.0
|
||||||
total_closed = total_win = total_loss = 0
|
total_closed = total_win = total_loss = 0
|
||||||
total_float = 0.0
|
total_float = 0.0
|
||||||
|
total_funding = 0.0
|
||||||
|
total_trading = 0.0
|
||||||
|
total_open_positions = 0
|
||||||
|
funding_known = trading_known = 0
|
||||||
for ac in accounts:
|
for ac in accounts:
|
||||||
if ac.get("status") == "未监控":
|
if ac.get("status") == "未监控":
|
||||||
continue
|
continue
|
||||||
@@ -207,47 +365,128 @@ def build_daily_context(
|
|||||||
total_win += int(st.get("win_count") or 0)
|
total_win += int(st.get("win_count") or 0)
|
||||||
total_loss += int(st.get("loss_count") or 0)
|
total_loss += int(st.get("loss_count") or 0)
|
||||||
total_float += float(ac.get("float_pnl_u") or 0)
|
total_float += float(ac.get("float_pnl_u") or 0)
|
||||||
|
total_open_positions += int(ac.get("open_position_count") or _account_open_position_count(ac))
|
||||||
|
fu = _safe_float(ac.get("funding_usdt"))
|
||||||
|
tu = _safe_float(ac.get("trading_usdt"))
|
||||||
|
if fu is not None:
|
||||||
|
total_funding += fu
|
||||||
|
funding_known += 1
|
||||||
|
if tu is not None:
|
||||||
|
total_trading += tu
|
||||||
|
trading_known += 1
|
||||||
|
if not funding_known:
|
||||||
|
total_funding = None
|
||||||
|
if not trading_known:
|
||||||
|
total_trading = None
|
||||||
|
|
||||||
totals = {
|
totals = {
|
||||||
"trading_day": day,
|
"trading_day": day,
|
||||||
|
"prev_trading_day": previous_trading_day(day),
|
||||||
"total_pnl_u": round(total_closed_pnl, 4),
|
"total_pnl_u": round(total_closed_pnl, 4),
|
||||||
"closed_count": total_closed,
|
"closed_count": total_closed,
|
||||||
"win_count": total_win,
|
"win_count": total_win,
|
||||||
"loss_count": total_loss,
|
"loss_count": total_loss,
|
||||||
"float_pnl_u": round(total_float, 4),
|
"float_pnl_u": round(total_float, 4),
|
||||||
|
"open_position_count": total_open_positions,
|
||||||
|
"total_funding_usdt": round(total_funding, 4) if total_funding is not None else None,
|
||||||
|
"total_trading_usdt": round(total_trading, 4) if total_trading is not None else None,
|
||||||
|
}
|
||||||
|
record_fund_snapshot(day, accounts, keep_days=FUND_HISTORY_DAYS)
|
||||||
|
fund_history = get_fund_history(anchor_day=day, keep_days=FUND_HISTORY_DAYS)
|
||||||
|
account_names = {str(ac.get("key") or ac.get("id")): ac.get("name") for ac in accounts}
|
||||||
|
fund_history_text = format_fund_history_text(fund_history, account_names=account_names)
|
||||||
|
payload = {
|
||||||
|
"trading_day": day,
|
||||||
|
"prev_trading_day": previous_trading_day(day),
|
||||||
|
"totals": totals,
|
||||||
|
"accounts": accounts,
|
||||||
|
"fund_history": fund_history,
|
||||||
|
"fund_history_text": fund_history_text,
|
||||||
}
|
}
|
||||||
payload = {"trading_day": day, "totals": totals, "accounts": accounts}
|
|
||||||
text = format_context_text(payload)
|
text = format_context_text(payload)
|
||||||
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
|
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
|
||||||
return {"trading_day": day, "totals": totals, "accounts": accounts, "text": text, "context_hash": digest}
|
return {
|
||||||
|
"trading_day": day,
|
||||||
|
"prev_trading_day": previous_trading_day(day),
|
||||||
|
"totals": totals,
|
||||||
|
"accounts": accounts,
|
||||||
|
"fund_history": fund_history,
|
||||||
|
"fund_history_text": fund_history_text,
|
||||||
|
"text": text,
|
||||||
|
"context_hash": digest,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def format_context_text(payload: dict) -> str:
|
def format_context_text(payload: dict) -> str:
|
||||||
lines = []
|
lines = []
|
||||||
totals = payload.get("totals") or {}
|
totals = payload.get("totals") or {}
|
||||||
|
day = totals.get("trading_day")
|
||||||
|
prev_day = totals.get("prev_trading_day") or previous_trading_day(str(day or ""))
|
||||||
lines.append(
|
lines.append(
|
||||||
f"【合计】交易日 {totals.get('trading_day')} | "
|
f"【合计·今日 {day}】平仓盈亏 {totals.get('total_pnl_u')}U | "
|
||||||
f"平仓盈亏 {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('float_pnl_u')}U"
|
f"实盘持仓 {totals.get('open_position_count', 0)} 仓 | "
|
||||||
|
f"浮盈亏 {totals.get('float_pnl_u')}U | "
|
||||||
|
f"资金账户合计 {_fmt_fund(totals.get('total_funding_usdt'))} | "
|
||||||
|
f"交易账户合计 {_fmt_fund(totals.get('total_trading_usdt'))}"
|
||||||
)
|
)
|
||||||
|
lines.append(
|
||||||
|
f"【对比交易日】昨日={prev_day},今日={day}。"
|
||||||
|
"「持仓」= 交易所 Agent 实盘;「趋势/关键位/监控单/加仓」= 本地计划,不等于已开仓。"
|
||||||
|
)
|
||||||
|
fund_txt = str(payload.get("fund_history_text") or "").strip()
|
||||||
|
if fund_txt:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(fund_txt)
|
||||||
lines.append("")
|
lines.append("")
|
||||||
for ac in payload.get("accounts") or []:
|
for ac in payload.get("accounts") or []:
|
||||||
st = ac.get("trade_stats") or {}
|
st = ac.get("trade_stats") or {}
|
||||||
|
sty = ac.get("trade_stats_yesterday") or {}
|
||||||
lines.append(f"--- 账户:{ac.get('name')} ({ac.get('key')}) ---")
|
lines.append(f"--- 账户:{ac.get('name')} ({ac.get('key')}) ---")
|
||||||
lines.append(f"状态:{ac.get('status')}")
|
lines.append(f"状态:{ac.get('status')}")
|
||||||
if ac.get("status") == "未监控":
|
if ac.get("status") == "未监控":
|
||||||
lines.append("")
|
lines.append("")
|
||||||
continue
|
continue
|
||||||
lines.append(
|
lines.append(
|
||||||
f"当日平仓:{st.get('closed_count')} 笔,盈亏 {st.get('total_pnl_u')}U "
|
f"资金账户 {_fmt_fund(ac.get('funding_usdt'))} | "
|
||||||
|
f"交易账户 {_fmt_fund(ac.get('trading_usdt'))} | "
|
||||||
|
f"可用 {_fmt_fund(ac.get('available_trading_usdt'))}"
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
f"今日({day})平仓:{st.get('closed_count')} 笔,盈亏 {st.get('total_pnl_u')}U "
|
||||||
f"(胜{st.get('win_count')}/负{st.get('loss_count')})"
|
f"(胜{st.get('win_count')}/负{st.get('loss_count')})"
|
||||||
)
|
)
|
||||||
lines.append(f"合约可用余额:{ac.get('balance_usdt') if ac.get('balance_usdt') is not None else '未知'} USDT")
|
lines.append(
|
||||||
lines.append(f"当前持仓浮盈亏:{ac.get('float_pnl_u')}U | 下单监控 {ac.get('active_orders')} | 趋势计划 {ac.get('active_trends')}")
|
f"昨日({prev_day})平仓:{sty.get('closed_count')} 笔,盈亏 {sty.get('total_pnl_u')}U "
|
||||||
|
f"(胜{sty.get('win_count')}/负{sty.get('loss_count')})"
|
||||||
|
)
|
||||||
|
open_n = int(ac.get("open_position_count") or _account_open_position_count(ac))
|
||||||
|
if open_n <= 0:
|
||||||
|
lines.append("当前交易所持仓:无(空仓)")
|
||||||
|
else:
|
||||||
|
lines.append(
|
||||||
|
f"当前交易所持仓:{open_n} 仓 | 浮盈亏合计 {ac.get('float_pnl_u')}U"
|
||||||
|
)
|
||||||
|
mon = ac.get("monitor_lines") or {}
|
||||||
|
if mon.get("trends"):
|
||||||
|
lines.append("趋势回调计划(本地,非持仓):")
|
||||||
|
for row in mon["trends"][:8]:
|
||||||
|
lines.append(f" - {row}")
|
||||||
|
if mon.get("rolls"):
|
||||||
|
lines.append("顺势加仓(本地,非持仓):")
|
||||||
|
for row in mon["rolls"][:8]:
|
||||||
|
lines.append(f" - {row}")
|
||||||
|
if mon.get("keys"):
|
||||||
|
lines.append("关键位监控(本地,非持仓):")
|
||||||
|
for row in mon["keys"][:8]:
|
||||||
|
lines.append(f" - {row}")
|
||||||
|
if mon.get("orders"):
|
||||||
|
lines.append("进行中的下单监控(本地,非持仓):")
|
||||||
|
for row in mon["orders"][:8]:
|
||||||
|
lines.append(f" - {row}")
|
||||||
positions = ac.get("positions") or []
|
positions = ac.get("positions") or []
|
||||||
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
|
||||||
@@ -256,14 +495,21 @@ def format_context_text(payload: dict) -> str:
|
|||||||
contracts = p.get("contracts") or p.get("size") or "?"
|
contracts = p.get("contracts") or p.get("size") or "?"
|
||||||
upnl = _position_float_pnl(p)
|
upnl = _position_float_pnl(p)
|
||||||
lines.append(f" - {sym} {side} 张数{contracts} 浮盈亏{upnl:.4f}U")
|
lines.append(f" - {sym} {side} 张数{contracts} 浮盈亏{upnl:.4f}U")
|
||||||
trades = ac.get("trades") or []
|
|
||||||
if trades:
|
|
||||||
lines.append("当日平仓明细:")
|
|
||||||
for t in trades[:15]:
|
|
||||||
lines.append(
|
lines.append(
|
||||||
f" - {t.get('symbol')} {t.get('direction')} {t.get('result')} "
|
f"Agent合约余额:{ac.get('balance_usdt') if ac.get('balance_usdt') is not None else '未知'} USDT"
|
||||||
f"{t.get('pnl_amount')}U @ {t.get('closed_at') or '?'}"
|
|
||||||
)
|
)
|
||||||
|
trades_today = ac.get("trades") or []
|
||||||
|
if trades_today:
|
||||||
|
lines.append(f"今日平仓明细:")
|
||||||
|
for t in trades_today[:15]:
|
||||||
|
lines.append(f" - {_format_trade_line(t)}")
|
||||||
|
trades_y = ac.get("trades_yesterday") or []
|
||||||
|
if trades_y:
|
||||||
|
lines.append(f"昨日平仓明细:")
|
||||||
|
for t in trades_y[:15]:
|
||||||
|
lines.append(f" - {_format_trade_line(t)}")
|
||||||
|
if not trades_today and not trades_y:
|
||||||
|
lines.append("平仓明细:无")
|
||||||
issues = ac.get("issues") or []
|
issues = ac.get("issues") or []
|
||||||
if issues:
|
if issues:
|
||||||
lines.append("关注点:" + ";".join(issues))
|
lines.append("关注点:" + ";".join(issues))
|
||||||
@@ -272,29 +518,99 @@ def format_context_text(payload: dict) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def format_account_remark(ac: dict) -> str:
|
def format_account_remark(ac: dict) -> str:
|
||||||
"""分户表格备注:持仓摘要或关注点。"""
|
"""分户表格备注:监控摘要 + 持仓。"""
|
||||||
positions = ac.get("positions") or []
|
|
||||||
if not positions:
|
|
||||||
issues = ac.get("issues") or []
|
|
||||||
if issues:
|
|
||||||
return ";".join(str(x) for x in issues[:2])
|
|
||||||
return "无"
|
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
for p in positions[:3]:
|
mon = ac.get("monitor_lines") or {}
|
||||||
|
if mon.get("trends"):
|
||||||
|
parts.append(f"趋势{len(mon['trends'])}")
|
||||||
|
if mon.get("rolls"):
|
||||||
|
parts.append(f"加仓{len(mon['rolls'])}")
|
||||||
|
if mon.get("keys"):
|
||||||
|
parts.append(f"关键位{len(mon['keys'])}")
|
||||||
|
if mon.get("orders"):
|
||||||
|
parts.append(f"监控单{len(mon['orders'])}")
|
||||||
|
positions = ac.get("positions") or []
|
||||||
|
if positions:
|
||||||
|
for p in positions[:2]:
|
||||||
if not isinstance(p, dict):
|
if not isinstance(p, dict):
|
||||||
continue
|
continue
|
||||||
sym = p.get("symbol") or "?"
|
sym = p.get("symbol") or "?"
|
||||||
side = p.get("side") or "?"
|
side = p.get("side") or "?"
|
||||||
contracts = p.get("contracts") if p.get("contracts") is not None else p.get("size")
|
|
||||||
upnl = _position_float_pnl(p)
|
upnl = _position_float_pnl(p)
|
||||||
parts.append(f"持仓: {sym} {side} 张数{contracts} 浮盈亏{upnl:.4f}U")
|
parts.append(f"{sym} {side} 浮{upnl:.2f}U")
|
||||||
if len(positions) > 3:
|
if len(positions) > 2:
|
||||||
parts.append(f"另有{len(positions) - 3}仓")
|
parts.append(f"+{len(positions) - 2}仓")
|
||||||
return ";".join(parts) if parts else "无"
|
if not parts:
|
||||||
|
issues = ac.get("issues") or []
|
||||||
|
if issues:
|
||||||
|
return ";".join(str(x) for x in issues[:2])
|
||||||
|
return "无"
|
||||||
|
return ";".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def format_chat_context_brief(payload: dict, max_chars: int = 2500) -> str:
|
def collect_closed_trades_snapshot(accounts: list[dict], *, today: str, yesterday: str) -> list[dict]:
|
||||||
text = format_context_text(payload)
|
rows: list[dict] = []
|
||||||
|
for ac in accounts or []:
|
||||||
|
name = ac.get("name") or ac.get("key")
|
||||||
|
for t in ac.get("trades_yesterday") or []:
|
||||||
|
if not isinstance(t, dict):
|
||||||
|
continue
|
||||||
|
rows.append({**t, "account_name": name, "trading_day": yesterday})
|
||||||
|
for t in ac.get("trades") or []:
|
||||||
|
if not isinstance(t, dict):
|
||||||
|
continue
|
||||||
|
rows.append({**t, "account_name": name, "trading_day": today})
|
||||||
|
rows.sort(key=lambda x: str(x.get("closed_at") or x.get("opened_at") or ""), reverse=True)
|
||||||
|
return rows[:80]
|
||||||
|
|
||||||
|
|
||||||
|
def format_chat_position_overview(payload: dict) -> str:
|
||||||
|
totals = payload.get("totals") or {}
|
||||||
|
total_open = int(totals.get("open_position_count") or 0)
|
||||||
|
if total_open <= 0:
|
||||||
|
head = f"【实盘持仓总览】当前空仓(监控户合计 0 仓)。浮盈亏 0U 表示无持仓,不是「有仓但不动」。"
|
||||||
|
else:
|
||||||
|
head = (
|
||||||
|
f"【实盘持仓总览】监控户合计 {total_open} 仓,"
|
||||||
|
f"浮盈亏合计 {totals.get('float_pnl_u')}U。"
|
||||||
|
)
|
||||||
|
lines = [
|
||||||
|
head,
|
||||||
|
"【区分】只有带「持仓明细/交易所实盘」字样的才是已开仓;趋势回调、关键位、下单监控、顺势加仓是本地计划/监控,不算持仓。",
|
||||||
|
]
|
||||||
|
for ac in payload.get("accounts") or []:
|
||||||
|
if ac.get("status") == "未监控":
|
||||||
|
continue
|
||||||
|
n = int(ac.get("open_position_count") or _account_open_position_count(ac))
|
||||||
|
mc = _monitor_counts(ac)
|
||||||
|
mon_parts = []
|
||||||
|
if mc["trends"]:
|
||||||
|
mon_parts.append(f"趋势{mc['trends']}")
|
||||||
|
if mc["rolls"]:
|
||||||
|
mon_parts.append(f"加仓{mc['rolls']}")
|
||||||
|
if mc["keys"]:
|
||||||
|
mon_parts.append(f"关键位{mc['keys']}")
|
||||||
|
if mc["orders"]:
|
||||||
|
mon_parts.append(f"监控单{mc['orders']}")
|
||||||
|
mon_txt = f";本地监控 {' '.join(mon_parts)}" if mon_parts else ""
|
||||||
|
if n <= 0:
|
||||||
|
lines.append(f"- {ac.get('name')}:空仓{mon_txt}")
|
||||||
|
else:
|
||||||
|
lines.append(
|
||||||
|
f"- {ac.get('name')}:{n}仓 浮盈亏{ac.get('float_pnl_u')}U{mon_txt}"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def format_chat_context_for_chat(payload: dict, max_chars: int = 5200) -> str:
|
||||||
|
overview = format_chat_position_overview(payload)
|
||||||
|
body = format_context_text(payload)
|
||||||
|
text = overview + "\n\n" + body
|
||||||
if len(text) <= max_chars:
|
if len(text) <= max_chars:
|
||||||
return text
|
return text
|
||||||
return text[: max_chars - 3].rstrip() + "..."
|
budget = max(800, max_chars - len(overview) - 4)
|
||||||
|
return overview + "\n\n" + body[:budget].rstrip() + "..."
|
||||||
|
|
||||||
|
|
||||||
|
def format_chat_context_brief(payload: dict, max_chars: int = 4500) -> str:
|
||||||
|
return format_chat_context_for_chat(payload, max_chars=max_chars)
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""中控 AI:分户资金快照(保留 15 天,供总结/聊天上下文)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from hub_ai.config import FUND_HISTORY_DAYS
|
||||||
|
|
||||||
|
HUB_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
FUND_HISTORY_PATH = HUB_DIR / "hub_ai_fund_history.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _now_str() -> str:
|
||||||
|
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_write(path: Path, data: dict) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_store() -> dict:
|
||||||
|
if not FUND_HISTORY_PATH.is_file():
|
||||||
|
return {"version": 1, "days": {}}
|
||||||
|
try:
|
||||||
|
loaded = json.loads(FUND_HISTORY_PATH.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(loaded, dict):
|
||||||
|
loaded.setdefault("version", 1)
|
||||||
|
loaded.setdefault("days", {})
|
||||||
|
return loaded
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"version": 1, "days": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_days(days: dict, *, keep_days: int, anchor_day: str) -> dict:
|
||||||
|
try:
|
||||||
|
anchor = datetime.strptime(anchor_day[:10], "%Y-%m-%d")
|
||||||
|
except ValueError:
|
||||||
|
anchor = datetime.now()
|
||||||
|
cutoff = (anchor - timedelta(days=max(1, keep_days) - 1)).strftime("%Y-%m-%d")
|
||||||
|
out = {k: v for k, v in (days or {}).items() if str(k) >= cutoff}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def record_fund_snapshot(
|
||||||
|
trading_day: str,
|
||||||
|
accounts: list[dict],
|
||||||
|
*,
|
||||||
|
keep_days: int = FUND_HISTORY_DAYS,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""写入当日各户资金账户/交易账户余额,并裁剪历史。"""
|
||||||
|
day = (trading_day or "").strip()[:10]
|
||||||
|
if not day:
|
||||||
|
return {}
|
||||||
|
store = _load_store()
|
||||||
|
days = dict(store.get("days") or {})
|
||||||
|
row_accounts: dict[str, dict] = {}
|
||||||
|
for ac in accounts or []:
|
||||||
|
key = str(ac.get("key") or ac.get("id") or "")
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
row_accounts[key] = {
|
||||||
|
"name": ac.get("name"),
|
||||||
|
"funding_usdt": ac.get("funding_usdt"),
|
||||||
|
"trading_usdt": ac.get("trading_usdt"),
|
||||||
|
"recorded_at": _now_str(),
|
||||||
|
}
|
||||||
|
days[day] = {"accounts": row_accounts, "updated_at": _now_str()}
|
||||||
|
days = _prune_days(days, keep_days=keep_days, anchor_day=day)
|
||||||
|
_atomic_write(FUND_HISTORY_PATH, {"version": 1, "days": days})
|
||||||
|
return days
|
||||||
|
|
||||||
|
|
||||||
|
def get_fund_history(*, anchor_day: str, keep_days: int = FUND_HISTORY_DAYS) -> dict[str, dict]:
|
||||||
|
store = _load_store()
|
||||||
|
days = _prune_days(
|
||||||
|
dict(store.get("days") or {}),
|
||||||
|
keep_days=keep_days,
|
||||||
|
anchor_day=anchor_day,
|
||||||
|
)
|
||||||
|
return days
|
||||||
|
|
||||||
|
|
||||||
|
def format_fund_history_text(history: dict[str, dict], *, account_names: Optional[dict[str, str]] = None) -> str:
|
||||||
|
if not history:
|
||||||
|
return "(暂无资金历史快照)"
|
||||||
|
names = account_names or {}
|
||||||
|
lines = ["【近15日资金快照(资金账户 / 交易账户 USDT)】"]
|
||||||
|
for day in sorted(history.keys()):
|
||||||
|
block = history.get(day) or {}
|
||||||
|
ac_map = block.get("accounts") or {}
|
||||||
|
if not ac_map:
|
||||||
|
continue
|
||||||
|
parts = []
|
||||||
|
for key, ac in ac_map.items():
|
||||||
|
label = names.get(key) or ac.get("name") or key
|
||||||
|
fu = ac.get("funding_usdt")
|
||||||
|
tu = ac.get("trading_usdt")
|
||||||
|
fu_txt = f"{fu}U" if fu is not None else "未知"
|
||||||
|
tu_txt = f"{tu}U" if tu is not None else "未知"
|
||||||
|
parts.append(f"{label}: 资金{fu_txt} / 交易{tu_txt}")
|
||||||
|
lines.append(f"- {day}: " + ";".join(parts))
|
||||||
|
return "\n".join(lines) if len(lines) > 1 else "(暂无资金历史快照)"
|
||||||
@@ -5,27 +5,34 @@ SUMMARY_SYSTEM = """
|
|||||||
|
|
||||||
硬性规则:
|
硬性规则:
|
||||||
- 只能陈述数据中明确出现的数字与事实;禁止编造成交、止损、扛单、行情预测。
|
- 只能陈述数据中明确出现的数字与事实;禁止编造成交、止损、扛单、行情预测。
|
||||||
|
- 上下文含「昨日+今日」两个交易日的平仓与「近15日资金快照」;须连贯引用,不得只写单日而忽略另一日。
|
||||||
- 未监控的账户必须标注「未监控」,不得臆测其盈亏。
|
- 未监控的账户必须标注「未监控」,不得臆测其盈亏。
|
||||||
- 连接失败或数据缺失的账户如实写明,不要猜测。
|
- 连接失败或数据缺失的账户如实写明,不要猜测。
|
||||||
- 不要用安慰、说教、建议口吻(那些属于聊天功能)。
|
- 趋势回调计划、顺势加仓、关键位监控、进行中的下单监控:仅据数据列示,无则写「无」。
|
||||||
|
- 第1~4节保持客观台账;**第5节操作建议**可基于资金账户/交易账户余额、15日资金变化、仓位与监控单,给出简短、可执行的资金与仓位安排建议(仍禁止预测涨跌、保证收益)。
|
||||||
- 禁止夸张词(致命、崩溃、灾难等)。
|
- 禁止夸张词(致命、崩溃、灾难等)。
|
||||||
|
|
||||||
输出格式(Markdown,标题必须一致):
|
输出格式(Markdown,标题必须一致):
|
||||||
**今日交易总结({trading_day})**
|
**今日交易总结({trading_day})**
|
||||||
|
|
||||||
**1. 总览**
|
**1. 总览**
|
||||||
- **合计盈亏(U)**:…
|
- **对比说明**:昨日 vs 今日(交易日日期见数据)
|
||||||
- **平仓笔数**:…(胜 / 负 / 平)
|
- **合计盈亏(U)**:今日平仓合计 …
|
||||||
- **当前持仓浮盈亏(U)**:…(仅汇总已监控且有数据的账户)
|
- **平仓笔数**:今日 …(胜 / 负 / 平);昨日 …
|
||||||
|
- **当前持仓浮盈亏(U)**:…
|
||||||
|
- **资金合计**:资金账户 … / 交易账户 …(仅已监控且有数据账户)
|
||||||
|
|
||||||
**2. 分户明细**
|
**2. 分户明细**
|
||||||
中控页面会自动渲染分户表格,本节不要输出 pipe 分隔行或 Markdown 表格;可写一句「见下表」或直接留空。
|
中控页面会自动渲染分户表格,本节不要输出 pipe 分隔行或 Markdown 表格;可写一句「见下表」或直接留空。
|
||||||
|
|
||||||
**3. 需关注**
|
**3. 需关注**
|
||||||
仅有依据时列出(如:某户当日亏损最大、浮亏偏大、Flask/Agent 异常、有持仓但无本地监控等);若无则写「无」。
|
仅有依据时列出(亏损、浮亏、监控/趋势/关键位异常、资金缺口等);若无则写「无」。
|
||||||
|
|
||||||
**4. 数据说明**
|
**4. 数据说明**
|
||||||
列出数据缺口(某户未启用、接口失败等)。
|
列出数据缺口(某户未启用、接口失败、缺15日资金快照等)。
|
||||||
|
|
||||||
|
**5. 操作建议**
|
||||||
|
基于各户资金账户与交易账户余额、近15日资金走势、持仓与监控单,给出 2~5 条简短建议(如:是否需要从资金账户补充交易账户、哪户风险敞口偏高等)。无依据则写「暂无」。
|
||||||
""".strip()
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
@@ -38,16 +45,19 @@ CHAT_SYSTEM = """
|
|||||||
- 不要「第1点第2点你应该…」;不要「作为你的教练我必须…」。
|
- 不要「第1点第2点你应该…」;不要「作为你的教练我必须…」。
|
||||||
- 不预测涨跌,不保证收益,不替用户做决定。
|
- 不预测涨跌,不保证收益,不替用户做决定。
|
||||||
- 只能依据提供的监控与交易数据说话;看不到的就说「我这边看不到,你可以去 xx 实例页确认」。
|
- 只能依据提供的监控与交易数据说话;看不到的就说「我这边看不到,你可以去 xx 实例页确认」。
|
||||||
|
- **持仓判定**:只有快照里「实盘持仓总览 / 持仓明细 / 交易所实盘」才算已开仓;「空仓 / 0 仓」就是没仓位。浮盈亏 0U 且空仓时,不要说「还有仓」「卡着不动」。
|
||||||
若附带「今日总结摘要」,可自然引用,但保持口语,不要复读整份报告。
|
- **监控单 ≠ 持仓**:趋势回调、关键位、顺势加仓、下单监控是本地计划或挂单监控,用户说已平仓时,即使还有这些监控,也不要当成手里还有仓。
|
||||||
|
- 用户口述与快照冲突时,以快照为准并口语说明「我这边看到是空仓/有N仓」。
|
||||||
|
- 若附带「今日总结摘要」,那是较早生成的缓存,**实盘持仓以【当前多账户快照】里的「实盘持仓总览」为准**,摘要里若提到持仓可能已过时。
|
||||||
|
- 若用户上传图片,可结合图中可见信息讨论,看不清的明确说看不清。
|
||||||
""".strip()
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
def build_summary_user_prompt(context_text: str, trading_day: str) -> str:
|
def build_summary_user_prompt(context_text: str, trading_day: str) -> str:
|
||||||
return f"""
|
return f"""
|
||||||
交易日:{trading_day}
|
交易日(今日):{trading_day}
|
||||||
|
|
||||||
以下为中控聚合的多账户数据(含未监控账户标记):
|
以下为中控聚合的多账户数据(含昨日+今日平仓、近15日资金快照、趋势回调/顺势加仓/关键位/监控单):
|
||||||
|
|
||||||
{context_text}
|
{context_text}
|
||||||
""".strip()
|
""".strip()
|
||||||
@@ -60,15 +70,21 @@ def build_chat_user_prompt(
|
|||||||
summary_excerpt: str,
|
summary_excerpt: str,
|
||||||
history_lines: str,
|
history_lines: str,
|
||||||
user_message: str,
|
user_message: str,
|
||||||
|
attachment_note: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
parts = [
|
parts = [
|
||||||
f"【交易日】{trading_day}",
|
f"【交易日】{trading_day}",
|
||||||
"【当前多账户快照】",
|
"【当前多账户快照(含实盘持仓与本地监控,发送时已刷新)】",
|
||||||
context_text.strip() or "(无监控数据)",
|
context_text.strip() or "(无监控数据)",
|
||||||
]
|
]
|
||||||
if summary_excerpt.strip():
|
if summary_excerpt.strip():
|
||||||
parts.extend(["【今日总结摘要(供参考)】", summary_excerpt.strip()])
|
parts.extend([
|
||||||
|
"【今日总结摘要(可能滞后,持仓以快照「实盘持仓总览」为准)】",
|
||||||
|
summary_excerpt.strip(),
|
||||||
|
])
|
||||||
if history_lines.strip():
|
if history_lines.strip():
|
||||||
parts.extend(["【此前对话】", history_lines.strip()])
|
parts.extend(["【此前对话】", history_lines.strip()])
|
||||||
|
if attachment_note.strip():
|
||||||
|
parts.extend(["【用户附件说明】", attachment_note.strip()])
|
||||||
parts.extend(["【用户现在说】", user_message.strip()])
|
parts.extend(["【用户现在说】", user_message.strip()])
|
||||||
return "\n\n".join(parts)
|
return "\n\n".join(parts)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from hub_ai.chat import get_chat_state, send_chat_message, start_new_chat
|
from hub_ai.chat import get_chat_state, send_chat_message, start_new_chat
|
||||||
@@ -94,12 +94,29 @@ def create_hub_ai_router(*, load_all_exchanges: Callable[[], list]) -> APIRouter
|
|||||||
return start_new_chat(trading_day=day)
|
return start_new_chat(trading_day=day)
|
||||||
|
|
||||||
@router.post("/chat/send")
|
@router.post("/chat/send")
|
||||||
def api_ai_chat_send(body: ChatSendBody):
|
async def api_ai_chat_send(
|
||||||
|
message: str = Form(""),
|
||||||
|
trading_day: str = Form(""),
|
||||||
|
files: list[UploadFile] = File(default=[]),
|
||||||
|
):
|
||||||
exchanges = load_all_exchanges()
|
exchanges = load_all_exchanges()
|
||||||
|
raw_attachments = []
|
||||||
|
for f in files or []:
|
||||||
|
if not f or not f.filename:
|
||||||
|
continue
|
||||||
|
data = await f.read()
|
||||||
|
raw_attachments.append(
|
||||||
|
{
|
||||||
|
"filename": f.filename,
|
||||||
|
"content_type": f.content_type or "",
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
)
|
||||||
result = send_chat_message(
|
result = send_chat_message(
|
||||||
exchanges,
|
exchanges,
|
||||||
body.message,
|
message,
|
||||||
trading_day=_day(body.trading_day) if body.trading_day.strip() else None,
|
trading_day=_day(trading_day) if trading_day.strip() else None,
|
||||||
|
raw_attachments=raw_attachments,
|
||||||
)
|
)
|
||||||
if not result.get("ok"):
|
if not result.get("ok"):
|
||||||
raise HTTPException(status_code=502, detail=result.get("msg") or "发送失败")
|
raise HTTPException(status_code=502, detail=result.get("msg") or "发送失败")
|
||||||
|
|||||||
@@ -163,7 +163,13 @@ def ensure_active_session(*, trading_day: str) -> dict:
|
|||||||
return create_new_session(trading_day=trading_day)
|
return create_new_session(trading_day=trading_day)
|
||||||
|
|
||||||
|
|
||||||
def append_chat_message(session_id: str, role: str, content: str) -> dict:
|
def append_chat_message(
|
||||||
|
session_id: str,
|
||||||
|
role: str,
|
||||||
|
content: str,
|
||||||
|
*,
|
||||||
|
attachments: Optional[list] = None,
|
||||||
|
) -> dict:
|
||||||
store = load_chat_store()
|
store = load_chat_store()
|
||||||
sessions = store.get("sessions") or []
|
sessions = store.get("sessions") or []
|
||||||
target = None
|
target = None
|
||||||
@@ -174,6 +180,8 @@ def append_chat_message(session_id: str, role: str, content: str) -> dict:
|
|||||||
if not target:
|
if not target:
|
||||||
raise KeyError("session_not_found")
|
raise KeyError("session_not_found")
|
||||||
msg = {"role": role, "content": content.strip(), "at": _now_str()}
|
msg = {"role": role, "content": content.strip(), "at": _now_str()}
|
||||||
|
if attachments:
|
||||||
|
msg["attachments"] = list(attachments)
|
||||||
target.setdefault("messages", []).append(msg)
|
target.setdefault("messages", []).append(msg)
|
||||||
target["updated_at"] = _now_str()
|
target["updated_at"] = _now_str()
|
||||||
if role == "user" and (target.get("title") in (None, "", "新对话")):
|
if role == "user" and (target.get("title") in (None, "", "新对话")):
|
||||||
|
|||||||
@@ -4,11 +4,46 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from hub_ai.client import generate_text, model_label
|
from hub_ai.client import generate_text, model_label
|
||||||
from hub_ai.context import build_daily_context, format_account_remark
|
from hub_ai.context import (
|
||||||
|
build_daily_context,
|
||||||
|
collect_closed_trades_snapshot,
|
||||||
|
format_account_remark,
|
||||||
|
)
|
||||||
from hub_ai.prompts import SUMMARY_SYSTEM, build_summary_user_prompt
|
from hub_ai.prompts import SUMMARY_SYSTEM, build_summary_user_prompt
|
||||||
from hub_ai.store import append_summary, get_latest_summary, list_summaries
|
from hub_ai.store import append_summary, get_latest_summary, list_summaries
|
||||||
|
|
||||||
|
|
||||||
|
def _stats_snapshot_from_ctx(ctx: dict) -> dict:
|
||||||
|
day = ctx.get("trading_day")
|
||||||
|
prev = ctx.get("prev_trading_day")
|
||||||
|
accounts = ctx.get("accounts") or []
|
||||||
|
return {
|
||||||
|
"totals": ctx.get("totals"),
|
||||||
|
"prev_trading_day": prev,
|
||||||
|
"fund_history": ctx.get("fund_history"),
|
||||||
|
"closed_trades": collect_closed_trades_snapshot(accounts, today=day, yesterday=prev),
|
||||||
|
"by_account": {
|
||||||
|
str(ac.get("key") or ac.get("id")): {
|
||||||
|
"key": ac.get("key"),
|
||||||
|
"name": ac.get("name"),
|
||||||
|
"status": ac.get("status"),
|
||||||
|
"funding_usdt": ac.get("funding_usdt"),
|
||||||
|
"trading_usdt": ac.get("trading_usdt"),
|
||||||
|
"available_trading_usdt": ac.get("available_trading_usdt"),
|
||||||
|
"pnl_u": (ac.get("trade_stats") or {}).get("total_pnl_u"),
|
||||||
|
"pnl_u_yesterday": (ac.get("trade_stats_yesterday") or {}).get("total_pnl_u"),
|
||||||
|
"closed_count": (ac.get("trade_stats") or {}).get("closed_count"),
|
||||||
|
"closed_count_yesterday": (ac.get("trade_stats_yesterday") or {}).get("closed_count"),
|
||||||
|
"float_pnl_u": ac.get("float_pnl_u"),
|
||||||
|
"remark": format_account_remark(ac),
|
||||||
|
"monitor_lines": ac.get("monitor_lines") or {},
|
||||||
|
"issues": ac.get("issues") or [],
|
||||||
|
}
|
||||||
|
for ac in accounts
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def generate_daily_summary(
|
def generate_daily_summary(
|
||||||
exchanges: list[dict],
|
exchanges: list[dict],
|
||||||
*,
|
*,
|
||||||
@@ -34,22 +69,7 @@ def generate_daily_summary(
|
|||||||
if content.startswith("AI 调用失败"):
|
if content.startswith("AI 调用失败"):
|
||||||
return {"ok": False, "msg": content, "trading_day": day}
|
return {"ok": False, "msg": content, "trading_day": day}
|
||||||
|
|
||||||
stats_snapshot = {
|
stats_snapshot = _stats_snapshot_from_ctx(ctx)
|
||||||
"totals": ctx.get("totals"),
|
|
||||||
"by_account": {
|
|
||||||
str(ac.get("key") or ac.get("id")): {
|
|
||||||
"key": ac.get("key"),
|
|
||||||
"name": ac.get("name"),
|
|
||||||
"status": ac.get("status"),
|
|
||||||
"pnl_u": (ac.get("trade_stats") or {}).get("total_pnl_u"),
|
|
||||||
"closed_count": (ac.get("trade_stats") or {}).get("closed_count"),
|
|
||||||
"float_pnl_u": ac.get("float_pnl_u"),
|
|
||||||
"remark": format_account_remark(ac),
|
|
||||||
"issues": ac.get("issues") or [],
|
|
||||||
}
|
|
||||||
for ac in ctx.get("accounts") or []
|
|
||||||
},
|
|
||||||
}
|
|
||||||
row = append_summary(
|
row = append_summary(
|
||||||
trading_day=day,
|
trading_day=day,
|
||||||
content_md=content,
|
content_md=content,
|
||||||
|
|||||||
@@ -3719,15 +3719,73 @@ body.hub-page-ai #page-ai {
|
|||||||
opacity: 1;
|
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 {
|
.ai-chat-form {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr auto;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: end;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding-top: 4px;
|
padding-top: 4px;
|
||||||
border-top: 1px solid var(--border-soft);
|
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 {
|
.ai-chat-form textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
resize: none;
|
resize: none;
|
||||||
|
|||||||
@@ -2978,9 +2978,16 @@
|
|||||||
out = out.replace(/\*\*2\.\s*(?:👥\s*)?分户明细\*\*/g, "## 2. 👥 分户明细");
|
out = out.replace(/\*\*2\.\s*(?:👥\s*)?分户明细\*\*/g, "## 2. 👥 分户明细");
|
||||||
out = out.replace(/\*\*3\.\s*(?:⚠️\s*)?需关注\*\*/g, "## 3. ⚠️ 需关注");
|
out = out.replace(/\*\*3\.\s*(?:⚠️\s*)?需关注\*\*/g, "## 3. ⚠️ 需关注");
|
||||||
out = out.replace(/\*\*4\.\s*(?:ℹ️\s*)?数据说明\*\*/g, "## 4. ℹ️ 数据说明");
|
out = out.replace(/\*\*4\.\s*(?:ℹ️\s*)?数据说明\*\*/g, "## 4. ℹ️ 数据说明");
|
||||||
|
out = out.replace(/\*\*5\.\s*(?:💡\s*)?操作建议\*\*/g, "## 5. 💡 操作建议");
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function aiFmtFund(v) {
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isFinite(n)) return "—";
|
||||||
|
return `${fmt(n, 2)}U`;
|
||||||
|
}
|
||||||
|
|
||||||
function aiPnlCellHtml(v, digits) {
|
function aiPnlCellHtml(v, digits) {
|
||||||
const cls = aiPnlClass(v);
|
const cls = aiPnlClass(v);
|
||||||
const valCls = cls ? ` ai-stat-val ${cls}` : " ai-stat-val";
|
const valCls = cls ? ` ai-stat-val ${cls}` : " ai-stat-val";
|
||||||
@@ -3002,7 +3009,7 @@
|
|||||||
if (!rows.length) return "";
|
if (!rows.length) return "";
|
||||||
const head =
|
const head =
|
||||||
"<thead><tr>" +
|
"<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>";
|
"</tr></thead>";
|
||||||
const body = rows
|
const body = rows
|
||||||
.map((ac) => {
|
.map((ac) => {
|
||||||
@@ -3012,12 +3019,15 @@
|
|||||||
ac.remark ||
|
ac.remark ||
|
||||||
(Array.isArray(ac.issues) && ac.issues.length ? ac.issues.join(";") : "无");
|
(Array.isArray(ac.issues) && ac.issues.length ? ac.issues.join(";") : "无");
|
||||||
const statusCls = aiAccountStatusClass(ac.status);
|
const statusCls = aiAccountStatusClass(ac.status);
|
||||||
|
const countLabel = `${Number(ac.closed_count) || 0}${Number(ac.closed_count_yesterday) ? ` / 昨${Number(ac.closed_count_yesterday)}` : ""}`;
|
||||||
return (
|
return (
|
||||||
"<tr>" +
|
"<tr>" +
|
||||||
`<td class="ai-ac-name">${esc(ac.name || "—")}</td>` +
|
`<td class="ai-ac-name">${esc(ac.name || "—")}</td>` +
|
||||||
`<td class="${statusCls}">${esc(ac.status || "—")}</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>${aiPnlCellHtml(closedPnl, 2)}</td>` +
|
||||||
`<td>${Number(ac.closed_count) || 0}</td>` +
|
`<td>${countLabel}</td>` +
|
||||||
`<td>${aiPnlCellHtml(floatPnl, 2)}</td>` +
|
`<td>${aiPnlCellHtml(floatPnl, 2)}</td>` +
|
||||||
`<td class="ai-ac-remark">${esc(remark)}</td>` +
|
`<td class="ai-ac-remark">${esc(remark)}</td>` +
|
||||||
"</tr>"
|
"</tr>"
|
||||||
@@ -3027,6 +3037,35 @@
|
|||||||
return `<div class="ai-ac-table-wrap"><table class="ai-ac-table">${head}<tbody>${body}</tbody></table></div>`;
|
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) {
|
function renderAiSummaryBody(contentMd, snapshot) {
|
||||||
const md = enhanceHubSummaryMarkdown(contentMd);
|
const md = enhanceHubSummaryMarkdown(contentMd);
|
||||||
const sec2 = /##\s*2\.\s*👥\s*分户明细/;
|
const sec2 = /##\s*2\.\s*👥\s*分户明细/;
|
||||||
@@ -3034,13 +3073,14 @@
|
|||||||
const i2 = md.search(sec2);
|
const i2 = md.search(sec2);
|
||||||
const i3 = md.search(sec3);
|
const i3 = md.search(sec3);
|
||||||
const tableHtml = renderAiAccountTable(snapshot);
|
const tableHtml = renderAiAccountTable(snapshot);
|
||||||
|
const closedHtml = renderAiClosedTradesBlock(snapshot);
|
||||||
if (i2 >= 0 && i3 > i2 && tableHtml) {
|
if (i2 >= 0 && i3 > i2 && tableHtml) {
|
||||||
const headEnd = i2 + md.slice(i2).match(sec2)[0].length;
|
const headEnd = i2 + md.slice(i2).match(sec2)[0].length;
|
||||||
const part1 = md.slice(0, headEnd);
|
const part1 = md.slice(0, headEnd);
|
||||||
const part2 = md.slice(i3);
|
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) {
|
function setAiSummaryMarkdown(body, contentMd, snapshot) {
|
||||||
@@ -3075,7 +3115,7 @@
|
|||||||
].join("");
|
].join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAiChatRow(role, content, extraClass) {
|
function renderAiChatRow(role, content, extraClass, attachments) {
|
||||||
const isUser = role === "user";
|
const isUser = role === "user";
|
||||||
const label = isUser ? "主人" : "AI教练";
|
const label = isUser ? "主人" : "AI教练";
|
||||||
const rowCls = isUser ? "ai-msg-row-user" : "ai-msg-row-coach";
|
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 isThinking = extraClass && String(extraClass).includes("ai-bubble-thinking");
|
||||||
const bubbleInner = isUser || isThinking ? esc(content || "") : renderHubMarkdown(content || "");
|
const bubbleInner = isUser || isThinking ? esc(content || "") : renderHubMarkdown(content || "");
|
||||||
const mdCls = !isUser && !isThinking ? " ai-result-md" : "";
|
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 (
|
return (
|
||||||
`<div class="ai-msg-row ${rowCls}">` +
|
`<div class="ai-msg-row ${rowCls}">` +
|
||||||
`<span class="ai-msg-role">${label}</span>` +
|
`<span class="ai-msg-role">${label}</span>` +
|
||||||
|
`${attHtml}` +
|
||||||
`<div class="ai-bubble ${bubbleCls}${mdCls}${extraClass ? " " + extraClass : ""}">${bubbleInner}</div>` +
|
`<div class="ai-bubble ${bubbleCls}${mdCls}${extraClass ? " " + extraClass : ""}">${bubbleInner}</div>` +
|
||||||
`</div>`
|
`</div>`
|
||||||
);
|
);
|
||||||
@@ -3104,14 +3151,21 @@
|
|||||||
!msgs.length && !options.pendingUser && !options.thinking;
|
!msgs.length && !options.pendingUser && !options.thinking;
|
||||||
if (showPlaceholder) {
|
if (showPlaceholder) {
|
||||||
box.innerHTML =
|
box.innerHTML =
|
||||||
'<p class="ai-placeholder">主人发消息会立刻出现在右侧;AI教练 会先显示「正在思考…」再回复。</p>';
|
'<p class="ai-placeholder">主人发消息会立刻出现在右侧;AI教练 会先显示「正在思考…」再回复。可点「附件」上传图片或文档。</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let html = msgs
|
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("");
|
.join("");
|
||||||
if (options.pendingUser) {
|
if (options.pendingUser) {
|
||||||
html += renderAiChatRow("user", options.pendingUser);
|
html += renderAiChatRow("user", options.pendingUser, null, options.pendingAttachments);
|
||||||
}
|
}
|
||||||
if (options.thinking) {
|
if (options.thinking) {
|
||||||
html += renderAiChatRow("assistant", "正在思考…", "ai-bubble-thinking");
|
html += renderAiChatRow("assistant", "正在思考…", "ai-bubble-thinking");
|
||||||
@@ -3206,21 +3260,33 @@
|
|||||||
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();
|
||||||
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 = "";
|
if (input) input.value = "";
|
||||||
setAiChatBusy(true);
|
setAiChatBusy(true);
|
||||||
renderAiChatMessages(aiChatSessionCache, { pendingUser: text, thinking: true });
|
renderAiChatMessages(aiChatSessionCache, {
|
||||||
try {
|
pendingUser: text || (files.length ? `(上传 ${files.length} 个附件)` : ""),
|
||||||
const r = await apiFetch("/api/ai/chat/send", {
|
pendingAttachments,
|
||||||
method: "POST",
|
thinking: true,
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ message: text }),
|
|
||||||
});
|
});
|
||||||
|
try {
|
||||||
|
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();
|
const j = await r.json();
|
||||||
if (!r.ok) throw new Error(j.detail || j.msg || "发送失败");
|
if (!r.ok) throw new Error(j.detail || j.msg || "发送失败");
|
||||||
aiChatSessionCache = j.session || null;
|
aiChatSessionCache = j.session || null;
|
||||||
renderAiChatMessages(aiChatSessionCache);
|
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) {
|
} catch (e) {
|
||||||
showToast(String(e), true);
|
showToast(String(e), true);
|
||||||
renderAiChatMessages(aiChatSessionCache);
|
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");
|
const aiSummaryBtn = document.getElementById("btn-ai-summary");
|
||||||
if (aiSummaryBtn) aiSummaryBtn.onclick = () => generateAiSummary();
|
if (aiSummaryBtn) aiSummaryBtn.onclick = () => generateAiSummary();
|
||||||
const aiChatNewBtn = document.getElementById("btn-ai-chat-new");
|
const aiChatNewBtn = document.getElementById("btn-ai-chat-new");
|
||||||
|
|||||||
@@ -231,8 +231,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<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">
|
||||||
<textarea id="ai-chat-input" rows="2" placeholder="聊聊行情、心态、纪律、执行…" autocomplete="off"></textarea>
|
<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>
|
<button type="submit" id="btn-ai-chat-send" class="primary">发送</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -286,6 +295,6 @@
|
|||||||
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
<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/chart.js?v=20260604-upnl-contracts"></script>
|
||||||
<script src="/assets/ai_review_render.js?v=2"></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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user