| | 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, """) |
| | .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 = `<div class="session-meta">暂无对局日志。</div>`; |
| | 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 = ` |
| | <div class="session-id">${escapeHtml(session.id)}</div> |
| | <div class="session-meta">${escapeHtml(roles || "未知")} - ${escapeHtml(session.events)} 条</div> |
| | <div class="session-meta">更新于 ${formatDate(session.updated_at)}</div> |
| | `; |
| | 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(); |
| | 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 = `<div class="session-meta">没有匹配的事件。</div>`; |
| | 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 = ` |
| | <div class="event-header"> |
| | <span>${escapeHtml(kindLabel(kind))} · ${escapeHtml(statusLabel(status))} - 第${escapeHtml(event.round ?? "-")}轮</span> |
| | <span>${escapeHtml(formatDate(event.ts))}</span> |
| | </div> |
| | <div class="event-meta"> |
| | <span class="badge">${escapeHtml(role || "role?")}</span> |
| | <span class="badge">${escapeHtml(event.name || "name?")}</span> |
| | <span class="badge">${escapeHtml(line)}</span> |
| | ${ |
| | event.fallback_used |
| | ? `<span class="badge">fallback: ${escapeHtml(event.fallback_reason || "yes")}</span>` |
| | : "" |
| | } |
| | </div> |
| | `; |
| | 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 = `<div class="detail-empty">点击左侧事件查看详情。</div>`; |
| | 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 ` |
| | <div class="detail-row"> |
| | <div class="detail-label">${escapeHtml(label)}</div> |
| | <div class="detail-value">${escapeHtml(rendered)}</div> |
| | </div> |
| | `; |
| | } |
| |
|
| | 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 = `<div class="session-meta">加载失败: ${err.message}</div>`; |
| | }); |
| |
|