Remove the mentor immersive rules in shared/shell.css that hid the module nav, header actions, market tape, overview strip and status bar while mentorView was active, which made the page look like an independent site. Mentor now stays inside the canonical 小白复盘 shell and owns its own "问师 + 数据日期" title header inside #mentorView (B-92 content intact). Also fix the shell's latent 1024px overflow: .main min-width 1080 now only applies at >=1280, and the overview strip scrolls internally instead of clipping its right side when it is wider than the content column. Update the contract and e2e baselines to assert the shell stays visible and usable on mentor, page switching leaves no residue, and add a dedicated shell-integration e2e test. Co-authored-by: multica-agent <github@multica.ai>
736 lines
31 KiB
JavaScript
736 lines
31 KiB
JavaScript
window.XiaobaiPageModules.register("mentor", ["mentorView"], {
|
|
bind: bindMentorEvents,
|
|
enter: ["loadMentor"],
|
|
});
|
|
|
|
async function loadMentorSetup(force = false) {
|
|
const requestedDate = elements.tradeDate.value.replaceAll("-", "");
|
|
if (!force && state.mentorSetup?.requestedDate === requestedDate) {
|
|
renderMentorWorkspace();
|
|
return;
|
|
}
|
|
try {
|
|
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 || "";
|
|
state.mentorMessages = await loadMentorMessages();
|
|
renderMentorWorkspace();
|
|
} catch (error) {
|
|
showMentorNotice(error.message || "问师模块加载失败");
|
|
showToast(error.message || "问师模块加载失败");
|
|
}
|
|
}
|
|
|
|
const MENTOR_AVATAR_TONES = {
|
|
"xiaobai-perspective": "violet",
|
|
"kobe92-perspective": "blue",
|
|
"beijingchaojia-perspective": "green",
|
|
"chaojiyangjia-perspective": "orange",
|
|
"chenxiaoqun-perspective": "red",
|
|
"longfeihu-perspective": "teal",
|
|
"chuangshiji-perspective": "purple",
|
|
"foshanwuyingjiao-perspective": "yellow",
|
|
};
|
|
|
|
const MENTOR_AVATAR_TONE_CLASSES = [
|
|
"mentor-avatar-tone-violet", "mentor-avatar-tone-blue", "mentor-avatar-tone-green",
|
|
"mentor-avatar-tone-orange", "mentor-avatar-tone-red", "mentor-avatar-tone-teal",
|
|
"mentor-avatar-tone-purple", "mentor-avatar-tone-yellow",
|
|
];
|
|
|
|
function mentorAvatarTone(mentor) {
|
|
return MENTOR_AVATAR_TONES[String(mentor?.id || "")] || "blue";
|
|
}
|
|
|
|
function mentorAvatarToneClass(mentor) {
|
|
return `mentor-avatar-tone-${mentorAvatarTone(mentor)}`;
|
|
}
|
|
|
|
function applyMentorAvatarTone(element, toneClass) {
|
|
if (!(element instanceof Element)) return;
|
|
element.classList.remove(...MENTOR_AVATAR_TONE_CLASSES);
|
|
if (toneClass) element.classList.add(toneClass);
|
|
}
|
|
|
|
function renderMentorWorkspace() {
|
|
const setup = state.mentorSetup;
|
|
if (!setup) return;
|
|
const selected = setup.mentors.find((item) => item.id === state.selectedMentorId) || null;
|
|
setText("mentorPageSubtitle", `与不同交易思维模型持续对话 · 数据日期 ${displayCompactDate(setup.trade_date)}`);
|
|
setText("activeMentorName", selected?.name || "--");
|
|
setText("activeMentorAvatar", mentorAvatarText(selected));
|
|
applyMentorAvatarTone(document.querySelector("#activeMentorAvatar"), mentorAvatarToneClass(selected));
|
|
const pinButton = document.querySelector("#mentorPinButton");
|
|
if (pinButton) {
|
|
pinButton.classList.toggle("active", Boolean(selected?.pinned));
|
|
pinButton.setAttribute("aria-label", selected?.pinned ? "取消置顶当前思维模型" : "置顶当前思维模型");
|
|
pinButton.setAttribute("aria-pressed", String(Boolean(selected?.pinned)));
|
|
}
|
|
populateMentorDialogs(selected);
|
|
renderMentorDirectory();
|
|
renderMentorMessages();
|
|
}
|
|
|
|
function populateMentorDialogs(selected) {
|
|
const noteInput = document.querySelector("#mentorNoteInput");
|
|
if (noteInput) {
|
|
noteInput.value = loadMentorNote(selected);
|
|
}
|
|
setText("mentorProfileDialogName", selected?.name || "--");
|
|
setText("mentorProfileDialogTagline", selected?.tagline || selected?.description || "--");
|
|
setText("mentorProfileDialogSource", selected?.evidence?.label || "公开资料整理");
|
|
setText("mentorProfileDialogEvidence", selected?.evidence?.note || selected?.description || "--");
|
|
setText("mentorProfileDialogDataDate", `行情数据 ${displayCompactDate(state.mentorSetup?.trade_date || elements.tradeDate.value)}`);
|
|
setText("mentorProfileDialogAvatar", mentorAvatarText(selected));
|
|
applyMentorAvatarTone(document.querySelector("#mentorProfileDialogAvatar"), mentorAvatarToneClass(selected));
|
|
document.querySelector("#mentorProfileDialogBadges").innerHTML = selected ? renderMentorBadges(selected) : "";
|
|
document.querySelector("#mentorProfileDialogFocus").innerHTML = (selected?.focus || []).slice(0, 4)
|
|
.map((item) => `<span>${escapeHtml(item)}</span>`).join("");
|
|
}
|
|
|
|
function mentorNoteStorageKey(selected) {
|
|
const accountId = state.user?.id || state.user?.username || "anon";
|
|
return `xiaobai-mentor-note-${accountId}-${String(selected?.id || "")}`;
|
|
}
|
|
|
|
function loadMentorNote(selected) {
|
|
if (!selected) return "";
|
|
try {
|
|
return window.localStorage.getItem(mentorNoteStorageKey(selected)) || "";
|
|
} catch (_error) {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function saveMentorNote() {
|
|
const selected = selectedMentor();
|
|
if (!selected) return;
|
|
const input = document.querySelector("#mentorNoteInput");
|
|
if (!input) return;
|
|
try {
|
|
window.localStorage.setItem(mentorNoteStorageKey(selected), input.value);
|
|
} catch (_error) {
|
|
showToast("备注保存失败");
|
|
}
|
|
}
|
|
|
|
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 = [
|
|
mentor.name,
|
|
mentor.description,
|
|
mentor.tagline,
|
|
mentor.evidence?.label,
|
|
mentor.evidence?.note,
|
|
...(mentor.focus || []),
|
|
].filter(Boolean).join(" ").toLocaleLowerCase("zh-CN");
|
|
return haystack.includes(query);
|
|
});
|
|
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.querySelector("#mentorFilterToggle").disabled = state.mentorSortMode;
|
|
document.querySelectorAll("[data-mentor-grade]").forEach((button) => {
|
|
button.disabled = state.mentorSortMode;
|
|
});
|
|
const filterOptions = document.querySelector("#mentorFilterOptions");
|
|
if (state.mentorSortMode && filterOptions && !filterOptions.hidden) {
|
|
filterOptions.hidden = true;
|
|
document.querySelector("#mentorFilterToggle").setAttribute("aria-expanded", "false");
|
|
}
|
|
const container = document.querySelector("#mentorList");
|
|
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-avatar ${mentorAvatarToneClass(mentor)}" data-grade="${escapeHtml(String(mentor.evidence?.grade || "").toLowerCase())}" aria-hidden="true">${escapeHtml(mentorAvatarText(mentor))}</span>
|
|
<span class="mentor-option-copy">
|
|
<span class="mentor-option-heading">
|
|
<strong>${escapeHtml(mentor.name)}</strong>
|
|
<span class="mentor-option-badges">${renderMentorBadges(mentor)}</span>
|
|
</span>
|
|
<em title="${escapeHtml(mentor.description || "")}">${escapeHtml(mentor.description || mentor.tagline || "思维模型")}</em>
|
|
<span class="mentor-option-meta">${escapeHtml((mentor.focus || [])[0] || mentor.evidence?.label || "公开资料模型")}</span>
|
|
</span>
|
|
</button>
|
|
${state.mentorSortMode ? `
|
|
<span class="mentor-option-tools">
|
|
<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-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();
|
|
}
|
|
|
|
function toggleMentorFilterMenu() {
|
|
if (state.mentorSortMode || state.mentorLoading) return;
|
|
const options = document.querySelector("#mentorFilterOptions");
|
|
const toggle = document.querySelector("#mentorFilterToggle");
|
|
if (!options || !toggle) return;
|
|
const open = options.hidden;
|
|
options.hidden = !open;
|
|
toggle.setAttribute("aria-expanded", String(open));
|
|
if (open) {
|
|
const active = options.querySelector("[data-mentor-grade].active");
|
|
(active || options.querySelector("[data-mentor-grade]"))?.focus({ preventScroll: true });
|
|
}
|
|
}
|
|
|
|
function closeMentorFilterMenu() {
|
|
const options = document.querySelector("#mentorFilterOptions");
|
|
const toggle = document.querySelector("#mentorFilterToggle");
|
|
if (!options || options.hidden) return;
|
|
options.hidden = true;
|
|
toggle?.setAttribute("aria-expanded", "false");
|
|
}
|
|
|
|
function selectMentorGrade(grade) {
|
|
state.mentorGrade = grade || "all";
|
|
document.querySelectorAll("[data-mentor-grade]").forEach((button) => {
|
|
button.classList.toggle("active", button.dataset.mentorGrade === state.mentorGrade);
|
|
});
|
|
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) {
|
|
const badges = [];
|
|
if (mentor.private) {
|
|
badges.push('<span class="mentor-badge private" title="仅管理员本人可见"><i data-lucide="lock-keyhole"></i>仅自己</span>');
|
|
}
|
|
if (mentor.pinned) {
|
|
badges.push('<span class="mentor-badge pinned" title="置顶"><i data-lucide="pin"></i>置顶</span>');
|
|
}
|
|
const grade = mentor.evidence?.grade;
|
|
if (grade) {
|
|
badges.push(`<span class="mentor-badge evidence grade-${escapeHtml(grade.toLowerCase())}" title="${escapeHtml(mentor.evidence?.note || "素材等级")}">${escapeHtml(grade)}级</span>`);
|
|
}
|
|
return badges.join("");
|
|
}
|
|
|
|
function mentorAvatarText(mentor) {
|
|
return Array.from(String(mentor?.name || "师").trim())[0] || "师";
|
|
}
|
|
|
|
function mentorMessageTime(message) {
|
|
const parsed = new Date(String(message?.created_at || ""));
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
return new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
|
|
}
|
|
return parsed.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
|
|
}
|
|
|
|
async function selectMentor(mentorId) {
|
|
if (mentorId === state.selectedMentorId) return;
|
|
state.selectedMentorId = mentorId;
|
|
state.mentorMessages = [];
|
|
hideMentorNotice();
|
|
renderMentorWorkspace();
|
|
state.mentorMessages = await loadMentorMessages();
|
|
renderMentorMessages();
|
|
}
|
|
|
|
function renderMentorMessages() {
|
|
const container = document.querySelector("#mentorMessages");
|
|
const selected = state.mentorSetup?.mentors.find((item) => item.id === state.selectedMentorId);
|
|
if (!state.mentorMessages.length && !state.mentorLoading) {
|
|
container.innerHTML = `
|
|
<div class="mentor-empty-state">
|
|
<span class="mentor-empty-mark" aria-hidden="true"><i data-lucide="messages-square"></i></span>
|
|
<strong>向「${escapeHtml(selected?.name || "问师")}」请教</strong>
|
|
<p>${escapeHtml(selected?.tagline || selected?.description || "从一个具体问题开始对话")}</p>
|
|
</div>
|
|
`;
|
|
refreshIcons();
|
|
} else {
|
|
container.innerHTML = state.mentorMessages.map((message) => `
|
|
<article class="mentor-message ${message.role} ${message.error ? "is-error" : ""}">
|
|
<span class="mentor-message-avatar ${message.role === "user" ? "mentor-avatar-tone-blue" : escapeHtml(mentorAvatarToneClass(selected))}" aria-hidden="true">${message.role === "user" ? "我" : escapeHtml(mentorAvatarText(selected))}</span>
|
|
<div class="mentor-message-body">
|
|
<div class="mentor-message-label">${message.role === "user"
|
|
? escapeHtml(mentorMessageTime(message))
|
|
: `${escapeHtml(selected?.name || "问师")} · ${escapeHtml(mentorMessageTime(message))}`}</div>
|
|
<div class="mentor-message-content">${message.role === "assistant"
|
|
? (message.content ? formatMentorAnswer(message.content) : '<p class="mentor-loading-copy">正在读取复盘数据并推演...</p>')
|
|
: escapeHtml(message.content)}</div>
|
|
${message.streaming ? '<span class="assistant-stream-caret" aria-hidden="true"></span>' : ""}
|
|
${renderMentorFollowUps(message)}
|
|
</div>
|
|
</article>
|
|
`).join("");
|
|
}
|
|
document.querySelector("#mentorQuickPrompts").hidden = false;
|
|
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("#sendMentorQuestion").hidden = state.mentorLoading;
|
|
document.querySelector("#stopMentorQuestion").hidden = !state.mentorLoading;
|
|
document.querySelector("#mentorSortToggle").disabled = state.mentorLoading;
|
|
setText("activeMentorStatus", state.mentorLoading ? "正在生成回答..." : (selected?.tagline || selected?.description || "思维模型已就绪"));
|
|
container.querySelectorAll("[data-mentor-follow-up]").forEach((button) => {
|
|
button.addEventListener("click", () => useMentorQuickPrompt(button.dataset.mentorFollowUp));
|
|
});
|
|
refreshIcons();
|
|
requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
|
|
}
|
|
|
|
function renderMentorFollowUps(message) {
|
|
if (message.role !== "assistant" || message.streaming || message.error || !Array.isArray(message.followUps)) return "";
|
|
const items = message.followUps.filter(Boolean).slice(0, 3);
|
|
if (items.length < 2) return "";
|
|
return `
|
|
<div class="mentor-follow-ups" aria-label="继续追问">
|
|
<span>继续追问</span>
|
|
${items.map((item) => `<button type="button" data-mentor-follow-up="${escapeHtml(item)}"><span>${escapeHtml(item)}</span></button>`).join("")}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
async function sendMentorQuestion(event) {
|
|
event.preventDefault();
|
|
if (state.mentorLoading || !state.selectedMentorId) return;
|
|
const input = document.querySelector("#mentorQuestion");
|
|
const question = input.value.trim();
|
|
if (!question) return;
|
|
const history = state.mentorMessages.slice(-6).map((item) => ({
|
|
role: item.role,
|
|
content: item.content.slice(0, 3500),
|
|
}));
|
|
state.mentorMessages.forEach((message) => { delete message.followUps; });
|
|
state.mentorMessages.push({ role: "user", content: question });
|
|
const responseMessage = { role: "assistant", content: "", streaming: true, meta: "", followUps: [] };
|
|
state.mentorMessages.push(responseMessage);
|
|
input.value = "";
|
|
syncMentorComposerHeight();
|
|
state.mentorLoading = true;
|
|
state.mentorController = new AbortController();
|
|
hideMentorNotice();
|
|
renderMentorMessages();
|
|
renderMentorDirectory();
|
|
setStatus("问师正在读取复盘数据");
|
|
try {
|
|
await streamMentorRequest(
|
|
{
|
|
mentor_id: state.selectedMentorId,
|
|
trade_date: elements.tradeDate.value,
|
|
question,
|
|
history,
|
|
},
|
|
state.mentorController.signal,
|
|
(chunk) => {
|
|
responseMessage.content += chunk;
|
|
scheduleMentorRender();
|
|
},
|
|
(meta) => {
|
|
responseMessage.followUps = Array.isArray(meta.follow_ups)
|
|
? meta.follow_ups.filter((item) => typeof item === "string" && item.trim()).slice(0, 3)
|
|
: [];
|
|
if (meta.notice) showMentorNotice(meta.notice);
|
|
},
|
|
);
|
|
responseMessage.streaming = false;
|
|
setStatus("问师回答完成");
|
|
} catch (error) {
|
|
responseMessage.streaming = false;
|
|
responseMessage.followUps = [];
|
|
if (state.mentorController?.signal.aborted) {
|
|
if (responseMessage.content) {
|
|
responseMessage.meta = "生成已停止";
|
|
} else {
|
|
state.mentorMessages = state.mentorMessages.filter((item) => item !== responseMessage);
|
|
}
|
|
setStatus("已停止问师回答");
|
|
} else {
|
|
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();
|
|
}
|
|
}
|
|
|
|
function stopMentorGeneration() {
|
|
if (!state.mentorLoading || !state.mentorController) return;
|
|
state.mentorController.abort();
|
|
setText("activeMentorStatus", "正在停止...");
|
|
}
|
|
|
|
let mentorRenderFrame = 0;
|
|
|
|
function scheduleMentorRender() {
|
|
if (mentorRenderFrame) return;
|
|
mentorRenderFrame = requestAnimationFrame(() => {
|
|
mentorRenderFrame = 0;
|
|
renderMentorMessages();
|
|
});
|
|
}
|
|
|
|
async function streamMentorRequest(body, signal, onDelta, onMeta) {
|
|
await window.XiaobaiAPI.streamNdjson("/api/mentors/chat", {
|
|
method: "POST",
|
|
body,
|
|
signal,
|
|
errorMessage: "问师暂不可用",
|
|
onEvent: (event) => {
|
|
if (event.type === "delta") onDelta(String(event.content || ""));
|
|
if (event.type === "meta") onMeta(event);
|
|
},
|
|
});
|
|
}
|
|
|
|
function useMentorQuickPrompt(prompt) {
|
|
const input = document.querySelector("#mentorQuestion");
|
|
input.value = prompt || "";
|
|
syncMentorComposerHeight();
|
|
input.focus();
|
|
}
|
|
|
|
async function clearMentorConversation() {
|
|
if (!state.mentorMessages.length || !window.confirm("确定清空当前老师的对话记录吗?")) return;
|
|
try {
|
|
const query = new URLSearchParams({
|
|
mentor_id: state.selectedMentorId,
|
|
trade_date: state.mentorSetup?.trade_date || elements.tradeDate.value,
|
|
});
|
|
await apiRequest(`/api/mentors/messages?${query}`, "DELETE");
|
|
state.mentorMessages = [];
|
|
hideMentorNotice();
|
|
renderMentorMessages();
|
|
} catch (error) {
|
|
showToast(error.message || "对话记录清空失败");
|
|
}
|
|
}
|
|
|
|
async function loadMentorMessages() {
|
|
if (!state.selectedMentorId) return [];
|
|
try {
|
|
const query = new URLSearchParams({
|
|
mentor_id: state.selectedMentorId,
|
|
trade_date: state.mentorSetup?.trade_date || elements.tradeDate.value,
|
|
});
|
|
const payload = await apiRequest(`/api/mentors/messages?${query}`);
|
|
return (payload.items || []).filter(
|
|
(item) => ["user", "assistant"].includes(item?.role) && typeof item.content === "string",
|
|
).slice(-100);
|
|
} catch (error) {
|
|
showMentorNotice(error.message || "对话记录加载失败");
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function showMentorNotice(message) {
|
|
const notice = document.querySelector("#mentorNotice");
|
|
notice.textContent = message;
|
|
notice.hidden = false;
|
|
}
|
|
|
|
function hideMentorNotice() {
|
|
document.querySelector("#mentorNotice").hidden = true;
|
|
}
|
|
|
|
function formatMentorAnswer(content) {
|
|
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+(.+)$/);
|
|
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) {
|
|
return content.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
|
}
|
|
|
|
|
|
function bindMentorEvents() {
|
|
document.querySelector("#mentorChatForm").addEventListener("submit", sendMentorQuestion);
|
|
document.querySelector("#stopMentorQuestion").addEventListener("click", stopMentorGeneration);
|
|
document.querySelector("#clearMentorChatButton").addEventListener("click", clearMentorConversation);
|
|
document.querySelector("#mentorSortToggle").addEventListener("click", toggleMentorSortMode);
|
|
document.querySelector("#mentorFilterToggle").addEventListener("click", toggleMentorFilterMenu);
|
|
document.querySelector("#mentorSearchInput").addEventListener("input", (event) => {
|
|
state.mentorQuery = event.target.value.trim().toLocaleLowerCase("zh-CN");
|
|
renderMentorDirectory();
|
|
});
|
|
document.querySelectorAll("[data-mentor-grade]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
selectMentorGrade(button.dataset.mentorGrade);
|
|
closeMentorFilterMenu();
|
|
});
|
|
});
|
|
document.addEventListener("click", (event) => {
|
|
if (event.target.closest(".mentor-filter-menu")) return;
|
|
closeMentorFilterMenu();
|
|
});
|
|
document.querySelectorAll("[data-mentor-prompt]").forEach((button) => {
|
|
button.addEventListener("click", () => useMentorQuickPrompt(button.dataset.mentorPrompt));
|
|
});
|
|
document.querySelector("#mentorQuestion").addEventListener("keydown", (event) => {
|
|
if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
|
|
event.preventDefault();
|
|
document.querySelector("#mentorChatForm").requestSubmit();
|
|
});
|
|
document.querySelector("#mentorQuestion").addEventListener("input", syncMentorComposerHeight);
|
|
document.querySelector("#mentorNoteButton").addEventListener("click", openMentorNoteDialog);
|
|
document.querySelector("#mentorProfileButton").addEventListener("click", openMentorProfileDialog);
|
|
document.querySelector("#mentorPinButton").addEventListener("click", () => toggleMentorPin(state.selectedMentorId));
|
|
document.querySelector("#mentorNoteInput").addEventListener("input", saveMentorNote);
|
|
document.querySelectorAll("[data-mentor-dialog-close]").forEach((button) => {
|
|
button.addEventListener("click", () => closeMentorDialog(button.dataset.mentorDialogClose));
|
|
});
|
|
window.addEventListener("resize", centerOpenMentorDialogs);
|
|
}
|
|
|
|
function selectedMentor() {
|
|
return (state.mentorSetup?.mentors || []).find((item) => item.id === state.selectedMentorId) || null;
|
|
}
|
|
|
|
function syncMentorComposerHeight() {
|
|
const input = document.querySelector("#mentorQuestion");
|
|
if (!input) return;
|
|
input.style.height = "0";
|
|
const nextHeight = Math.min(input.scrollHeight, 168);
|
|
input.style.height = `${nextHeight}px`;
|
|
input.style.overflowY = nextHeight >= 168 ? "auto" : "hidden";
|
|
}
|
|
|
|
function openMentorDialog(dialogId) {
|
|
const dialog = document.querySelector(`#${dialogId}`);
|
|
if (!(dialog instanceof HTMLDialogElement)) return;
|
|
if (!dialog.open) dialog.showModal();
|
|
positionMentorDialog(dialog);
|
|
}
|
|
|
|
function closeMentorDialog(dialogId) {
|
|
const dialog = document.querySelector(`#${dialogId}`);
|
|
if (dialog instanceof HTMLDialogElement && dialog.open) dialog.close();
|
|
}
|
|
|
|
function openMentorNoteDialog() {
|
|
const input = document.querySelector("#mentorNoteInput");
|
|
if (input) input.value = loadMentorNote(selectedMentor());
|
|
openMentorDialog("mentorNoteDialog");
|
|
const noteInput = document.querySelector("#mentorNoteInput");
|
|
if (noteInput) requestAnimationFrame(() => noteInput.focus());
|
|
}
|
|
|
|
function openMentorProfileDialog() {
|
|
populateMentorDialogs(selectedMentor());
|
|
openMentorDialog("mentorProfileDialog");
|
|
}
|
|
|
|
function positionMentorDialog(dialog) {
|
|
if (!(dialog instanceof HTMLDialogElement) || !dialog.open) return;
|
|
const viewportPadding = 12;
|
|
const rect = dialog.getBoundingClientRect();
|
|
const width = Math.min(400, window.innerWidth - viewportPadding * 2);
|
|
const height = Math.min(rect.height, window.innerHeight - viewportPadding * 2);
|
|
dialog.style.top = `${Math.max(viewportPadding, Math.round((window.innerHeight - height) / 2))}px`;
|
|
dialog.style.left = `${Math.max(viewportPadding, Math.round((window.innerWidth - width) / 2))}px`;
|
|
dialog.style.right = "auto";
|
|
dialog.style.bottom = "auto";
|
|
dialog.style.margin = "0";
|
|
}
|
|
|
|
function centerOpenMentorDialogs() {
|
|
document.querySelectorAll("#mentorNoteDialog, #mentorProfileDialog").forEach((dialog) => {
|
|
if (dialog instanceof HTMLDialogElement && dialog.open) positionMentorDialog(dialog);
|
|
});
|
|
}
|