diff --git a/api_access.py b/api_access.py index 2df7024..3d2a6d5 100644 --- a/api_access.py +++ b/api_access.py @@ -26,6 +26,7 @@ MEMBER_POST_PATHS = frozenset( "/api/screener/run", "/api/screener/tracking/refresh", "/api/mentors/chat", + "/api/mentors/preferences", "/api/heaven/hexagram", "/api/heaven/personal", "/api/heaven/interpret", diff --git a/database.py b/database.py index f0368ca..4a557e2 100644 --- a/database.py +++ b/database.py @@ -257,6 +257,19 @@ class ReviewDatabase: CREATE INDEX IF NOT EXISTS idx_mentor_messages_conversation ON mentor_messages(user_id, mentor_id, trade_date, id DESC); + CREATE TABLE IF NOT EXISTS mentor_preferences ( + user_id INTEGER NOT NULL, + mentor_id TEXT NOT NULL, + pinned INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL, + PRIMARY KEY (user_id, mentor_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_mentor_preferences_user_order + ON mentor_preferences(user_id, pinned DESC, sort_order, mentor_id); + CREATE TABLE IF NOT EXISTS strategy_tracks ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, @@ -1417,6 +1430,48 @@ class ReviewDatabase: ) return int(cursor.rowcount) + def list_mentor_preferences(self, user_id: int) -> list[dict[str, Any]]: + with self.connect() as connection: + rows = connection.execute( + """ + SELECT mentor_id, pinned, sort_order + FROM mentor_preferences + WHERE user_id = ? + ORDER BY sort_order, mentor_id + """, + (int(user_id),), + ).fetchall() + return [ + { + "mentor_id": str(row["mentor_id"]), + "pinned": bool(row["pinned"]), + "sort_order": int(row["sort_order"]), + } + for row in rows + ] + + def save_mentor_preferences( + self, user_id: int, ordered_ids: list[str], pinned_ids: set[str] + ) -> None: + now = datetime.now().astimezone().isoformat(timespec="seconds") + values = [ + (int(user_id), mentor_id, int(mentor_id in pinned_ids), index, now) + for index, mentor_id in enumerate(ordered_ids) + ] + with self.connect() as connection: + connection.execute( + "DELETE FROM mentor_preferences WHERE user_id = ?", + (int(user_id),), + ) + connection.executemany( + """ + INSERT INTO mentor_preferences + (user_id, mentor_id, pinned, sort_order, updated_at) + VALUES (?, ?, ?, ?, ?) + """, + values, + ) + def save_strategy_tracks( self, user_id: int, diff --git a/mentor_agent.py b/mentor_agent.py index ac9dd9a..58cac2a 100644 --- a/mentor_agent.py +++ b/mentor_agent.py @@ -5,6 +5,7 @@ import re import time import urllib.error import urllib.request +from collections.abc import Iterator from dataclasses import dataclass from pathlib import Path from typing import Any @@ -162,6 +163,29 @@ def chat_with_mentor( model: str, timeout: int = 90, ) -> dict[str, Any]: + started = time.perf_counter() + answer = "".join( + stream_with_mentor( + skill, market_context, question, history, api_key, base_url, model, timeout + ) + ).strip() + return { + "answer": answer, + "model": model, + "latency_ms": round((time.perf_counter() - started) * 1000), + } + + +def stream_with_mentor( + skill: MentorSkill, + market_context: dict[str, Any], + question: str, + history: list[dict[str, str]], + api_key: str, + base_url: str, + model: str, + timeout: int = 90, +) -> Iterator[str]: if not api_key or not model: raise MentorAgentError("LLM API Key 或模型尚未配置。") @@ -170,7 +194,7 @@ def chat_with_mentor( messages.extend(history[-10:]) messages.append({"role": "user", "content": question}) payload = json.dumps( - {"model": model, "messages": messages, "stream": False}, + {"model": model, "messages": messages, "stream": True}, ensure_ascii=False, ).encode("utf-8") request = urllib.request.Request( @@ -180,25 +204,41 @@ def chat_with_mentor( "Content-Type": "application/json", "Authorization": f"Bearer {api_key}", "User-Agent": "XiaobaiReviewWeb/0.6", + "Accept": "text/event-stream", }, method="POST", ) - started = time.perf_counter() try: with urllib.request.urlopen(request, timeout=timeout) as response: - result = json.loads(response.read().decode("utf-8")) - answer = str(result["choices"][0]["message"]["content"]).strip() - if not answer: - raise KeyError("empty 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: + result = json.loads(line) + except json.JSONDecodeError: + continue + choices = result.get("choices") or [] + if not choices: + continue + choice = choices[0] or {} + content = (choice.get("delta") or {}).get("content") + if content is None: + content = (choice.get("message") or {}).get("content") + if content: + yielded = True + yield str(content) + if not yielded: + raise MentorAgentError("问师模型未返回有效内容。") except urllib.error.HTTPError as exc: raise MentorAgentError(_http_error_message(exc)) from exc - except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc: + except (urllib.error.URLError, TimeoutError, OSError) as exc: raise MentorAgentError(f"问师模型调用失败:{exc}") from exc - return { - "answer": answer, - "model": model, - "latency_ms": round((time.perf_counter() - started) * 1000), - } def _build_system_prompt(skill: MentorSkill, market_context: dict[str, Any]) -> str: diff --git a/server.py b/server.py index 96c0ec9..0c14250 100644 --- a/server.py +++ b/server.py @@ -50,7 +50,7 @@ from heaven_engine import ( hexagram_from_lines, ) from llm_strategy import LLMCompilerError, compile_strategy_with_llm, test_llm_connection -from mentor_agent import MentorAgentError, MentorSkillRegistry, chat_with_mentor +from mentor_agent import MentorAgentError, MentorSkillRegistry, stream_with_mentor from realtime_aggregator import WebRealtimeAggregator from screener import ( FACTOR_FIELDS, @@ -1353,11 +1353,26 @@ class DashboardService: ] if not mentors: raise ValueError("游资skills 目录中没有可用的 SKILL.md。") + stored_preferences = self.database.list_mentor_preferences(self.current_user_id) + preferences = {item["mentor_id"]: item for item in stored_preferences} + for default_order, mentor in enumerate(mentors): + preference = preferences.get(str(mentor.get("id") or ""), {}) + mentor["pinned"] = bool(preference.get("pinned")) + mentor["sort_order"] = int(preference.get("sort_order", 10000 + default_order)) + mentors.sort( + key=lambda item: ( + not bool(item.get("pinned")), + int(item.get("sort_order") or 0), + ) + ) + for sort_order, mentor in enumerate(mentors): + mentor["sort_order"] = sort_order snapshot = self.database.get_snapshot(normalized_date) actual_date = str((snapshot or {}).get("meta", {}).get("trade_date") or normalized_date) return { "trade_date": actual_date, "mentors": mentors, + "preferences_configured": bool(stored_preferences), "llm": { "configured": self.llm_configured, "model": self.llm_primary_model if self.llm_configured else "", @@ -1366,7 +1381,38 @@ class DashboardService: }, } - def mentor_chat(self, payload: dict[str, Any]) -> dict[str, Any]: + def save_mentor_preferences(self, payload: dict[str, Any]) -> dict[str, Any]: + available_ids = [ + skill.skill_id + for skill in self.mentor_skills.list_skills( + include_private=self.membership()["is_admin"] + ) + ] + available = set(available_ids) + raw_order = payload.get("order") + raw_pinned = payload.get("pinned") + if not isinstance(raw_order, list) or not isinstance(raw_pinned, list): + raise ValueError("问师排序格式不正确。") + ordered_ids: list[str] = [] + for raw_id in raw_order: + mentor_id = validate_text(raw_id, "问师角色", 100, required=True) + if mentor_id not in available: + raise ValueError("问师排序中包含不可用的思维模型。") + if mentor_id not in ordered_ids: + ordered_ids.append(mentor_id) + ordered_ids.extend(mentor_id for mentor_id in available_ids if mentor_id not in ordered_ids) + pinned_ids = { + validate_text(raw_id, "问师角色", 100, required=True) + for raw_id in raw_pinned + } + if not pinned_ids.issubset(available): + raise ValueError("问师置顶中包含不可用的思维模型。") + self.database.save_mentor_preferences( + self.current_user_id, ordered_ids, pinned_ids + ) + return {"saved": True} + + def mentor_stream(self, payload: dict[str, Any]): mentor_id = validate_text(payload.get("mentor_id"), "问师角色", 100, required=True) question = validate_text(payload.get("question"), "问题", 2000, required=True) trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat())) @@ -1377,66 +1423,73 @@ class DashboardService: context = self._build_mentor_context(trade_date, question) source = self.ensure_llm_access("mentor") - primary_error = "" - result = None - compiler = "primary" + profiles = [] if self.llm_configured: - try: - result = chat_with_mentor( - skill, - context, - question, - history, + profiles.append( + ( + "primary", self.llm_primary_api_key, self.llm_primary_base_url, self.llm_primary_model, ) - except MentorAgentError as exc: - primary_error = str(exc) - if result is None and self.llm_fallback_configured: - try: - result = chat_with_mentor( - skill, - context, - question, - history, + ) + if self.llm_fallback_configured: + profiles.append( + ( + "fallback", self.llm_fallback_api_key, self.llm_fallback_base_url, self.llm_fallback_model, ) - compiler = "fallback" - except MentorAgentError as exc: - fallback_error = str(exc) - self.record_llm_usage( - "mentor", source, self.llm_fallback_model, "failed" + ) + + def generate(): + started = time.perf_counter() + last_error: Exception | None = None + for compiler, api_key, base_url, model in profiles: + try: + upstream = iter( + stream_with_mentor( + skill, context, question, history, api_key, base_url, model + ) + ) + first = next(upstream) + except (MentorAgentError, StopIteration) as exc: + last_error = exc + continue + answer_parts = [first] + yield {"type": "delta", "content": first} + try: + for chunk in upstream: + answer_parts.append(chunk) + yield {"type": "delta", "content": chunk} + except MentorAgentError as exc: + self.record_llm_usage("mentor", source, model, "failed") + raise ValueError("智能解读连接中断,请稍后重试。") from exc + answer = "".join(answer_parts).strip() + latency_ms = round((time.perf_counter() - started) * 1000) + self.database.save_mentor_exchange( + self.current_user_id, + mentor_id, + trade_date, + question, + answer, + context["data_trade_date"], ) - raise ValueError("智能解读服务暂不可用,请稍后重试。") from exc - if result is None: - self.record_llm_usage("mentor", source, self.llm_primary_model, "failed") - raise ValueError("智能解读服务暂不可用,请稍后重试。") - self.record_llm_usage( - "mentor", - source, - str(result.get("model") or ""), - "success", - int(result.get("latency_ms") or 0), - ) - self.database.save_mentor_exchange( - self.current_user_id, - mentor_id, - trade_date, - question, - str(result.get("answer") or ""), - context["data_trade_date"], - ) - return { - **result, - "mentor": skill.public(), - "compiler": compiler, - "requested_trade_date": trade_date, - "data_trade_date": context["data_trade_date"], - "notice": "智能解读已自动切换可用服务。" if compiler == "fallback" else "", - } + self.record_llm_usage("mentor", source, model, "success", latency_ms) + yield { + "type": "meta", + "data_trade_date": context["data_trade_date"], + "notice": "智能解读已自动切换可用服务。" + if compiler == "fallback" + else "", + } + return + failed_model = profiles[-1][3] if profiles else self.llm_primary_model + self.record_llm_usage("mentor", source, failed_model, "failed") + raise ValueError("智能解读服务暂不可用,请稍后重试。") from last_error + + return generate() def mentor_messages(self, mentor_id: str, trade_date: str) -> list[dict[str, Any]]: mentor_id = validate_text(mentor_id, "问师角色", 100, required=True) @@ -3929,8 +3982,15 @@ class RequestHandler(BaseHTTPRequestHandler): if parsed.path == "/api/screener/tracking/refresh": self.refresh_screener_tracking() return + if parsed.path == "/api/mentors/preferences": + try: + result = SERVICE.save_mentor_preferences(self.read_json_body()) + self.send_json({"ok": True, **result}) + except (ValueError, json.JSONDecodeError) as exc: + self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + return if parsed.path == "/api/mentors/chat": - self.mentor_chat() + self.stream_mentor_chat() return if parsed.path == "/api/heaven/hexagram": self.heaven_hexagram() @@ -4439,13 +4499,29 @@ class RequestHandler(BaseHTTPRequestHandler): except Exception as exc: self.send_json({"error": f"跟踪刷新失败:{exc}"}, HTTPStatus.INTERNAL_SERVER_ERROR) - def mentor_chat(self) -> None: + def stream_mentor_chat(self) -> None: try: body = self.read_json_body() - result = SERVICE.mentor_chat(body) - self.send_json({"ok": True, **result}) + stream = SERVICE.mentor_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 event in stream: + self._write_stream_event(event) + self._write_stream_event({"type": "done"}) + except (ValueError, MentorAgentError) as exc: + self._write_stream_event({"type": "error", "error": str(exc)}) + except (BrokenPipeError, ConnectionResetError): + pass + finally: + self.close_connection = True def heaven_hexagram(self) -> None: try: diff --git a/static/app.js b/static/app.js index 44a025b..d354fc4 100644 --- a/static/app.js +++ b/static/app.js @@ -65,6 +65,9 @@ const state = { mentorQuery: "", mentorGrade: "all", mentorDirectoryOpen: false, + mentorSortMode: false, + mentorSavingPreferences: false, + mentorController: null, heavenSetup: null, heavenManualData: null, personalField: null, @@ -554,6 +557,7 @@ function bindEvents() { }); document.querySelector("#closeMentorDirectory").addEventListener("click", () => toggleMentorDirectory(false)); document.querySelector("#mentorDirectoryBackdrop").addEventListener("click", () => toggleMentorDirectory(false)); + document.querySelector("#mentorSortToggle").addEventListener("click", toggleMentorSortMode); document.querySelector("#mentorSearchInput").addEventListener("input", (event) => { state.mentorQuery = event.target.value.trim().toLocaleLowerCase("zh-CN"); renderMentorDirectory(); @@ -2045,6 +2049,13 @@ async function loadMentorSetup(force = false) { const query = new URLSearchParams({ trade_date: elements.tradeDate.value }); const payload = await apiRequest(`/api/mentors/setup?${query}`); payload.requestedDate = requestedDate; + if (!payload.preferences_configured) { + payload.mentors.sort((first, second) => { + if (Boolean(first.private) !== Boolean(second.private)) return first.private ? -1 : 1; + return String(first.name || "").localeCompare(String(second.name || ""), "zh-CN"); + }); + payload.mentors.forEach((mentor, index) => { mentor.sort_order = index; }); + } state.mentorSetup = payload; const selectedExists = payload.mentors.some((item) => item.id === state.selectedMentorId); state.selectedMentorId = selectedExists ? state.selectedMentorId : payload.mentors[0]?.id || ""; @@ -2075,6 +2086,7 @@ function renderMentorDirectory() { const mentors = state.mentorSetup?.mentors || []; const query = state.mentorQuery; const filtered = mentors.filter((mentor) => { + if (state.mentorSortMode) return true; if (state.mentorGrade !== "all" && mentor.evidence?.grade !== state.mentorGrade) return false; if (!query) return true; const haystack = [ @@ -2086,28 +2098,180 @@ function renderMentorDirectory() { ...(mentor.focus || []), ].filter(Boolean).join(" ").toLocaleLowerCase("zh-CN"); return haystack.includes(query); - }).sort((first, second) => { - if (Boolean(first.private) !== Boolean(second.private)) return first.private ? -1 : 1; - return String(first.name || "").localeCompare(String(second.name || ""), "zh-CN"); }); setText("mentorCount", filtered.length === mentors.length ? `${mentors.length} 位` : `${filtered.length} / ${mentors.length} 位`); + const sortToggle = document.querySelector("#mentorSortToggle"); + sortToggle.classList.toggle("active", state.mentorSortMode); + sortToggle.setAttribute("aria-pressed", String(state.mentorSortMode)); + sortToggle.querySelector("span").textContent = state.mentorSortMode ? "完成" : "整理"; + document.querySelector("#mentorSortHint").hidden = !state.mentorSortMode; + document.querySelector("#mentorSearchInput").disabled = state.mentorSortMode; + document.querySelectorAll("[data-mentor-grade]").forEach((button) => { + button.disabled = state.mentorSortMode; + }); const container = document.querySelector("#mentorList"); - container.innerHTML = filtered.map((mentor) => ` - - `).join(""); + container.classList.toggle("is-sorting", state.mentorSortMode); + container.innerHTML = filtered.map((mentor) => { + const group = mentors.filter((item) => Boolean(item.pinned) === Boolean(mentor.pinned)); + const groupIndex = group.findIndex((item) => item.id === mentor.id); + return ` +
+ + + + ${state.mentorSortMode ? ` + + + ` : ""} + +
+ `; + }).join(""); document.querySelector("#mentorListEmpty").hidden = filtered.length > 0; document.querySelectorAll("[data-mentor-id]").forEach((button) => { button.addEventListener("click", () => selectMentor(button.dataset.mentorId)); }); + document.querySelectorAll("[data-mentor-pin]").forEach((button) => { + button.addEventListener("click", () => toggleMentorPin(button.dataset.mentorPin)); + }); + document.querySelectorAll("[data-mentor-move]").forEach((button) => { + button.addEventListener("click", () => moveMentor(button.dataset.mentorTarget, button.dataset.mentorMove)); + }); + document.querySelectorAll("[data-mentor-card]").forEach((card) => { + card.addEventListener("dragstart", handleMentorDragStart); + card.addEventListener("dragover", handleMentorDragOver); + card.addEventListener("drop", handleMentorDrop); + card.addEventListener("dragend", clearMentorDragState); + }); refreshIcons(); } +function toggleMentorSortMode() { + state.mentorSortMode = !state.mentorSortMode; + if (state.mentorSortMode) { + state.mentorQuery = ""; + state.mentorGrade = "all"; + document.querySelector("#mentorSearchInput").value = ""; + document.querySelectorAll("[data-mentor-grade]").forEach((button) => { + button.classList.toggle("active", button.dataset.mentorGrade === "all"); + }); + } + renderMentorDirectory(); +} + +async function toggleMentorPin(mentorId) { + if (state.mentorSavingPreferences) return; + const mentors = state.mentorSetup?.mentors || []; + const index = mentors.findIndex((item) => item.id === mentorId); + if (index < 0) return; + const [mentor] = mentors.splice(index, 1); + mentor.pinned = !mentor.pinned; + if (mentor.pinned) { + mentors.unshift(mentor); + } else { + const firstUnpinned = mentors.findIndex((item) => !item.pinned); + mentors.splice(firstUnpinned < 0 ? mentors.length : firstUnpinned, 0, mentor); + } + normalizeMentorOrder(); + renderMentorWorkspace(); + await persistMentorPreferences(); +} + +async function moveMentor(mentorId, direction) { + if (state.mentorSavingPreferences) return; + const mentors = state.mentorSetup?.mentors || []; + const index = mentors.findIndex((item) => item.id === mentorId); + if (index < 0) return; + const step = direction === "up" ? -1 : 1; + const targetIndex = index + step; + if (targetIndex < 0 || targetIndex >= mentors.length) return; + if (Boolean(mentors[index].pinned) !== Boolean(mentors[targetIndex].pinned)) return; + [mentors[index], mentors[targetIndex]] = [mentors[targetIndex], mentors[index]]; + normalizeMentorOrder(); + renderMentorDirectory(); + await persistMentorPreferences(); +} + +function handleMentorDragStart(event) { + if (!state.mentorSortMode || state.mentorSavingPreferences) { + event.preventDefault(); + return; + } + state.mentorDragId = event.currentTarget.dataset.mentorCard || ""; + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", state.mentorDragId); + event.currentTarget.classList.add("is-dragging"); +} + +function handleMentorDragOver(event) { + const source = state.mentorSetup?.mentors.find((item) => item.id === state.mentorDragId); + const target = state.mentorSetup?.mentors.find((item) => item.id === event.currentTarget.dataset.mentorCard); + if (!source || !target || Boolean(source.pinned) !== Boolean(target.pinned)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + event.currentTarget.classList.add("is-drag-over"); +} + +async function handleMentorDrop(event) { + event.preventDefault(); + const sourceId = state.mentorDragId || event.dataTransfer.getData("text/plain"); + const targetId = event.currentTarget.dataset.mentorCard || ""; + clearMentorDragState(); + if (!sourceId || !targetId || sourceId === targetId) return; + const mentors = state.mentorSetup?.mentors || []; + const sourceIndex = mentors.findIndex((item) => item.id === sourceId); + const targetIndex = mentors.findIndex((item) => item.id === targetId); + if (sourceIndex < 0 || targetIndex < 0) return; + if (Boolean(mentors[sourceIndex].pinned) !== Boolean(mentors[targetIndex].pinned)) return; + const [mentor] = mentors.splice(sourceIndex, 1); + const insertionIndex = mentors.findIndex((item) => item.id === targetId); + mentors.splice(insertionIndex, 0, mentor); + normalizeMentorOrder(); + renderMentorDirectory(); + await persistMentorPreferences(); +} + +function clearMentorDragState() { + state.mentorDragId = ""; + document.querySelectorAll(".mentor-option.is-dragging, .mentor-option.is-drag-over").forEach((item) => { + item.classList.remove("is-dragging", "is-drag-over"); + }); +} + +function normalizeMentorOrder() { + (state.mentorSetup?.mentors || []).forEach((mentor, index) => { + mentor.sort_order = index; + }); +} + +async function persistMentorPreferences() { + const mentors = state.mentorSetup?.mentors || []; + state.mentorSavingPreferences = true; + renderMentorDirectory(); + try { + await apiRequest("/api/mentors/preferences", "POST", { + order: mentors.map((item) => item.id), + pinned: mentors.filter((item) => item.pinned).map((item) => item.id), + }); + } catch (error) { + showToast(error.message || "问师顺序保存失败"); + await loadMentorSetup(true); + } finally { + state.mentorSavingPreferences = false; + renderMentorDirectory(); + } +} + function renderMentorBadges(mentor, expanded = false) { const badges = []; if (mentor.private) { @@ -2166,13 +2330,14 @@ function renderMentorMessages() { `; } else { container.innerHTML = state.mentorMessages.map((message) => ` -
+
${message.role === "user" ? "我" : escapeHtml(selected?.name || "问师")}
${message.role === "assistant" ? formatMentorAnswer(message.content) : escapeHtml(message.content)}
- ${message.meta ? `${escapeHtml(message.meta)}` : ""} + ${message.streaming ? '' : ""} + ${message.meta && !message.streaming ? `${escapeHtml(message.meta)}` : ""}
`).join(""); - if (state.mentorLoading) { + if (state.mentorLoading && !state.mentorMessages.some((message) => message.streaming)) { container.insertAdjacentHTML("beforeend", `
${escapeHtml(selected?.name || "问师")}
@@ -2184,6 +2349,7 @@ function renderMentorMessages() { document.querySelector("#clearMentorChatButton").disabled = !state.mentorMessages.length || state.mentorLoading; document.querySelector("#mentorQuestion").disabled = state.mentorLoading || !state.selectedMentorId; document.querySelector("#sendMentorQuestion").disabled = state.mentorLoading || !state.selectedMentorId; + document.querySelector("#mentorSortToggle").disabled = state.mentorLoading; requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; }); } @@ -2198,36 +2364,99 @@ async function sendMentorQuestion(event) { content: item.content.slice(0, 3500), })); state.mentorMessages.push({ role: "user", content: question }); + const responseMessage = { role: "assistant", content: "", streaming: true, meta: "" }; + state.mentorMessages.push(responseMessage); input.value = ""; state.mentorLoading = true; + state.mentorController = new AbortController(); hideMentorNotice(); renderMentorMessages(); + renderMentorDirectory(); setStatus("问师正在读取复盘数据"); try { - const payload = await apiRequest("/api/mentors/chat", "POST", { - mentor_id: state.selectedMentorId, - trade_date: elements.tradeDate.value, - question, - history, - }); - state.mentorMessages.push({ - role: "assistant", - content: payload.answer, - meta: `${displayCompactDate(payload.data_trade_date)} · 回答完成`, - }); - if (payload.notice) showMentorNotice(payload.notice); + await streamMentorRequest( + { + mentor_id: state.selectedMentorId, + trade_date: elements.tradeDate.value, + question, + history, + }, + state.mentorController.signal, + (chunk) => { + responseMessage.content += chunk; + scheduleMentorRender(); + }, + (meta) => { + responseMessage.meta = `${displayCompactDate(meta.data_trade_date || elements.tradeDate.value)} · 回答完成`; + if (meta.notice) showMentorNotice(meta.notice); + }, + ); + responseMessage.streaming = false; setStatus("问师回答完成"); } catch (error) { + responseMessage.streaming = false; + responseMessage.error = true; + if (!responseMessage.content) { + state.mentorMessages = state.mentorMessages.filter((item) => item !== responseMessage); + } showMentorNotice(error.message || "问师回答失败"); showToast(error.message || "问师回答失败"); setStatus("问师回答失败"); } finally { state.mentorLoading = false; + state.mentorController = null; renderMentorMessages(); + renderMentorDirectory(); input.focus(); } } +let mentorRenderFrame = 0; + +function scheduleMentorRender() { + if (mentorRenderFrame) return; + mentorRenderFrame = requestAnimationFrame(() => { + mentorRenderFrame = 0; + renderMentorMessages(); + }); +} + +async function streamMentorRequest(body, signal, onDelta, onMeta) { + const response = await fetch("/api/mentors/chat", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(state.csrfToken ? { "X-CSRF-Token": state.csrfToken } : {}), + }, + body: JSON.stringify(body), + 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 = ""; + const consume = (line) => { + if (!line.trim()) return; + const event = JSON.parse(line); + if (event.type === "delta") onDelta(String(event.content || "")); + if (event.type === "meta") onMeta(event); + if (event.type === "error") throw new Error(event.error || "问师回答失败"); + }; + while (true) { + const { value, done } = await reader.read(); + buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + lines.forEach(consume); + if (done) break; + } + if (buffer.trim()) consume(buffer); +} + function useMentorQuickPrompt(prompt) { const input = document.querySelector("#mentorQuestion"); input.value = prompt || ""; @@ -2278,13 +2507,45 @@ function hideMentorNotice() { } function formatMentorAnswer(content) { - return escapeHtml(content).split("\n").map((line) => { + const blocks = []; + let listType = ""; + let listItems = []; + const flushList = () => { + if (!listItems.length) return; + blocks.push(`<${listType} class="mentor-answer-list">${listItems.map((item) => `
  • ${item}
  • `).join("")}`); + listItems = []; + listType = ""; + }; + String(content || "").replace(/\r\n?/g, "\n").replace(/\n{3,}/g, "\n\n").split("\n").forEach((rawLine) => { + const line = rawLine.trim(); + if (!line) { + flushList(); + return; + } const heading = line.match(/^#{1,3}\s+(.+)$/); - if (heading) return `${formatMentorInline(heading[1])}`; - if (/^-{3,}$/.test(line.trim())) return ''; - if (line.startsWith("> ")) return `${formatMentorInline(line.slice(5))}`; - return formatMentorInline(line); - }).join("
    "); + const bullet = line.match(/^[-*]\s+(.+)$/); + const ordered = line.match(/^\d+[.、]\s*(.+)$/); + if (heading) { + flushList(); + blocks.push(`${formatMentorInline(escapeHtml(heading[1]))}`); + } else if (/^-{3,}$/.test(line)) { + flushList(); + blocks.push(''); + } else if (line.startsWith("> ")) { + flushList(); + blocks.push(`${formatMentorInline(escapeHtml(line.slice(2)))}`); + } else if (bullet || ordered) { + const nextType = bullet ? "ul" : "ol"; + if (listType && listType !== nextType) flushList(); + listType = nextType; + listItems.push(formatMentorInline(escapeHtml((bullet || ordered)[1]))); + } else { + flushList(); + blocks.push(`

    ${formatMentorInline(escapeHtml(line))}

    `); + } + }); + flushList(); + return blocks.join(""); } function formatMentorInline(content) { diff --git a/static/index.html b/static/index.html index 8f0e2d7..5b4d315 100644 --- a/static/index.html +++ b/static/index.html @@ -813,7 +813,10 @@

    思维模型

    0 位
    - +
    + + +
    +

    素材等级反映蒸馏依据,不代表人物能力或收益水平。

    diff --git a/static/styles.css b/static/styles.css index a4e2d4f..6da5980 100644 --- a/static/styles.css +++ b/static/styles.css @@ -11779,7 +11779,7 @@ button.account-role-badge:focus-visible { outline: 2px solid var(--blue); outlin height: 100%; min-height: 0; display: grid; - grid-template-rows: auto auto auto minmax(0, 1fr) auto; + grid-template-rows: auto auto auto auto minmax(0, 1fr) auto; gap: 10px; padding: 14px 12px 10px; } @@ -11805,6 +11805,39 @@ button.account-role-badge:focus-visible { outline: 2px solid var(--blue); outlin font-size: 11px; } +.mentor-directory-actions { + display: flex; + align-items: center; + gap: 4px; +} + +.mentor-sort-toggle { + min-height: 30px; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 0 8px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--surface); + color: var(--text-muted); + cursor: pointer; + font: inherit; + font-size: 10px; +} + +.mentor-sort-toggle:hover, +.mentor-sort-toggle.active { + border-color: color-mix(in srgb, var(--action) 38%, var(--border)); + background: var(--action-soft); + color: var(--action); +} + +.mentor-sort-toggle .lucide { + width: 13px; + height: 13px; +} + .mentor-search-field { height: 40px; display: grid; @@ -11876,6 +11909,14 @@ button.account-role-badge:focus-visible { outline: 2px solid var(--blue); outlin outline-offset: 1px; } +.mentor-sort-hint { + margin: 0; + padding: 0 3px; + color: var(--text-muted); + font-size: 9px; + line-height: 1.4; +} + .mentor-list { min-height: 0; display: grid; @@ -11893,7 +11934,7 @@ button.account-role-badge:focus-visible { outline: 2px solid var(--blue); outlin grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; - padding: 8px 9px; + padding: 0 5px 0 0; border: 1px solid transparent; border-bottom-color: var(--border); border-radius: 4px; @@ -11901,6 +11942,96 @@ button.account-role-badge:focus-visible { outline: 2px solid var(--blue); outlin transition: border-color var(--motion-fast) ease, background-color var(--motion-fast) ease; } +.mentor-option-main { + min-width: 0; + min-height: 64px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 8px 5px 8px 9px; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + font: inherit; + text-align: left; +} + +.mentor-option-main:disabled { + cursor: default; +} + +.mentor-option .mentor-option-tools { + display: flex; + align-items: center; + gap: 2px; + margin: 0; + overflow: visible; + color: inherit; + font-size: inherit; + line-height: normal; + white-space: normal; +} + +.mentor-pin-button, +.mentor-order-button { + width: 28px; + min-width: 28px; + height: 30px; + display: grid; + place-items: center; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--text-muted); + cursor: pointer; +} + +.mentor-pin-button:hover, +.mentor-order-button:hover:not(:disabled) { + background: var(--surface-muted); + color: var(--text); +} + +.mentor-pin-button.active { + background: #fff7df; + color: #9a6815; +} + +.mentor-pin-button.active .lucide { + fill: currentColor; +} + +.mentor-pin-button .lucide, +.mentor-order-button .lucide { + width: 14px; + height: 14px; +} + +.mentor-order-button:disabled { + color: color-mix(in srgb, var(--text-muted) 35%, transparent); + cursor: default; +} + +.mentor-list.is-sorting .mentor-option { + cursor: grab; +} + +.mentor-list.is-sorting .mentor-option-badges { + display: none; +} + +.mentor-option.is-dragging { + opacity: 0.45; +} + +.mentor-option.is-drag-over { + border-color: var(--action); + background: var(--action-soft); +} + .mentor-option:hover { border-color: var(--border-strong); background: var(--surface); @@ -11923,6 +12054,16 @@ button.account-role-badge:focus-visible { outline: 2px solid var(--blue); outlin gap: 4px; } +.mentor-option .mentor-option-copy { + display: grid; + margin: 0; + overflow: visible; + color: inherit; + font-size: inherit; + line-height: normal; + white-space: normal; +} + .mentor-option-copy strong { overflow: hidden; font-size: 13px; @@ -11949,6 +12090,16 @@ button.account-role-badge:focus-visible { outline: 2px solid var(--blue); outlin gap: 4px; } +.mentor-option .mentor-option-badges { + display: flex; + margin: 0; + overflow: visible; + color: inherit; + font-size: inherit; + line-height: normal; + white-space: normal; +} + .mentor-option-badges { max-width: 76px; } @@ -11971,6 +12122,13 @@ button.account-role-badge:focus-visible { outline: 2px solid var(--blue); outlin white-space: nowrap; } +.mentor-option .mentor-badge { + display: inline-flex; + margin: 0; + overflow: visible; + line-height: 1; +} + .mentor-badge .lucide { width: 11px; height: 11px; @@ -12057,6 +12215,47 @@ button.account-role-badge:focus-visible { outline: 2px solid var(--blue); outlin max-height: none; } +.mentor-message { + margin-bottom: 12px; +} + +.mentor-message-content { + line-height: 1.62; + white-space: normal; +} + +.mentor-message .mentor-answer-paragraph { + margin: 0 0 6px; + line-height: inherit; +} + +.mentor-message .mentor-answer-paragraph:last-child { + margin-bottom: 0; +} + +.mentor-answer-heading { + display: block; + margin: 9px 0 4px; + line-height: 1.45; +} + +.mentor-message-content > .mentor-answer-heading:first-child { + margin-top: 0; +} + +.mentor-answer-list { + margin: 3px 0 7px; + padding-left: 20px; +} + +.mentor-answer-list li + li { + margin-top: 3px; +} + +.mentor-message.is-error { + border-color: var(--danger); +} + @media (min-width: 721px) and (max-width: 1023px) { .mentor-layout { grid-template-columns: 280px minmax(0, 1fr); @@ -12181,6 +12380,12 @@ button.account-role-badge:focus-visible { outline: 2px solid var(--blue); outlin display: grid; } + .mentor-sort-toggle { + min-height: 44px; + padding: 0 10px; + font-size: 11px; + } + .mentor-search-field { height: 44px; } @@ -12199,6 +12404,13 @@ button.account-role-badge:focus-visible { outline: 2px solid var(--blue); outlin min-height: 68px; } + .mentor-pin-button, + .mentor-order-button { + width: 40px; + min-width: 40px; + height: 44px; + } + .mentor-chat-panel { min-height: 580px; } diff --git a/tests/e2e/app-shell.spec.js b/tests/e2e/app-shell.spec.js index bb14498..5f66c27 100644 --- a/tests/e2e/app-shell.spec.js +++ b/tests/e2e/app-shell.spec.js @@ -218,6 +218,18 @@ async function mockApplication(page, authSession = session()) { }; } else if (url.pathname === "/api/mentors/setup") { payload = { trade_date: "20260722", mentors: mentorDirectory(authSession.user.role) }; + } else if (url.pathname === "/api/mentors/chat") { + await route.fulfill({ + status: 200, + contentType: "application/x-ndjson; charset=utf-8", + body: [ + JSON.stringify({ type: "delta", content: "## 判断\n先看市场结构。\n\n" }), + JSON.stringify({ type: "delta", content: "- 等待确认\n- 控制仓位" }), + JSON.stringify({ type: "meta", data_trade_date: "20260722", notice: "" }), + JSON.stringify({ type: "done" }), + ].join("\n"), + }); + return; } else if (url.pathname === "/api/heaven/setup") { await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: "测试环境不加载问天数据" }) }); @@ -515,6 +527,30 @@ test("mentor directory exposes evidence filters and private owner metadata", asy await expect(page.locator("#activeMentorEvidence")).toHaveText("公开访谈与多源材料"); }); +test("mentor pins, custom order and streamed replies work together", async ({ page }) => { + await mockApplication(page, session("admin", true)); + await page.goto("/index.html"); + await page.locator('[data-view="mentorView"]').first().click(); + + await page.locator('[data-mentor-pin="source-c"]').click(); + await expect(page.locator("#mentorList [data-mentor-card]").first()).toHaveAttribute("data-mentor-card", "source-c"); + await page.locator('[data-mentor-pin="source-b"]').click(); + await expect(page.locator("#mentorList [data-mentor-card]").first()).toHaveAttribute("data-mentor-card", "source-b"); + + await page.locator("#mentorSortToggle").click(); + await page.locator('[data-mentor-target="source-b"][data-mentor-move="down"]').click(); + await expect(page.locator("#mentorList [data-mentor-card]").first()).toHaveAttribute("data-mentor-card", "source-c"); + + await page.locator('[data-mentor-id="source-c"]').click(); + await page.locator("#mentorQuestion").fill("现在怎么看?"); + await page.locator("#sendMentorQuestion").click(); + const answer = page.locator("#mentorMessages .mentor-message.assistant").last(); + await expect(answer).toContainText("先看市场结构。"); + await expect(answer.locator(".mentor-answer-list li")).toHaveCount(2); + await expect(answer.locator("br")).toHaveCount(0); + await expect(page.locator("#mentorMessages .assistant-stream-caret")).toHaveCount(0); +}); + test("mobile mentor directory opens as a searchable selector and hides private mentors", async ({ page }) => { await page.setViewportSize({ width: 375, height: 812 }); await mockApplication(page, session("user", true)); diff --git a/tests/test_account_data_boundaries.py b/tests/test_account_data_boundaries.py index 85a08d5..d636774 100644 --- a/tests/test_account_data_boundaries.py +++ b/tests/test_account_data_boundaries.py @@ -78,6 +78,21 @@ class AccountDataBoundaryTests(unittest.TestCase): self.database.delete_mentor_messages(self.first["id"], "mentor-a", "20260721"), 2 ) + def test_mentor_preferences_are_scoped_by_user(self): + self.database.save_mentor_preferences( + self.first["id"], ["mentor-b", "mentor-a"], {"mentor-b"} + ) + self.database.save_mentor_preferences( + self.second["id"], ["mentor-a", "mentor-b"], set() + ) + + first = self.database.list_mentor_preferences(self.first["id"]) + second = self.database.list_mentor_preferences(self.second["id"]) + self.assertEqual([item["mentor_id"] for item in first], ["mentor-b", "mentor-a"]) + self.assertTrue(first[0]["pinned"]) + self.assertEqual([item["mentor_id"] for item in second], ["mentor-a", "mentor-b"]) + self.assertFalse(any(item["pinned"] for item in second)) + def test_latest_data_snapshot_skips_demo_and_future_records(self): self.database.save_data_snapshot( "stock_detail", "002141:20260718", "tushare", {"marker": "real"} diff --git a/tests/test_api_access.py b/tests/test_api_access.py index 76201cc..74184f8 100644 --- a/tests/test_api_access.py +++ b/tests/test_api_access.py @@ -17,6 +17,7 @@ class ApiAccessPolicyTests(unittest.TestCase): ("POST", "/api/screener/run"): "member", ("POST", "/api/screener/tracking/refresh"): "member", ("POST", "/api/mentors/chat"): "member", + ("POST", "/api/mentors/preferences"): "member", ("POST", "/api/heaven/interpret"): "member", ("POST", "/api/assistant/chat"): "member", ("DELETE", "/api/screener/strategies/42"): "member", diff --git a/tests/test_mentor_stream.py b/tests/test_mentor_stream.py new file mode 100644 index 0000000..003bed0 --- /dev/null +++ b/tests/test_mentor_stream.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import json +import unittest +from pathlib import Path +from unittest.mock import patch + +from mentor_agent import MentorSkill, chat_with_mentor, stream_with_mentor + + +class FakeStreamResponse: + def __init__(self, lines: list[bytes]) -> None: + self.lines = lines + + def __enter__(self): + return iter(self.lines) + + def __exit__(self, exc_type, exc_value, traceback): + return False + + +class MentorStreamTests(unittest.TestCase): + def setUp(self) -> None: + self.skill = MentorSkill( + skill_id="test-mentor", + name="测试老师", + description="测试", + tagline="先看事实", + focus=("纪律",), + content="只做条件化判断。", + path=Path("SKILL.md"), + ) + self.lines = [ + b'data: {"choices":[{"delta":{"content":"first"}}]}\n', + b'data: {"choices":[{"delta":{"content":" second"}}]}\n', + b"data: [DONE]\n", + ] + + def test_stream_requests_upstream_streaming_and_yields_deltas(self): + captured = {} + + def open_request(request, timeout): + captured["payload"] = json.loads(request.data.decode("utf-8")) + captured["accept"] = request.headers.get("Accept") + return FakeStreamResponse(self.lines) + + with patch("mentor_agent.urllib.request.urlopen", side_effect=open_request): + chunks = list( + stream_with_mentor( + self.skill, {"data_trade_date": "20260723"}, "怎么看?", [], + "key", "https://example.test/v1", "model", + ) + ) + + self.assertEqual(chunks, ["first", " second"]) + self.assertTrue(captured["payload"]["stream"]) + self.assertEqual(captured["accept"], "text/event-stream") + + def test_non_streaming_compatibility_wrapper_collects_chunks(self): + with patch( + "mentor_agent.urllib.request.urlopen", + return_value=FakeStreamResponse(self.lines), + ): + result = chat_with_mentor( + self.skill, {}, "怎么看?", [], "key", "https://example.test/v1", "model" + ) + self.assertEqual(result["answer"], "first second") + + +if __name__ == "__main__": + unittest.main()