diff --git a/.gitignore b/.gitignore
index cfab7ef..af2ff84 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,8 @@
**/.env.bak
**/.env.local
manual_trading_hub/hub_settings.json
+manual_trading_hub/hub_ai_summaries.json
+manual_trading_hub/hub_ai_chat.json
manual_trading_hub/data/
# 数据库与上传(运行时生成)
diff --git a/hub_bridge.py b/hub_bridge.py
index e50ab8b..9543709 100644
--- a/hub_bridge.py
+++ b/hub_bridge.py
@@ -401,6 +401,48 @@ def register_hub_routes(app):
)
)
+ @app.route("/api/hub/trades/today")
+ @_hub_auth_required
+ def api_hub_trades_today():
+ """中控 AI:当日已平仓记录(按实例交易日)。"""
+ from hub_trades_lib import (
+ current_trading_day,
+ fetch_trades_for_trading_day,
+ summarize_trades,
+ )
+
+ c = _ctx()
+ get_db = c.get("get_db")
+ if not get_db:
+ return jsonify({"ok": False, "msg": "HUB_CTX 缺少 get_db"}), 500
+ day_arg = (request.args.get("trading_day") or request.args.get("date") or "").strip()[:10]
+ try:
+ import os
+
+ reset_hour = int(os.getenv("TRADING_DAY_RESET_HOUR", "8") or "8")
+ except ValueError:
+ reset_hour = 8
+ trading_day = day_arg or current_trading_day(reset_hour=reset_hour)
+ conn = get_db()
+ try:
+ trades = fetch_trades_for_trading_day(
+ conn,
+ trading_day,
+ row_to_dict_fn=c.get("row_to_dict"),
+ )
+ finally:
+ conn.close()
+ stats = summarize_trades(trades)
+ return jsonify(
+ {
+ "ok": True,
+ "trading_day": trading_day,
+ "trading_day_reset_hour": reset_hour,
+ "trades": trades,
+ "stats": stats,
+ }
+ )
+
@app.route("/api/hub/ohlcv")
@_hub_auth_required
def api_hub_ohlcv():
diff --git a/hub_trades_lib.py b/hub_trades_lib.py
new file mode 100644
index 0000000..ba0bc37
--- /dev/null
+++ b/hub_trades_lib.py
@@ -0,0 +1,120 @@
+"""各实例当日平仓记录查询(供 hub_bridge /api/hub/trades/today 与中控 AI 聚合)。"""
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+from typing import Any, Callable, Optional
+
+
+def trading_day_from_dt(dt: datetime, reset_hour: int = 8) -> str:
+ """与实例 get_trading_day 一致:小时 < reset_hour 归属上一日历日。"""
+ if dt.hour < reset_hour:
+ dt = dt - timedelta(days=1)
+ return dt.strftime("%Y-%m-%d")
+
+
+def current_trading_day(*, now: datetime | None = None, reset_hour: int = 8) -> str:
+ return trading_day_from_dt(now or datetime.now(), reset_hour)
+
+
+def _row_dict(row, row_to_dict: Optional[Callable] = None) -> dict:
+ if row is None:
+ return {}
+ if row_to_dict:
+ try:
+ return dict(row_to_dict(row))
+ except Exception:
+ pass
+ try:
+ keys = row.keys() if hasattr(row, "keys") else ()
+ if keys:
+ return {k: row[k] for k in keys}
+ except Exception:
+ pass
+ try:
+ return dict(row)
+ except Exception:
+ return {}
+
+
+def fetch_trades_for_trading_day(
+ conn,
+ trading_day: str,
+ *,
+ row_to_dict_fn: Optional[Callable] = None,
+ limit: int = 200,
+) -> list[dict[str, Any]]:
+ """返回指定交易日的已平仓记录(优先 session_date,否则 closed_at 日期)。"""
+ day = (trading_day or "").strip()[:10]
+ if not day:
+ return []
+ lim = max(1, min(int(limit or 200), 500))
+ rows = conn.execute(
+ f"""
+ SELECT symbol, exchange_symbol, direction, result, pnl_amount,
+ closed_at, opened_at, session_date, monitor_type,
+ actual_rr, planned_rr, trade_style, entry_reason
+ FROM trade_records
+ WHERE (
+ (session_date IS NOT NULL AND TRIM(session_date) != '' AND session_date = ?)
+ OR (
+ (session_date IS NULL OR TRIM(session_date) = '')
+ AND closed_at IS NOT NULL AND TRIM(closed_at) != ''
+ AND substr(closed_at, 1, 10) = ?
+ )
+ )
+ AND result IS NOT NULL AND TRIM(result) != ''
+ ORDER BY COALESCE(closed_at, opened_at) ASC
+ LIMIT ?
+ """,
+ (day, day, lim),
+ ).fetchall()
+ out: list[dict[str, Any]] = []
+ for row in rows:
+ d = _row_dict(row, row_to_dict_fn)
+ try:
+ pnl = float(d.get("pnl_amount") or 0)
+ except (TypeError, ValueError):
+ pnl = 0.0
+ out.append(
+ {
+ "symbol": d.get("symbol"),
+ "exchange_symbol": d.get("exchange_symbol"),
+ "direction": d.get("direction"),
+ "result": d.get("result"),
+ "pnl_amount": round(pnl, 4),
+ "closed_at": d.get("closed_at"),
+ "opened_at": d.get("opened_at"),
+ "session_date": d.get("session_date"),
+ "monitor_type": d.get("monitor_type"),
+ "actual_rr": d.get("actual_rr"),
+ "planned_rr": d.get("planned_rr"),
+ "trade_style": d.get("trade_style"),
+ "entry_reason": d.get("entry_reason"),
+ }
+ )
+ return out
+
+
+def summarize_trades(trades: list[dict]) -> dict[str, Any]:
+ """单笔列表 → 笔数 / 盈亏 / 胜败统计。"""
+ total_pnl = 0.0
+ win = loss = flat = 0
+ for t in trades or []:
+ try:
+ pnl = float(t.get("pnl_amount") or 0)
+ except (TypeError, ValueError):
+ pnl = 0.0
+ total_pnl += pnl
+ if pnl > 1e-9:
+ win += 1
+ elif pnl < -1e-9:
+ loss += 1
+ else:
+ flat += 1
+ return {
+ "closed_count": len(trades or []),
+ "win_count": win,
+ "loss_count": loss,
+ "flat_count": flat,
+ "total_pnl_u": round(total_pnl, 4),
+ }
diff --git a/manual_trading_hub/.env.example b/manual_trading_hub/.env.example
index 772c023..e5f8b71 100644
--- a/manual_trading_hub/.env.example
+++ b/manual_trading_hub/.env.example
@@ -80,3 +80,20 @@ HUB_TRUST_LAN=true
# EXCHANGE=binance
# PORT=15200
# HOST=127.0.0.1
+
+# ---------- 中控 AI 教练(/ai,模块 hub_ai/,存 hub_ai_*.json)----------
+# 与四实例相同变量名;默认 OpenAI 兼容网关(改 AI_PROVIDER=ollama 可走本机 Ollama)
+# 详见 manual_trading_hub/AI教练说明.md 与仓库根 AI复盘与模型配置说明.md
+AI_TIMEOUT_SECONDS=120
+
+# AI 提供方:openai(默认,OpenAI 兼容网关)| ollama(本机 Ollama)
+AI_PROVIDER=openai
+OPENAI_API_BASE=https://op.bz121.com/v1
+OPENAI_API_KEY=你的密钥
+OPENAI_MODEL=gemma4:e4b
+# 本机 Ollama(AI_PROVIDER=ollama 时使用)
+OLLAMA_API=http://127.0.0.1:11434/api/generate
+AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest
+
+# 交易日切分(与四实例 TRADING_DAY_RESET_HOUR 一致,定义「今日总结」的日期)
+TRADING_DAY_RESET_HOUR=8
diff --git a/manual_trading_hub/AI教练说明.md b/manual_trading_hub/AI教练说明.md
new file mode 100644
index 0000000..f3eb29b
--- /dev/null
+++ b/manual_trading_hub/AI教练说明.md
@@ -0,0 +1,62 @@
+# 中控 AI 教练说明
+
+中控 **AI 教练**(`/ai`)与四实例 `/records` 里的 **AI 复盘** 分离:模块在 `manual_trading_hub/hub_ai/`,数据存同目录 JSON。
+
+## 能力
+
+| 功能 | 说明 |
+|------|------|
+| **今日总结** | 聚合四户(含未启用 →「未监控」)当日平仓、持仓浮盈亏、连接状态;语气偏冷、台账式 |
+| **AI 聊天** | 单会话直到点「新开对话」;口语化、安慰体贴、轻修正;注入监控快照与今日总结摘要 |
+
+## 存储
+
+与 `hub_settings.json` 同目录(`manual_trading_hub/`):
+
+- `hub_ai_summaries.json` — 历史总结
+- `hub_ai_chat.json` — 聊天会话(`active_session_id` 指向当前会话)
+
+升级 / 迁移时请一并备份(见 [本地数据迁移到云端.md](./本地数据迁移到云端.md))。
+
+## 模型配置
+
+在 **`manual_trading_hub/.env`** 配置,**变量名与四实例完全相同**;中控 `hub_ai/client.py` 共用仓库根 `ai_client.py`,**默认也是 OpenAI 兼容网关**(`AI_PROVIDER=openai`),与你在四所 `.env` 里配的那套一致即可。
+
+**推荐(与四实例默认一致):**
+
+```env
+AI_PROVIDER=openai
+OPENAI_API_BASE=https://op.bz121.com/v1
+OPENAI_API_KEY=你的密钥
+OPENAI_MODEL=gemma4:e4b
+
+# 本机 Ollama 备用(仅当 AI_PROVIDER=ollama 时生效)
+OLLAMA_API=http://127.0.0.1:11434/api/generate
+AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest
+```
+
+改走本机无限制模型时,将 `AI_PROVIDER=ollama`,并填好 `OLLAMA_API` / `AI_MODEL`;`OPENAI_*` 可保留不动。
+
+总结与聊天使用**同一模型**(同一套 `OPENAI_MODEL` 或 `AI_MODEL`);总结 temperature≈0.15,聊天≈0.5。
+
+可选:`TRADING_DAY_RESET_HOUR=8`(与实例一致,定义「今日」交易日)。
+
+## 依赖接口
+
+中控通过 HTTP 拉取各实例:
+
+- `GET /api/hub/monitor`(已有)
+- `GET /api/hub/trades/today?trading_day=YYYY-MM-DD`(`hub_bridge` 注册,需四实例更新代码并重启)
+
+子代理 `GET /status` 提供持仓与余额。
+
+## 与实例 AI 复盘的分工
+
+| | 中控 AI 教练 | 实例 AI 复盘 |
+|--|-------------|-------------|
+| 入口 | `/ai` | 各所 `/records` |
+| 数据 | 四户聚合 | 单户 `journal_entries` |
+| 语气 | 总结冷 / 聊天搭档 | 结构化教练报告 |
+| 代码 | `hub_ai/*` | `ai_review_lib` + 各 `app.py` |
+
+详见仓库根 [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)(实例侧)。
diff --git a/manual_trading_hub/README.md b/manual_trading_hub/README.md
index 3d33213..23ae07e 100644
--- a/manual_trading_hub/README.md
+++ b/manual_trading_hub/README.md
@@ -1,6 +1,6 @@
# 复盘系统中控(manual_trading_hub)
-> **完整说明**:[使用说明.md](./使用说明.md) · **行情区**:[行情区说明.md](./行情区说明.md) · **部署**:[部署文档.md](./部署文档.md) · **云服务器**:[云服务器部署说明.md](./云服务器部署说明.md) · **本地→云端迁移**:[本地数据迁移到云端.md](./本地数据迁移到云端.md) · **局域网/反代**:[局域网与反代部署说明.md](./局域网与反代部署说明.md) · **故障**:[常见问题.md](./常见问题.md)
+> **完整说明**:[使用说明.md](./使用说明.md) · **AI 教练**:[AI教练说明.md](./AI教练说明.md) · **行情区**:[行情区说明.md](./行情区说明.md) · **部署**:[部署文档.md](./部署文档.md) · **云服务器**:[云服务器部署说明.md](./云服务器部署说明.md) · **本地→云端迁移**:[本地数据迁移到云端.md](./本地数据迁移到云端.md) · **局域网/反代**:[局域网与反代部署说明.md](./局域网与反代部署说明.md) · **故障**:[常见问题.md](./常见问题.md)
多账户 **监控聚合 + 紧急全平**;**不在中控网页下单**。人工下单、关键位、**策略交易**(`/strategy`)、复盘请在各 `crypto_monitor_*` 实例网页操作(监控卡片 **「实例」** / **「复盘」**)。**增加子账户**见 [使用说明 §4.3](./使用说明.md#43-增加账户例如再挂一个-gate)。
@@ -12,6 +12,7 @@
|------|------|
| 监控区 | 持仓、余额、关键位摘要、趋势计划、机器人单(只读) |
| 行情区 | K 线(多周期、本地缓存、技术指标、从监控跳转持仓线) |
+| **AI 教练** | 四户今日总结 + 口语化聊天(`/ai`;见 [AI教练说明.md](./AI教练说明.md)) |
| 紧急全平 | 单户 / 全局市价减仓 |
| 系统设置 | `hub_settings.json` 管理 URL、启用、**监控关键位 / 监控趋势计划**(不控制策略交易页) |
| Web 登录 | `.env` 设 `HUB_PASSWORD` 后用户名+密码保护(反代公网**务必**配置) |
@@ -22,7 +23,7 @@
## 架构
```
-浏览器 → hub.py (:5100) 监控 / 行情 / 设置 / 登录
+浏览器 → hub.py (:5100) 监控 / 行情 / **AI 教练** / 设置 / 登录
├→ agent.py × N (:15200~15203) 持仓、全平
└→ 各 Flask (:5000/5001/5002/5004) /api/hub/monitor 只读聚合
```
diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py
index fb39a01..6254982 100644
--- a/manual_trading_hub/hub.py
+++ b/manual_trading_hub/hub.py
@@ -77,7 +77,7 @@ _allow_pub_raw = (os.getenv("HUB_ALLOW_PUBLIC") or "").strip().lower()
# 云服务器 + 域名反代时设为 true:不做 IP 限制,仅靠 HUB_PASSWORD / 登录页保护
HUB_ALLOW_PUBLIC = _allow_pub_raw in ("1", "true", "yes", "on")
DIR = Path(__file__).resolve().parent
-HUB_BUILD = "20260603-hub-board-sse"
+HUB_BUILD = "20260606-hub-ai"
HUB_AGENT_TIMEOUT = float(os.getenv("HUB_AGENT_TIMEOUT", "8"))
HUB_FLASK_TIMEOUT = float(os.getenv("HUB_FLASK_TIMEOUT", "10"))
HUB_BOARD_TIMEOUT = float(os.getenv("HUB_BOARD_TIMEOUT", "45"))
@@ -362,11 +362,22 @@ def root_redirect():
@app.get("/monitor")
@app.get("/market")
+@app.get("/ai")
@app.get("/settings")
def shell_pages():
return _shell_page()
+def _all_exchanges_for_ai() -> list:
+ """AI 聚合用:含未启用账户(标记未监控)。"""
+ return list(load_settings().get("exchanges") or [])
+
+
+from hub_ai.routes import create_hub_ai_router
+
+app.include_router(create_hub_ai_router(load_all_exchanges=_all_exchanges_for_ai))
+
+
@app.get("/trade")
def trade_removed_redirect():
from fastapi.responses import RedirectResponse
diff --git a/manual_trading_hub/hub_ai/__init__.py b/manual_trading_hub/hub_ai/__init__.py
new file mode 100644
index 0000000..07b3bda
--- /dev/null
+++ b/manual_trading_hub/hub_ai/__init__.py
@@ -0,0 +1 @@
+"""中控 AI 模块:今日总结 + 交易员聊天(与实例 ai_review 分离)。"""
diff --git a/manual_trading_hub/hub_ai/chat.py b/manual_trading_hub/hub_ai/chat.py
new file mode 100644
index 0000000..111ba44
--- /dev/null
+++ b/manual_trading_hub/hub_ai/chat.py
@@ -0,0 +1,88 @@
+"""中控 AI:单会话聊天(直到用户点击新开)。"""
+from __future__ import annotations
+
+from typing import Any
+
+from hub_ai.client import generate_text, model_label
+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.prompts import CHAT_SYSTEM, build_chat_user_prompt
+from hub_ai.store import (
+ append_chat_message,
+ create_new_session,
+ ensure_active_session,
+ get_active_session,
+ load_chat_store,
+ summary_excerpt_for_chat,
+)
+
+
+def _history_lines(messages: list[dict], max_turns: int = CHAT_MAX_HISTORY_TURNS) -> str:
+ rows = [m for m in (messages or []) if m.get("role") in ("user", "assistant")]
+ rows = rows[-max_turns * 2 :]
+ lines = []
+ for m in rows:
+ role = "用户" if m.get("role") == "user" else "搭档"
+ lines.append(f"{role}:{m.get('content') or ''}")
+ return "\n".join(lines)
+
+
+def get_chat_state() -> dict[str, Any]:
+ store = load_chat_store()
+ session = get_active_session()
+ return {
+ "active_session_id": store.get("active_session_id"),
+ "session": session,
+ "model": model_label(),
+ }
+
+
+def start_new_chat(*, trading_day: str) -> dict:
+ session = create_new_session(trading_day=trading_day)
+ return {"ok": True, "session": session, "model": model_label()}
+
+
+def send_chat_message(
+ exchanges: list[dict],
+ message: str,
+ *,
+ trading_day: str | None = None,
+) -> dict[str, Any]:
+ text = (message or "").strip()
+ if not text:
+ return {"ok": False, "msg": "消息不能为空"}
+
+ ctx = build_daily_context(exchanges, trading_day=trading_day)
+ day = ctx["trading_day"]
+ session = ensure_active_session(trading_day=day)
+ sid = session["id"]
+ history = _history_lines(session.get("messages") or [])
+
+ append_chat_message(sid, "user", text)
+
+ brief_ctx = format_chat_context_brief(ctx)
+ excerpt = summary_excerpt_for_chat(day)
+
+ user_prompt = build_chat_user_prompt(
+ context_text=brief_ctx,
+ trading_day=day,
+ summary_excerpt=excerpt,
+ history_lines=history,
+ user_message=text,
+ )
+ reply = generate_text(
+ system=CHAT_SYSTEM,
+ user=user_prompt,
+ temperature=CHAT_TEMPERATURE,
+ )
+ if reply.startswith("AI 调用失败"):
+ return {"ok": False, "msg": reply, "session_id": sid}
+
+ session = append_chat_message(sid, "assistant", reply)
+ return {
+ "ok": True,
+ "trading_day": day,
+ "session": session,
+ "reply": reply,
+ "model": model_label(),
+ }
diff --git a/manual_trading_hub/hub_ai/client.py b/manual_trading_hub/hub_ai/client.py
new file mode 100644
index 0000000..706a543
--- /dev/null
+++ b/manual_trading_hub/hub_ai/client.py
@@ -0,0 +1,20 @@
+"""中控 AI 模型调用(共用 ai_client 配置,逻辑独立)。"""
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+_REPO_ROOT = Path(__file__).resolve().parents[2]
+if str(_REPO_ROOT) not in sys.path:
+ sys.path.insert(0, str(_REPO_ROOT))
+
+from ai_client import ai_generate, ai_provider_label # noqa: E402
+
+
+def model_label() -> str:
+ return ai_provider_label()
+
+
+def generate_text(*, system: str, user: str, temperature: float) -> str:
+ prompt = f"{system.strip()}\n\n---\n\n{user.strip()}"
+ return ai_generate(prompt, temperature=temperature)
diff --git a/manual_trading_hub/hub_ai/config.py b/manual_trading_hub/hub_ai/config.py
new file mode 100644
index 0000000..53f4986
--- /dev/null
+++ b/manual_trading_hub/hub_ai/config.py
@@ -0,0 +1,33 @@
+"""中控 AI 配置(读 hub .env,与实例同名 AI 变量)。"""
+from __future__ import annotations
+
+import os
+
+HUB_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+SUMMARY_TEMPERATURE = 0.15
+CHAT_TEMPERATURE = 0.5
+CHAT_MAX_HISTORY_TURNS = 20
+SUMMARY_RETENTION_DAYS = 90
+CHAT_SESSION_RETENTION_DAYS = 60
+
+
+def trading_day_reset_hour() -> int:
+ try:
+ return int(os.getenv("TRADING_DAY_RESET_HOUR", "8") or "8")
+ except ValueError:
+ return 8
+
+
+def hub_flask_timeout() -> float:
+ try:
+ return float(os.getenv("HUB_FLASK_TIMEOUT", "10") or "10")
+ except ValueError:
+ return 10.0
+
+
+def hub_agent_timeout() -> float:
+ try:
+ return float(os.getenv("HUB_AGENT_TIMEOUT", "8") or "8")
+ except ValueError:
+ return 8.0
diff --git a/manual_trading_hub/hub_ai/context.py b/manual_trading_hub/hub_ai/context.py
new file mode 100644
index 0000000..0492d28
--- /dev/null
+++ b/manual_trading_hub/hub_ai/context.py
@@ -0,0 +1,278 @@
+"""中控 AI:四户数据聚合为结构化上下文。"""
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+from typing import Any, Callable, Optional
+
+import httpx
+
+from hub_ai.config import hub_agent_timeout, hub_flask_timeout, trading_day_reset_hour
+from hub_trades_lib import current_trading_day, summarize_trades
+
+
+def _hub_token() -> str:
+ return (os.getenv("HUB_BRIDGE_TOKEN") or os.getenv("CONTROL_TOKEN") or "").strip()
+
+
+def _hub_headers() -> dict[str, str]:
+ tok = _hub_token()
+ return {"X-Hub-Token": tok} if tok else {}
+
+
+def _agent_headers() -> dict[str, str]:
+ tok = (os.getenv("CONTROL_TOKEN") or os.getenv("HUB_BRIDGE_TOKEN") or "").strip()
+ return {"X-Control-Token": tok} if tok else {}
+
+
+def _safe_float(v: Any) -> Optional[float]:
+ try:
+ if v is None or v == "":
+ return None
+ return float(v)
+ except (TypeError, ValueError):
+ return None
+
+
+def _position_float_pnl(pos: dict) -> float:
+ for key in ("unrealized_pnl", "unrealizedPnl", "upnl"):
+ v = _safe_float(pos.get(key))
+ if v is not None:
+ return v
+ return 0.0
+
+
+def _collect_open_issues(
+ *,
+ monitored: bool,
+ agent_ok: bool,
+ flask_ok: bool,
+ positions: list,
+ hub_mon: Optional[dict],
+ day_pnl: float,
+) -> list[str]:
+ issues: list[str] = []
+ if not monitored:
+ return issues
+ if not agent_ok:
+ issues.append("Agent 连接异常")
+ if not flask_ok:
+ issues.append("Flask 监控连接异常")
+ if day_pnl < -0.01:
+ issues.append(f"当日平仓亏损 {day_pnl:.2f}U")
+ float_pnl = sum(_position_float_pnl(p) for p in positions if isinstance(p, dict))
+ if float_pnl < -0.5:
+ issues.append(f"当前浮亏 {float_pnl:.2f}U")
+ if isinstance(hub_mon, dict) and hub_mon.get("ok") is not False:
+ orders = hub_mon.get("orders") or []
+ trends = hub_mon.get("trends") or []
+ if positions and not orders and not trends:
+ issues.append("交易所有持仓但无本地 active 监控/趋势计划")
+ return issues
+
+
+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 ""
+ enabled = bool(ex.get("enabled"))
+ env_disabled = bool(ex.get("env_disabled"))
+ monitored = enabled and not env_disabled
+
+ base: dict[str, Any] = {
+ "id": ex.get("id"),
+ "key": key,
+ "name": name,
+ "enabled": enabled,
+ "env_disabled": env_disabled,
+ "status": "未监控" if not monitored else "已监控",
+ "trades": [],
+ "trade_stats": summarize_trades([]),
+ "positions": [],
+ "float_pnl_u": 0.0,
+ "balance_usdt": None,
+ "issues": [],
+ "agent_ok": False,
+ "flask_ok": False,
+ "hub_monitor": None,
+ "active_orders": 0,
+ "active_trends": 0,
+ }
+ if not monitored:
+ base["issues"] = []
+ return base
+
+ agent_url = (ex.get("agent_url") or "").rstrip("/")
+ flask_url = (ex.get("flask_url") or "").rstrip("/")
+ agent_body = None
+ if agent_url:
+ try:
+ r = client.get(
+ f"{agent_url}/status",
+ headers=_agent_headers(),
+ timeout=hub_agent_timeout(),
+ )
+ if r.status_code == 200:
+ agent_body = r.json()
+ base["agent_ok"] = True
+ except Exception as exc:
+ base["issues"].append(f"Agent: {exc}")
+
+ if isinstance(agent_body, dict):
+ base["balance_usdt"] = _safe_float(agent_body.get("balance_usdt"))
+ positions = agent_body.get("positions") or []
+ if isinstance(positions, list):
+ base["positions"] = positions
+ base["float_pnl_u"] = round(
+ sum(_position_float_pnl(p) for p in positions if isinstance(p, dict)), 4
+ )
+
+ hub_mon = None
+ if flask_url:
+ try:
+ r = client.get(
+ f"{flask_url}/api/hub/trades/today",
+ headers=_hub_headers(),
+ params={"trading_day": trading_day},
+ timeout=hub_flask_timeout(),
+ )
+ if r.status_code == 200:
+ trades_body = r.json()
+ if isinstance(trades_body, dict) and trades_body.get("ok"):
+ base["trades"] = trades_body.get("trades") or []
+ base["trade_stats"] = trades_body.get("stats") or summarize_trades(base["trades"])
+ base["flask_ok"] = True
+ except Exception as exc:
+ base["issues"].append(f"成交接口: {exc}")
+
+ try:
+ r = client.get(
+ f"{flask_url}/api/hub/monitor",
+ headers=_hub_headers(),
+ timeout=hub_flask_timeout(),
+ )
+ if r.status_code == 200:
+ hub_mon = r.json()
+ if isinstance(hub_mon, dict) and hub_mon.get("ok") is not False:
+ base["hub_monitor"] = hub_mon
+ base["flask_ok"] = True
+ base["active_orders"] = len(hub_mon.get("orders") or [])
+ base["active_trends"] = len(hub_mon.get("trends") or [])
+ except Exception as exc:
+ if "成交接口" not in str(base["issues"]):
+ base["issues"].append(f"监控接口: {exc}")
+
+ if monitored and not base["agent_ok"] and not base["flask_ok"]:
+ base["status"] = "连接异常"
+ elif base["issues"]:
+ base["status"] = "已监控·需关注"
+
+ day_pnl = float((base.get("trade_stats") or {}).get("total_pnl_u") or 0)
+ base["issues"].extend(
+ _collect_open_issues(
+ monitored=monitored,
+ agent_ok=base["agent_ok"],
+ flask_ok=base["flask_ok"],
+ positions=base["positions"],
+ hub_mon=hub_mon if isinstance(hub_mon, dict) else None,
+ day_pnl=day_pnl,
+ )
+ )
+ base["issues"] = list(dict.fromkeys(base["issues"]))
+ return base
+
+
+def build_daily_context(
+ exchanges: list[dict],
+ *,
+ trading_day: Optional[str] = None,
+) -> dict[str, Any]:
+ day = (trading_day or "").strip()[:10] or current_trading_day(
+ reset_hour=trading_day_reset_hour()
+ )
+ accounts: list[dict] = []
+ with httpx.Client() as client:
+ for ex in exchanges or []:
+ accounts.append(_fetch_account_bundle(client, ex, day))
+
+ total_closed_pnl = 0.0
+ total_closed = total_win = total_loss = 0
+ total_float = 0.0
+ for ac in accounts:
+ if ac.get("status") == "未监控":
+ continue
+ st = ac.get("trade_stats") or {}
+ total_closed_pnl += float(st.get("total_pnl_u") or 0)
+ total_closed += int(st.get("closed_count") or 0)
+ total_win += int(st.get("win_count") or 0)
+ total_loss += int(st.get("loss_count") or 0)
+ total_float += float(ac.get("float_pnl_u") or 0)
+
+ totals = {
+ "trading_day": day,
+ "total_pnl_u": round(total_closed_pnl, 4),
+ "closed_count": total_closed,
+ "win_count": total_win,
+ "loss_count": total_loss,
+ "float_pnl_u": round(total_float, 4),
+ }
+ payload = {"trading_day": day, "totals": totals, "accounts": accounts}
+ text = format_context_text(payload)
+ digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
+ return {"trading_day": day, "totals": totals, "accounts": accounts, "text": text, "context_hash": digest}
+
+
+def format_context_text(payload: dict) -> str:
+ lines = []
+ totals = payload.get("totals") or {}
+ lines.append(
+ f"【合计】交易日 {totals.get('trading_day')} | "
+ f"平仓盈亏 {totals.get('total_pnl_u')}U | "
+ f"笔数 {totals.get('closed_count')}(胜{totals.get('win_count')}/负{totals.get('loss_count')})| "
+ f"监控户浮盈亏合计 {totals.get('float_pnl_u')}U"
+ )
+ lines.append("")
+ for ac in payload.get("accounts") or []:
+ st = ac.get("trade_stats") or {}
+ lines.append(f"--- 账户:{ac.get('name')} ({ac.get('key')}) ---")
+ lines.append(f"状态:{ac.get('status')}")
+ if ac.get("status") == "未监控":
+ lines.append("")
+ continue
+ lines.append(
+ f"当日平仓:{st.get('closed_count')} 笔,盈亏 {st.get('total_pnl_u')}U "
+ 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(f"当前持仓浮盈亏:{ac.get('float_pnl_u')}U | 下单监控 {ac.get('active_orders')} | 趋势计划 {ac.get('active_trends')}")
+ positions = ac.get("positions") or []
+ if positions:
+ 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")
+ trades = ac.get("trades") or []
+ if trades:
+ lines.append("当日平仓明细:")
+ for t in trades[:15]:
+ lines.append(
+ f" - {t.get('symbol')} {t.get('direction')} {t.get('result')} "
+ f"{t.get('pnl_amount')}U @ {t.get('closed_at') or '?'}"
+ )
+ issues = ac.get("issues") or []
+ if issues:
+ lines.append("关注点:" + ";".join(issues))
+ lines.append("")
+ return "\n".join(lines).strip()
+
+
+def format_chat_context_brief(payload: dict, max_chars: int = 2500) -> str:
+ text = format_context_text(payload)
+ if len(text) <= max_chars:
+ return text
+ return text[: max_chars - 3].rstrip() + "..."
diff --git a/manual_trading_hub/hub_ai/prompts.py b/manual_trading_hub/hub_ai/prompts.py
new file mode 100644
index 0000000..818607e
--- /dev/null
+++ b/manual_trading_hub/hub_ai/prompts.py
@@ -0,0 +1,74 @@
+"""中控 AI 提示词(与实例 ai_review 分离)。"""
+
+SUMMARY_SYSTEM = """
+你是多账户加密货币合约交易的台账助手。只根据用户提供的结构化数据输出中文 Markdown,语气克制、偏冷、客观,像值班记录。
+
+硬性规则:
+- 只能陈述数据中明确出现的数字与事实;禁止编造成交、止损、扛单、行情预测。
+- 未监控的账户必须标注「未监控」,不得臆测其盈亏。
+- 连接失败或数据缺失的账户如实写明,不要猜测。
+- 不要用安慰、说教、建议口吻(那些属于聊天功能)。
+- 禁止夸张词(致命、崩溃、灾难等)。
+
+输出格式(Markdown,标题必须一致):
+**今日交易总结({trading_day})**
+
+**1. 总览**
+- **合计盈亏(U)**:…
+- **平仓笔数**:…(胜 / 负 / 平)
+- **当前持仓浮盈亏(U)**:…(仅汇总已监控且有数据的账户)
+
+**2. 分户明细**
+每个账户一行:账户名 | 状态(已监控/未监控/连接异常) | 当日平仓盈亏 | 笔数 | 当前浮盈亏 | 备注
+
+**3. 需关注**
+仅有依据时列出(如:某户当日亏损最大、浮亏偏大、Flask/Agent 异常、有持仓但无本地监控等);若无则写「无」。
+
+**4. 数据说明**
+列出数据缺口(某户未启用、接口失败等)。
+""".strip()
+
+
+CHAT_SYSTEM = """
+你是和用户一起盯盘的老搭档交易员,熟悉他多个交易所账户的分工。用中文、口语化、短句交流。
+
+语气要求:
+- 先理解对方的压力和情绪,再轻轻帮他把事想清楚(安慰、体贴)。
+- 可以指出执行或心态上的偏差点,但用商量、陪伴的口吻,绝不用教育、训诫、上课、列清单式说教。
+- 不要「第1点第2点你应该…」;不要「作为你的教练我必须…」。
+- 不预测涨跌,不保证收益,不替用户做决定。
+- 只能依据提供的监控与交易数据说话;看不到的就说「我这边看不到,你可以去 xx 实例页确认」。
+
+若附带「今日总结摘要」,可自然引用,但保持口语,不要复读整份报告。
+""".strip()
+
+
+def build_summary_user_prompt(context_text: str, trading_day: str) -> str:
+ return f"""
+交易日:{trading_day}
+
+以下为中控聚合的多账户数据(含未监控账户标记):
+
+{context_text}
+""".strip()
+
+
+def build_chat_user_prompt(
+ *,
+ context_text: str,
+ trading_day: str,
+ summary_excerpt: str,
+ history_lines: str,
+ user_message: str,
+) -> str:
+ parts = [
+ f"【交易日】{trading_day}",
+ "【当前多账户快照】",
+ context_text.strip() or "(无监控数据)",
+ ]
+ if summary_excerpt.strip():
+ parts.extend(["【今日总结摘要(供参考)】", summary_excerpt.strip()])
+ if history_lines.strip():
+ parts.extend(["【此前对话】", history_lines.strip()])
+ parts.extend(["【用户现在说】", user_message.strip()])
+ return "\n\n".join(parts)
diff --git a/manual_trading_hub/hub_ai/routes.py b/manual_trading_hub/hub_ai/routes.py
new file mode 100644
index 0000000..958ea81
--- /dev/null
+++ b/manual_trading_hub/hub_ai/routes.py
@@ -0,0 +1,108 @@
+"""中控 AI FastAPI 路由。"""
+from __future__ import annotations
+
+from typing import Callable
+
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel, Field
+
+from hub_ai.chat import get_chat_state, send_chat_message, start_new_chat
+from hub_ai.client import model_label
+from hub_ai.config import trading_day_reset_hour
+from hub_ai.context import build_daily_context
+from hub_ai.store import get_latest_summary, list_summaries
+from hub_ai.summary import generate_daily_summary
+from hub_trades_lib import current_trading_day
+
+
+class ChatSendBody(BaseModel):
+ message: str = ""
+ trading_day: str = ""
+
+
+class SummaryGenerateBody(BaseModel):
+ trading_day: str = ""
+ force: bool = False
+
+
+class ChatNewBody(BaseModel):
+ trading_day: str = ""
+
+
+def create_hub_ai_router(*, load_all_exchanges: Callable[[], list]) -> APIRouter:
+ router = APIRouter(prefix="/api/ai", tags=["hub-ai"])
+
+ def _day(raw: str = "") -> str:
+ d = (raw or "").strip()[:10]
+ return d or current_trading_day(reset_hour=trading_day_reset_hour())
+
+ @router.get("/meta")
+ def api_ai_meta():
+ return {
+ "ok": True,
+ "model": model_label(),
+ "trading_day_reset_hour": trading_day_reset_hour(),
+ "trading_day": current_trading_day(reset_hour=trading_day_reset_hour()),
+ "storage": {
+ "summaries": "hub_ai_summaries.json",
+ "chat": "hub_ai_chat.json",
+ },
+ }
+
+ @router.get("/context")
+ def api_ai_context(trading_day: str = ""):
+ exchanges = load_all_exchanges()
+ ctx = build_daily_context(exchanges, trading_day=_day(trading_day))
+ return {"ok": True, **ctx}
+
+ @router.get("/summary")
+ def api_ai_summary_list(trading_day: str = ""):
+ day = _day(trading_day) if trading_day.strip() else ""
+ items = list_summaries(trading_day=day or None, limit=20)
+ latest = get_latest_summary(_day(trading_day)) if trading_day.strip() else (
+ items[0] if items else None
+ )
+ return {
+ "ok": True,
+ "trading_day": _day(trading_day) if trading_day.strip() else None,
+ "summaries": items,
+ "latest": latest,
+ "model": model_label(),
+ }
+
+ @router.post("/summary/generate")
+ def api_ai_summary_generate(body: SummaryGenerateBody = SummaryGenerateBody()):
+ exchanges = load_all_exchanges()
+ result = generate_daily_summary(
+ exchanges,
+ trading_day=_day(body.trading_day) if body.trading_day.strip() else None,
+ force=bool(body.force),
+ )
+ if not result.get("ok"):
+ raise HTTPException(status_code=502, detail=result.get("msg") or "生成失败")
+ result.pop("context", None)
+ return result
+
+ @router.get("/chat/session")
+ def api_ai_chat_session():
+ state = get_chat_state()
+ return {"ok": True, **state, "model": model_label()}
+
+ @router.post("/chat/new")
+ def api_ai_chat_new(body: ChatNewBody = ChatNewBody()):
+ day = _day(body.trading_day)
+ return start_new_chat(trading_day=day)
+
+ @router.post("/chat/send")
+ def api_ai_chat_send(body: ChatSendBody):
+ exchanges = load_all_exchanges()
+ result = send_chat_message(
+ exchanges,
+ body.message,
+ trading_day=_day(body.trading_day) if body.trading_day.strip() else None,
+ )
+ if not result.get("ok"):
+ raise HTTPException(status_code=502, detail=result.get("msg") or "发送失败")
+ return result
+
+ return router
diff --git a/manual_trading_hub/hub_ai/store.py b/manual_trading_hub/hub_ai/store.py
new file mode 100644
index 0000000..26365c3
--- /dev/null
+++ b/manual_trading_hub/hub_ai/store.py
@@ -0,0 +1,195 @@
+"""中控 AI:JSON 持久化(与 hub_settings.json 同目录)。"""
+from __future__ import annotations
+
+import json
+import os
+import uuid
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Any, Optional
+
+from hub_ai.config import CHAT_SESSION_RETENTION_DAYS, SUMMARY_RETENTION_DAYS
+
+HUB_DIR = Path(__file__).resolve().parent.parent
+SUMMARIES_PATH = HUB_DIR / "hub_ai_summaries.json"
+CHAT_PATH = HUB_DIR / "hub_ai_chat.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_json(path: Path, default: dict) -> dict:
+ if not path.is_file():
+ return dict(default)
+ try:
+ loaded = json.loads(path.read_text(encoding="utf-8"))
+ if isinstance(loaded, dict):
+ return loaded
+ except Exception:
+ pass
+ return dict(default)
+
+
+def _prune_summaries(items: list, *, keep_days: int) -> list:
+ cutoff = (datetime.now() - timedelta(days=max(1, keep_days))).strftime("%Y-%m-%d")
+ out = [x for x in items if str(x.get("trading_day") or "") >= cutoff]
+ return out[-500:]
+
+
+def _prune_chat_sessions(sessions: list, *, keep_days: int) -> list:
+ cutoff_dt = datetime.now() - timedelta(days=max(1, keep_days))
+ out = []
+ for s in sessions:
+ ts = str(s.get("updated_at") or s.get("created_at") or "")
+ try:
+ dt = datetime.strptime(ts[:19], "%Y-%m-%d %H:%M:%S")
+ except ValueError:
+ out.append(s)
+ continue
+ if dt >= cutoff_dt:
+ out.append(s)
+ return out[-50:]
+
+
+def load_summaries_store() -> dict:
+ return _load_json(SUMMARIES_PATH, {"version": 1, "summaries": []})
+
+
+def save_summaries_store(data: dict) -> None:
+ summaries = _prune_summaries(
+ list(data.get("summaries") or []),
+ keep_days=SUMMARY_RETENTION_DAYS,
+ )
+ _atomic_write(SUMMARIES_PATH, {"version": 1, "summaries": summaries})
+
+
+def append_summary(
+ *,
+ trading_day: str,
+ content_md: str,
+ model: str,
+ context_hash: str,
+ stats_snapshot: dict,
+) -> dict:
+ store = load_summaries_store()
+ row = {
+ "id": uuid.uuid4().hex,
+ "trading_day": trading_day,
+ "generated_at": _now_str(),
+ "model": model,
+ "context_hash": context_hash,
+ "content_md": content_md,
+ "stats_snapshot": stats_snapshot,
+ }
+ store.setdefault("summaries", []).append(row)
+ save_summaries_store(store)
+ return row
+
+
+def list_summaries(*, trading_day: Optional[str] = None, limit: int = 30) -> list[dict]:
+ store = load_summaries_store()
+ items = list(store.get("summaries") or [])
+ if trading_day:
+ items = [x for x in items if str(x.get("trading_day")) == trading_day]
+ items.sort(key=lambda x: str(x.get("generated_at") or ""), reverse=True)
+ return items[: max(1, min(limit, 100))]
+
+
+def get_latest_summary(trading_day: str) -> Optional[dict]:
+ rows = list_summaries(trading_day=trading_day, limit=1)
+ return rows[0] if rows else None
+
+
+def load_chat_store() -> dict:
+ default = {"version": 1, "sessions": [], "active_session_id": None}
+ data = _load_json(CHAT_PATH, default)
+ data.setdefault("version", 1)
+ data.setdefault("sessions", [])
+ return data
+
+
+def save_chat_store(data: dict) -> None:
+ sessions = _prune_chat_sessions(
+ list(data.get("sessions") or []),
+ keep_days=CHAT_SESSION_RETENTION_DAYS,
+ )
+ active = data.get("active_session_id")
+ ids = {str(s.get("id")) for s in sessions}
+ if active and str(active) not in ids:
+ active = sessions[-1]["id"] if sessions else None
+ _atomic_write(
+ CHAT_PATH,
+ {"version": 1, "sessions": sessions, "active_session_id": active},
+ )
+
+
+def get_active_session() -> Optional[dict]:
+ store = load_chat_store()
+ sid = store.get("active_session_id")
+ for s in store.get("sessions") or []:
+ if str(s.get("id")) == str(sid):
+ return s
+ return None
+
+
+def create_new_session(*, trading_day: str, title: str = "新对话") -> dict:
+ store = load_chat_store()
+ session = {
+ "id": uuid.uuid4().hex,
+ "trading_day": trading_day,
+ "title": title,
+ "created_at": _now_str(),
+ "updated_at": _now_str(),
+ "messages": [],
+ }
+ store.setdefault("sessions", []).append(session)
+ store["active_session_id"] = session["id"]
+ save_chat_store(store)
+ return session
+
+
+def ensure_active_session(*, trading_day: str) -> dict:
+ active = get_active_session()
+ if active:
+ return active
+ return create_new_session(trading_day=trading_day)
+
+
+def append_chat_message(session_id: str, role: str, content: str) -> dict:
+ store = load_chat_store()
+ sessions = store.get("sessions") or []
+ target = None
+ for s in sessions:
+ if str(s.get("id")) == str(session_id):
+ target = s
+ break
+ if not target:
+ raise KeyError("session_not_found")
+ msg = {"role": role, "content": content.strip(), "at": _now_str()}
+ target.setdefault("messages", []).append(msg)
+ target["updated_at"] = _now_str()
+ if role == "user" and (target.get("title") in (None, "", "新对话")):
+ title = content.strip().replace("\n", " ")[:24]
+ if title:
+ target["title"] = title
+ store["active_session_id"] = target["id"]
+ save_chat_store(store)
+ return target
+
+
+def summary_excerpt_for_chat(trading_day: str, max_chars: int = 600) -> str:
+ latest = get_latest_summary(trading_day)
+ if not latest:
+ return ""
+ text = str(latest.get("content_md") or "").strip()
+ if len(text) <= max_chars:
+ return text
+ return text[: max_chars - 3].rstrip() + "..."
diff --git a/manual_trading_hub/hub_ai/summary.py b/manual_trading_hub/hub_ai/summary.py
new file mode 100644
index 0000000..d2dd066
--- /dev/null
+++ b/manual_trading_hub/hub_ai/summary.py
@@ -0,0 +1,69 @@
+"""中控 AI:今日总结生成。"""
+from __future__ import annotations
+
+from typing import Any
+
+from hub_ai.client import generate_text, model_label
+from hub_ai.context import build_daily_context
+from hub_ai.prompts import SUMMARY_SYSTEM, build_summary_user_prompt
+from hub_ai.store import append_summary, get_latest_summary, list_summaries
+
+
+def generate_daily_summary(
+ exchanges: list[dict],
+ *,
+ trading_day: str | None = None,
+ force: bool = False,
+) -> dict[str, Any]:
+ ctx = build_daily_context(exchanges, trading_day=trading_day)
+ day = ctx["trading_day"]
+ if not force:
+ latest = get_latest_summary(day)
+ if latest and latest.get("context_hash") == ctx.get("context_hash"):
+ return {
+ "ok": True,
+ "cached": True,
+ "trading_day": day,
+ "summary": latest,
+ "model": latest.get("model") or model_label(),
+ }
+
+ system = SUMMARY_SYSTEM.replace("{trading_day}", day)
+ user = build_summary_user_prompt(ctx["text"], day)
+ content = generate_text(system=system, user=user, temperature=0.15)
+ if content.startswith("AI 调用失败"):
+ return {"ok": False, "msg": content, "trading_day": day}
+
+ stats_snapshot = {
+ "totals": ctx.get("totals"),
+ "by_account": {
+ str(ac.get("key") or ac.get("id")): {
+ "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"),
+ "issues": ac.get("issues") or [],
+ }
+ for ac in ctx.get("accounts") or []
+ },
+ }
+ row = append_summary(
+ trading_day=day,
+ content_md=content,
+ model=model_label(),
+ context_hash=ctx.get("context_hash") or "",
+ stats_snapshot=stats_snapshot,
+ )
+ return {
+ "ok": True,
+ "cached": False,
+ "trading_day": day,
+ "summary": row,
+ "model": model_label(),
+ "context": ctx,
+ }
+
+
+def summary_list(trading_day: str | None = None) -> list[dict]:
+ return list_summaries(trading_day=trading_day)
diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css
index 88f95e7..db97ac6 100644
--- a/manual_trading_hub/static/app.css
+++ b/manual_trading_hub/static/app.css
@@ -3342,3 +3342,132 @@ html[data-theme="light"] button.danger {
background: rgba(201, 53, 82, 0.1);
border-color: rgba(201, 53, 82, 0.45);
}
+
+/* --- Hub AI 教练 --- */
+.ai-layout {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+ align-items: start;
+}
+@media (max-width: 960px) {
+ .ai-layout {
+ grid-template-columns: 1fr;
+ }
+}
+.ai-panel {
+ background: var(--panel);
+ border: 1px solid var(--border-soft);
+ border-radius: var(--radius);
+ padding: 14px 16px;
+ min-height: 420px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+.ai-panel-head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+.ai-panel-head h2 {
+ margin: 0;
+ font-size: 1rem;
+ font-family: var(--display);
+ letter-spacing: 0.04em;
+}
+.ai-panel-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+}
+.ai-stats-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 14px;
+ font-size: 0.82rem;
+ color: var(--muted);
+}
+.ai-stat-chip {
+ padding: 4px 8px;
+ border-radius: 6px;
+ background: var(--inset-surface);
+ border: 1px solid var(--border-soft);
+}
+.ai-stat-chip strong {
+ color: var(--text);
+ margin-right: 4px;
+}
+.ai-md-body {
+ flex: 1;
+ overflow: auto;
+ max-height: min(62vh, 640px);
+ padding: 12px;
+ border-radius: 8px;
+ background: var(--inset-surface);
+ border: 1px solid var(--border-soft);
+ font-size: 0.86rem;
+ line-height: 1.55;
+ color: var(--text);
+}
+.ai-placeholder {
+ color: var(--muted);
+ margin: 0;
+}
+.ai-chat-panel {
+ min-height: 520px;
+}
+.ai-chat-messages {
+ flex: 1;
+ overflow: auto;
+ max-height: min(52vh, 520px);
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 8px 4px;
+}
+.ai-bubble {
+ max-width: 92%;
+ padding: 10px 12px;
+ border-radius: 10px;
+ font-size: 0.88rem;
+ line-height: 1.5;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+.ai-bubble-user {
+ align-self: flex-end;
+ background: var(--accent-dim);
+ border: 1px solid var(--border);
+}
+.ai-bubble-assistant {
+ align-self: flex-start;
+ background: var(--inset-surface);
+ border: 1px solid var(--border-soft);
+}
+.ai-chat-form {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 8px;
+ align-items: end;
+}
+.ai-chat-form textarea {
+ width: 100%;
+ resize: vertical;
+ min-height: 72px;
+ padding: 10px 12px;
+ border-radius: 8px;
+ border: 1px solid var(--border-soft);
+ background: var(--inset-surface);
+ color: var(--text);
+ font-family: var(--font);
+ font-size: 0.88rem;
+}
+.ai-chat-form textarea:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+
diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js
index 93de5e4..a7164e8 100644
--- a/manual_trading_hub/static/app.js
+++ b/manual_trading_hub/static/app.js
@@ -623,12 +623,14 @@
const p = window.location.pathname.replace(/\/$/, "") || "/monitor";
if (p.includes("settings")) return "settings";
if (p.includes("market")) return "market";
+ if (p.includes("/ai")) return "ai";
return "monitor";
}
function pageElementId(page) {
if (page === "settings") return "page-settings";
if (page === "market") return "page-market";
+ if (page === "ai") return "page-ai";
return "page-monitor";
}
@@ -648,6 +650,7 @@
if (page === "monitor") startMonitorPoll();
else stopMonitorPoll();
if (page === "settings") loadSettingsUI();
+ if (page === "ai") loadAiPage();
if (page === "market" && window.hubMarketChart) {
window.hubMarketChart.init();
} else if (window.hubMarketChart) {
@@ -2935,6 +2938,184 @@
showToast("已添加一行,请填写 URL 后点「保存设置」");
};
+ let aiMeta = null;
+ let aiSummaryLoading = false;
+ let aiChatLoading = false;
+
+ function renderAiMarkdown(text) {
+ const esc = (s) =>
+ String(s || "")
+ .replace(/&/g, "&")
+ .replace(//g, ">");
+ return esc(text)
+ .replace(/\*\*(.+?)\*\*/g, "$1")
+ .replace(/\n/g, "
");
+ }
+
+ function renderAiSummaryStats(snapshot) {
+ const el = document.getElementById("ai-summary-stats");
+ if (!el) return;
+ if (!snapshot || !snapshot.totals) {
+ el.innerHTML = "";
+ return;
+ }
+ const t = snapshot.totals;
+ const pnl = Number(t.total_pnl_u);
+ const pnlCls = pnl > 0 ? "pos" : pnl < 0 ? "neg" : "";
+ el.innerHTML = [
+ `交易日${esc(t.trading_day || "—")}`,
+ `平仓盈亏${fmt(pnl, 2)}U`,
+ `笔数${t.closed_count || 0}(胜${t.win_count || 0}/负${t.loss_count || 0})`,
+ `浮盈亏${fmt(Number(t.float_pnl_u), 2)}U`,
+ ].join("");
+ }
+
+ function renderAiChatMessages(session) {
+ const box = document.getElementById("ai-chat-messages");
+ const title = document.getElementById("ai-chat-title");
+ if (!box) return;
+ const msgs = (session && session.messages) || [];
+ if (title) {
+ title.textContent = session && session.title ? `聊天 · ${session.title}` : "聊天";
+ }
+ if (!msgs.length) {
+ box.innerHTML =
+ '
随便聊:行情、心态、纪律、执行都行。我会先看四户监控数据,用搭档口吻回你。
'; + return; + } + box.innerHTML = msgs + .map((m) => { + const role = m.role === "user" ? "user" : "assistant"; + return `${esc(String(e))}
`; + } + } + + async function loadAiChatSession() { + const r = await apiFetch("/api/ai/chat/session"); + const j = await r.json(); + renderAiChatMessages(j.session); + } + + async function loadAiPage() { + await loadAiMeta(); + await Promise.all([loadAiSummary(), loadAiChatSession()]); + } + + async function generateAiSummary() { + if (aiSummaryLoading) return; + aiSummaryLoading = true; + const btn = document.getElementById("btn-ai-summary"); + const body = document.getElementById("ai-summary-body"); + if (btn) btn.disabled = true; + if (body) body.innerHTML = '正在聚合四户数据并生成总结…
'; + try { + const r = await apiFetch("/api/ai/summary/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ force: true }), + }); + const j = await r.json(); + if (!r.ok) throw new Error(j.detail || j.msg || "生成失败"); + if (!j.ok && j.detail) throw new Error(j.detail); + const sum = j.summary; + if (sum && sum.content_md && body) { + body.innerHTML = renderAiMarkdown(sum.content_md); + renderAiSummaryStats(sum.stats_snapshot); + } + showToast(j.cached ? "已是最新上下文,返回缓存总结" : "今日总结已生成"); + await loadAiSummary(); + } catch (e) { + showToast(String(e), true); + if (body) body.innerHTML = `${esc(String(e))}
`; + } finally { + aiSummaryLoading = false; + if (btn) btn.disabled = false; + } + } + + async function newAiChat() { + try { + const r = await apiFetch("/api/ai/chat/new", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const j = await r.json(); + renderAiChatMessages(j.session); + showToast("已开始新对话"); + } catch (e) { + showToast(String(e), true); + } + } + + async function sendAiChat(ev) { + if (ev) ev.preventDefault(); + if (aiChatLoading) return; + const input = document.getElementById("ai-chat-input"); + const text = (input && input.value || "").trim(); + if (!text) return; + aiChatLoading = true; + const btn = document.getElementById("btn-ai-chat-send"); + if (btn) btn.disabled = true; + try { + const r = await apiFetch("/api/ai/chat/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: text }), + }); + const j = await r.json(); + if (!r.ok) throw new Error(j.detail || j.msg || "发送失败"); + if (j.detail) throw new Error(j.detail); + if (input) input.value = ""; + renderAiChatMessages(j.session); + } catch (e) { + showToast(String(e), true); + } finally { + aiChatLoading = false; + if (btn) btn.disabled = false; + } + } + + const aiSummaryBtn = document.getElementById("btn-ai-summary"); + if (aiSummaryBtn) aiSummaryBtn.onclick = () => generateAiSummary(); + const aiChatNewBtn = document.getElementById("btn-ai-chat-new"); + if (aiChatNewBtn) aiChatNewBtn.onclick = () => newAiChat(); + const aiChatForm = document.getElementById("ai-chat-form"); + if (aiChatForm) aiChatForm.addEventListener("submit", sendAiChat); + initTpslModal(); initInstanceFrame(); initFullscreen(); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 30ba99d..8cb6c31 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -15,7 +15,7 @@ - + @@ -46,6 +46,7 @@ @@ -203,6 +204,42 @@ + +