feat: add streaming unified review assistant
This commit is contained in:
@@ -10,6 +10,8 @@
|
||||
|
||||
智能选股模块包含 45 日全市场因子库、六阶段市场识别、六套内置策略、受控公式 DSL、自然语言策略编译、候选排名和滚动回测。首次使用需在页面点击“同步因子数据”。未配置 LLM 时使用本地策略模板;配置兼容 API 后自动切换为主模型编译,主模型失败时自动使用辅助模型,两者均支持独立连通性测试。
|
||||
|
||||
每次选股结果会自动进入五交易日持续跟踪,展示 T+1 开盘/收盘、T+3、T+5、最大涨幅与最大回撤。提醒中心支持手工日期提醒,并在策略首日反馈和五日跟踪完成时生成账号私有的站内提醒。
|
||||
|
||||
问师模块会读取当前复盘、近十日市场情绪、涨跌停、昨日反馈、板块轮动、市场阶段、龙虎榜和指定个股数据,再按选中的游资思维 Skill 进行单师对话。对话记录按账号、老师和交易日期保存在服务端;主模型不可用时自动切换辅助模型。
|
||||
|
||||
新增问师角色时,在 `游资skills` 下增加一个包含 `SKILL.md` 的独立目录即可。系统会从 Skill 的 frontmatter、一级标题、核心模型和引用语中自动生成角色信息,无需修改注册代码。
|
||||
@@ -18,6 +20,8 @@
|
||||
|
||||
问天模块使用项目本地的 `lunar-python` 计算历法,并使用 `data/iching_zh.json` 中的固定六十四卦、卦辞和爻辞。第三方授权见 `THIRD_PARTY_NOTICES.md`。
|
||||
|
||||
“我的复盘”包含结构化手工交易日志,可记录方向、价格、数量、仓位、盈亏、逻辑、执行、情绪和标签,不接券商也不自动下单。顶部“复盘助手”以流式方式读取市场统计、策略跟踪、提醒、个人复盘和交易日志;对话按账号保存,只提供分析和条件化计划。
|
||||
|
||||
## 启动
|
||||
|
||||
```powershell
|
||||
|
||||
+3
-1
@@ -13,6 +13,7 @@ MEMBER_GET_PATHS = frozenset(
|
||||
"/api/mentors/setup",
|
||||
"/api/mentors/messages",
|
||||
"/api/heaven/setup",
|
||||
"/api/assistant/messages",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -27,6 +28,7 @@ MEMBER_POST_PATHS = frozenset(
|
||||
"/api/heaven/hexagram",
|
||||
"/api/heaven/personal",
|
||||
"/api/heaven/interpret",
|
||||
"/api/assistant/chat",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -54,7 +56,7 @@ def required_role(method: str, path: str) -> AccessRole:
|
||||
if method == "DELETE":
|
||||
if re.fullmatch(r"/api/heaven/sector-phases/.+", path):
|
||||
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
|
||||
):
|
||||
return "member"
|
||||
|
||||
@@ -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
@@ -321,6 +321,19 @@ class ReviewDatabase:
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_trade_entries_user_date
|
||||
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 = {
|
||||
@@ -1663,6 +1676,50 @@ class ReviewDatabase:
|
||||
)
|
||||
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:
|
||||
started_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
with self.connect() as connection:
|
||||
|
||||
@@ -8,6 +8,7 @@ import os
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from http import HTTPStatus
|
||||
from http.cookies import SimpleCookie
|
||||
@@ -16,6 +17,7 @@ from typing import Any
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
from alert_service import AlertService
|
||||
from assistant_agent import ReviewAssistantError, stream_review_assistant
|
||||
from api_access import required_role
|
||||
from app_config import (
|
||||
DATA_DIR,
|
||||
@@ -1147,6 +1149,111 @@ class DashboardService:
|
||||
deleted = self.database.delete_trade_entry(self.current_user_id, trade_id)
|
||||
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]:
|
||||
if not self.configured:
|
||||
raise ValueError("请先配置 Tushare Token。")
|
||||
@@ -3436,6 +3543,9 @@ class RequestHandler(BaseHTTPRequestHandler):
|
||||
except ValueError as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
if parsed.path == "/api/assistant/messages":
|
||||
self.send_json({"items": SERVICE.assistant_messages()})
|
||||
return
|
||||
if parsed.path == "/api/dashboard":
|
||||
query = parse_qs(parsed.query)
|
||||
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
|
||||
@@ -3660,6 +3770,9 @@ class RequestHandler(BaseHTTPRequestHandler):
|
||||
if parsed.path == "/api/trades":
|
||||
self.save_trade_entry()
|
||||
return
|
||||
if parsed.path == "/api/assistant/chat":
|
||||
self.stream_assistant_chat()
|
||||
return
|
||||
if parsed.path == "/api/admin/settings":
|
||||
self.save_system_settings()
|
||||
return
|
||||
@@ -3729,6 +3842,10 @@ class RequestHandler(BaseHTTPRequestHandler):
|
||||
deleted = SERVICE.database.delete_user_birth_profile(SERVICE.current_user_id)
|
||||
self.send_json({"ok": True, "deleted": deleted})
|
||||
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":
|
||||
query = parse_qs(parsed.query)
|
||||
try:
|
||||
@@ -3889,6 +4006,36 @@ class RequestHandler(BaseHTTPRequestHandler):
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
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:
|
||||
cookie = SimpleCookie()
|
||||
try:
|
||||
|
||||
+162
@@ -50,6 +50,9 @@ const state = {
|
||||
alerts: [],
|
||||
alertFilter: "all",
|
||||
alertUnreadCount: 0,
|
||||
assistantMessages: [],
|
||||
assistantLoading: false,
|
||||
assistantController: null,
|
||||
screenerMobileView: "strategy",
|
||||
sentimentHistory: null,
|
||||
sentimentRange: 20,
|
||||
@@ -93,6 +96,7 @@ const elements = {
|
||||
toast: document.querySelector("#toast"),
|
||||
stockDialog: document.querySelector("#stockDialog"),
|
||||
alertsDialog: document.querySelector("#alertsDialog"),
|
||||
assistantDialog: document.querySelector("#assistantDialog"),
|
||||
globalSearchDialog: document.querySelector("#globalSearchDialog"),
|
||||
globalSearchInput: document.querySelector("#globalSearchInput"),
|
||||
globalSearchResults: document.querySelector("#globalSearchResults"),
|
||||
@@ -141,6 +145,7 @@ let stockPreviewAnchor = null;
|
||||
let sentimentChartAnimationFrame = null;
|
||||
let heavenResizeTimer = null;
|
||||
let globalSearchTimer = null;
|
||||
let assistantRenderFrame = 0;
|
||||
|
||||
const heartSound = {
|
||||
enabled: false,
|
||||
@@ -385,6 +390,14 @@ function bindEvents() {
|
||||
});
|
||||
document.querySelector("#globalSearchButton").addEventListener("click", openGlobalSearch);
|
||||
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("#alertForm").addEventListener("submit", saveAlert);
|
||||
document.querySelector("#markAllAlertsRead").addEventListener("click", markAllAlertsRead);
|
||||
@@ -4498,6 +4511,152 @@ function renderAlerts() {
|
||||
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() {
|
||||
if (!state.user) return;
|
||||
toggleHeaderCommandMenu(false);
|
||||
@@ -4869,6 +5028,9 @@ function applyMembershipAccess() {
|
||||
control.disabled = !unlocked;
|
||||
});
|
||||
});
|
||||
const assistantButton = document.querySelector("#assistantButton");
|
||||
assistantButton.classList.toggle("member-locked-control", !unlocked);
|
||||
assistantButton.title = unlocked ? "复盘助手" : "复盘助手(会员可用)";
|
||||
}
|
||||
|
||||
function openView(viewId, updateHash = true) {
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
</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="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>
|
||||
<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>
|
||||
@@ -1139,6 +1140,34 @@
|
||||
</section>
|
||||
</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">
|
||||
<div class="dialog-header">
|
||||
<div>
|
||||
|
||||
@@ -11530,3 +11530,61 @@ button.account-role-badge:focus-visible { outline: 2px solid var(--blue); outlin
|
||||
.trade-log-form-grid,
|
||||
.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; }
|
||||
}
|
||||
|
||||
@@ -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/alerts") payload = { items: [], unread_count: 0 };
|
||||
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/dragon-tiger") {
|
||||
payload = {
|
||||
@@ -93,6 +94,9 @@ test("admin shell opens every primary workspace and global search", async ({ pag
|
||||
await page.locator("#alertButton").click();
|
||||
await expect(page.locator("#alertsDialog")).toBeVisible();
|
||||
await page.locator("#closeAlertsDialog").click();
|
||||
await page.locator("#assistantButton").click();
|
||||
await expect(page.locator("#assistantDialog")).toBeVisible();
|
||||
await page.locator("#closeAssistantDialog").click();
|
||||
|
||||
const views = [
|
||||
"sentimentCycleView", "limitPool", "brokenView", "downView", "yesterdayView",
|
||||
|
||||
@@ -12,12 +12,15 @@ class ApiAccessPolicyTests(unittest.TestCase):
|
||||
("GET", "/api/screener/tracking"): "member",
|
||||
("GET", "/api/mentors/messages"): "member",
|
||||
("GET", "/api/heaven/setup"): "member",
|
||||
("GET", "/api/assistant/messages"): "member",
|
||||
("POST", "/api/screener/run"): "member",
|
||||
("POST", "/api/screener/tracking/refresh"): "member",
|
||||
("POST", "/api/mentors/chat"): "member",
|
||||
("POST", "/api/heaven/interpret"): "member",
|
||||
("POST", "/api/assistant/chat"): "member",
|
||||
("DELETE", "/api/screener/strategies/42"): "member",
|
||||
("DELETE", "/api/mentors/messages"): "member",
|
||||
("DELETE", "/api/assistant/messages"): "member",
|
||||
}
|
||||
for (method, path), role in cases.items():
|
||||
with self.subTest(method=method, path=path):
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user