diff --git a/tools/st-request-diff/.gitignore b/tools/st-request-diff/.gitignore new file mode 100644 index 0000000..b3c7b3d --- /dev/null +++ b/tools/st-request-diff/.gitignore @@ -0,0 +1,7 @@ +# 抓包对比工具的临时产物,不进库 +.st-data/ +.wa-data/ +captures/ +presets/ +*.log +*.err diff --git a/tools/st-request-diff/README.md b/tools/st-request-diff/README.md new file mode 100644 index 0000000..635448a --- /dev/null +++ b/tools/st-request-diff/README.md @@ -0,0 +1,67 @@ +# 真实请求对比(酒馆 vs 本仓库) + +目的:让**本机真酒馆**和**本仓库**用同一个预设发一次真实请求,把两边真正发到上游的 JSON 落盘,再逐字段、逐条消息对齐,弄清"预设兼容"还差在哪。 + +不猜、不看内部对象,只看**线上真实请求体**。 + +## 前提 + +- 酒馆源码在 `D:/game/sillytavern`,能 `node server.js` 起来(版本 1.18)。 +- Node 22+(用到内置 `WebSocket`)、本机有 Edge 或 Chrome(headless 也行)。 +- 不改你 `D:/game/sillytavern/data` 里的任何东西:两边都用独立数据目录 + (酒馆 `--dataRoot ./.st-data`,本仓库 `WRITING_AGENT_DATA_DIR=./.wa-data`)。 + +## 链路(两边都走"纯导入 json") + +1. 假上游 `capture-server.mjs`:实现够用的 OpenAI 兼容端点,把收到的**原始请求体**原样写进 `captures/`,并回一句固定文本。 +2. 酒馆侧:起 ST → 起 headless 浏览器 → CDP 进页面 → 用页面上的「导入预设」文件输入把 json 丢进去(等于人手点导入)→ 选预设 → 连接指向假上游 → 选探针角色 `ProbeCard` → 清空历史 → `ctx.generate("normal")` 真发一轮。 +3. 本仓库侧:起 `src/server/web-server.ts`(独立数据目录)→ `POST /api/presets/import` → `POST /api/direct-chat/ephemeral/complete` 真发一轮。 +4. `diff.mjs` 对齐两份请求体,出 `captures/report-<预设名>.md`。 + +## 用法 + +全自动(两个预设、两边、报告): + + node run-all.mjs + node run-all.mjs --only st --preset "C:/Users/zed/Downloads/linux_1.3.json" + node run-all.mjs --only wa|diff + +手动挡(我起环境、你点发送、我只监控): + + node manual-st.mjs --preset "C:/Users/zed/Downloads/NVWA-Galaxia-ST.json" + # 浏览器窗口打开后,你在里面写内容点发送即可;抓到会打出来 + +配套小工具(都对着已开着的页面,不动浏览器): + + node attach-preset.mjs --preset # 往活页面里再导入一个预设并选中 + node attach-clear.mjs --preset <名字> # 切预设 + 清空历史 + 打印当前生效参数 + node attach-send.mjs --preset <名字> --text 你好 + node inspect-capture.mjs captures/st-001-xxx.json [--full 3] + node inspect-prompt-order.mjs # 看 order/开关/marker/system_prompt/注入位置 + node peek-preset-flags.mjs # 看 function_calling / squash / extensions + node peek-gen-fields.mjs # 看采样参数字段类型 + node inspect-wa-assembly.mjs # 用本仓库的装配代码看会拼出什么(不发请求) + +## 已确认的机制差异(2026-10 实测) + +| 预设 | 酒馆侧机制 | 证据 | +| --- | --- | --- | +| `linux_1.3` | 预设里是纯文本的伪工具协议(6 条 `【ToolCall引导】…`),运行期被 `tavern_helper` 脚本抽 description/fields **动态注册成真 tool** | 4 条消息 + `tools(1)=dispatch_stream_payload_xxx`、`tool_choice:auto` | +| `Reborn2.4` | 预设自带 `function_calling:true`、`tool_call_recurse_limit:5`、`extensions.ToolBindings.game_content`,提示词要求必须调用 `game_content` → 设计上就是真 function calling | 两次抓包都是 33 条、**无 tools** → 绑定没注册上,退化成纯文本 | +| `NVWA-Galaxia-ST` | 无任何工具痕迹(无 tool 条目、无 `function_calling`) | 14 条、无 tools,纯文本 | + +## 已确认的差距(按优先级) + +1. **脚本/正则层**(最贵):本仓库导入时明确不执行 ST 正则与前端脚本;linux_1.3 的 tools 升格、Reborn2.4 的 `game_content` 都在这层。 +2. **`prompts[].enabled` 双闸**(确定 bug):酒馆只用 `prompt_order.enabled`(`openai.js` 从不读 `prompts[].enabled`),本仓库要求两者都开 → linux_1.3 少发 `<世界设定与背景>`、`作者简介 古典武侠`、`结尾` 共约 1 万字。 +3. **`squash_system_messages` 语义**(确定 bug):酒馆只合并**相邻 system**(跳过带 `name` 的、跳过 `newMainChat/newChat/groupNudge`),本仓库按"同角色合并"把 `结尾(框架闭合)`、`/历史记录`、`你好` 三条 user 合成一条。 +4. **角色卡插槽 + 格式模板**:`charDescription/charPersonality/scenario` 及 `personality_format/scenario_format` 包装(如 `…`)。 +5. **示例对话**:`new_example_chat_prompt` 打头,`mes_example` 按 `` 切段成多条 system,并带 `name=example_user/example_assistant`。 +6. **`new_chat_prompt`**:空聊天时插一条 `[Start a new Chat]` / `[NVWA chat history begins below.]`。 +7. 本仓库多发的:`姓名:玩家`(persona 无描述时的兜底)、`### 任务契约 …`、`stream_options:{include_usage:true}`。 + +## 决策记录 + +- **采样参数跟默认走**:预设缺 `temperature/top_p/max_tokens` 时,本仓库回落到 API 配置默认即可(酒馆是"缺就不改,沿用上次的值",不做对齐)。所以 `NVWA-Galaxia-ST` 报告里那行参数差异算已知取舍。 +- `names_behavior`(`NONE:-1/DEFAULT:0/COMPLETION:1/CONTENT:2`)只影响**聊天历史**是否加 `名字: ` 前缀,三个预设都是 0,单角色下无效果 → 暂不实现。 +- `system_prompt:true` 标的是 ST 的**内置槽位**(不可删/不在用户列表/不参与深度注入,落位由 `openai.js` 里 `systemPrompts=['nsfw','jailbreak']` 等硬编码决定),不是位置规则;当前三份抓包里两边位置一致 → 不是当前核心差距。 diff --git a/tools/st-request-diff/attach-clear.mjs b/tools/st-request-diff/attach-clear.mjs new file mode 100644 index 0000000..4c5ff55 --- /dev/null +++ b/tools/st-request-diff/attach-clear.mjs @@ -0,0 +1,50 @@ +// 连到已开着的酒馆页面:清空当前角色的历史,并打印当前生效的预设/连接状态。 +// 用法: node attach-clear.mjs [--browser-port 8223] [--st-url 127.0.0.1:8123] [--preset 名字] +import { CdpSession, evaluate } from "./cdp.mjs"; + +const argv = process.argv.slice(2); +const arg = (name, dflt) => { + const i = argv.indexOf("--" + name); + return i >= 0 ? String(argv[i + 1]) : dflt; +}; +const browserPort = Number(arg("browser-port", "8223")); +const stHint = arg("st-url", "127.0.0.1:8123"); +const presetName = arg("preset", ""); + +const version = await (await fetch("http://127.0.0.1:" + browserPort + "/json/version")).json(); +const cdp = await CdpSession.connect(version.webSocketDebuggerUrl, "browser"); +const { targetInfos } = await cdp.send("Target.getTargets"); +const target = targetInfos.find((t) => t.type === "page" && String(t.url).includes(stHint)); +if (!target) throw new Error("没找到酒馆页面"); +const { sessionId } = await cdp.send("Target.attachToTarget", { targetId: target.targetId, flatten: true }); +await cdp.send("Runtime.enable", {}, sessionId); +const page = { cdp, sessionId }; + +const out = await evaluate(page, `(async () => { + const ctx = window.SillyTavern.getContext(); + const jq = window.jQuery; + const names = ctx.getPresetManager("openai").getPresetList().preset_names || {}; + const want = ${JSON.stringify(presetName)}; + if (want && names[want] !== undefined) { + ctx.chatCompletionSettings.bind_preset_to_connection = false; + jq("#settings_preset_openai").val(String(names[want])).trigger("change"); + await new Promise((r) => setTimeout(r, 800)); + } + await window.SillyTavern.getContext().clearChat({ clearData: true }); + const f = window.SillyTavern.getContext(); + const s = f.chatCompletionSettings; + return { + availablePresets: Object.keys(names), + preset: s.preset_settings_openai, + temp: s.temp_openai, + maxTokens: s.openai_max_tokens, + functionCalling: s.function_calling, + squash: s.squash_system_messages, + stream: s.stream_openai, + customUrl: s.custom_url, + charName: f.name2, + chatLen: f.chat.length, + }; +})()`); +console.log("[attach] " + JSON.stringify(out, null, 1)); +cdp.close(); diff --git a/tools/st-request-diff/attach-preset.mjs b/tools/st-request-diff/attach-preset.mjs new file mode 100644 index 0000000..8c33958 --- /dev/null +++ b/tools/st-request-diff/attach-preset.mjs @@ -0,0 +1,66 @@ +// 往"已经开着的"酒馆页面里再导入一个预设并选中(不动浏览器,不重启酒馆)。 +// 用法: node attach-preset.mjs --preset "C:/Users/zed/Downloads/Reborn2.4.json" [--browser-port 8223] [--st-url 8123] +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { CdpSession, evaluate, sleep } from "./cdp.mjs"; + +const argv = process.argv.slice(2); +const arg = (name, dflt) => { + const i = argv.indexOf("--" + name); + return i >= 0 ? String(argv[i + 1]) : dflt; +}; +const presetFile = arg("preset", ""); +const browserPort = Number(arg("browser-port", "8223")); +const stHint = arg("st-url", "127.0.0.1:8123"); +if (!presetFile || !existsSync(presetFile)) throw new Error("缺少 --preset"); +const presetName = path.basename(presetFile, ".json"); +const presetText = readFileSync(presetFile, "utf8"); + +const version = await (await fetch("http://127.0.0.1:" + browserPort + "/json/version")).json(); +const cdp = await CdpSession.connect(version.webSocketDebuggerUrl, "browser"); +const { targetInfos } = await cdp.send("Target.getTargets"); +const target = targetInfos.find((t) => t.type === "page" && String(t.url).includes(stHint)); +if (!target) throw new Error("没找到酒馆页面,现有 target: " + targetInfos.map((t) => t.type + " " + t.url).join(" | ")); +const { sessionId } = await cdp.send("Target.attachToTarget", { targetId: target.targetId, flatten: true }); +await cdp.send("Runtime.enable", {}, sessionId); +const page = { cdp, sessionId }; + +await evaluate(page, `(async () => { + const input = document.getElementById("openai_preset_import_file"); + const file = new File([${JSON.stringify(presetText)}], ${JSON.stringify(presetName + ".json")}, { type: "application/json" }); + const dt = new DataTransfer(); + dt.items.add(file); + input.files = dt.files; + window.jQuery(input).trigger("input"); + return true; +})()`); +console.log("[attach] 已把 " + presetName + " 丢进导入框,等落盘…"); + +for (let i = 0; i < 120; i++) { + const s = await evaluate(page, `(() => { + const ctx = window.SillyTavern.getContext(); + const names = ctx.getPresetManager("openai").getPresetList().preset_names || {}; + const popup = document.querySelector(".popup-button-cancel"); + if (popup) popup.click(); + return { has: Object.keys(names).includes(${JSON.stringify(presetName)}), keys: Object.keys(names) }; + })()`); + if (s.has) { + const applied = await evaluate(page, `(async () => { + const ctx = window.SillyTavern.getContext(); + const names = ctx.getPresetManager("openai").getPresetList().preset_names || {}; + const idx = names[${JSON.stringify(presetName)}]; + const jq = window.jQuery; + ctx.chatCompletionSettings.bind_preset_to_connection = false; + jq("#settings_preset_openai").val(String(idx)).trigger("change"); + const s = ctx.chatCompletionSettings; + return { preset: s.preset_settings_openai, temp: s.temp_openai, maxTokens: s.openai_max_tokens, functionCalling: s.function_calling, squash: s.squash_system_messages, charName: window.SillyTavern.getContext().name2, chatLen: window.SillyTavern.getContext().chat.length }; + })()`); + console.log("[attach] 已导入并选中: " + JSON.stringify(applied)); + cdp.close(); + process.exit(0); + } + await sleep(500); +} +console.log("[attach] 超时:预设没出现在列表里"); +cdp.close(); +process.exit(1); diff --git a/tools/st-request-diff/attach-send.mjs b/tools/st-request-diff/attach-send.mjs new file mode 100644 index 0000000..11c10af --- /dev/null +++ b/tools/st-request-diff/attach-send.mjs @@ -0,0 +1,47 @@ +// 连到已开着的酒馆页面:清历史 + 发一句话(走酒馆自己的 Generate)。 +// 用法: node attach-send.mjs --preset 名字 --text 你好 [--browser-port 8223] [--st-url 127.0.0.1:8123] +import { CdpSession, evaluate } from "./cdp.mjs"; + +const argv = process.argv.slice(2); +const arg = (name, dflt) => { + const i = argv.indexOf("--" + name); + return i >= 0 ? String(argv[i + 1]) : dflt; +}; +const browserPort = Number(arg("browser-port", "8223")); +const stHint = arg("st-url", "127.0.0.1:8123"); +const presetName = arg("preset", ""); +const text = arg("text", "你好"); + +const version = await (await fetch("http://127.0.0.1:" + browserPort + "/json/version")).json(); +const cdp = await CdpSession.connect(version.webSocketDebuggerUrl, "browser"); +const { targetInfos } = await cdp.send("Target.getTargets"); +const target = targetInfos.find((t) => t.type === "page" && String(t.url).includes(stHint)); +if (!target) throw new Error("没找到酒馆页面"); +const { sessionId } = await cdp.send("Target.attachToTarget", { targetId: target.targetId, flatten: true }); +await cdp.send("Runtime.enable", {}, sessionId); +const page = { cdp, sessionId }; + +const out = await evaluate(page, `(async () => { + const jq = window.jQuery; + const names = window.SillyTavern.getContext().getPresetManager("openai").getPresetList().preset_names || {}; + const want = ${JSON.stringify(presetName)}; + if (want && names[want] !== undefined) { + jq("#settings_preset_openai").val(String(names[want])).trigger("change"); + await new Promise((r) => setTimeout(r, 900)); + } + await window.SillyTavern.getContext().clearChat({ clearData: true }); + const before = window.SillyTavern.getContext().chatCompletionSettings; + const state = { preset: before.preset_settings_openai, temp: before.temp_openai, maxTokens: before.openai_max_tokens, functionCalling: before.function_calling, squash: before.squash_system_messages }; + jq("#send_textarea").val(${JSON.stringify(text)}).trigger("input"); + let error = null; + window.SillyTavern.getContext().generate("normal").catch((e) => { error = String((e && e.stack) || e); window.__genErr = error; }); + for (let i = 0; i < 120; i++) { + await new Promise((r) => setTimeout(r, 500)); + const f = window.SillyTavern.getContext(); + if (f.chat.length >= 2 || error) break; + } + const f = window.SillyTavern.getContext(); + return { state, chatLen: f.chat.length, error: error || window.__genErr || null, last: f.chat.length ? String(f.chat[f.chat.length - 1].mes).slice(0, 60) : "" }; +})()`); +console.log("[send] " + JSON.stringify(out, null, 1)); +cdp.close(); diff --git a/tools/st-request-diff/capture-server.mjs b/tools/st-request-diff/capture-server.mjs new file mode 100644 index 0000000..10bb88a --- /dev/null +++ b/tools/st-request-diff/capture-server.mjs @@ -0,0 +1,166 @@ +/** + * 假上游:实现够用的 OpenAI 兼容端点,把收到的原始请求体原样落盘。 + * + * 用法: + * node capture-server.mjs --label st --port 8801 + * + * 落盘目录默认 ./captures(相对本文件)。 + * 每个请求一个