feat: add streaming unified review assistant

This commit is contained in:
leefer
2026-07-23 00:25:43 +08:00
parent 7d8d270e98
commit 0f4bed17c9
11 changed files with 638 additions and 1 deletions
+4
View File
@@ -10,6 +10,8 @@
智能选股模块包含 45 日全市场因子库、六阶段市场识别、六套内置策略、受控公式 DSL、自然语言策略编译、候选排名和滚动回测。首次使用需在页面点击“同步因子数据”。未配置 LLM 时使用本地策略模板;配置兼容 API 后自动切换为主模型编译,主模型失败时自动使用辅助模型,两者均支持独立连通性测试。 智能选股模块包含 45 日全市场因子库、六阶段市场识别、六套内置策略、受控公式 DSL、自然语言策略编译、候选排名和滚动回测。首次使用需在页面点击“同步因子数据”。未配置 LLM 时使用本地策略模板;配置兼容 API 后自动切换为主模型编译,主模型失败时自动使用辅助模型,两者均支持独立连通性测试。
每次选股结果会自动进入五交易日持续跟踪,展示 T+1 开盘/收盘、T+3、T+5、最大涨幅与最大回撤。提醒中心支持手工日期提醒,并在策略首日反馈和五日跟踪完成时生成账号私有的站内提醒。
问师模块会读取当前复盘、近十日市场情绪、涨跌停、昨日反馈、板块轮动、市场阶段、龙虎榜和指定个股数据,再按选中的游资思维 Skill 进行单师对话。对话记录按账号、老师和交易日期保存在服务端;主模型不可用时自动切换辅助模型。 问师模块会读取当前复盘、近十日市场情绪、涨跌停、昨日反馈、板块轮动、市场阶段、龙虎榜和指定个股数据,再按选中的游资思维 Skill 进行单师对话。对话记录按账号、老师和交易日期保存在服务端;主模型不可用时自动切换辅助模型。
新增问师角色时,在 `游资skills` 下增加一个包含 `SKILL.md` 的独立目录即可。系统会从 Skill 的 frontmatter、一级标题、核心模型和引用语中自动生成角色信息,无需修改注册代码。 新增问师角色时,在 `游资skills` 下增加一个包含 `SKILL.md` 的独立目录即可。系统会从 Skill 的 frontmatter、一级标题、核心模型和引用语中自动生成角色信息,无需修改注册代码。
@@ -18,6 +20,8 @@
问天模块使用项目本地的 `lunar-python` 计算历法,并使用 `data/iching_zh.json` 中的固定六十四卦、卦辞和爻辞。第三方授权见 `THIRD_PARTY_NOTICES.md` 问天模块使用项目本地的 `lunar-python` 计算历法,并使用 `data/iching_zh.json` 中的固定六十四卦、卦辞和爻辞。第三方授权见 `THIRD_PARTY_NOTICES.md`
“我的复盘”包含结构化手工交易日志,可记录方向、价格、数量、仓位、盈亏、逻辑、执行、情绪和标签,不接券商也不自动下单。顶部“复盘助手”以流式方式读取市场统计、策略跟踪、提醒、个人复盘和交易日志;对话按账号保存,只提供分析和条件化计划。
## 启动 ## 启动
```powershell ```powershell
+3 -1
View File
@@ -13,6 +13,7 @@ MEMBER_GET_PATHS = frozenset(
"/api/mentors/setup", "/api/mentors/setup",
"/api/mentors/messages", "/api/mentors/messages",
"/api/heaven/setup", "/api/heaven/setup",
"/api/assistant/messages",
} }
) )
@@ -27,6 +28,7 @@ MEMBER_POST_PATHS = frozenset(
"/api/heaven/hexagram", "/api/heaven/hexagram",
"/api/heaven/personal", "/api/heaven/personal",
"/api/heaven/interpret", "/api/heaven/interpret",
"/api/assistant/chat",
} }
) )
@@ -54,7 +56,7 @@ def required_role(method: str, path: str) -> AccessRole:
if method == "DELETE": if method == "DELETE":
if re.fullmatch(r"/api/heaven/sector-phases/.+", path): if re.fullmatch(r"/api/heaven/sector-phases/.+", path):
return "admin" return "admin"
if path == "/api/mentors/messages" or re.fullmatch( if path in {"/api/mentors/messages", "/api/assistant/messages"} or re.fullmatch(
r"/api/screener/strategies/\d+", path r"/api/screener/strategies/\d+", path
): ):
return "member" return "member"
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
import json
import urllib.error
import urllib.request
from collections.abc import Iterator
from typing import Any
class ReviewAssistantError(RuntimeError):
pass
def stream_review_assistant(
context: dict[str, Any],
question: str,
history: list[dict[str, str]],
api_key: str,
base_url: str,
model: str,
timeout: int = 120,
) -> Iterator[str]:
if not api_key or not model:
raise ReviewAssistantError("智能解读服务尚未配置。")
messages = [{"role": "system", "content": _system_prompt(context)}]
messages.extend(history[-12:])
messages.append({"role": "user", "content": question})
request = urllib.request.Request(
f"{base_url.rstrip('/')}/chat/completions",
data=json.dumps(
{"model": model, "messages": messages, "stream": True}, ensure_ascii=False
).encode("utf-8"),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
"User-Agent": "XiaobaiReviewWeb/1.0",
"Accept": "text/event-stream",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
yielded = False
for raw_line in response:
line = raw_line.decode("utf-8", errors="replace").strip()
if not line or line.startswith(":"):
continue
if line.startswith("data:"):
line = line[5:].strip()
if line == "[DONE]":
break
try:
payload = json.loads(line)
except json.JSONDecodeError:
continue
choices = payload.get("choices") or []
if not choices:
continue
choice = choices[0] or {}
delta = choice.get("delta") or {}
content = delta.get("content")
if content is None:
content = (choice.get("message") or {}).get("content")
if content:
yielded = True
yield str(content)
if not yielded:
raise ReviewAssistantError("智能解读未返回有效内容。")
except urllib.error.HTTPError as exc:
raise ReviewAssistantError(f"智能解读服务暂不可用({exc.code})。") from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise ReviewAssistantError("智能解读连接中断,请稍后重试。") from exc
def _system_prompt(context: dict[str, Any]) -> str:
context_json = json.dumps(context, ensure_ascii=False, separators=(",", ":"))
return f"""
你是“小白复盘”的统一复盘助手。你负责把网页中已经存在的市场统计、策略跟踪、提醒、复盘笔记和手工交易日志连接起来,帮助用户复盘和形成下一步观察计划。
最高优先级规则:
1. 只能使用下方“网页复盘数据”,数据缺失就明确说明,不得补造行情、交易或胜率。
2. 不自动下单,不声称已执行任何操作,不修改策略、提醒、笔记或交易日志。
3. 不承诺收益,不给无条件买卖指令。建议必须写成条件、失效条件和风险边界。
4. 区分市场事实、用户记录和你的推断。引用数字时写明数据日期。
5. 优先结合用户自己的策略跟踪与交易日志寻找可验证的重复模式;样本不足时明确标注。
6. 使用中文,先直接回答,再给数据依据和下一步观察。避免空泛口号,不展示模型、接口或内部工程信息。
7. 控制在 800 个中文字符以内,除非用户明确要求展开。
网页复盘数据:
{context_json}
""".strip()
+57
View File
@@ -321,6 +321,19 @@ class ReviewDatabase:
CREATE INDEX IF NOT EXISTS idx_trade_entries_user_date CREATE INDEX IF NOT EXISTS idx_trade_entries_user_date
ON trade_entries(user_id, trade_date DESC, id DESC); ON trade_entries(user_id, trade_date DESC, id DESC);
CREATE TABLE IF NOT EXISTS assistant_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
context_date TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_assistant_messages_user
ON assistant_messages(user_id, id DESC);
""" """
) )
user_columns = { user_columns = {
@@ -1663,6 +1676,50 @@ class ReviewDatabase:
) )
return cursor.rowcount > 0 return cursor.rowcount > 0
def save_assistant_exchange(
self, user_id: int, question: str, answer: str, context_date: str
) -> None:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
connection.executemany(
"""
INSERT INTO assistant_messages
(user_id, role, content, context_date, created_at)
VALUES (?, ?, ?, ?, ?)
""",
[
(int(user_id), "user", question, context_date, now),
(int(user_id), "assistant", answer, context_date, now),
],
)
connection.execute(
"""
DELETE FROM assistant_messages WHERE user_id = ? AND id NOT IN (
SELECT id FROM assistant_messages
WHERE user_id = ? ORDER BY id DESC LIMIT 200
)
""",
(int(user_id), int(user_id)),
)
def list_assistant_messages(self, user_id: int, limit: int = 100) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(
"""
SELECT role, content, context_date, created_at FROM assistant_messages
WHERE user_id = ? ORDER BY id DESC LIMIT ?
""",
(int(user_id), max(1, min(200, int(limit)))),
).fetchall()
return [dict(row) for row in reversed(rows)]
def delete_assistant_messages(self, user_id: int) -> int:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM assistant_messages WHERE user_id = ?", (int(user_id),)
)
return int(cursor.rowcount)
def start_sync(self, trade_date: str, source: str) -> int: def start_sync(self, trade_date: str, source: str) -> int:
started_at = datetime.now().astimezone().isoformat(timespec="seconds") started_at = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection: with self.connect() as connection:
+147
View File
@@ -8,6 +8,7 @@ import os
import re import re
import secrets import secrets
import threading import threading
import time
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from http import HTTPStatus from http import HTTPStatus
from http.cookies import SimpleCookie from http.cookies import SimpleCookie
@@ -16,6 +17,7 @@ from typing import Any
from urllib.parse import parse_qs, unquote, urlparse from urllib.parse import parse_qs, unquote, urlparse
from alert_service import AlertService from alert_service import AlertService
from assistant_agent import ReviewAssistantError, stream_review_assistant
from api_access import required_role from api_access import required_role
from app_config import ( from app_config import (
DATA_DIR, DATA_DIR,
@@ -1147,6 +1149,111 @@ class DashboardService:
deleted = self.database.delete_trade_entry(self.current_user_id, trade_id) deleted = self.database.delete_trade_entry(self.current_user_id, trade_id)
return {"deleted": deleted, **self.trade_entries()} return {"deleted": deleted, **self.trade_entries()}
def assistant_messages(self) -> list[dict[str, Any]]:
return self.database.list_assistant_messages(self.current_user_id)
def clear_assistant_messages(self) -> int:
return self.database.delete_assistant_messages(self.current_user_id)
def assistant_stream(self, payload: dict[str, Any]):
question = validate_text(payload.get("question"), "问题", 2000, required=True)
trade_date = normalize_date(
str(payload.get("trade_date") or date.today().isoformat())
)
context = self._assistant_context(trade_date)
history = [
{"role": item["role"], "content": str(item["content"])[:4000]}
for item in self.assistant_messages()[-12:]
if item.get("role") in {"user", "assistant"}
]
source = self.ensure_llm_access("assistant")
profiles = [
(
self.llm_primary_api_key,
self.llm_primary_base_url,
self.llm_primary_model,
)
]
if self.llm_fallback_configured:
profiles.append(
(
self.llm_fallback_api_key,
self.llm_fallback_base_url,
self.llm_fallback_model,
)
)
def generate():
started = time.perf_counter()
last_error: Exception | None = None
for api_key, base_url, model in profiles:
try:
upstream = iter(
stream_review_assistant(
context, question, history, api_key, base_url, model
)
)
first = next(upstream)
except (ReviewAssistantError, StopIteration) as exc:
last_error = exc
continue
answer_parts = [first]
yield first
try:
for chunk in upstream:
answer_parts.append(chunk)
yield chunk
except ReviewAssistantError as exc:
self.record_llm_usage("assistant", source, model, "failed")
raise ValueError("智能解读连接中断,请稍后重试。") from exc
answer = "".join(answer_parts).strip()
latency_ms = round((time.perf_counter() - started) * 1000)
self.database.save_assistant_exchange(
self.current_user_id, question, answer, trade_date
)
self.record_llm_usage("assistant", source, model, "success", latency_ms)
return
self.record_llm_usage("assistant", source, self.llm_primary_model, "failed")
raise ValueError("智能解读服务暂不可用,请稍后重试。") from last_error
return generate()
def _assistant_context(self, trade_date: str) -> dict[str, Any]:
dashboard = self.get_dashboard(trade_date)
actual_date = normalize_date(
str((dashboard.get("meta") or {}).get("trade_date") or trade_date)
)
sentiment = self.sentiment_history(actual_date, 10)
tracking = self.strategy_tracking.list_tracking(self.current_user_id, 5)
alerts = self.alert_service.list_alerts(
self.current_user_id, "all", date.today().isoformat()
)
trades = self.trade_journal.list_entries(
self.current_user_id, end_date=actual_date
)
return {
"data_date": actual_date,
"market": {
"overview": dashboard.get("overview") or {},
"top_sectors": (dashboard.get("sectors") or [])[:8],
"limit_performance": dashboard.get("limit_performance") or {},
"sentiment_history": (sentiment.get("rows") or [])[-10:],
},
"personal": {
"watchlist": self.database.list_watchlist(self.current_user_id)[:30],
"review_notes": self.database.list_notes(
self.current_user_id, scope="daily"
)[:10],
"strategy_tracking": {
"summary": tracking.get("summary") or {},
"batches": (tracking.get("batches") or [])[:5],
},
"alerts": (alerts.get("items") or [])[:20],
"trade_summary": trades.get("summary") or {},
"trade_entries": (trades.get("items") or [])[:30],
},
}
def sync_screener_data(self, trade_date: str, lookback: int = 45) -> dict[str, Any]: def sync_screener_data(self, trade_date: str, lookback: int = 45) -> dict[str, Any]:
if not self.configured: if not self.configured:
raise ValueError("请先配置 Tushare Token。") raise ValueError("请先配置 Tushare Token。")
@@ -3436,6 +3543,9 @@ class RequestHandler(BaseHTTPRequestHandler):
except ValueError as exc: except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return return
if parsed.path == "/api/assistant/messages":
self.send_json({"items": SERVICE.assistant_messages()})
return
if parsed.path == "/api/dashboard": if parsed.path == "/api/dashboard":
query = parse_qs(parsed.query) query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0] trade_date = query.get("trade_date", [date.today().isoformat()])[0]
@@ -3660,6 +3770,9 @@ class RequestHandler(BaseHTTPRequestHandler):
if parsed.path == "/api/trades": if parsed.path == "/api/trades":
self.save_trade_entry() self.save_trade_entry()
return return
if parsed.path == "/api/assistant/chat":
self.stream_assistant_chat()
return
if parsed.path == "/api/admin/settings": if parsed.path == "/api/admin/settings":
self.save_system_settings() self.save_system_settings()
return return
@@ -3729,6 +3842,10 @@ class RequestHandler(BaseHTTPRequestHandler):
deleted = SERVICE.database.delete_user_birth_profile(SERVICE.current_user_id) deleted = SERVICE.database.delete_user_birth_profile(SERVICE.current_user_id)
self.send_json({"ok": True, "deleted": deleted}) self.send_json({"ok": True, "deleted": deleted})
return return
if parsed.path == "/api/assistant/messages":
deleted = SERVICE.clear_assistant_messages()
self.send_json({"ok": True, "deleted": deleted})
return
if parsed.path == "/api/mentors/messages": if parsed.path == "/api/mentors/messages":
query = parse_qs(parsed.query) query = parse_qs(parsed.query)
try: try:
@@ -3889,6 +4006,36 @@ class RequestHandler(BaseHTTPRequestHandler):
except (ValueError, json.JSONDecodeError) as exc: except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def stream_assistant_chat(self) -> None:
try:
body = self.read_json_body()
stream = SERVICE.assistant_stream(body)
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
self.send_header("Cache-Control", "no-cache, no-transform")
self.send_header("X-Accel-Buffering", "no")
self.send_header("Connection", "close")
self.end_headers()
try:
for chunk in stream:
self._write_stream_event({"type": "delta", "content": chunk})
self._write_stream_event({"type": "done"})
except (ValueError, ReviewAssistantError) as exc:
self._write_stream_event({"type": "error", "error": str(exc)})
except (BrokenPipeError, ConnectionResetError):
pass
finally:
self.close_connection = True
def _write_stream_event(self, payload: dict[str, Any]) -> None:
self.wfile.write(
(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
)
self.wfile.flush()
def session_token(self) -> str: def session_token(self) -> str:
cookie = SimpleCookie() cookie = SimpleCookie()
try: try:
+162
View File
@@ -50,6 +50,9 @@ const state = {
alerts: [], alerts: [],
alertFilter: "all", alertFilter: "all",
alertUnreadCount: 0, alertUnreadCount: 0,
assistantMessages: [],
assistantLoading: false,
assistantController: null,
screenerMobileView: "strategy", screenerMobileView: "strategy",
sentimentHistory: null, sentimentHistory: null,
sentimentRange: 20, sentimentRange: 20,
@@ -93,6 +96,7 @@ const elements = {
toast: document.querySelector("#toast"), toast: document.querySelector("#toast"),
stockDialog: document.querySelector("#stockDialog"), stockDialog: document.querySelector("#stockDialog"),
alertsDialog: document.querySelector("#alertsDialog"), alertsDialog: document.querySelector("#alertsDialog"),
assistantDialog: document.querySelector("#assistantDialog"),
globalSearchDialog: document.querySelector("#globalSearchDialog"), globalSearchDialog: document.querySelector("#globalSearchDialog"),
globalSearchInput: document.querySelector("#globalSearchInput"), globalSearchInput: document.querySelector("#globalSearchInput"),
globalSearchResults: document.querySelector("#globalSearchResults"), globalSearchResults: document.querySelector("#globalSearchResults"),
@@ -141,6 +145,7 @@ let stockPreviewAnchor = null;
let sentimentChartAnimationFrame = null; let sentimentChartAnimationFrame = null;
let heavenResizeTimer = null; let heavenResizeTimer = null;
let globalSearchTimer = null; let globalSearchTimer = null;
let assistantRenderFrame = 0;
const heartSound = { const heartSound = {
enabled: false, enabled: false,
@@ -385,6 +390,14 @@ function bindEvents() {
}); });
document.querySelector("#globalSearchButton").addEventListener("click", openGlobalSearch); document.querySelector("#globalSearchButton").addEventListener("click", openGlobalSearch);
document.querySelector("#alertButton").addEventListener("click", openAlerts); document.querySelector("#alertButton").addEventListener("click", openAlerts);
document.querySelector("#assistantButton").addEventListener("click", openReviewAssistant);
document.querySelector("#closeAssistantDialog").addEventListener("click", () => elements.assistantDialog.close());
document.querySelector("#assistantForm").addEventListener("submit", sendAssistantQuestion);
document.querySelector("#stopAssistant").addEventListener("click", stopAssistantResponse);
document.querySelector("#clearAssistantMessages").addEventListener("click", clearAssistantConversation);
document.querySelectorAll("[data-assistant-prompt]").forEach((button) => {
button.addEventListener("click", () => useAssistantPrompt(button.dataset.assistantPrompt));
});
document.querySelector("#closeAlertsDialog").addEventListener("click", () => elements.alertsDialog.close()); document.querySelector("#closeAlertsDialog").addEventListener("click", () => elements.alertsDialog.close());
document.querySelector("#alertForm").addEventListener("submit", saveAlert); document.querySelector("#alertForm").addEventListener("submit", saveAlert);
document.querySelector("#markAllAlertsRead").addEventListener("click", markAllAlertsRead); document.querySelector("#markAllAlertsRead").addEventListener("click", markAllAlertsRead);
@@ -4498,6 +4511,152 @@ function renderAlerts() {
refreshIcons(); refreshIcons();
} }
async function openReviewAssistant() {
if (!hasMemberAccess()) {
showToast("复盘助手仅对会员开放");
openSettings("membership");
return;
}
toggleHeaderCommandMenu(false);
toggleAccountDropdown(false);
if (!elements.assistantDialog.open) elements.assistantDialog.showModal();
try {
const payload = await apiRequest("/api/assistant/messages");
state.assistantMessages = payload.items || [];
renderAssistantMessages();
} catch (error) {
showToast(error.message || "对话记录加载失败");
}
document.querySelector("#assistantQuestion").focus();
}
function useAssistantPrompt(prompt) {
const input = document.querySelector("#assistantQuestion");
input.value = prompt;
input.focus();
}
async function sendAssistantQuestion(event) {
event.preventDefault();
if (state.assistantLoading) return;
const input = document.querySelector("#assistantQuestion");
const question = input.value.trim();
if (!question) return;
input.value = "";
state.assistantMessages.push({ role: "user", content: question, context_date: elements.tradeDate.value.replaceAll("-", "") });
state.assistantMessages.push({ role: "assistant", content: "", streaming: true, context_date: elements.tradeDate.value.replaceAll("-", "") });
state.assistantLoading = true;
state.assistantController = new AbortController();
updateAssistantControls();
renderAssistantMessages();
try {
await streamAssistantRequest(question, state.assistantController.signal, (chunk) => {
const message = state.assistantMessages.at(-1);
if (message?.role === "assistant") message.content += chunk;
scheduleAssistantRender();
});
const message = state.assistantMessages.at(-1);
if (message) message.streaming = false;
setStatus("复盘助手回答完成");
} catch (error) {
const message = state.assistantMessages.at(-1);
if (message?.role === "assistant") {
message.streaming = false;
message.error = true;
if (!message.content) message.content = error.name === "AbortError" ? "已停止生成。" : error.message || "回答失败,请稍后重试。";
}
if (error.name !== "AbortError") showToast(error.message || "复盘助手回答失败");
} finally {
state.assistantLoading = false;
state.assistantController = null;
updateAssistantControls();
renderAssistantMessages();
input.focus();
}
}
async function streamAssistantRequest(question, signal, onDelta) {
const response = await fetch("/api/assistant/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(state.csrfToken ? { "X-CSRF-Token": state.csrfToken } : {}),
},
body: JSON.stringify({ question, trade_date: elements.tradeDate.value }),
signal,
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.error || "复盘助手暂不可用");
}
if (!response.body) throw new Error("当前浏览器不支持流式回答");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.trim()) continue;
const event = JSON.parse(line);
if (event.type === "delta") onDelta(String(event.content || ""));
if (event.type === "error") throw new Error(event.error || "复盘助手回答失败");
}
if (done) break;
}
if (buffer.trim()) {
const event = JSON.parse(buffer);
if (event.type === "delta") onDelta(String(event.content || ""));
if (event.type === "error") throw new Error(event.error || "复盘助手回答失败");
}
}
function stopAssistantResponse() {
state.assistantController?.abort();
}
async function clearAssistantConversation() {
if (state.assistantLoading || !state.assistantMessages.length) return;
if (!window.confirm("确定清空复盘助手的对话记录吗?")) return;
try {
await apiRequest("/api/assistant/messages", "DELETE");
state.assistantMessages = [];
renderAssistantMessages();
} catch (error) {
showToast(error.message || "对话记录清空失败");
}
}
function scheduleAssistantRender() {
if (assistantRenderFrame) return;
assistantRenderFrame = requestAnimationFrame(() => {
assistantRenderFrame = 0;
renderAssistantMessages();
});
}
function renderAssistantMessages() {
const container = document.querySelector("#assistantMessages");
container.innerHTML = state.assistantMessages.map((message) => `
<article class="assistant-message ${message.role} ${message.error ? "is-error" : ""}">
<div class="assistant-message-label">${message.role === "user" ? "我" : "复盘助手"}${message.context_date ? `<time>${displayCompactDate(message.context_date)}</time>` : ""}</div>
<div class="assistant-message-content">${message.role === "assistant" ? (message.content ? formatMentorAnswer(message.content) : '<span class="assistant-thinking">正在整理复盘数据</span>') : escapeHtml(message.content)}</div>
${message.streaming ? '<span class="assistant-stream-caret" aria-hidden="true"></span>' : ""}
</article>
`).join("") || '<div class="empty-state">可以从市场、策略或自己的交易记录开始复盘</div>';
updateAssistantControls();
requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
}
function updateAssistantControls() {
document.querySelector("#assistantQuestion").disabled = state.assistantLoading;
document.querySelector("#sendAssistant").disabled = state.assistantLoading;
document.querySelector("#stopAssistant").hidden = !state.assistantLoading;
document.querySelector("#clearAssistantMessages").disabled = state.assistantLoading || !state.assistantMessages.length;
}
function openGlobalSearch() { function openGlobalSearch() {
if (!state.user) return; if (!state.user) return;
toggleHeaderCommandMenu(false); toggleHeaderCommandMenu(false);
@@ -4869,6 +5028,9 @@ function applyMembershipAccess() {
control.disabled = !unlocked; control.disabled = !unlocked;
}); });
}); });
const assistantButton = document.querySelector("#assistantButton");
assistantButton.classList.toggle("member-locked-control", !unlocked);
assistantButton.title = unlocked ? "复盘助手" : "复盘助手(会员可用)";
} }
function openView(viewId, updateHash = true) { function openView(viewId, updateHash = true) {
+29
View File
@@ -63,6 +63,7 @@
</div> </div>
<button id="globalSearchButton" class="icon-button global-search-button" type="button" title="全局搜索(Ctrl+K" aria-label="全局搜索"><i data-lucide="search"></i></button> <button id="globalSearchButton" class="icon-button global-search-button" type="button" title="全局搜索(Ctrl+K" aria-label="全局搜索"><i data-lucide="search"></i></button>
<button id="alertButton" class="icon-button alert-button" type="button" title="提醒中心" aria-label="提醒中心"><i data-lucide="bell"></i><span id="alertBadge" class="alert-badge" hidden>0</span></button> <button id="alertButton" class="icon-button alert-button" type="button" title="提醒中心" aria-label="提醒中心"><i data-lucide="bell"></i><span id="alertBadge" class="alert-badge" hidden>0</span></button>
<button id="assistantButton" class="icon-button assistant-button" type="button" title="复盘助手" aria-label="复盘助手"><i data-lucide="message-circle-more"></i></button>
<button id="headerMenuButton" class="icon-button header-menu-button" type="button" title="打开命令菜单" aria-label="打开命令菜单" aria-expanded="false" aria-controls="headerCommandGroup"><i data-lucide="ellipsis"></i></button> <button id="headerMenuButton" class="icon-button header-menu-button" type="button" title="打开命令菜单" aria-label="打开命令菜单" aria-expanded="false" aria-controls="headerCommandGroup"><i data-lucide="ellipsis"></i></button>
<div id="headerCommandGroup" class="header-command-group"> <div id="headerCommandGroup" class="header-command-group">
<button id="refreshButton" class="button command-button" type="button"><i data-lucide="refresh-cw"></i><span>刷新</span></button> <button id="refreshButton" class="button command-button" type="button"><i data-lucide="refresh-cw"></i><span>刷新</span></button>
@@ -1139,6 +1140,34 @@
</section> </section>
</dialog> </dialog>
<dialog id="assistantDialog" class="settings-dialog assistant-dialog" aria-labelledby="assistantDialogTitle">
<div class="dialog-header">
<div><span class="dialog-eyebrow">智能复盘</span><h2 id="assistantDialogTitle">复盘助手</h2></div>
<div class="dialog-header-actions">
<button id="clearAssistantMessages" class="button" type="button">清空对话</button>
<button id="closeAssistantDialog" class="icon-button" type="button" aria-label="关闭" title="关闭"><i data-lucide="x"></i></button>
</div>
</div>
<div id="assistantMessages" class="assistant-messages" aria-live="polite">
<div class="empty-state">可以从市场、策略或自己的交易记录开始复盘</div>
</div>
<div class="assistant-quick-prompts" aria-label="快捷问题">
<button type="button" data-assistant-prompt="结合近十日情绪和今天的数据,当前市场处于什么位置?">市场位置</button>
<button type="button" data-assistant-prompt="总结当前市场主线,并说明证据和可能的失效条件。">市场主线</button>
<button type="button" data-assistant-prompt="结合我的策略跟踪和交易日志,找出最值得修正的一个行为模式。">交易复盘</button>
<button type="button" data-assistant-prompt="为下一个交易日给出条件化观察清单,不给无条件买卖指令。">明日清单</button>
</div>
<form id="assistantForm" class="assistant-form">
<label class="sr-only" for="assistantQuestion">向复盘助手提问</label>
<textarea id="assistantQuestion" maxlength="2000" placeholder="询问市场位置、主线、策略表现或自己的交易模式" required></textarea>
<div class="assistant-form-actions">
<button id="stopAssistant" class="button" type="button" hidden><i data-lucide="square"></i>停止</button>
<button id="sendAssistant" class="button primary" type="submit"><i data-lucide="send"></i>发送</button>
</div>
</form>
<p class="assistant-disclaimer">助手仅用于复盘与条件化计划,不执行交易,不构成投资建议。</p>
</dialog>
<dialog id="settingsDialog" class="settings-dialog"> <dialog id="settingsDialog" class="settings-dialog">
<div class="dialog-header"> <div class="dialog-header">
<div> <div>
+58
View File
@@ -11530,3 +11530,61 @@ button.account-role-badge:focus-visible { outline: 2px solid var(--blue); outlin
.trade-log-form-grid, .trade-log-form-grid,
.trade-log-text-grid { grid-template-columns: 1fr; } .trade-log-text-grid { grid-template-columns: 1fr; }
} }
.assistant-button.member-locked-control { color: var(--text-secondary); opacity: 0.72; }
.assistant-dialog { width: min(820px, calc(100vw - 32px)); max-height: min(860px, calc(100dvh - 32px)); }
.assistant-messages { min-height: 320px; max-height: min(540px, calc(100dvh - 340px)); overflow-y: auto; padding: 18px; background: var(--surface-muted); scroll-behavior: smooth; }
.assistant-message { max-width: 88%; margin-bottom: 14px; }
.assistant-message.user { margin-left: auto; }
.assistant-message-label { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; color: var(--text-secondary); font-size: 10px; }
.assistant-message.user .assistant-message-label { justify-content: flex-end; }
.assistant-message-content {
padding: 11px 13px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
font-size: 13px;
line-height: 1.72;
overflow-wrap: anywhere;
}
.assistant-message.user .assistant-message-content { border-color: #bed2eb; background: var(--action-soft); }
.assistant-message.is-error .assistant-message-content { border-color: #efc5c8; background: var(--market-up-soft); color: #8b2f34; }
.assistant-message-content p { margin: 0 0 8px; }
.assistant-message-content p:last-child { margin-bottom: 0; }
.assistant-thinking { color: var(--text-secondary); }
.assistant-stream-caret {
display: inline-block;
width: 6px;
height: 14px;
margin: 0 0 -2px 3px;
background: var(--action);
animation: assistant-caret 900ms steps(1) infinite;
}
@keyframes assistant-caret { 50% { opacity: 0; } }
.assistant-quick-prompts { display: flex; flex-wrap: wrap; gap: 7px; padding: 12px 18px; border-top: 1px solid var(--border); background: var(--surface); }
.assistant-quick-prompts button { min-height: 32px; padding: 0 10px; border: 1px solid var(--border); border-radius: 4px; background: var(--surface); color: var(--text-secondary); cursor: pointer; font: inherit; font-size: 11px; transition: border-color var(--motion-fast) ease, color var(--motion-fast) ease, background-color var(--motion-fast) ease; }
.assistant-quick-prompts button:hover { border-color: var(--action); background: var(--action-soft); color: var(--action); }
.assistant-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: end; padding: 14px 18px; border-top: 1px solid var(--border); }
.assistant-form textarea { width: 100%; min-height: 76px; max-height: 180px; resize: vertical; padding: 10px 12px; border: 1px solid var(--border-strong); border-radius: 4px; color: var(--text); font: inherit; line-height: 1.6; }
.assistant-form textarea:focus { border-color: var(--action); outline: 2px solid color-mix(in srgb, var(--action) 20%, transparent); outline-offset: 1px; }
.assistant-form-actions { display: flex; gap: 8px; }
.assistant-disclaimer { margin: 0; padding: 0 18px 14px; color: var(--text-secondary); font-size: 10px; }
@media (max-width: 720px) {
.assistant-dialog { width: calc(100vw - 16px); max-height: calc(100dvh - 16px); margin: 8px auto; }
.assistant-messages { min-height: 260px; max-height: calc(100dvh - 390px); padding: 13px; }
.assistant-message { max-width: 96%; }
.assistant-form { grid-template-columns: 1fr; }
.assistant-form textarea { font-size: 16px; }
.assistant-form-actions { justify-content: flex-end; }
}
@media (prefers-reduced-motion: reduce) {
.assistant-stream-caret { animation: none; }
.assistant-messages { scroll-behavior: auto; }
}
+4
View File
@@ -56,6 +56,7 @@ async function mockApplication(page, authSession = session()) {
else if (url.pathname === "/api/watchlist" || url.pathname === "/api/notes") payload = { items: [] }; else if (url.pathname === "/api/watchlist" || url.pathname === "/api/notes") payload = { items: [] };
else if (url.pathname === "/api/alerts") payload = { items: [], unread_count: 0 }; else if (url.pathname === "/api/alerts") payload = { items: [], unread_count: 0 };
else if (url.pathname === "/api/trades") payload = { items: [], summary: {} }; else if (url.pathname === "/api/trades") payload = { items: [], summary: {} };
else if (url.pathname === "/api/assistant/messages") payload = { items: [] };
else if (url.pathname === "/api/search") payload = { groups: { stocks: [], sectors: [], themes: [], indices: [] } }; else if (url.pathname === "/api/search") payload = { groups: { stocks: [], sectors: [], themes: [], indices: [] } };
else if (url.pathname === "/api/dragon-tiger") { else if (url.pathname === "/api/dragon-tiger") {
payload = { payload = {
@@ -93,6 +94,9 @@ test("admin shell opens every primary workspace and global search", async ({ pag
await page.locator("#alertButton").click(); await page.locator("#alertButton").click();
await expect(page.locator("#alertsDialog")).toBeVisible(); await expect(page.locator("#alertsDialog")).toBeVisible();
await page.locator("#closeAlertsDialog").click(); await page.locator("#closeAlertsDialog").click();
await page.locator("#assistantButton").click();
await expect(page.locator("#assistantDialog")).toBeVisible();
await page.locator("#closeAssistantDialog").click();
const views = [ const views = [
"sentimentCycleView", "limitPool", "brokenView", "downView", "yesterdayView", "sentimentCycleView", "limitPool", "brokenView", "downView", "yesterdayView",
+3
View File
@@ -12,12 +12,15 @@ class ApiAccessPolicyTests(unittest.TestCase):
("GET", "/api/screener/tracking"): "member", ("GET", "/api/screener/tracking"): "member",
("GET", "/api/mentors/messages"): "member", ("GET", "/api/mentors/messages"): "member",
("GET", "/api/heaven/setup"): "member", ("GET", "/api/heaven/setup"): "member",
("GET", "/api/assistant/messages"): "member",
("POST", "/api/screener/run"): "member", ("POST", "/api/screener/run"): "member",
("POST", "/api/screener/tracking/refresh"): "member", ("POST", "/api/screener/tracking/refresh"): "member",
("POST", "/api/mentors/chat"): "member", ("POST", "/api/mentors/chat"): "member",
("POST", "/api/heaven/interpret"): "member", ("POST", "/api/heaven/interpret"): "member",
("POST", "/api/assistant/chat"): "member",
("DELETE", "/api/screener/strategies/42"): "member", ("DELETE", "/api/screener/strategies/42"): "member",
("DELETE", "/api/mentors/messages"): "member", ("DELETE", "/api/mentors/messages"): "member",
("DELETE", "/api/assistant/messages"): "member",
} }
for (method, path), role in cases.items(): for (method, path), role in cases.items():
with self.subTest(method=method, path=path): with self.subTest(method=method, path=path):
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
import io
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from assistant_agent import ReviewAssistantError, stream_review_assistant
from database import ReviewDatabase
from server import RequestHandler
class StreamingResponse:
def __init__(self, lines: list[bytes]):
self.lines = lines
def __enter__(self):
return iter(self.lines)
def __exit__(self, exc_type, exc, traceback):
return False
class ReviewAssistantStreamingTests(unittest.TestCase):
def test_openai_compatible_sse_is_yielded_in_order(self):
response = StreamingResponse(
[
b'data: {"choices":[{"delta":{"content":"first"}}]}\n',
b'data: {"choices":[{"delta":{"content":" second"}}]}\n',
b'data: [DONE]\n',
]
)
with patch("assistant_agent.urllib.request.urlopen", return_value=response):
chunks = list(
stream_review_assistant({}, "question", [], "key", "https://example.test/v1", "model")
)
self.assertEqual(chunks, ["first", " second"])
def test_empty_stream_is_rejected(self):
response = StreamingResponse([b"data: [DONE]\n"])
with patch("assistant_agent.urllib.request.urlopen", return_value=response):
with self.assertRaises(ReviewAssistantError):
list(
stream_review_assistant({}, "question", [], "key", "https://example.test/v1", "model")
)
def test_ndjson_event_writer_flushes_complete_line(self):
handler = RequestHandler.__new__(RequestHandler)
handler.wfile = io.BytesIO()
RequestHandler._write_stream_event(handler, {"type": "delta", "content": "片段"})
self.assertEqual(
handler.wfile.getvalue().decode("utf-8"),
'{"type":"delta","content":"片段"}\n',
)
class ReviewAssistantHistoryTests(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.database = ReviewDatabase(Path(self.temp.name) / "review.db")
self.owner = self.database.create_user("assistant_owner", "salt", "hash")
self.other = self.database.create_user("assistant_other", "salt", "hash")
def tearDown(self) -> None:
self.temp.cleanup()
def test_history_is_private_and_clear_is_scoped(self):
self.database.save_assistant_exchange(
self.owner["id"], "今天怎么看?", "先看承接。", "20260722"
)
owner_messages = self.database.list_assistant_messages(self.owner["id"])
self.assertEqual([item["role"] for item in owner_messages], ["user", "assistant"])
self.assertEqual(self.database.list_assistant_messages(self.other["id"]), [])
self.assertEqual(self.database.delete_assistant_messages(self.other["id"]), 0)
self.assertEqual(self.database.delete_assistant_messages(self.owner["id"]), 2)
if __name__ == "__main__":
unittest.main()