Files
writing-agent/tools/st-request-diff/diff.mjs
T

245 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 对比两份真实请求:酒馆抓包 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;
}