diff --git a/README.md b/README.md index 3b6a4f7..2a2068a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/api_access.py b/api_access.py index d53f373..ad27193 100644 --- a/api_access.py +++ b/api_access.py @@ -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" diff --git a/assistant_agent.py b/assistant_agent.py new file mode 100644 index 0000000..d948626 --- /dev/null +++ b/assistant_agent.py @@ -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() diff --git a/database.py b/database.py index 9455bc3..b086046 100644 --- a/database.py +++ b/database.py @@ -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: diff --git a/server.py b/server.py index aa6bbc4..c3f9e41 100644 --- a/server.py +++ b/server.py @@ -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: diff --git a/static/app.js b/static/app.js index d07596f..41007f5 100644 --- a/static/app.js +++ b/static/app.js @@ -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) => ` +
+
${message.role === "user" ? "我" : "复盘助手"}${message.context_date ? `` : ""}
+
${message.role === "assistant" ? (message.content ? formatMentorAnswer(message.content) : '正在整理复盘数据') : escapeHtml(message.content)}
+ ${message.streaming ? '' : ""} +
+ `).join("") || '
可以从市场、策略或自己的交易记录开始复盘
'; + 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) { diff --git a/static/index.html b/static/index.html index 46329aa..6a51cd6 100644 --- a/static/index.html +++ b/static/index.html @@ -63,6 +63,7 @@ +
@@ -1139,6 +1140,34 @@ + +
+
智能复盘

复盘助手

+
+ + +
+
+
+
可以从市场、策略或自己的交易记录开始复盘
+
+
+ + + + +
+
+ + +
+ + +
+
+

助手仅用于复盘与条件化计划,不执行交易,不构成投资建议。

+
+
diff --git a/static/styles.css b/static/styles.css index 9ce3828..cfc93a7 100644 --- a/static/styles.css +++ b/static/styles.css @@ -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; } +} diff --git a/tests/e2e/app-shell.spec.js b/tests/e2e/app-shell.spec.js index 015a298..b9786da 100644 --- a/tests/e2e/app-shell.spec.js +++ b/tests/e2e/app-shell.spec.js @@ -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", diff --git a/tests/test_api_access.py b/tests/test_api_access.py index a577eaf..dbf995b 100644 --- a/tests/test_api_access.py +++ b/tests/test_api_access.py @@ -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): diff --git a/tests/test_review_assistant.py b/tests/test_review_assistant.py new file mode 100644 index 0000000..dd767ba --- /dev/null +++ b/tests/test_review_assistant.py @@ -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()