feat: personalize and stream mentor chat
This commit is contained in:
@@ -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",
|
||||
|
||||
+55
@@ -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,
|
||||
|
||||
+52
-12
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+294
-33
@@ -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) => `
|
||||
<button type="button" class="mentor-option ${mentor.id === state.selectedMentorId ? "active" : ""}" data-mentor-id="${escapeHtml(mentor.id)}" aria-pressed="${mentor.id === state.selectedMentorId}">
|
||||
<span class="mentor-option-copy">
|
||||
<strong>${escapeHtml(mentor.name)}</strong>
|
||||
<em title="${escapeHtml(mentor.description || "")}">${escapeHtml(mentor.description || mentor.tagline || "思维模型")}</em>
|
||||
</span>
|
||||
<span class="mentor-option-badges">${renderMentorBadges(mentor)}</span>
|
||||
</button>
|
||||
`).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 `
|
||||
<article class="mentor-option ${mentor.id === state.selectedMentorId ? "active" : ""} ${mentor.pinned ? "is-pinned" : ""}"
|
||||
data-mentor-card="${escapeHtml(mentor.id)}" draggable="${state.mentorSortMode && !state.mentorSavingPreferences}">
|
||||
<button type="button" class="mentor-option-main" data-mentor-id="${escapeHtml(mentor.id)}" aria-pressed="${mentor.id === state.selectedMentorId}" ${state.mentorLoading ? "disabled" : ""}>
|
||||
<span class="mentor-option-copy">
|
||||
<strong>${escapeHtml(mentor.name)}</strong>
|
||||
<em title="${escapeHtml(mentor.description || "")}">${escapeHtml(mentor.description || mentor.tagline || "思维模型")}</em>
|
||||
</span>
|
||||
<span class="mentor-option-badges">${renderMentorBadges(mentor)}</span>
|
||||
</button>
|
||||
<span class="mentor-option-tools">
|
||||
<button type="button" class="mentor-pin-button ${mentor.pinned ? "active" : ""}" data-mentor-pin="${escapeHtml(mentor.id)}"
|
||||
aria-label="${mentor.pinned ? "取消置顶" : "置顶"}${escapeHtml(mentor.name)}" title="${mentor.pinned ? "取消置顶" : "置顶"}" ${state.mentorSavingPreferences ? "disabled" : ""}>
|
||||
<i data-lucide="pin"></i>
|
||||
</button>
|
||||
${state.mentorSortMode ? `
|
||||
<button type="button" class="mentor-order-button" data-mentor-move="up" data-mentor-target="${escapeHtml(mentor.id)}" aria-label="上移${escapeHtml(mentor.name)}" title="上移" ${groupIndex <= 0 || state.mentorSavingPreferences ? "disabled" : ""}><i data-lucide="chevron-up"></i></button>
|
||||
<button type="button" class="mentor-order-button" data-mentor-move="down" data-mentor-target="${escapeHtml(mentor.id)}" aria-label="下移${escapeHtml(mentor.name)}" title="下移" ${groupIndex >= group.length - 1 || state.mentorSavingPreferences ? "disabled" : ""}><i data-lucide="chevron-down"></i></button>
|
||||
` : ""}
|
||||
</span>
|
||||
</article>
|
||||
`;
|
||||
}).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) => `
|
||||
<article class="mentor-message ${message.role}">
|
||||
<article class="mentor-message ${message.role} ${message.error ? "is-error" : ""}">
|
||||
<div class="mentor-message-label">${message.role === "user" ? "我" : escapeHtml(selected?.name || "问师")}</div>
|
||||
<div class="mentor-message-content">${message.role === "assistant" ? formatMentorAnswer(message.content) : escapeHtml(message.content)}</div>
|
||||
${message.meta ? `<small>${escapeHtml(message.meta)}</small>` : ""}
|
||||
${message.streaming ? '<span class="assistant-stream-caret" aria-hidden="true"></span>' : ""}
|
||||
${message.meta && !message.streaming ? `<small>${escapeHtml(message.meta)}</small>` : ""}
|
||||
</article>
|
||||
`).join("");
|
||||
if (state.mentorLoading) {
|
||||
if (state.mentorLoading && !state.mentorMessages.some((message) => message.streaming)) {
|
||||
container.insertAdjacentHTML("beforeend", `
|
||||
<article class="mentor-message assistant loading-message">
|
||||
<div class="mentor-message-label">${escapeHtml(selected?.name || "问师")}</div>
|
||||
@@ -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) => `<li>${item}</li>`).join("")}</${listType}>`);
|
||||
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 `<strong class="mentor-answer-heading">${formatMentorInline(heading[1])}</strong>`;
|
||||
if (/^-{3,}$/.test(line.trim())) return '<span class="mentor-answer-rule"></span>';
|
||||
if (line.startsWith("> ")) return `<span class="mentor-answer-quote">${formatMentorInline(line.slice(5))}</span>`;
|
||||
return formatMentorInline(line);
|
||||
}).join("<br>");
|
||||
const bullet = line.match(/^[-*]\s+(.+)$/);
|
||||
const ordered = line.match(/^\d+[.、]\s*(.+)$/);
|
||||
if (heading) {
|
||||
flushList();
|
||||
blocks.push(`<strong class="mentor-answer-heading">${formatMentorInline(escapeHtml(heading[1]))}</strong>`);
|
||||
} else if (/^-{3,}$/.test(line)) {
|
||||
flushList();
|
||||
blocks.push('<span class="mentor-answer-rule"></span>');
|
||||
} else if (line.startsWith("> ")) {
|
||||
flushList();
|
||||
blocks.push(`<span class="mentor-answer-quote">${formatMentorInline(escapeHtml(line.slice(2)))}</span>`);
|
||||
} 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(`<p class="mentor-answer-paragraph">${formatMentorInline(escapeHtml(line))}</p>`);
|
||||
}
|
||||
});
|
||||
flushList();
|
||||
return blocks.join("");
|
||||
}
|
||||
|
||||
function formatMentorInline(content) {
|
||||
|
||||
+5
-1
@@ -813,7 +813,10 @@
|
||||
<div id="mentorDirectoryContent" class="mentor-directory-content">
|
||||
<div class="workspace-heading mentor-directory-heading">
|
||||
<div><h3>思维模型</h3><span id="mentorCount">0 位</span></div>
|
||||
<button id="closeMentorDirectory" class="icon-button mentor-directory-close" type="button" aria-label="关闭思维模型目录" title="关闭"><i data-lucide="x"></i></button>
|
||||
<div class="mentor-directory-actions">
|
||||
<button id="mentorSortToggle" class="mentor-sort-toggle" type="button" aria-pressed="false" title="整理顺序"><i data-lucide="list-ordered"></i><span>整理</span></button>
|
||||
<button id="closeMentorDirectory" class="icon-button mentor-directory-close" type="button" aria-label="关闭思维模型目录" title="关闭"><i data-lucide="x"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="mentor-search-field">
|
||||
<span class="visually-hidden">搜索思维模型</span>
|
||||
@@ -826,6 +829,7 @@
|
||||
<button type="button" data-mentor-grade="B">B 多源</button>
|
||||
<button type="button" data-mentor-grade="C">C 推演</button>
|
||||
</div>
|
||||
<p id="mentorSortHint" class="mentor-sort-hint" hidden>拖动卡片,或使用箭头调整顺序</p>
|
||||
<div id="mentorList" class="mentor-list"></div>
|
||||
<div id="mentorListEmpty" class="mentor-list-empty" hidden>没有符合条件的思维模型</div>
|
||||
<p class="mentor-evidence-legend">素材等级反映蒸馏依据,不代表人物能力或收益水平。</p>
|
||||
|
||||
+214
-2
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user