const state = { sessions: [], current: null, currentId: null, events: [], filtered: [], statusFilters: new Set(), roleFilters: new Set(), search: "", autoRefresh: false, refreshTimer: null, lastTs: null, seen: new Set(), selectedKey: null, }; const elements = { sessions: document.getElementById("sessions"), refreshBtn: document.getElementById("refresh-btn"), sessionTitle: document.getElementById("session-title"), sessionMeta: document.getElementById("session-meta"), statTotal: document.getElementById("stat-total"), statIo: document.getElementById("stat-io"), statFallback: document.getElementById("stat-fallback"), statInvalid: document.getElementById("stat-roles"), statusFilters: document.getElementById("status-filters"), roleFilters: document.getElementById("role-filters"), search: document.getElementById("search"), events: document.getElementById("events"), detail: document.getElementById("detail-card"), liveLabel: document.getElementById("live-label"), autoRefresh: document.getElementById("auto-refresh"), gameKey: document.getElementById("game-key"), voteBoard: document.getElementById("vote-board"), skillBoard: document.getElementById("skill-board"), memoryBoard: document.getElementById("memory-board"), copyMdBtn: document.getElementById("copy-md-btn"), downloadMdBtn: document.getElementById("download-md-btn"), }; function safeText(el, text) { if (!el) return; el.textContent = text ?? ""; } function asString(value) { if (value === null || value === undefined) return ""; if (typeof value === "string") return value; if (typeof value === "number" || typeof value === "boolean") return String(value); try { return JSON.stringify(value); } catch (err) { return String(value); } } function escapeHtml(value) { return asString(value) .replace(/&/g, "&") .replace(//g, ">") .replace(/\"/g, """) .replace(/'/g, "'"); } function getStatus(e) { return e?.status || e?.event?.status || ""; } function getRole(e) { return e?.role || e?.event?.role || ""; } function inferKind(e) { if (!e) return ""; if (e.kind) return e.kind; if (e.final_result !== undefined || Object.prototype.hasOwnProperty.call(e, "final_result")) return "interact"; if (e.event !== undefined || Object.prototype.hasOwnProperty.call(e, "event")) return "perceive"; return ""; } function formatDate(ts) { const num = typeof ts === "number" ? ts : Number.parseFloat(ts); if (!num) return "-"; const date = new Date(num * 1000); return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`; } function statusLabel(status) { const m = { start: "开始", night: "夜晚", day: "白天", discuss: "发言", vote: "投票", vote_result: "投票结果", skill: "技能", skill_result: "技能结果", night_info: "夜间信息", wolf_speech: "狼人交流", sheriff_election: "上警选择", sheriff_speech: "警上发言", sheriff_pk: "警上PK", sheriff_vote: "警上投票", sheriff_speech_order: "发言顺序", sheriff: "警徽转移", hunter: "开枪状态", hunter_result: "开枪结果", result: "结算", }; return m[status] || status || "未知"; } function kindLabel(kind) { if (kind === "perceive") return "输入"; if (kind === "interact") return "输出"; return kind || "未知"; } function setLive(active) { elements.liveLabel.textContent = active ? "实时" : "未开启"; } async function fetchJson(url) { const res = await fetch(url, { cache: "no-store" }); if (!res.ok) { throw new Error(`Request failed: ${res.status}`); } return res.json(); } async function loadSessions() { const data = await fetchJson("./api/sessions"); state.sessions = data.sessions || []; renderSessions(); } function renderSessions() { elements.sessions.innerHTML = ""; if (!state.sessions.length) { elements.sessions.innerHTML = `
暂无对局日志。
`; return; } state.sessions.forEach((session) => { const item = document.createElement("div"); item.className = "session"; if (state.currentId && state.currentId === session.id) { item.classList.add("active"); } const roles = Array.isArray(session.roles) ? session.roles.join(", ") : ""; item.innerHTML = `
${escapeHtml(session.id)}
${escapeHtml(roles || "未知")} - ${escapeHtml(session.events)} 条
更新于 ${formatDate(session.updated_at)}
`; item.addEventListener("click", () => selectSession(session.id)); elements.sessions.appendChild(item); }); } function _eventKey(e) { const parts = [ e.ts, e.kind, e.status, e.role, e.name, e.round, e.final_result, e.final_skillTargetPlayer, ]; return parts.map((x) => (x === undefined || x === null ? "" : String(x))).join("|"); } function _ingestEvents(events) { let changed = false; for (const e of events || []) { const key = _eventKey(e); if (state.seen.has(key)) continue; state.seen.add(key); state.events.push(e); const ts = typeof e.ts === "number" ? e.ts : Number.parseFloat(e.ts); if (Number.isFinite(ts)) { state.lastTs = state.lastTs === null ? ts : Math.max(state.lastTs, ts); } changed = true; } state.events.sort((a, b) => { const ta = typeof a.ts === "number" ? a.ts : Number.parseFloat(a.ts); const tb = typeof b.ts === "number" ? b.ts : Number.parseFloat(b.ts); return (Number.isFinite(ta) ? ta : 0) - (Number.isFinite(tb) ? tb : 0); }); return changed; } async function selectSession(sessionId) { try { const data = await fetchJson(`./api/session/${encodeURIComponent(sessionId)}`); state.current = data; state.currentId = data.session_id; state.events = data.events || []; state.seen = new Set(); state.lastTs = null; for (const e of state.events) state.seen.add(_eventKey(e)); if (state.events.length) state.lastTs = state.events[state.events.length - 1]?.ts ?? null; state.filtered = state.events; state.statusFilters.clear(); state.roleFilters.clear(); state.search = ""; if (elements.search) elements.search.value = ""; renderSessions(); renderHeader(); renderStats(); renderFilters(); renderDash(); applyFilters(); } catch (err) { console.error(err); safeText(elements.sessionMeta, `加载/渲染失败: ${err?.message || err}`); } } function renderHeader() { if (!state.current) { safeText(elements.sessionTitle, "请选择一个对局"); safeText(elements.sessionMeta, "尚未加载数据"); return; } safeText(elements.sessionTitle, state.current.session_id); const last = state.events[state.events.length - 1]; safeText(elements.sessionMeta, `${state.events.length} 条 - 最近 ${formatDate(last?.ts)}`); } function renderStats() { const stats = state.current?.stats; if (!stats) { safeText(elements.statTotal, "-"); safeText(elements.statIo, "-"); safeText(elements.statFallback, "-"); safeText(elements.statInvalid, "-"); return; } safeText(elements.statTotal, stats.total); safeText(elements.statIo, `${stats.perceive ?? "-"} / ${stats.interact ?? "-"}`); safeText(elements.statFallback, stats.fallback); safeText(elements.statInvalid, stats.invalid); } function renderFilters() { if (!elements.statusFilters || !elements.roleFilters) return; const statuses = new Set(); const roles = new Set(); state.events.forEach((e) => { const status = getStatus(e); const role = getRole(e); if (status) statuses.add(status); if (role) roles.add(role); }); elements.statusFilters.innerHTML = ""; elements.roleFilters.innerHTML = ""; Array.from(statuses).sort().forEach((status) => { const chip = createChip(status, state.statusFilters, () => applyFilters()); elements.statusFilters.appendChild(chip); }); Array.from(roles).sort().forEach((role) => { const chip = createChip(role, state.roleFilters, () => applyFilters()); elements.roleFilters.appendChild(chip); }); } function createChip(label, set, onToggle) { const chip = document.createElement("div"); chip.className = "chip"; chip.textContent = label; chip.addEventListener("click", () => { if (set.has(label)) { set.delete(label); chip.classList.remove("active"); } else { set.add(label); chip.classList.add("active"); } onToggle(); }); return chip; } function deriveKeyFacts(events) { const out = { round: null, sheriff: null, lastNight: null, lastExile: null, }; for (let i = events.length - 1; i >= 0; i--) { const e = events[i]; if (out.round === null) { const r = typeof e.round === "number" ? e.round : Number.parseInt(e.round, 10); if (Number.isFinite(r)) out.round = r; } const st = e.state || e.memory?.state; if (!out.sheriff && st && st.sheriff) out.sheriff = asString(st.sheriff); const fact = e.fact; if (!out.sheriff && fact && fact.type === "sheriff" && fact.name) out.sheriff = asString(fact.name); const kind = inferKind(e); const status = getStatus(e); if (!out.lastNight && kind === "perceive" && status === "night_info") { out.lastNight = asString(e.event?.message); } if (!out.lastExile && kind === "perceive" && status === "vote_result") { out.lastExile = asString(e.event?.name || e.event?.message); } } return out; } function deriveVotes(events) { const rounds = new Map(); // round -> {votes: [{who,to,kind}], counts: Map} for (const e of events) { if (inferKind(e) !== "perceive") continue; const status = getStatus(e); if (status !== "vote" && status !== "sheriff_vote") continue; let r = typeof e.round === "number" ? e.round : Number.parseInt(e.round, 10); if (!Number.isFinite(r)) r = -1; const who = asString(e.event?.name || e.event?.voter || e.event?.from || e.name); const to = asString(e.event?.message || e.event?.target || e.message); if (!who || !to) continue; if (!rounds.has(r)) rounds.set(r, { votes: [], counts: new Map() }); const bucket = rounds.get(r); bucket.votes.push({ who, to, kind: status }); bucket.counts.set(to, (bucket.counts.get(to) || 0) + 1); } return rounds; } function deriveSkills(events) { const items = []; for (let i = 0; i < events.length; i++) { const e = events[i]; const status = getStatus(e); if (inferKind(e) === "interact" && status === "skill") { const result = asString(e.final_result); const target = asString(e.final_skillTargetPlayer); let judge = ""; for (let j = i + 1; j < events.length; j++) { const n = events[j]; if (inferKind(n) === "perceive" && getStatus(n) === "skill_result") { judge = asString(n.event?.message || n.event?.name); break; } } items.push({ round: e.round, ts: e.ts, result, target, judge }); } } return items; } function deriveLatestMemory(events) { for (let i = events.length - 1; i >= 0; i--) { const e = events[i]; const mem = e.memory; if (mem && (mem.summary || (mem.history_tail && mem.history_tail.length))) return mem; } return null; } function renderDash() { if (!elements.gameKey || !elements.voteBoard || !elements.skillBoard || !elements.memoryBoard) return; if (!state.currentId) { safeText(elements.gameKey, "未加载"); safeText(elements.voteBoard, "未加载"); safeText(elements.skillBoard, "未加载"); safeText(elements.memoryBoard, "未加载"); return; } const key = deriveKeyFacts(state.events); safeText( elements.gameKey, [ `回合: ${key.round ?? "-"}`, `警长: ${key.sheriff ?? "-"}`, `最近夜间信息: ${key.lastNight ? asString(key.lastNight).slice(0, 160) : "-"}`, `最近放逐: ${key.lastExile || "-"}`, ].join("\n") ); const rounds = deriveVotes(state.events); const lastRounds = Array.from(rounds.keys()).sort((a, b) => a - b).slice(-3); if (!lastRounds.length) { safeText(elements.voteBoard, "暂无投票记录"); } else { const blocks = []; for (const r of lastRounds) { const bucket = rounds.get(r); const counts = Array.from(bucket.counts.entries()) .sort((a, b) => b[1] - a[1]) .map(([t, c]) => `${asString(t)}(${c})`) .join(" "); const lines = bucket.votes .slice(-20) .map((v) => `${asString(v.who)} -> ${asString(v.to)}${v.kind === "sheriff_vote" ? " (警上)" : ""}`); blocks.push(`第${r}天\n统计: ${counts || "-"}\n${lines.join("\n")}`); } safeText(elements.voteBoard, blocks.join("\n\n")); } const skills = deriveSkills(state.events).slice(-10); if (!skills.length) { safeText(elements.skillBoard, "暂无技能交互"); } else { safeText( elements.skillBoard, skills .map((s) => { const parts = [`第${s.round ?? "-"}轮`, `我: ${asString(s.result) || "-"}`]; if (s.target) parts.push(`目标: ${s.target}`); if (s.judge) parts.push(`裁判: ${asString(s.judge).slice(0, 140)}`); return parts.join(" | "); }) .join("\n") ); } const mem = deriveLatestMemory(state.events); if (!mem) { safeText(elements.memoryBoard, "暂无记忆快照(请跑一轮 interact 后会出现)"); } else { const summary = mem.summary ? `摘要:\n${mem.summary}\n\n` : ""; const tail = (mem.history_tail || []).slice(-30).join("\n"); safeText(elements.memoryBoard, `${summary}最近对话(尾部):\n${tail || "-"}`); } } function applyFilters() { const query = state.search.trim().toLowerCase(); state.filtered = state.events.filter((e) => { const status = getStatus(e); const role = getRole(e); if (state.statusFilters.size && !state.statusFilters.has(status)) return false; if (state.roleFilters.size && !state.roleFilters.has(role)) return false; if (!query) return true; const event = e.event || {}; const haystack = [ inferKind(e), status, role, e.name, event.name, event.message, e.final_result, e.final_skillTargetPlayer, e.fallback_reason, ] .map(asString) .join(" ") .toLowerCase(); return haystack.includes(query); }); renderEvents(); } function renderEvents() { if (!elements.events) return; elements.events.innerHTML = ""; if (!state.filtered.length) { elements.events.innerHTML = `
没有匹配的事件。
`; return; } state.filtered.forEach((event, index) => { const item = document.createElement("div"); item.className = "event"; const kind = inferKind(event) || "perceive"; const status = getStatus(event); const role = getRole(event); const ev = event.event || {}; const line = (() => { if (kind === "perceive") { if (status === "vote") { return `${asString(ev.name) || "-"} 投票: ${asString(ev.message).slice(0, 40)}`; } if (status === "vote_result") return `放逐结果: ${asString(ev.name || ev.message).slice(0, 60)}`; if (status === "night_info") return `夜间信息: ${asString(ev.message).slice(0, 60)}`; if (status === "skill_result") return `技能结果: ${asString(ev.message || ev.name).slice(0, 60)}`; if (status === "discuss" || status === "sheriff_speech" || status === "wolf_speech") { return `${asString(ev.name) || "主持人"}: ${asString(ev.message).slice(0, 70)}`; } return `${asString(ev.name) || "主持人"}: ${asString(ev.message).slice(0, 70)}`; } if (status === "vote") return `我投: ${asString(event.final_result || "-").slice(0, 40)}`; if (status === "skill") return `我用技能: ${asString(event.final_result || "-").slice(0, 60)}`; return `我说: ${asString(event.final_result || "-").slice(0, 70)}`; })(); const key = _eventKey(event); item.dataset.key = key; item.innerHTML = `
${escapeHtml(kindLabel(kind))} · ${escapeHtml(statusLabel(status))} - 第${escapeHtml(event.round ?? "-")}轮 ${escapeHtml(formatDate(event.ts))}
${escapeHtml(role || "role?")} ${escapeHtml(event.name || "name?")} ${escapeHtml(line)} ${ event.fallback_used ? `fallback: ${escapeHtml(event.fallback_reason || "yes")}` : "" }
`; item.addEventListener("click", () => { document.querySelectorAll(".event.active").forEach((el) => el.classList.remove("active")); item.classList.add("active"); state.selectedKey = key; renderDetail(event, index); }); elements.events.appendChild(item); }); if (state.selectedKey) { const active = Array.from(document.querySelectorAll(".event")).find( (el) => el.dataset.key === state.selectedKey ); if (active) active.classList.add("active"); } } function renderDetail(event) { if (!elements.detail) return; if (!event) { elements.detail.classList.add("empty"); elements.detail.innerHTML = `
点击左侧事件查看详情。
`; return; } const payload = event.payload || {}; const promptPreview = payload.prompt_preview || ""; const kind = inferKind(event) || "perceive"; const status = getStatus(event); const ev = event.event || {}; const fact = event.fact || {}; const st = event.state || {}; const mem = event.memory || {}; const memState = mem.state || {}; elements.detail.classList.remove("empty"); const choices = Array.isArray(event.choices) ? event.choices.join(", ") : asString(event.choices); elements.detail.innerHTML = ` ${detailRow("类型", kindLabel(kind))} ${detailRow("状态", statusLabel(status))} ${detailRow("角色", getRole(event))} ${detailRow("我的名字", event.name)} ${detailRow("轮次", event.round)} ${detailRow("警长(事件)", st.sheriff ?? "-")} ${detailRow("警长(记忆)", memState.sheriff ?? "-")} ${detailRow("我的输出", event.final_result)} ${detailRow("技能目标", event.final_skillTargetPlayer ?? "-")} ${detailRow("候选列表", choices)} ${detailRow("是否兜底", event.fallback_used ? "是" : "否")} ${detailRow("兜底原因", event.fallback_reason || "-")} ${detailRow("模型原始输出", event.llm_raw || event.attempt1_raw || "-")} ${detailRow("模型重试输出", event.llm_retry_raw || event.attempt2_raw || "-")} ${detailRow("Prompt 预览", promptPreview || "-")} ${detailRow("输入来源", ev.name || "-")} ${detailRow("输入内容", ev.message || "-")} ${detailRow("关键事实", fact)} ${detailRow("记忆摘要", mem.summary || "-")} ${detailRow("记忆(尾部)", (mem.history_tail || []).join("\\n") || "-")} `; } function detailRow(label, value) { const rendered = (() => { if (value === null || value === undefined || value === "") return "-"; if (typeof value === "string") return value; try { return JSON.stringify(value, null, 2); } catch (err) { return String(value); } })(); return `
${escapeHtml(label)}
${escapeHtml(rendered)}
`; } function clipText(value, maxLen) { const s = asString(value); if (!maxLen || maxLen <= 0) return s; if (s.length <= maxLen) return s; return `${s.slice(0, Math.max(0, maxLen - 1))}…`; } function buildMarkdownReport() { if (!state.currentId) return ""; const now = new Date(); const stats = state.current?.stats || {}; const key = deriveKeyFacts(state.events); const voteRounds = deriveVotes(state.events); const skillItems = deriveSkills(state.events); const latestMem = deriveLatestMemory(state.events); const lines = []; lines.push(`# 狼人杀对局调试报告`); lines.push(""); lines.push(`- 对局ID: \`${state.currentId}\``); lines.push(`- 导出时间: ${now.toLocaleString()}`); lines.push(`- 说明: 本报告基于调试台当前已加载事件(默认最多 5000 条;若对局更长可能未包含最早部分)`); if (stats && Object.keys(stats).length) { lines.push( `- 事件数: ${stats.total ?? "-"}(输入 ${stats.perceive ?? "-"} / 输出 ${stats.interact ?? "-"})` ); lines.push(`- 兜底次数: ${stats.fallback ?? "-"}`); lines.push(`- 无效次数: ${stats.invalid ?? "-"}`); } lines.push(""); lines.push(`## 对局关键`); lines.push(`- 回合: ${key.round ?? "-"}`); lines.push(`- 警长: ${key.sheriff ?? "-"}`); lines.push(`- 最近夜间信息: ${clipText(key.lastNight || "-", 240)}`); lines.push(`- 最近放逐: ${clipText(key.lastExile || "-", 240)}`); lines.push(""); lines.push(`## 投票汇总(从裁判广播提取,可能不完整)`); const rounds = Array.from(voteRounds.keys()).sort((a, b) => a - b); if (!rounds.length) { lines.push(`暂无投票记录`); } else { for (const r of rounds) { const bucket = voteRounds.get(r); const counts = Array.from(bucket.counts.entries()) .sort((a, b) => b[1] - a[1]) .map(([t, c]) => `${asString(t)}(${c})`) .join(" "); lines.push(""); lines.push(`### 第${r}天`); lines.push(`- 统计: ${counts || "-"}`); lines.push(`- 明细:`); for (const v of bucket.votes) { lines.push(` - ${asString(v.who)} -> ${asString(v.to)}${v.kind === "sheriff_vote" ? " (警上)" : ""}`); } } } lines.push(""); lines.push(`## 技能过程(我)`); if (!skillItems.length) { lines.push(`暂无技能交互`); } else { for (const s of skillItems) { const parts = [`第${s.round ?? "-"}轮`, `我: ${clipText(s.result || "-", 240)}`]; if (s.target) parts.push(`目标: ${clipText(s.target, 80)}`); if (s.judge) parts.push(`裁判: ${clipText(s.judge, 240)}`); lines.push(`- ${parts.join(" | ")}`); } } lines.push(""); lines.push(`## 记忆快照(最新)`); if (!latestMem) { lines.push(`暂无记忆快照(至少跑过一次 interact 才会有)`); } else { if (latestMem.summary) { lines.push(`### 摘要`); lines.push("```"); lines.push(asString(latestMem.summary).trim()); lines.push("```"); lines.push(""); } lines.push(`### 最近对话(尾部)`); lines.push("```"); lines.push((latestMem.history_tail || []).map((x) => asString(x)).join("\n").trim() || "-"); lines.push("```"); } lines.push(""); lines.push(`## 我的输出时间线(interact)`); const interacts = state.events.filter((e) => inferKind(e) === "interact"); if (!interacts.length) { lines.push(`暂无输出记录`); } else { for (const e of interacts) { const status = getStatus(e); const round = typeof e.round === "number" ? e.round : Number.parseInt(e.round, 10); const label = statusLabel(status); const out = clipText(e.final_result || "-", 240); const extra = status === "skill" && e.final_skillTargetPlayer ? ` | 目标: ${asString(e.final_skillTargetPlayer)}` : ""; const fb = e.fallback_used ? ` | 兜底: ${asString(e.fallback_reason || "yes")}` : ""; lines.push(`- 第${Number.isFinite(round) ? round : "-"}轮 | ${label} | ${out}${extra}${fb}`); } } lines.push(""); lines.push(`## 场上信息提取(perceive)`); const speaks = state.events.filter((e) => inferKind(e) === "perceive"); if (!speaks.length) { lines.push(`暂无输入记录`); } else { for (const e of speaks) { const status = getStatus(e); const round = typeof e.round === "number" ? e.round : Number.parseInt(e.round, 10); const label = statusLabel(status); const from = asString(e.event?.name || e.name || "主持人"); const msg = clipText(e.event?.message || e.event?.name || "-", 240); lines.push(`- 第${Number.isFinite(round) ? round : "-"}轮 | ${label} | ${from}: ${msg}`); } } return lines.join("\n").trim() + "\n"; } async function copyMarkdownReport() { if (!state.currentId) { window.alert("请先选择一个对局,再复制Markdown。"); return; } const md = buildMarkdownReport(); if (!md) return; try { await navigator.clipboard.writeText(md); flashButton(elements.copyMdBtn, "已复制", 1200); } catch (err) { try { const ta = document.createElement("textarea"); ta.value = md; ta.style.position = "fixed"; ta.style.left = "-9999px"; ta.style.top = "0"; document.body.appendChild(ta); ta.focus(); ta.select(); document.execCommand("copy"); document.body.removeChild(ta); flashButton(elements.copyMdBtn, "已复制", 1200); } catch (err2) { window.alert("复制失败:浏览器未授予剪贴板权限。你也可以用“下载Markdown”。"); } } } function downloadMarkdownReport() { if (!state.currentId) { window.alert("请先选择一个对局,再下载Markdown。"); return; } const md = buildMarkdownReport(); const blob = new Blob([md], { type: "text/markdown;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${state.currentId}.md`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 500); flashButton(elements.downloadMdBtn, "已下载", 1200); } function flashButton(btn, text, ms) { if (!btn) return; const old = btn.textContent; btn.textContent = text; btn.disabled = true; setTimeout(() => { btn.textContent = old; btn.disabled = false; }, ms || 1000); } async function pollCurrent() { if (!state.currentId) return; const after = state.lastTs; const url = after ? `./api/session/${encodeURIComponent(state.currentId)}/delta?after_ts=${after}` : `./api/session/${encodeURIComponent(state.currentId)}`; const data = await fetchJson(url); state.current = data; const changed = _ingestEvents(data.events || []); if (changed) { renderHeader(); renderStats(); renderFilters(); renderDash(); applyFilters(); } else { renderStats(); } } function setAutoRefresh(active) { state.autoRefresh = active; if (state.refreshTimer) { clearInterval(state.refreshTimer); state.refreshTimer = null; } if (active) { state.refreshTimer = setInterval(async () => { await loadSessions(); await pollCurrent(); }, 5000); setLive(true); } else { setLive(false); } } elements.refreshBtn.addEventListener("click", () => { loadSessions(); pollCurrent(); }); elements.search.addEventListener("input", (e) => { state.search = e.target.value || ""; applyFilters(); }); elements.autoRefresh.addEventListener("change", (e) => { setAutoRefresh(e.target.checked); }); if (elements.copyMdBtn) { elements.copyMdBtn.addEventListener("click", () => { copyMarkdownReport(); }); } if (elements.downloadMdBtn) { elements.downloadMdBtn.addEventListener("click", () => { downloadMarkdownReport(); }); } loadSessions().catch((err) => { elements.sessions.innerHTML = `
加载失败: ${err.message}
`; });