245 lines
12 KiB
JavaScript
245 lines
12 KiB
JavaScript
// 对比两份真实请求:酒馆抓包 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 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 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("");
|
||
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("");
|
||
|
||
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 + " 条");
|
||
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;
|
||
}
|