// 「100% 对齐」的可测量化:同一个预设,两边各发一轮,逐字段/逐条消息比对,输出对齐度与差异明细。 // 用法: node conform.mjs --preset "C:/path/x.json" [--st-port 8124] [--wa-port 23377] [--st-capture 8801] [--wa-capture 8802] // 退出码: 0 = 完全对齐, 1 = 有差异 import { spawn } from "node:child_process"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { waitFor } from "./cdp.mjs"; const HERE = path.dirname(fileURLToPath(import.meta.url)); function readArgs(argv) { const out = { preset: "", message: "你好", stPort: 8124, waPort: 23377, stCapture: 8801, waCapture: 8802, out: path.join(HERE, "captures"), }; for (let i = 0; i < argv.length; i++) { const k = argv[i]; const v = argv[i + 1]; if (k === "--preset") { out.preset = path.resolve(String(v)); i++; } else if (k === "--message") { out.message = String(v); i++; } else if (k === "--st-port") { out.stPort = Number(v); i++; } else if (k === "--wa-port") { out.waPort = Number(v); i++; } else if (k === "--st-capture") { out.stCapture = Number(v); i++; } else if (k === "--wa-capture") { out.waCapture = Number(v); i++; } } if (!out.preset) throw new Error("缺少 --preset"); return out; } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function runScript(script, args) { return new Promise((resolve) => { const child = spawn(process.execPath, [path.join(HERE, script), ...args], { cwd: HERE, stdio: ["ignore", "pipe", "pipe"] }); let out = ""; const pipe = (c) => { const t = String(c); out += t; }; child.stdout.on("data", pipe); child.stderr.on("data", pipe); child.on("close", (code) => resolve({ code, out })); }); } function lastJsonLine(out) { const lines = out.split(/\r?\n/).filter((l) => l.trim().startsWith("{") && l.includes("\"capture\"")); if (!lines.length) return null; try { return JSON.parse(lines[lines.length - 1]); } catch (e) { return null; } } // ---- 比对 ---- const norm = (s) => String(s ?? "").replace(/\r/g, ""); function ratio(a, b) { a = norm(a); b = norm(b); if (a === b) return 1; if (!a || !b) return 0; const max = Math.max(a.length, b.length); let same = 0; const n = Math.min(a.length, b.length); for (let i = 0; i < n; i++) if (a[i] === b[i]) same++; return same / max; } const mark = (ok) => (ok ? "✅" : "❌"); function loadBody(file) { const outer = JSON.parse(readFileSync(file, "utf8")); return JSON.parse(outer.body); } const REQUEST_KEYS = [ "model", "temperature", "top_p", "max_tokens", "presence_penalty", "frequency_penalty", "stop", "stream", "stream_options", "tools", "tool_choice", "response_format", "reasoning_effort", "top_k", "min_p", "repetition_penalty", ]; function compareRequest(st, wa) { const rows = []; const keys = new Set([...Object.keys(st), ...Object.keys(wa)]); for (const k of REQUEST_KEYS) if (!(k in st) && !(k in wa)) continue; for (const k of keys) { if (k === "messages") continue; if (!REQUEST_KEYS.includes(k)) { rows.push({ key: k, st: "<有>", wa: "<有>", ok: false, note: "未纳入比对的字段" }); continue; } const a = JSON.stringify(st[k]); const b = JSON.stringify(wa[k]); rows.push({ key: k, st: a === undefined ? "(无)" : a, wa: b === undefined ? "(无)" : b, ok: a === b }); } return rows; } function compareMessages(stMsgs, waMsgs) { const rows = []; const n = Math.max(stMsgs.length, waMsgs.length); for (let i = 0; i < n; i++) { const a = stMsgs[i]; const b = waMsgs[i]; if (!a) { rows.push({ i, ok: false, note: "只在本仓库有", wa: b }); continue; } if (!b) { rows.push({ i, ok: false, note: "只在酒馆有", st: a }); continue; } const roleOk = a.role === b.role; const nameOk = (a.name ?? null) === (b.name ?? null); const contentOk = norm(a.content) === norm(b.content); rows.push({ i, ok: roleOk && nameOk && contentOk, roleOk, nameOk, contentOk, similarity: ratio(a.content, b.content), st: a, wa: b, }); } return rows; } function firstDiff(rows) { const hit = rows.find((r) => !r.ok); if (!hit) return "无"; if (hit.note) return "#" + hit.i + " " + hit.note; const bits = []; if (!hit.roleOk) bits.push("role " + hit.st.role + " vs " + hit.wa.role); if (!hit.nameOk) bits.push("name " + (hit.st.name ?? "无") + " vs " + (hit.wa.name ?? "无")); if (!hit.contentOk) bits.push("正文相似度 " + (hit.similarity * 100).toFixed(1) + "%"); return "#" + hit.i + " " + bits.join(", "); } async function main() { const args = readArgs(process.argv.slice(2)); mkdirSync(args.out, { recursive: true }); const presetName = path.basename(args.preset, ".json"); const caps = [ spawn(process.execPath, [path.join(HERE, "capture-server.mjs"), "--label", "st", "--port", String(args.stCapture), "--out", args.out], { cwd: HERE, stdio: "ignore" }), spawn(process.execPath, [path.join(HERE, "capture-server.mjs"), "--label", "wa", "--port", String(args.waCapture), "--out", args.out], { cwd: HERE, stdio: "ignore" }), ]; try { for (const port of [args.stCapture, args.waCapture]) { await waitFor(async () => (await fetch("http://127.0.0.1:" + port + "/v1/models").catch(() => null)) !== null, { timeoutMs: 15000, label: "假上游 " + port }); } console.log("[conform] 酒馆侧发一轮…"); const st = await runScript("drive-st.mjs", ["--preset", args.preset, "--message", args.message, "--capture-port", String(args.stCapture), "--st-port", String(args.stPort), "--out", args.out]); if (st.code !== 0) throw new Error("酒馆侧失败:\n" + st.out.slice(-2000)); const stMeta = lastJsonLine(st.out); if (!stMeta) throw new Error("酒馆侧没拿到抓包文件:\n" + st.out.slice(-2000)); console.log("[conform] 本仓库侧发一轮…"); const wa = await runScript("drive-wa.mjs", ["--preset", args.preset, "--message", args.message, "--capture-port", String(args.waCapture), "--port", String(args.waPort), "--out", args.out]); if (wa.code !== 0) throw new Error("本仓库侧失败:\n" + wa.out.slice(-2000)); const waMeta = lastJsonLine(wa.out); if (!waMeta) throw new Error("本仓库侧没拿到抓包文件:\n" + wa.out.slice(-2000)); const stBody = loadBody(stMeta.capture); const waBody = loadBody(waMeta.capture); const reqRows = compareRequest(stBody, waBody); const msgRows = compareMessages(stBody.messages, waBody.messages); const msgOk = msgRows.filter((r) => r.ok).length; const reqOk = reqRows.filter((r) => r.ok).length; const total = msgRows.length + reqRows.length; const okTotal = msgOk + reqOk; const pct = ((okTotal / total) * 100).toFixed(1); const lines = []; lines.push("# 对齐报告: " + presetName); lines.push(""); lines.push("| 项目 | 值 |"); lines.push("| --- | --- |"); lines.push("| 酒馆抓包 | `" + path.basename(stMeta.capture) + "` |"); lines.push("| 本仓库抓包 | `" + path.basename(waMeta.capture) + "` |"); lines.push("| 请求级字段 | " + reqOk + "/" + reqRows.length + " 对齐 |"); lines.push("| 消息条数 | " + stBody.messages.length + " vs " + waBody.messages.length + " |"); lines.push("| 消息逐条 | " + msgOk + "/" + msgRows.length + " 对齐 |"); lines.push("| **总对齐度** | **" + pct + "%** |"); lines.push("| 首个差异 | " + firstDiff(msgRows) + " |"); lines.push(""); lines.push("## 请求级字段"); lines.push(""); lines.push("| 对齐 | 字段 | 酒馆 | 本仓库 |"); lines.push("| --- | --- | --- | --- |"); for (const r of reqRows) { lines.push("| " + mark(r.ok) + " | `" + r.key + "` | " + String(r.st).slice(0, 80) + " | " + String(r.wa).slice(0, 80) + " |"); } lines.push(""); lines.push("## 消息逐条"); lines.push(""); lines.push("| # | 对齐 | 酒馆 | 本仓库 |"); lines.push("| --- | --- | --- | --- |"); for (const r of msgRows) { const one = (m) => m ? "[" + m.role + (m.name ? "/" + m.name : "") + "] " + norm(m.content).length + " 字: " + JSON.stringify(norm(m.content).slice(0, 40)) : "—"; const why = r.ok ? "" : " **" + (r.note ? r.note : (!r.contentOk ? "正文差 " + ((1 - r.similarity) * 100).toFixed(1) + "%" : !r.roleOk ? "role 不同" : "name 不同")) + "**"; lines.push("| " + r.i + " | " + mark(r.ok) + " | " + one(r.st) + " | " + one(r.wa) + why + " |"); } lines.push(""); lines.push("## 差异明细"); lines.push(""); const bad = msgRows.filter((r) => !r.ok); if (!bad.length && reqRows.every((r) => r.ok)) lines.push("完全对齐。"); for (const r of bad) { if (r.note) { lines.push("- #" + r.i + " " + r.note); continue; } lines.push("- #" + r.i + (r.roleOk ? "" : " role " + r.st.role + " vs " + r.wa.role) + (r.nameOk ? "" : " name " + (r.st.name ?? "无") + " vs " + (r.wa.name ?? "无"))); if (!r.contentOk) { lines.push(" - 酒馆: " + JSON.stringify(norm(r.st.content).slice(0, 160))); lines.push(" - 我们: " + JSON.stringify(norm(r.wa.content).slice(0, 160))); } } const report = path.join(args.out, "conform-" + presetName.replace(/[^\w.\-]+/g, "_") + ".md"); writeFileSync(report, lines.join("\n") + "\n", "utf8"); console.log("\n" + lines.slice(0, 12).join("\n")); console.log("\n[conform] 对齐度 " + pct + "%(消息 " + msgOk + "/" + msgRows.length + ",字段 " + reqOk + "/" + reqRows.length + ")"); console.log("[conform] 报告: " + report); process.exitCode = okTotal === total ? 0 : 1; } finally { for (const c of caps) { try { c.kill(); } catch (e) {} } await sleep(300); } } await main();