Download web_debug/app.js from brannnnn/killer2: direct link, hf CLI and curl.
- Browser
- Download file 27.8 kB
-
https://huggingface.co/spaces/brannnnn/killer2/resolve/main/web_debug/app.js
- Command line
-
hf download hf://spaces/brannnnn/killer2/web_debug/app.js
-
curl -L -o app.js https://huggingface.co/spaces/brannnnn/killer2/resolve/main/web_debug/app.js
27.8 kB
| 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(); // 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 = `<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>`; | |
| }); | |