// 对比两份真实请求:酒馆抓包 vs 本仓库抓包。 // 用法: node diff.mjs --st captures/st-001-x.json --wa captures/wa-001-x.json --preset presets/x.json // 输出 captures/report-<预设名>.md,并在控制台打摘要。 import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const HERE = path.dirname(fileURLToPath(import.meta.url)); function readArgs(argv) { const out = { st: "", wa: "", preset: "", out: path.join(HERE, "captures") }; for (let i = 0; i < argv.length; i++) { const k = argv[i]; const v = argv[i + 1]; if (k === "--st") { out.st = path.resolve(String(v)); i++; } else if (k === "--wa") { out.wa = path.resolve(String(v)); i++; } else if (k === "--preset") { out.preset = path.resolve(String(v)); i++; } else if (k === "--out") { out.out = path.resolve(String(v)); i++; } } return out; } function readCapture(file) { const outer = JSON.parse(readFileSync(file, "utf8")); return { file, at: outer.receivedAt, url: outer.url, body: JSON.parse(outer.body) }; } const stripWs = (t) => String(t == null ? "" : t).replace(/\s+/g, " ").trim(); const stripMacros = (t) => stripWs(t).replace(/\{\{[^}]*\}\}/g, "·"); function presetEntries(presetFile) { const raw = JSON.parse(readFileSync(presetFile, "utf8")); const prompts = raw.prompts || []; const blocks = raw.prompt_order || []; const block = blocks.find((b) => b.character_id === 100001) || blocks[0] || { order: [] }; const byId = new Map(); prompts.forEach((p, i) => byId.set(p.identifier || String(i), p)); const out = []; for (const item of block.order || []) { const p = byId.get(item.identifier) || {}; out.push({ identifier: item.identifier, name: p.name || item.identifier, role: p.role || "system", orderEnabled: item.enabled !== false, entryEnabled: p.enabled !== false, marker: Boolean(p.marker), systemPrompt: Boolean(p.system_prompt), injection: [p.injection_position ?? 0, p.injection_depth ?? "", p.injection_order ?? ""].join("/"), content: p.content || "", }); } return out; } /** prefix = 本条以该条目开头;contains = 该条目被并进了别条消息 */ function matchMode(entryContent, messageContent) { const ec = stripMacros(entryContent); const text = stripMacros(messageContent); if (!ec || !text) return null; const max = Math.min(ec.length, text.length); let score = 0; while (score < max && ec[score] === text[score]) score++; if (score >= 30 && score >= ec.length * 0.6) return "prefix"; if (ec.length >= 70 && text.indexOf(ec.slice(0, 40)) >= 0) { const at = text.indexOf(ec.slice(0, 40)); if (text.indexOf(ec.slice(-30), at) > at) return "contains"; } if (ec.length >= 30 && text.indexOf(ec) >= 0) return "contains"; if (score >= 30) return "prefix"; return null; } function scanSide(entries, messages) { const hits = new Map(); messages.forEach((m, i) => { for (const e of entries) { if (!e.content) continue; const mode = matchMode(e.content, m.content); if (!mode) continue; const list = hits.get(e.identifier) || []; list.push({ index: i, role: m.role, mode, len: m.content.length }); hits.set(e.identifier, list); } }); return hits; } const fmt = (v) => { if (v === undefined) return "(无)"; if (v === null) return "null"; if (typeof v === "string") return v.length > 60 ? JSON.stringify(v.slice(0, 60)) + "…" : JSON.stringify(v); if (Array.isArray(v)) { const tools = v.filter((x) => x && x.function && x.function.name); if (tools.length) { return tools .map((t) => { const fn = t.function; const props = Object.keys((fn.parameters && fn.parameters.properties) || {}); return fn.name + "(参数: " + (props.join(",") || "-") + "; 描述 " + String(fn.description || "").length + " 字)"; }) .join("; "); } const names = v.map((x) => (x && x.function && x.function.name) || (x && x.name) || "").filter(Boolean); return "[" + v.length + " 项" + (names.length ? ": " + names.join(", ") : "") + "]"; } if (typeof v === "object") return JSON.stringify(v).slice(0, 200); return String(v); }; function compareFields(st, wa) { const keys = new Set([...Object.keys(st), ...Object.keys(wa)]); const rows = []; for (const key of [...keys].sort()) { if (key === "messages") continue; const a = st[key]; const b = wa[key]; let verdict = "一致"; if (a === undefined) verdict = "仅本仓库有"; else if (b === undefined) verdict = "仅酒馆有"; else if (JSON.stringify(a) !== JSON.stringify(b)) verdict = "不同"; rows.push({ key, a, b, verdict }); } return rows; } function messagesOf(capture) { const list = Array.isArray(capture.body.messages) ? capture.body.messages : []; return list.map((m) => ({ role: m.role, content: typeof m.content === "string" ? m.content : JSON.stringify(m.content), name: m.name, extras: Object.keys(m).filter((k) => k !== "role" && k !== "content"), })); } function whereHits(hits, id) { const list = hits.get(id); if (!list || !list.length) return "—"; return list.map((h) => "#" + h.index + (h.mode === "contains" ? "(并入)" : "")).join(", "); } function entryTable(entries, stHits, waHits) { const lines = [ "| 预设条目 | 酒馆 | 本仓库 | 结论 | 预设里的开关 |", "| --- | --- | --- | --- | --- |", ]; for (const e of entries) { if (!e.orderEnabled && !e.entryEnabled) continue; const a = whereHits(stHits, e.identifier); const b = whereHits(waHits, e.identifier); let verdict = "两边都在"; if (a === "—" && b === "—") verdict = "两边都没发"; else if (a === "—") verdict = "**仅本仓库有**"; else if (b === "—") verdict = "**仅酒馆有**"; const flags = "order=" + (e.orderEnabled ? "开" : "关") + " prompt=" + (e.entryEnabled ? "开" : "关") + (e.marker ? " marker" : "") + (e.systemPrompt ? " system_prompt" : "") + " inj=" + e.injection; lines.push("| " + e.name + " `" + e.identifier + "` | " + a + " | " + b + " | " + verdict + " | " + flags + " |"); } const known = new Set(entries.map((e) => e.identifier)); const orphans = []; for (const [id, list] of stHits) if (!known.has(id)) orphans.push("酒馆 " + id + " @ " + whereHits(stHits, id)); for (const [id, list] of waHits) if (!known.has(id)) orphans.push("本仓库 " + id + " @ " + whereHits(waHits, id)); if (orphans.length) lines.push("", "**归到了 order 里没有的条目**:" + orphans.join("; ")); return lines.join("\n"); } function messageTable(label, messages, entries) { const lines = ["### " + label, "", "| # | role | 字数 | 额外字段 | 开头 |", "| --- | --- | --- | --- | --- |"]; messages.forEach((m, i) => { const head = stripWs(m.content).slice(0, 60).replace(/\|/g, "\\|"); const extras = m.extras.length ? (m.name ? "name=" + m.name : m.extras.join(",")) : ""; lines.push("| " + i + " | " + m.role + " | " + m.content.length + " | " + extras + " | " + head + " |"); }); return lines.join("\n"); } function unattributed(entries, messages) { const out = []; messages.forEach((m, i) => { const anyPrefix = entries.some((e) => e.content && matchMode(e.content, m.content) === "prefix"); if (!anyPrefix) out.push("- #" + i + " [" + m.role + "] " + m.content.length + " 字:" + stripWs(m.content).slice(0, 70)); }); return out.length ? out.join("\n") : "(没有)"; } function onlyOnOneSide(stMsgs, waMsgs) { const key = (m) => stripWs(m.content).slice(0, 40); const stKeys = new Set(stMsgs.map(key)); const waKeys = new Set(waMsgs.map(key)); const onlySt = stMsgs.filter((m) => !waKeys.has(key(m))); const onlyWa = waMsgs.filter((m) => !stKeys.has(key(m))); const fmtList = (list) => list.length ? list.map((m, i) => "- [" + m.role + "] " + m.content.length + " 字:" + stripWs(m.content).slice(0, 60)).join("\n") : "(没有)"; return { onlySt, onlyWa, text: "**只在酒馆出现**:" + onlySt.length + " 条\n\n" + fmtList(onlySt) + "\n\n**只在本仓库出现**:" + onlyWa.length + " 条\n\n" + fmtList(onlyWa) }; } /** 把某一侧的最终 messages 原文落盘,方便逐字比对。 */ function writeMessageDump(outDir, label, msgs) { mkdirSync(outDir, { recursive: true }); const lines = []; msgs.forEach((m, i) => { const name = m.name ? " name=" + m.name : ""; lines.push("#" + i + " [" + m.role + "]" + name + " " + m.content.length + " 字"); lines.push(m.content); lines.push(""); }); writeFileSync(outDir + "/messages-" + label + ".txt", lines.join("\n"), "utf8"); } /** 按正文前 40 字把两边消息配对,并排打印。 */ function sideBySide(stMsgs, waMsgs) { const key = (m) => stripWs(m.content).slice(0, 40); const waIndex = new Map(); const used = new Set(); waMsgs.forEach((m, j) => { const k = key(m); if (!waIndex.has(k)) waIndex.set(k, []); waIndex.get(k).push(j); }); const rows = []; const pairedWa = new Set(); stMsgs.forEach((m, i) => { const candidates = (waIndex.get(key(m)) || []).filter((j) => !pairedWa.has(j)); const j = candidates.length ? candidates[0] : null; if (j !== null) pairedWa.add(j); const left = "#" + i + " [" + m.role + "] " + m.content.length + " 字: " + stripWs(m.content).slice(0, 50); const right = j === null ? "—" : "#" + j + " [" + waMsgs[j].role + "] " + waMsgs[j].content.length + " 字: " + stripWs(waMsgs[j].content).slice(0, 50); let verdict = "对上了"; if (j === null) verdict = "**只在酒馆**"; else if (stMsgs[i].role !== waMsgs[j].role) verdict = "**角色不同**"; else if (stMsgs[i].content.trim() !== waMsgs[j].content.trim()) verdict = "内容有差"; rows.push("| " + left + " | " + right + " | " + verdict + " |"); }); waMsgs.forEach((m, j) => { if (pairedWa.has(j)) return; rows.push( "| — | #" + j + " [" + m.role + "] " + m.content.length + " 字: " + stripWs(m.content).slice(0, 50) + " | **只在本仓库** |", ); }); return [ "| 酒馆 | 本仓库 | 结果 |", "| --- | --- | --- |", ...rows, ].join("\n"); } function main() { const args = readArgs(process.argv.slice(2)); if (!args.st || !args.wa) throw new Error("缺少 --st / --wa"); const stCap = readCapture(args.st); const waCap = readCapture(args.wa); const entries = args.preset ? presetEntries(args.preset) : []; const stMsgs = messagesOf(stCap); const waMsgs = messagesOf(waCap); const stHits = scanSide(entries, stMsgs); const waHits = scanSide(entries, waMsgs); const fields = compareFields(stCap.body, waCap.body); const presetName = args.preset ? path.basename(args.preset, ".json") : "capture"; const lines = []; lines.push("# 请求对比: " + presetName); lines.push(""); lines.push("两边都是真实发出的请求体(假上游原样落盘)。酒馆走页面导入预设,本仓库走 /api/presets/import。"); lines.push(""); lines.push("| 项目 | 酒馆 | 本仓库 |"); lines.push("| --- | --- | --- |"); lines.push("| 抓包文件 | " + path.basename(stCap.file) + " | " + path.basename(waCap.file) + " |"); lines.push("| 上游 URL | " + stCap.url + " | " + waCap.url + " |"); lines.push("| 消息条数 | " + stMsgs.length + " | " + waMsgs.length + " |"); lines.push("| 正文总字符 | " + stMsgs.reduce((n, m) => n + m.content.length, 0) + " | " + waMsgs.reduce((n, m) => n + m.content.length, 0) + " |"); lines.push(""); const mech = (cap) => { const ms = Array.isArray(cap.body.messages) ? cap.body.messages : []; return { tools: Array.isArray(cap.body.tools) ? cap.body.tools.length : 0, toolNames: Array.isArray(cap.body.tools) ? cap.body.tools.map((t) => (t.function && t.function.name) || "").join(", ") : "", named: ms.filter((m) => m.name).length, systems: ms.filter((m) => m.role === "system").length, maxMsg: ms.reduce((n, m) => Math.max(n, String(m.content || "").length), 0) }; }; const stM = mech(stCap); const waM = mech(waCap); lines.push("## 0. 机制速览"); lines.push(""); lines.push("| 项目 | 酒馆 | 本仓库 |"); lines.push("| --- | --- | --- |"); lines.push("| 是否带 tools | " + (stM.tools ? stM.tools + " 个: " + stM.toolNames : "无") + " | " + (waM.tools ? waM.tools + " 个: " + waM.toolNames : "无") + " |"); lines.push("| 带 name 的消息 | " + stM.named + " | " + waM.named + " |"); lines.push("| system 条数 | " + stM.systems + " | " + waM.systems + " |"); lines.push("| 最长单条 | " + stM.maxMsg + " | " + waM.maxMsg + " |"); lines.push(""); lines.push("## 1. 请求级字段"); lines.push(""); lines.push("| 字段 | 酒馆 | 本仓库 | 结论 |"); lines.push("| --- | --- | --- | --- |"); for (const f of fields) lines.push("| `" + f.key + "` | " + fmt(f.a) + " | " + fmt(f.b) + " | " + f.verdict + " |"); lines.push(""); lines.push("## 2. 预设条目对齐"); lines.push(""); lines.push("位置是 messages 下标;「并入」表示该条目被合并进了别条消息里。"); lines.push(""); lines.push(entryTable(entries, stHits, waHits)); lines.push(""); lines.push("## 3. 消息逐条"); lines.push(""); lines.push(messageTable("酒馆", stMsgs, entries)); lines.push(""); lines.push(messageTable("本仓库", waMsgs, entries)); lines.push(""); const side = onlyOnOneSide(stMsgs, waMsgs); writeMessageDump(args.out, "st", stMsgs); writeMessageDump(args.out, "wa", waMsgs); lines.push("## 4. 只在一侧出现的消息(按正文前 40 字配对)"); lines.push(""); lines.push(side.text); lines.push(""); lines.push("## 4. 开头对不上任何预设条目的消息"); lines.push(""); lines.push("酒馆:"); lines.push(""); lines.push(unattributed(entries, stMsgs)); lines.push(""); lines.push("本仓库:"); lines.push(""); lines.push(unattributed(entries, waMsgs)); lines.push("## 5. 最终 messages 并排(全文见同目录 messages-st.txt / messages-wa.txt)"); lines.push(""); lines.push(sideBySide(stMsgs, waMsgs)); lines.push(""); lines.push(""); mkdirSync(args.out, { recursive: true }); const reportFile = path.join(args.out, "report-" + presetName + ".md"); writeFileSync(reportFile, lines.join("\n"), "utf8"); const onlySt = fields.filter((f) => f.verdict === "仅酒馆有").length; const onlyWa = fields.filter((f) => f.verdict === "仅本仓库有").length; const diffCount = fields.filter((f) => f.verdict === "不同").length; const entriesOnlySt = entries.filter((e) => (e.orderEnabled || e.entryEnabled) && !waHits.get(e.identifier) && stHits.get(e.identifier)); const entriesOnlyWa = entries.filter((e) => (e.orderEnabled || e.entryEnabled) && !stHits.get(e.identifier) && waHits.get(e.identifier)); console.log("[diff] 报告: " + reportFile); console.log("[diff] 字段: " + fields.length + " 项, 仅酒馆 " + onlySt + ", 仅本仓库 " + onlyWa + ", 不同 " + diffCount); console.log("[diff] 消息: 酒馆 " + stMsgs.length + " 条 / 本仓库 " + waMsgs.length + " 条"); console.log("[diff] 只在酒馆 " + side.onlySt.length + " 条,只在本仓库 " + side.onlyWa.length + " 条"); if (entriesOnlySt.length) console.log("[diff] 只在酒馆出现的预设条目: " + entriesOnlySt.map((e) => e.name).join("、")); if (entriesOnlyWa.length) console.log("[diff] 只在本仓库出现的预设条目: " + entriesOnlyWa.map((e) => e.name).join("、")); } try { main(); } catch (err) { console.error("[diff] 失败: " + err.message); process.exitCode = 1; }