Files
writing-agent/tools/st-request-diff/diff.mjs
T
chenmoranzhi 9dc927282f 预设兼容:按真实抓包对齐装配与包装层,补上声明式工具与预设正则。
- 装配只认 prompt_order.enabled;squash_system_messages 只合并相邻 system(新增 system 档,连接符按酒馆用单换行)
- 包装层:new_chat_prompt、示例对话(new_example_chat_prompt + <START> 切段 + name)、角色卡三块与 personality_format/scenario_format、{{user}}/{{char}} 取值、任务契约并进预设条目
- 工具协议:按 ToolBindings / 伪工具协议数据声明真 tools(presetToolPolicy=preset),工具描述用到的条目不再重复作为消息发出
- 正则:promptOnly 作用于对话历史(含 depth 过滤)、写入时脚本改写回复;{{//}} 注释按配平扫描
- tools/st-request-diff:真假上游抓包对比工具、三份预设的机制结论与实测报告
2026-10-23 00:13:08 +08:00

336 lines
15 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 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;
}