feat(hub): add data dashboard and AI chat with session history
Add /dashboard with daily PnL overview and loss alerts. Extend AI coach chat with history sidebar, delete/switch sessions, message copy, and trading vs general bot modes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,15 +13,27 @@ from hub_ai.config import (
|
||||
CHAT_MAX_OUTPUT_TOKENS,
|
||||
CHAT_SUMMARY_EXCERPT_MAX_CHARS,
|
||||
CHAT_TEMPERATURE,
|
||||
trading_day_reset_hour,
|
||||
)
|
||||
from hub_trades_lib import current_trading_day
|
||||
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_GENERAL_SYSTEM,
|
||||
CHAT_SYSTEM,
|
||||
build_chat_user_prompt,
|
||||
build_general_chat_user_prompt,
|
||||
)
|
||||
from hub_ai.store import (
|
||||
CHAT_BOT_GENERAL,
|
||||
CHAT_BOT_TRADING,
|
||||
append_chat_message,
|
||||
create_new_session,
|
||||
delete_chat_session,
|
||||
ensure_active_session,
|
||||
get_active_session,
|
||||
list_chat_sessions,
|
||||
load_chat_store,
|
||||
set_active_session,
|
||||
summary_excerpt_for_chat,
|
||||
)
|
||||
|
||||
@@ -58,16 +70,48 @@ def _history_lines(
|
||||
def get_chat_state() -> dict[str, Any]:
|
||||
store = load_chat_store()
|
||||
session = get_active_session()
|
||||
if session:
|
||||
session.setdefault("bot_mode", CHAT_BOT_TRADING)
|
||||
return {
|
||||
"active_session_id": store.get("active_session_id"),
|
||||
"session": session,
|
||||
"sessions": list_chat_sessions(),
|
||||
"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 start_new_chat(*, trading_day: str, bot_mode: str = CHAT_BOT_TRADING) -> dict:
|
||||
session = create_new_session(trading_day=trading_day, bot_mode=bot_mode)
|
||||
return {
|
||||
"ok": True,
|
||||
"session": session,
|
||||
"sessions": list_chat_sessions(),
|
||||
"model": model_label(),
|
||||
}
|
||||
|
||||
|
||||
def switch_chat_session(session_id: str) -> dict[str, Any]:
|
||||
session = set_active_session(session_id)
|
||||
return {
|
||||
"ok": True,
|
||||
"session": session,
|
||||
"sessions": list_chat_sessions(),
|
||||
"model": model_label(),
|
||||
}
|
||||
|
||||
|
||||
def remove_chat_session(session_id: str) -> dict[str, Any]:
|
||||
deleted, new_active = delete_chat_session(session_id)
|
||||
if not deleted:
|
||||
return {"ok": False, "msg": "session_not_found"}
|
||||
session = get_active_session()
|
||||
return {
|
||||
"ok": True,
|
||||
"active_session_id": new_active,
|
||||
"session": session,
|
||||
"sessions": list_chat_sessions(),
|
||||
"model": model_label(),
|
||||
}
|
||||
|
||||
|
||||
def send_chat_message(
|
||||
@@ -90,8 +134,9 @@ def send_chat_message(
|
||||
if not user_visible and parsed.get("attachment_note"):
|
||||
user_visible = f"(上传了 {parsed['attachment_note']})"
|
||||
|
||||
ctx = build_daily_context(exchanges, trading_day=trading_day)
|
||||
day = ctx["trading_day"]
|
||||
day = (trading_day or "").strip()[:10] or current_trading_day(
|
||||
reset_hour=trading_day_reset_hour()
|
||||
)
|
||||
session = ensure_active_session(trading_day=day)
|
||||
sid = session["id"]
|
||||
history = _history_lines(
|
||||
@@ -106,22 +151,35 @@ def send_chat_message(
|
||||
attachments=parsed.get("attachment_meta") or [],
|
||||
)
|
||||
|
||||
brief_ctx = format_chat_context_for_chat(ctx, max_chars=CHAT_CONTEXT_MAX_CHARS)
|
||||
excerpt = summary_excerpt_for_chat(day, max_chars=CHAT_SUMMARY_EXCERPT_MAX_CHARS)
|
||||
|
||||
user_prompt = build_chat_user_prompt(
|
||||
context_text=brief_ctx,
|
||||
trading_day=day,
|
||||
summary_excerpt=excerpt,
|
||||
history_lines=history,
|
||||
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"]
|
||||
bot_mode = (session.get("bot_mode") or CHAT_BOT_TRADING).strip().lower()
|
||||
if bot_mode == CHAT_BOT_GENERAL:
|
||||
user_prompt = build_general_chat_user_prompt(
|
||||
history_lines=history,
|
||||
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"]
|
||||
system_prompt = CHAT_GENERAL_SYSTEM
|
||||
else:
|
||||
ctx = build_daily_context(exchanges, trading_day=day)
|
||||
day = ctx["trading_day"]
|
||||
brief_ctx = format_chat_context_for_chat(ctx, max_chars=CHAT_CONTEXT_MAX_CHARS)
|
||||
excerpt = summary_excerpt_for_chat(day, max_chars=CHAT_SUMMARY_EXCERPT_MAX_CHARS)
|
||||
user_prompt = build_chat_user_prompt(
|
||||
context_text=brief_ctx,
|
||||
trading_day=day,
|
||||
summary_excerpt=excerpt,
|
||||
history_lines=history,
|
||||
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"]
|
||||
system_prompt = CHAT_SYSTEM
|
||||
|
||||
reply = generate_text(
|
||||
system=CHAT_SYSTEM,
|
||||
system=system_prompt,
|
||||
user=user_prompt,
|
||||
temperature=CHAT_TEMPERATURE,
|
||||
images_b64=parsed.get("images_b64") or None,
|
||||
@@ -136,6 +194,7 @@ def send_chat_message(
|
||||
"ok": True,
|
||||
"trading_day": day,
|
||||
"session": session,
|
||||
"sessions": list_chat_sessions(),
|
||||
"reply": reply,
|
||||
"model": model_label(),
|
||||
"attachment_warnings": parsed.get("errors") or [],
|
||||
|
||||
@@ -65,6 +65,33 @@ def build_summary_user_prompt(context_text: str, trading_day: str) -> str:
|
||||
""".strip()
|
||||
|
||||
|
||||
CHAT_GENERAL_SYSTEM = """
|
||||
你是简洁、友好的中文助手,陪用户闲聊、答疑、整理思路。
|
||||
|
||||
规则:
|
||||
- 口语化、自然,不要列清单式说教,不要「作为 AI 我必须…」。
|
||||
- 用户未主动聊交易时,不要主动扯合约、仓位、盈亏、盯盘。
|
||||
- 你没有接入用户的交易账户数据;不要编造持仓、资金或监控状态。若被问到交易事实,说明这边看不到实盘,建议去中控监控区或实例页查看。
|
||||
- 若用户上传图片或文档,结合可见内容回应;看不清的直说。
|
||||
- 接续【此前对话】,不要重复开场白;回复须写完整,以句号/问号/感叹号收尾。
|
||||
""".strip()
|
||||
|
||||
|
||||
def build_general_chat_user_prompt(
|
||||
*,
|
||||
history_lines: str,
|
||||
user_message: str,
|
||||
attachment_note: str = "",
|
||||
) -> str:
|
||||
parts: list[str] = []
|
||||
if history_lines.strip():
|
||||
parts.extend(["【此前对话(须接续,勿重复开场)】", history_lines.strip()])
|
||||
if attachment_note.strip():
|
||||
parts.extend(["【用户附件说明】", attachment_note.strip()])
|
||||
parts.extend(["【用户现在说(优先回应这一条)】", user_message.strip()])
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def build_chat_user_prompt(
|
||||
*,
|
||||
context_text: str,
|
||||
|
||||
@@ -6,7 +6,13 @@ from typing import Callable
|
||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||
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,
|
||||
remove_chat_session,
|
||||
send_chat_message,
|
||||
start_new_chat,
|
||||
switch_chat_session,
|
||||
)
|
||||
from hub_ai.client import model_label
|
||||
from hub_ai.config import trading_day_reset_hour
|
||||
from hub_ai.context import build_daily_context
|
||||
@@ -27,6 +33,11 @@ class SummaryGenerateBody(BaseModel):
|
||||
|
||||
class ChatNewBody(BaseModel):
|
||||
trading_day: str = ""
|
||||
bot_mode: str = "trading"
|
||||
|
||||
|
||||
class ChatSwitchBody(BaseModel):
|
||||
session_id: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
def create_hub_ai_router(*, load_all_exchanges: Callable[[], list]) -> APIRouter:
|
||||
@@ -91,7 +102,21 @@ def create_hub_ai_router(*, load_all_exchanges: Callable[[], list]) -> APIRouter
|
||||
@router.post("/chat/new")
|
||||
def api_ai_chat_new(body: ChatNewBody = ChatNewBody()):
|
||||
day = _day(body.trading_day)
|
||||
return start_new_chat(trading_day=day)
|
||||
return start_new_chat(trading_day=day, bot_mode=body.bot_mode or "trading")
|
||||
|
||||
@router.post("/chat/switch")
|
||||
def api_ai_chat_switch(body: ChatSwitchBody):
|
||||
try:
|
||||
return switch_chat_session(body.session_id.strip())
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
|
||||
@router.delete("/chat/session/{session_id}")
|
||||
def api_ai_chat_delete(session_id: str):
|
||||
result = remove_chat_session(session_id.strip())
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
return result
|
||||
|
||||
@router.post("/chat/send")
|
||||
async def api_ai_chat_send(
|
||||
|
||||
@@ -140,12 +140,28 @@ def get_active_session() -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def create_new_session(*, trading_day: str, title: str = "新对话") -> dict:
|
||||
CHAT_BOT_TRADING = "trading"
|
||||
CHAT_BOT_GENERAL = "general"
|
||||
CHAT_BOT_MODES = frozenset({CHAT_BOT_TRADING, CHAT_BOT_GENERAL})
|
||||
|
||||
|
||||
def _normalize_bot_mode(raw: Any) -> str:
|
||||
mode = (raw or CHAT_BOT_TRADING).strip().lower()
|
||||
return mode if mode in CHAT_BOT_MODES else CHAT_BOT_TRADING
|
||||
|
||||
|
||||
def create_new_session(
|
||||
*,
|
||||
trading_day: str,
|
||||
title: str = "新对话",
|
||||
bot_mode: str = CHAT_BOT_TRADING,
|
||||
) -> dict:
|
||||
store = load_chat_store()
|
||||
session = {
|
||||
"id": uuid.uuid4().hex,
|
||||
"trading_day": trading_day,
|
||||
"title": title,
|
||||
"bot_mode": _normalize_bot_mode(bot_mode),
|
||||
"created_at": _now_str(),
|
||||
"updated_at": _now_str(),
|
||||
"messages": [],
|
||||
@@ -193,6 +209,71 @@ def append_chat_message(
|
||||
return target
|
||||
|
||||
|
||||
def _session_list_item(s: dict, *, active_id: Optional[str]) -> dict:
|
||||
msgs = s.get("messages") or []
|
||||
preview = ""
|
||||
for m in reversed(msgs):
|
||||
if m.get("role") == "user":
|
||||
preview = str(m.get("content") or "").replace("\n", " ")[:48]
|
||||
break
|
||||
if not preview and msgs:
|
||||
last = msgs[-1]
|
||||
preview = str(last.get("content") or "").replace("\n", " ")[:48]
|
||||
sid = str(s.get("id") or "")
|
||||
return {
|
||||
"id": sid,
|
||||
"title": s.get("title") or "新对话",
|
||||
"bot_mode": _normalize_bot_mode(s.get("bot_mode")),
|
||||
"trading_day": s.get("trading_day"),
|
||||
"created_at": s.get("created_at"),
|
||||
"updated_at": s.get("updated_at"),
|
||||
"message_count": len(msgs),
|
||||
"preview": preview,
|
||||
"is_active": sid and sid == str(active_id or ""),
|
||||
}
|
||||
|
||||
|
||||
def list_chat_sessions(*, limit: int = 50) -> list[dict]:
|
||||
store = load_chat_store()
|
||||
active_id = store.get("active_session_id")
|
||||
sessions = list(store.get("sessions") or [])
|
||||
for s in sessions:
|
||||
s.setdefault("bot_mode", CHAT_BOT_TRADING)
|
||||
sessions.sort(key=lambda x: str(x.get("updated_at") or ""), reverse=True)
|
||||
return [_session_list_item(s, active_id=active_id) for s in sessions[: max(1, min(limit, 100))]]
|
||||
|
||||
|
||||
def set_active_session(session_id: str) -> dict:
|
||||
store = load_chat_store()
|
||||
target = None
|
||||
for s in store.get("sessions") or []:
|
||||
if str(s.get("id")) == str(session_id):
|
||||
target = s
|
||||
break
|
||||
if not target:
|
||||
raise KeyError("session_not_found")
|
||||
target.setdefault("bot_mode", CHAT_BOT_TRADING)
|
||||
store["active_session_id"] = target["id"]
|
||||
save_chat_store(store)
|
||||
return target
|
||||
|
||||
|
||||
def delete_chat_session(session_id: str) -> tuple[bool, Optional[str]]:
|
||||
store = load_chat_store()
|
||||
sessions = list(store.get("sessions") or [])
|
||||
new_sessions = [s for s in sessions if str(s.get("id")) != str(session_id)]
|
||||
if len(new_sessions) == len(sessions):
|
||||
return False, None
|
||||
active = store.get("active_session_id")
|
||||
new_active = active
|
||||
if str(active) == str(session_id):
|
||||
new_active = new_sessions[0]["id"] if new_sessions else None
|
||||
store["sessions"] = new_sessions
|
||||
store["active_session_id"] = new_active
|
||||
save_chat_store(store)
|
||||
return True, new_active
|
||||
|
||||
|
||||
def summary_excerpt_for_chat(trading_day: str, max_chars: int = 600) -> str:
|
||||
latest = get_latest_summary(trading_day)
|
||||
if not latest:
|
||||
|
||||
Reference in New Issue
Block a user