加一套抓真实请求做预设对比的工具,并把三份预设的机制差异记进 README。

This commit is contained in:
2026-10-22 20:07:33 +08:00
parent 18f034bc2d
commit ed09e5c831
21 changed files with 1735 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
# 抓包对比工具的临时产物,不进库
.st-data/
.wa-data/
captures/
presets/
*.log
*.err
+67
View File
@@ -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 <json> # 往活页面里再导入一个预设并选中
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 <preset.json> # 看 order/开关/marker/system_prompt/注入位置
node peek-preset-flags.mjs <preset.json> # 看 function_calling / squash / extensions
node peek-gen-fields.mjs <preset.json> # 看采样参数字段类型
node inspect-wa-assembly.mjs <preset.json> # 用本仓库的装配代码看会拼出什么(不发请求)
## 已确认的机制差异(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` 包装(如 `<character_personality>…</character_personality>`)。
5. **示例对话**:`new_example_chat_prompt` 打头,`mes_example` 按 `<START>` 切段成多条 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']` 等硬编码决定),不是位置规则;当前三份抓包里两边位置一致 → 不是当前核心差距。
+50
View File
@@ -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();
+66
View File
@@ -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);
+47
View File
@@ -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();
+166
View File
@@ -0,0 +1,166 @@
/**
* 假上游:实现够用的 OpenAI 兼容端点,把收到的原始请求体原样落盘。
*
* 用法:
* node capture-server.mjs --label st --port 8801
*
* 落盘目录默认 ./captures(相对本文件)。
* 每个请求一个 <label>-<序号>.json,内容 { label, receivedAt, method, url, headers, body }。
* body 是原始字符串,diff 工具再解析 —— 保留原文便于核对是否有我们没建模的字段。
*/
import { createServer } from "node:http";
import { mkdirSync, 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 = { label: "unknown", port: 8800, out: path.join(HERE, "captures") };
for (let i = 0; i < argv.length; i++) {
const key = argv[i];
const next = argv[i + 1];
if (key === "--label") out.label = String(next);
else if (key === "--port") out.port = Number(next);
else if (key === "--out") out.out = path.resolve(String(next));
}
return out;
}
const args = readArgs(process.argv.slice(2));
mkdirSync(args.out, { recursive: true });
const REPLY_TEXT = "[[mock-reply]] 收到。";
const REPLY_REASONING = "[[mock-reasoning]] 想一下。";
function capture(req, url, raw) {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const seq = String(globalThis.__captureSeq = (globalThis.__captureSeq ?? 0) + 1).padStart(3, "0");
const file = path.join(args.out, `${args.label}-${seq}-${stamp}.json`);
const payload = {
label: args.label,
receivedAt: new Date().toISOString(),
method: req.method,
url,
headers: {
"content-type": req.headers["content-type"] ?? null,
authorization: req.headers.authorization ? "<redacted>" : null,
},
body: raw,
};
writeFileSync(file, JSON.stringify(payload, null, 2), "utf8");
// 只写一行摘要,不把正文打到控制台
let charCount = raw.length;
try {
const parsed = JSON.parse(raw);
charCount = Array.isArray(parsed.messages)
? parsed.messages.reduce((n, m) => n + String(m.content ?? "").length, 0)
: charCount;
} catch {
/* 非 JSON 就按原长度记 */
}
console.log(`[capture:${args.label}] ${req.method} ${url} -> ${path.basename(file)} (messages 正文 ${charCount} 字)`);
return file;
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
req.on("error", reject);
});
}
function sseHead(res) {
res.writeHead(200, {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
}
function sseChunk(delta, finishReason = null) {
return `data: ${JSON.stringify({
id: "chatcmpl-mock",
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: "mock-model",
choices: [{ index: 0, delta, finish_reason: finishReason }],
})}\n\n`;
}
const server = createServer(async (req, res) => {
const url = req.url ?? "/";
if (req.method === "GET" && url.includes("/models")) {
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
res.end(
JSON.stringify({
object: "list",
data: [{ id: "mock-model", object: "model", owned_by: "mock" }],
}),
);
return;
}
const raw = await readBody(req);
if (req.method !== "POST") {
res.writeHead(404).end("not found");
return;
}
let body = {};
try {
body = JSON.parse(raw);
} catch {
/* 保留原文,下面按默认处理 */
}
capture(req, url, raw);
if (url.includes("/embeddings")) {
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ object: "list", data: [{ embedding: [0] }] }));
return;
}
if (url.includes("/completions")) {
if (body.stream) {
sseHead(res);
const chunks = [];
if (body.reasoning_effort && body.reasoning_effort !== "none") {
chunks.push(sseChunk({ role: "assistant", reasoning_content: REPLY_REASONING }));
}
chunks.push(sseChunk({ role: "assistant", content: REPLY_TEXT }));
chunks.push(sseChunk({}, "stop"));
chunks.push("data: [DONE]\n\n");
res.end(chunks.join(""));
return;
}
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
res.end(
JSON.stringify({
id: "chatcmpl-mock",
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: "mock-model",
choices: [
{
index: 0,
message: { role: "assistant", content: REPLY_TEXT },
finish_reason: "stop",
},
],
usage: { prompt_tokens: 100, completion_tokens: 10, total_tokens: 110 },
}),
);
return;
}
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ ok: true }));
});
server.listen(args.port, "127.0.0.1", () => {
console.log(`[capture:${args.label}] listening on http://127.0.0.1:${args.port}/v1 -> ${args.out}`);
});
+199
View File
@@ -0,0 +1,199 @@
/**
* 极简 CDP 客户端(零依赖,靠 Node 22+ 自带的全局 WebSocket)。
*
* 只做三件事:拉起一个 Chromium/Edge、连上它的 browser target、在页面里求值。
* 够用来驱动酒馆前端把「真实发送」那一下点出去。
*/
import { spawn } from "node:child_process";
import { existsSync, mkdtempSync } from "node:fs";
import os from "node:os";
import path from "node:path";
const BROWSER_CANDIDATES = [
"C:/Program Files/Google/Chrome/Application/chrome.exe",
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
"C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
"C:/Program Files/Microsoft/Edge/Application/msedge.exe",
];
export function findBrowser(explicit) {
if (explicit) return explicit;
for (const p of BROWSER_CANDIDATES) {
if (existsSync(p)) return p;
}
throw new Error("找不到 chrome.exe / msedge.exe,可用 --browser 指定");
}
export async function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
export async function waitFor(fn, { timeoutMs = 60000, intervalMs = 300, label = "条件" } = {}) {
const start = Date.now();
let lastErr;
while (Date.now() - start < timeoutMs) {
try {
const value = await fn();
if (value) return value;
} catch (err) {
lastErr = err;
}
await sleep(intervalMs);
}
throw new Error(`等待「${label}」超时(${timeoutMs}ms)${lastErr ? `:${lastErr.message}` : ""}`);
}
export class CdpSession {
#ws;
#nextId = 1;
#pending = new Map();
#listeners = new Map();
constructor(ws, label) {
this.label = label;
this.#ws = ws;
ws.addEventListener("message", (event) => {
let msg;
try {
msg = JSON.parse(typeof event.data === "string" ? event.data : String(event.data));
} catch {
return;
}
if (msg.id !== undefined && this.#pending.has(msg.id)) {
const { resolve, reject } = this.#pending.get(msg.id);
this.#pending.delete(msg.id);
if (msg.error) reject(new Error(`${msg.error.message} (${msg.error.code})`));
else resolve(msg.result);
return;
}
if (msg.method) {
for (const cb of this.#listeners.get(msg.method) ?? []) cb(msg.params);
}
});
}
static async connect(wsUrl, label = "cdp") {
const ws = new WebSocket(wsUrl);
await new Promise((resolve, reject) => {
ws.addEventListener("open", () => resolve(), { once: true });
ws.addEventListener("error", (e) => reject(new Error(`ws 连接失败:${e.message ?? ""}`)), { once: true });
});
return new CdpSession(ws, label);
}
send(method, params = {}, sessionId) {
const id = this.#nextId++;
const payload = { id, method, params };
if (sessionId) payload.sessionId = sessionId;
return new Promise((resolve, reject) => {
this.#pending.set(id, { resolve, reject });
try {
this.#ws.send(JSON.stringify(payload));
} catch (err) {
this.#pending.delete(id);
reject(err);
}
});
}
on(method, cb) {
const list = this.#listeners.get(method) ?? [];
list.push(cb);
this.#listeners.set(method, list);
}
close() {
try {
this.#ws.close();
} catch {
/* ignore */
}
}
}
/** 起浏览器(默认 headless),返回进程 + 调试端口。 */
export async function launchBrowser({ exe, port = 9222, headless = true, userDataDir } = {}) {
const executable = findBrowser(exe);
const profileDir = userDataDir ?? mkdtempSync(path.join(os.tmpdir(), "wa-cdp-"));
const args = [
`--remote-debugging-port=${port}`,
`--user-data-dir=${profileDir}`,
"--no-first-run",
"--no-default-browser-check",
"--disable-extensions",
"--disable-background-networking",
"--disable-sync",
"--disable-gpu",
"--mute-audio",
"about:blank",
];
if (headless) args.unshift("--headless=new");
const child = spawn(executable, args, { stdio: ["ignore", "pipe", "pipe"] });
child.stdout.on("data", () => {});
child.stderr.on("data", () => {});
const version = await waitFor(
async () => (await fetch(`http://127.0.0.1:${port}/json/version`)).ok && (await fetch(`http://127.0.0.1:${port}/json/version`)).json(),
{ timeoutMs: 30000, label: `浏览器调试端口 ${port}` },
);
return { child, port, wsUrl: version.webSocketDebuggerUrl, exe: executable, profileDir };
}
/** 在浏览器里开一个页面并返回可求值的会话。 */
export async function openPage(browser, url) {
const cdp = await CdpSession.connect(browser.wsUrl, "browser");
const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
await cdp.send("Page.enable", {}, sessionId);
await cdp.send("Runtime.enable", {}, sessionId);
if (url) await cdp.send("Page.navigate", { url }, sessionId);
return { cdp, targetId, sessionId };
}
/** 页面内求值(支持 await)。非 byValue 友好的值请自行 JSON 化。 */
export async function evaluate(page, expression, { awaitPromise = true } = {}) {
const result = await page.cdp.send(
"Runtime.evaluate",
{
expression,
awaitPromise,
returnByValue: true,
userGesture: true,
allowUnsafeEvalBlockedByCSP: true,
},
page.sessionId,
);
if (result.exceptionDetails) {
const detail = result.exceptionDetails;
const text =
detail.exception?.description ??
detail.exception?.value ??
detail.text ??
"页面内求值失败";
throw new Error(String(text));
}
return result.result?.value;
}
/** 把页面的 console / 异常收进 page.logs,排查前端卡住用。 */
export function captureConsole(page) {
page.logs = [];
const push = (type, text) => {
page.logs.push({ type, text: String(text).slice(0, 800) });
if (page.logs.length > 300) page.logs.shift();
};
page.cdp.on("Runtime.consoleAPICalled", (params) => {
const text = (params.args || []).map((a) => a.value ?? a.description ?? a.type).join(" ");
push(params.type, text);
});
page.cdp.on("Runtime.exceptionThrown", (params) => {
const d = params.exceptionDetails || {};
push("exception", (d.exception && d.exception.description) || d.text || "");
});
page.cdp.on("Log.entryAdded", (params) => {
const e = params.entry || {};
push("log:" + e.level, e.text || "");
});
page.cdp.send("Log.enable", {}, page.sessionId).catch(() => {});
return page.logs;
}
+244
View File
@@ -0,0 +1,244 @@
// 对比两份真实请求:酒馆抓包 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;
}
+189
View File
@@ -0,0 +1,189 @@
// 让本机真酒馆发一次真实请求,落到 capture-server 上。
// 用法: node drive-st.mjs --preset "C:/Users/zed/Downloads/linux_1.3.json" --message 你好 --capture-port 8801 --st-port 8123
// 用独立 dataRoot,不改 D:\game\sillytavern\data 里的东西。
// 预设走页面上的「导入预设」文件输入(纯导入 json),和本仓库那边保持同一条链路。
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { captureConsole, evaluate, launchBrowser, openPage, sleep } from "./cdp.mjs";
import { listCaptures, makeLogger, parseArgs, waitForNewCapture, waitHttp, waitStReady, writeCharacter } from "./lib.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const log = makeLogger("drive-st");
const DEFAULTS = {
preset: "",
message: "你好",
capturePort: 8801,
stPort: 8123,
stRoot: "D:/game/sillytavern",
dataRoot: path.join(HERE, ".st-data"),
out: path.join(HERE, "captures"),
card: "tokens",
browser: "",
headful: false,
keep: false,
timeoutMs: 180000,
};
const CLOSE_POPUPS = `(() => {
document.querySelectorAll(".popup-button-cancel, .popup-button-ok").forEach((b) => b.click());
return true;
})()`;
async function main() {
const args = parseArgs(process.argv.slice(2), DEFAULTS);
if (!args.preset || !existsSync(args.preset)) throw new Error("预设文件不存在: " + args.preset);
if (!existsSync(args.stRoot)) throw new Error("酒馆目录不存在: " + args.stRoot);
mkdirSync(args.out, { recursive: true });
const presetName = path.basename(args.preset, ".json");
const presetText = readFileSync(args.preset, "utf8");
log("预设: " + args.preset + " (名字按酒馆规则取文件名: " + presetName + ")");
const cardName = "ProbeCard";
writeCharacter(args.dataRoot, cardName, args.card);
log("角色卡就位: " + cardName + ".png (card=" + args.card + ")");
const staleFiles = [path.join(args.dataRoot, "default-user", "OpenAI Settings", presetName + ".json")]; for (const stale of staleFiles) { try { rmSync(stale, { force: true }); } catch (e) {} } const known = new Set(listCaptures(args.out, "st"));
const st = spawn(process.execPath, ["server.js", "--port", String(args.stPort), "--dataRoot", args.dataRoot, "--browserLaunchEnabled=false"], { cwd: args.stRoot, stdio: ["ignore", "pipe", "pipe"] });
const stLog = [];
st.stdout.on("data", (d) => stLog.push(String(d)));
st.stderr.on("data", (d) => stLog.push(String(d)));
let browser;
let page;
try {
await waitHttp("http://127.0.0.1:" + args.stPort + "/", args.timeoutMs, "酒馆服务起来");
log("酒馆已启动");
browser = await launchBrowser({ exe: args.browser || undefined, port: args.stPort + 100, headless: !args.headful });
page = await openPage(browser, "http://127.0.0.1:" + args.stPort + "/");
captureConsole(page);
log("浏览器已打开页面");
await waitStReady(page, args.timeoutMs, log);
log("前端就绪");
const importExpr = `(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;
})()`;
await evaluate(page, importExpr);
log("已把预设丢进酒馆的导入框,等它落盘...");
let importState = null;
for (let i = 0; i < 120; i++) {
importState = 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), popup: Boolean(document.querySelector(".popup")) };
})()`);
if (importState.has) break;
await sleep(500);
}
if (!importState || !importState.has) throw new Error("预设导入后没出现在列表里: " + JSON.stringify(importState));
log("预设已导入: " + presetName);
const presetState = await evaluate(page, `(async () => {
const ctx = window.SillyTavern.getContext();
const names = ctx.getPresetManager("openai").getPresetList().preset_names || {};
const idx = names[${JSON.stringify(presetName)}];
if (idx === undefined) return { error: "preset-not-found", keys: Object.keys(names) };
ctx.chatCompletionSettings.bind_preset_to_connection = false;
const sel = window.jQuery("#settings_preset_openai");
sel.val(String(idx));
sel.trigger("change");
const s = ctx.chatCompletionSettings;
return {
preset: s.preset_settings_openai,
temp: s.temp_openai,
topP: s.top_p_openai,
maxTokens: s.openai_max_tokens,
stream: s.stream_openai,
squash: s.squash_system_messages,
useSysprompt: s.use_sysprompt, maxContext: s.openai_max_context, functionCalling: s.function_calling, unlocked: s.max_context_unlocked,
};
})()`);
if (presetState && presetState.error) throw new Error("预设没被酒馆识别: " + JSON.stringify(presetState));
const presetJson = JSON.parse(presetText); const expect = { temp: presetJson.temperature, topP: presetJson.top_p, maxTokens: presetJson.openai_max_tokens }; log("预设已选中: " + JSON.stringify(presetState) + " , 预设文件里: " + JSON.stringify(expect)); if (expect.temp !== undefined && Math.abs(Number(expect.temp) - Number(presetState.temp)) > 1e-6) throw new Error("导入后参数没生效: temp 期望 " + expect.temp + " 实得 " + presetState.temp);
const connUrl = "http://127.0.0.1:" + args.capturePort + "/v1";
const connState = await evaluate(page, `(async () => {
const ctx = window.SillyTavern.getContext();
const jq = window.jQuery;
jq("#main_api").val("openai").trigger("change"); jq("#chat_completion_source").val("custom").trigger("change");
const s = ctx.chatCompletionSettings;
s.chat_completion_source = "custom";
s.reverse_proxy = "";
s.proxy_password = "";
s.custom_url = ${JSON.stringify(connUrl)};
s.custom_model = "mock-model";
jq("#custom_url").val(s.custom_url);
jq("#custom_model").val(s.custom_model); s.openai_max_context = 2000000; s.max_context_unlocked = true; jq("#openai_max_context").val(2000000).trigger("input"); jq("#oai_max_context_unlocked").prop("checked", true).trigger("input"); s.function_calling = false; jq("#openai_function_calling").prop("checked", false).trigger("input");
return { source: s.chat_completion_source, url: s.custom_url, model: s.custom_model, mainApi: window.SillyTavern.getContext().mainApi, maxContext: s.openai_max_context, functionCalling: s.function_calling };
})()`);
log("连接已指向 mock: " + connState.url + " (source=" + connState.source + ")");
const charState = await evaluate(page, `(async () => {
const ctx = window.SillyTavern.getContext();
await ctx.getCharacters();
const list = ctx.characters || [];
const idx = list.findIndex((c) => c.name === ${JSON.stringify(cardName)});
const other = list.findIndex((c) => c.name !== ${JSON.stringify(cardName)});
if (idx < 0) return { error: "character-not-found", names: list.map((c) => c.name) };
if (other >= 0) await ctx.selectCharacterById(other);
for (let t = 0; t < 40 && window.SillyTavern.getContext().name2 !== ${JSON.stringify(cardName)}; t++) { if (t === 0 && other >= 0) await ctx.selectCharacterById(other); await ctx.selectCharacterById(idx); await new Promise((r) => setTimeout(r, 500)); }
await ctx.clearChat({ clearData: true });
const fresh = window.SillyTavern.getContext(); return { ok: true, chatLen: fresh.chat.length, charName: fresh.name2, chatId: fresh.chatId, chid: fresh.characterId };
})()`);
log("角色状态: " + JSON.stringify(charState));
if (!charState || charState.charName !== cardName) throw new Error("角色没选中: " + JSON.stringify(charState));
await evaluate(page, CLOSE_POPUPS);
await evaluate(page, `(() => {
const jq = window.jQuery;
const ta = jq("#send_textarea");
ta.val(${JSON.stringify(args.message)}).trigger("input");
window.__genErr = null; window.SillyTavern.getContext().generate("normal").catch((e) => { window.__genErr = String((e && e.stack) || e); });
return true;
})()`);
log("已点发送, 等 mock 落盘..."); for (let i = 0; i < 20; i++) { const sendState = await evaluate(page, `(() => { const c = window.SillyTavern.getContext(); const ta = document.getElementById("send_textarea"); return { chatLen: c.chat.length, err: window.__genErr, api: c.mainApi, txt: String(ta.value).slice(0, 20), ro: ta.readOnly, extCount: c.extensionSettings ? Object.keys(c.extensionSettings).length : -1 }; })()`); log("发送状态: " + JSON.stringify(sendState)); if (sendState.chatLen || sendState.err) break; await sleep(500); } log("页面日志尾: " + JSON.stringify(page.logs.slice(-12)));
const captureFile = await waitForNewCapture(args.out, "st", known, args.timeoutMs);
log("抓到请求: " + captureFile);
await sleep(1500);
console.log(JSON.stringify({ side: "st", preset: presetName, capture: path.join(args.out, captureFile) }));
} catch (err) {
console.error("[drive-st] 失败: " + err.message);
if (stLog.length) console.error(stLog.join("").slice(-2500));
if (page) {
const dump = await evaluate(page, `(() => ({
url: location.href,
ready: document.readyState,
toast: Array.from(document.querySelectorAll(".toast-message, .toast")).map((e) => e.textContent).slice(0, 6),
splash: Array.from(document.querySelectorAll(".splash-message")).map((e) => e.textContent),
popups: Array.from(document.querySelectorAll(".popup")).map((e) => e.innerText.slice(0, 300)),
chatLen: window.SillyTavern ? window.SillyTavern.getContext().chat.length : -1,
chid: window.SillyTavern ? window.SillyTavern.getContext().characterId : -1,
name2: window.SillyTavern ? window.SillyTavern.getContext().name2 : "",
sendDisabled: document.querySelector("#send_but") ? document.querySelector("#send_but").className : "",
}))()`).catch((e) => ({ error: e.message }));
console.error("页面状态: " + JSON.stringify(dump, null, 2));
if (page.logs) console.error("页面日志: " + JSON.stringify(page.logs.slice(-15), null, 2));
}
process.exitCode = 1;
} finally {
if (!args.keep) {
try { if (browser) browser.child.kill(); } catch (e) {}
try { st.kill(); } catch (e) {}
await sleep(500);
try { st.kill("SIGKILL"); } catch (e) {}
}
}
}
await main();
+105
View File
@@ -0,0 +1,105 @@
// 让本仓库发同一次真实请求,落到 capture-server 上。
// 用法: node drive-wa.mjs --preset "C:/Users/zed/Downloads/linux_1.3.json" --message 你好 --capture-port 8802
// 用独立 WRITING_AGENT_DATA_DIR,不碰 ~/.writing-agent 里的配置。
// 预设走本仓库自己的 /api/presets/import(纯导入 json),之后走直聊的 ephemeral complete 真发一次。
import { spawn } from "node:child_process";
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { listCaptures, makeLogger, parseArgs, waitForNewCapture, waitHttp } from "./lib.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO = path.resolve(HERE, "../..");
const log = makeLogger("drive-wa");
const DEFAULTS = {
preset: "",
message: "你好",
capturePort: 8802,
port: 23377,
out: path.join(HERE, "captures"),
dataRoot: path.join(HERE, ".wa-data"),
timeoutMs: 120000,
};
async function postJson(url, body) {
const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
const text = await res.text();
let data = null;
try { data = JSON.parse(text); } catch (e) { data = { raw: text.slice(0, 800) }; }
if (!res.ok) throw new Error("HTTP " + res.status + " " + JSON.stringify(data).slice(0, 800));
return data;
}
async function main() {
const args = parseArgs(process.argv.slice(2), DEFAULTS);
if (!args.preset) throw new Error("缺少 --preset");
const presetName = path.basename(args.preset, ".json");
const raw = JSON.parse(readFileSync(args.preset, "utf8"));
mkdirSync(args.dataRoot, { recursive: true });
mkdirSync(args.out, { recursive: true });
// 一条指向假上游的 API 配置。直接写盘,避免能力探测的额外请求混进抓包。
const now = new Date().toISOString();
const profiles = {
version: 1,
profiles: [
{
id: "probe-mock",
name: "mock",
baseUrl: "http://127.0.0.1:" + args.capturePort + "/v1",
apiKey: "sk-mock",
model: "mock-model",
createdAt: now,
updatedAt: now,
},
],
};
writeFileSync(path.join(args.dataRoot, "profiles.json"), JSON.stringify(profiles, null, 2), "utf8");
try { rmSync(path.join(args.dataRoot, "presets"), { recursive: true, force: true }); } catch (e) {} const known = new Set(listCaptures(args.out, "wa"));
const env = { ...process.env, PORT: String(args.port), WRITING_AGENT_DATA_DIR: args.dataRoot };
const server = spawn(process.execPath, [path.join(REPO, "node_modules/tsx/dist/cli.mjs"), path.join(REPO, "src/server/web-server.ts")], { cwd: REPO, env, stdio: ["ignore", "pipe", "pipe"] });
const logs = [];
server.stdout.on("data", (d) => logs.push(String(d)));
server.stderr.on("data", (d) => logs.push(String(d)));
const base = "http://127.0.0.1:" + args.port;
try {
await waitHttp(base + "/api/health", args.timeoutMs, "本仓库服务起来");
log("服务已启动: " + base);
const imported = await postJson(base + "/api/presets/import", { raw, name: presetName, activate: true });
const presetId = imported.preset.id;
log("预设已导入: " + presetName + " (id=" + presetId + ")");
if (imported.warnings && imported.warnings.length) log("导入提示: " + JSON.stringify(imported.warnings).slice(0, 600));
const body = {
text: args.message,
presetId,
complete: true,
turns: [],
windows: [{ id: "w1", profileId: "probe-mock" }],
systemPrompt: "",
loreBefore: "",
seedHistory: "",
loreAfter: "",
postTurn: "",
};
const result = await postJson(base + "/api/direct-chat/ephemeral/complete", body);
const first = result.multi && result.multi.results && result.multi.results[0];
if (first && first.error) throw new Error("本轮请求失败: " + first.error);
log("本轮完成, reply=" + JSON.stringify(String((first && first.reply) || "").slice(0, 40)));
const captureFile = await waitForNewCapture(args.out, "wa", known, args.timeoutMs);
log("抓到请求: " + captureFile);
console.log(JSON.stringify({ side: "wa", preset: presetName, capture: path.join(args.out, captureFile) }));
} catch (err) {
console.error("[drive-wa] 失败: " + err.message);
if (logs.length) console.error(logs.join("").slice(-4000));
process.exitCode = 1;
} finally {
try { server.kill(); } catch (e) {}
}
}
await main();
+25
View File
@@ -0,0 +1,25 @@
// 从本仓库存的预设包里把原始酒馆 JSON 抠出来,两边用同一个文件。
// 用法: node extract-preset.mjs [预设包路径] [输出名]
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const input =
process.argv[2] ??
path.join(os.homedir(), ".writing-agent", "presets", "linux_1-3-9c636433.json");
const name = process.argv[3] ?? "linux_1-3";
const pkg = JSON.parse(readFileSync(input, "utf8"));
const raw = pkg.raw ?? pkg;
if (!raw.prompts || !raw.prompt_order) {
throw new Error("这份预设里没有 prompts / prompt_order,不是酒馆格式");
}
raw.name = name;
const outDir = path.join(HERE, "presets");
mkdirSync(outDir, { recursive: true });
const file = path.join(outDir, name + ".json");
writeFileSync(file, JSON.stringify(raw, null, 2), "utf8");
console.log("写出 " + file + " (prompts=" + raw.prompts.length + ", prompt_order=" + raw.prompt_order.length + ")");
+20
View File
@@ -0,0 +1,20 @@
// 看一份抓包的逐条消息:role、额外字段(name 等)、字数、头尾片段。
// 用法: node inspect-capture.mjs captures/st-001-xxx.json [--full 序号]
import { readFileSync } from "node:fs";
const file = process.argv[2];
const fullIdx = process.argv.indexOf("--full");
const full = fullIdx > 0 ? Number(process.argv[fullIdx + 1]) : -1;
const outer = JSON.parse(readFileSync(file, "utf8"));
const body = JSON.parse(outer.body);
console.log("url: " + outer.url + ", 顶层字段: " + Object.keys(body).join(", ")); if (body.tools) { console.log("tool_choice: " + JSON.stringify(body.tool_choice)); console.log("tools(" + body.tools.length + "): " + JSON.stringify(body.tools).slice(0, 1200)); }
(body.messages || []).forEach((m, i) => {
const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
const extras = Object.keys(m).filter((k) => k !== "role" && k !== "content");
const head = text.replace(/\s+/g, " ").slice(0, 70);
const tail = text.replace(/\s+/g, " ").slice(-50);
console.log("#" + i + " [" + m.role + "] " + text.length + " 字 extra=" + JSON.stringify(extras) + (extras.length ? " name=" + JSON.stringify(m.name) : ""));
console.log(" 头: " + head);
console.log(" 尾: " + tail);
if (i === full) console.log("----全文----\n" + text);
});
+18
View File
@@ -0,0 +1,18 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const dir = path.join(os.homedir(), ".writing-agent", "presets");
for (const name of fs.readdirSync(dir)) {
const file = path.join(dir, name);
const json = JSON.parse(fs.readFileSync(file, "utf8"));
console.log("==", name, fs.statSync(file).size);
console.log("top keys:", Object.keys(json).join(", "));
const p = json.preset ?? json;
console.log("preset keys:", Object.keys(p).join(", "));
console.log("name:", p.name, "| prompts:", p.prompts?.length, "| order:", p.promptOrder?.length);
console.log("has raw:", Boolean(p.raw), "| raw keys:", p.raw ? Object.keys(p.raw).join(", ") : "-");
console.log("generation:", JSON.stringify(p.generation));
console.log("messagePolicy:", JSON.stringify(p.messagePolicy));
console.log("unsupported:", JSON.stringify(p.unsupported));
}
@@ -0,0 +1,22 @@
// 打印预设 prompt_order 里每条的关键字段,便于解释抓包差异。
// 用法: node inspect-prompt-order.mjs "C:/Users/zed/Downloads/linux_1.3.json"
import { readFileSync } from "node:fs";
const file = process.argv[2];
const raw = JSON.parse(readFileSync(file, "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));
console.log("prompt_order 套数: " + blocks.length + ", 选用 character_id=" + block.character_id);
console.log("idx | enabled | identifier | role | marker | system_prompt | forbid_overrides | inj(pos/depth/order) | 字数 | name");
(block.order || []).forEach((item, i) => {
const p = byId.get(item.identifier) || {};
const inj = [p.injection_position, p.injection_depth, p.injection_order].join("/");
console.log([i, item.enabled, item.identifier, p.role, p.marker, p.system_prompt, p.forbid_overrides, inj, (p.content || "").length, p.name].join(" | "));
});
const inOrder = new Set((block.order || []).map((x) => x.identifier));
const orphans = prompts.filter((p) => !inOrder.has(p.identifier));
if (orphans.length) console.log("不在 order 里的: " + orphans.map((p) => p.identifier).join(", "));
@@ -0,0 +1,33 @@
// 用本仓库自己的装配代码看同一份预设会拼出什么(不发请求)。
// 用法: node node_modules/tsx/dist/cli.mjs tools/st-request-diff/inspect-wa-assembly.mjs <preset.json>
import { readFileSync } from "node:fs";
import path from "node:path";
import { importSillyTavernPreset } from "../../src/preset/importer.js";
import { assemblePlayWorkerMessages } from "../../src/preset/play-frame.js";
import { applyExtendedMarkers } from "../../src/preset/markers.js";
const file = process.argv[2];
const raw = JSON.parse(readFileSync(file, "utf8"));
const report = importSillyTavernPreset(raw, { name: path.basename(file, ".json") });
const preset = report.preset;
console.log("导入: prompts=" + preset.prompts.length + " order=" + preset.promptOrder.length + " squash=" + JSON.stringify(preset.messagePolicy));
console.log("警告: " + JSON.stringify(report.warnings.slice(0, 6)));
const framed = applyExtendedMarkers(preset);
const messages = assemblePlayWorkerMessages({
systemPrompt: "",
preset,
pack: { worldBookBefore: [], history: [], worldBookAfter: [], turn: "你好", postTurn: [] },
});
console.log("--- 装配结果 " + messages.length + " 条 ---");
messages.forEach((m, i) => {
const text = String(m.content || "");
console.log("#" + i + " [" + m.role + "] " + text.length + " 字: " + text.replace(/\s+/g, " ").slice(0, 60));
});
const byId = new Map(preset.prompts.map((p) => [p.id, p]));
for (const id of ["dcb8d472-6dd4-46b4-a74d-3c5674d7c6af", "67b2c14a-f300-4715-93e1-5f34c34462f6", "ate-toolcall-guide-sys"]) {
const p = byId.get(id);
const order = preset.promptOrder.find((o) => o.promptId === id);
console.log("条目 " + id + ": 存在=" + Boolean(p) + " 内容=" + (p ? p.content.length : -1) + " entry.enabled=" + (p ? p.enabled : "-") + " order.enabled=" + (order ? order.enabled : "(不在 order)"));
}
+182
View File
@@ -0,0 +1,182 @@
/** 抓包对比工具共用件:参数、日志、等等,以及造一张最小角色卡。 */
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import zlib from "node:zlib";
import { waitFor } from "./cdp.mjs";
export function parseArgs(argv, defaults) {
const out = { ...defaults };
const map = {
"--preset": "preset",
"--message": "message",
"--capture-port": "capturePort",
"--st-port": "stPort",
"--st-root": "stRoot",
"--data-root": "dataRoot",
"--out": "out",
"--card": "card",
"--browser": "browser",
"--port": "port",
"--timeout": "timeoutMs",
};
for (let i = 0; i < argv.length; i++) {
const key = argv[i];
if (key === "--headful") {
out.headful = true;
continue;
}
if (key === "--keep") {
out.keep = true;
continue;
}
const name = map[key];
if (!name) continue;
const raw = argv[i + 1];
i++;
if (name === "preset" || name === "stRoot" || name === "dataRoot" || name === "out") {
out[name] = path.resolve(String(raw));
} else if (["capturePort", "stPort", "port", "timeoutMs"].includes(name)) {
out[name] = Number(raw);
} else {
out[name] = String(raw);
}
}
return out;
}
export function makeLogger(tag) {
return (...parts) => console.log(`[${tag}]`, ...parts);
}
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c >>> 0;
}
return table;
})();
function crc32(buf) {
let c = 0xffffffff;
for (const byte of buf) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function pngChunk(type, data) {
const typeBuf = Buffer.from(type, "ascii");
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
return Buffer.concat([len, typeBuf, data, crc]);
}
/** 1x1 透明 PNG + chara(tEXt) 文本块,酒馆认这个当角色卡 */
export function buildCharacterPng(card) {
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(1, 0);
ihdr.writeUInt32BE(1, 4);
ihdr[8] = 8;
ihdr[9] = 6;
const idat = zlib.deflateSync(Buffer.from([0x00, 0xff, 0xff, 0xff, 0x00]));
const b64 = Buffer.from(JSON.stringify(card), "utf8").toString("base64");
const text = Buffer.concat([Buffer.from("chara\0", "latin1"), Buffer.from(b64, "latin1")]);
return Buffer.concat([
sig,
pngChunk("IHDR", ihdr),
pngChunk("tEXt", text),
pngChunk("IDAT", idat),
pngChunk("IEND", Buffer.alloc(0)),
]);
}
const CARD_TOKENS = {
description: "DESC_TOKEN 这是一张测试角色卡的描述,用来确认酒馆把它放在哪一段。",
personality: "PERS_TOKEN 冷静、话少、只答问题。",
scenario: "SCEN_TOKEN 一间安静的书房,只有两个人在说话。",
first_mes: "GREET_TOKEN 你好,我在。",
mes_example: "<START>\n{{user}}: EX_USER_TOKEN 今天做什么?\n{{char}}: EX_CHAR_TOKEN 读书。",
};
const CARD_EMPTY = { description: "", personality: "", scenario: "", first_mes: "", mes_example: "" };
export function buildCard(kind, name) {
const body = kind === "empty" ? CARD_EMPTY : CARD_TOKENS;
return {
spec: "chara_card_v2",
spec_version: "2.0",
data: {
name,
...body,
creator_notes: "",
system_prompt: "",
post_history_instructions: "",
alternate_greetings: [],
character_book: null,
tags: [],
creator: "",
character_version: "",
extensions: {},
},
};
}
export function writeCharacter(dataRoot, name, kind) {
const dir = path.join(dataRoot, "default-user", "characters");
mkdirSync(dir, { recursive: true });
const file = path.join(dir, `${name}.png`);
writeFileSync(file, buildCharacterPng(buildCard(kind, name)));
return file;
}
export async function waitHttp(url, timeoutMs, label) {
return waitFor(async () => {
const res = await fetch(url).catch(() => null);
return res && res.ok ? res : null;
}, { timeoutMs, intervalMs: 500, label });
}
/** 等 captures 目录里出现一个没见过的 <label>-*.json */
export async function waitForNewCapture(outDir, prefix, known, timeoutMs) {
return waitFor(() => {
const files = readdirSync(outDir).filter((f) => f.startsWith(`${prefix}-`) && f.endsWith(".json"));
const fresh = files.filter((f) => !known.has(f));
return fresh.length ? fresh.sort().at(-1) : null;
}, { timeoutMs, intervalMs: 300, label: `mock 收到 ${prefix} 的请求` });
}
export function listCaptures(outDir, prefix) {
return existsSync(outDir)
? readdirSync(outDir).filter((f) => f.startsWith(`${prefix}-`) && f.endsWith(".json"))
: [];
}
import { evaluate } from "./cdp.mjs";
const ST_READY = `Boolean(window.SillyTavern && window.SillyTavern.getContext && document.querySelector("#send_textarea") && !document.querySelector(".splash-message") && Object.keys(window.SillyTavern.getContext().getPresetManager("openai").getPresetList().preset_names || {}).length > 0)`;
const ST_PROGRESS = `(() => ({
readyState: document.readyState,
splash: Array.from(document.querySelectorAll(".splash-message")).map((e) => e.textContent),
loaders: Array.from(document.querySelectorAll("[data-slug]")).map((e) => e.getAttribute("data-slug")).slice(0, 20),
api: performance.getEntriesByType("resource").map((e) => e.name).filter((n) => n.indexOf("/api/") >= 0).slice(-10),
}))()`;
/** 等酒馆前端初始化完;卡住时每 5 秒打一次进度,方便判断停在哪一步。 */
export async function waitStReady(page, timeoutMs, log) {
const start = Date.now();
let ticks = 0;
while (Date.now() - start < timeoutMs) {
const ready = await evaluate(page, ST_READY).catch(() => false);
if (ready) return true;
if (ticks % 5 === 4) {
const progress = await evaluate(page, ST_PROGRESS).catch((e) => ({ error: e.message }));
if (log) log("等前端就绪: " + JSON.stringify(progress));
}
ticks++;
await new Promise((r) => setTimeout(r, 1000));
}
throw new Error("等酒馆前端就绪超时");
}
+151
View File
@@ -0,0 +1,151 @@
// 手动挡:我把环境起好(假上游 + 酒馆 + 有头浏览器 + 预设导入 + 连接 + 角色 + 空历史),
// 你只管在页面里点发送,脚本只监控抓包,不自己反复重试。
// 用法: node manual-st.mjs --preset "C:/Users/zed/Downloads/linux_1.3.json"
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { captureConsole, evaluate, launchBrowser, openPage, sleep } from "./cdp.mjs";
import { listCaptures, makeLogger, parseArgs, waitHttp, waitStReady, writeCharacter } from "./lib.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const log = makeLogger("manual-st");
const DEFAULTS = {
preset: "C:/Users/zed/Downloads/linux_1.3.json",
capturePort: 8801,
stPort: 8123,
stRoot: "D:/game/sillytavern",
dataRoot: path.join(HERE, ".st-data"),
out: path.join(HERE, "captures"),
card: "tokens",
browser: "",
timeoutMs: 180000,
};
async function main() {
const args = parseArgs(process.argv.slice(2), DEFAULTS);
if (!existsSync(args.preset)) throw new Error("预设文件不存在: " + args.preset);
mkdirSync(args.out, { recursive: true });
const presetName = path.basename(args.preset, ".json");
const presetText = readFileSync(args.preset, "utf8");
const cap = spawn(process.execPath, [path.join(HERE, "capture-server.mjs"), "--label", "st", "--port", String(args.capturePort), "--out", args.out], { cwd: HERE, stdio: "inherit" });
await waitHttp("http://127.0.0.1:" + args.capturePort + "/v1/models", 15000, "假上游起来");
const cardName = "ProbeCard";
writeCharacter(args.dataRoot, cardName, args.card);
const stale = path.join(args.dataRoot, "default-user", "OpenAI Settings", presetName + ".json");
try { rmSync(stale, { force: true }); } catch (e) {}
const st = spawn(process.execPath, ["server.js", "--port", String(args.stPort), "--dataRoot", args.dataRoot, "--browserLaunchEnabled=false"], { cwd: args.stRoot, stdio: ["ignore", "pipe", "pipe"] });
const stLog = [];
st.stdout.on("data", (d) => stLog.push(String(d)));
st.stderr.on("data", (d) => stLog.push(String(d)));
let browser;
try {
await waitHttp("http://127.0.0.1:" + args.stPort + "/", args.timeoutMs, "酒馆服务起来");
browser = await launchBrowser({ exe: args.browser || undefined, port: args.stPort + 100, headless: false });
const page = await openPage(browser, "http://127.0.0.1:" + args.stPort + "/");
captureConsole(page);
await waitStReady(page, args.timeoutMs, log);
log("前端就绪");
const importExpr = `(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;
})()`;
await evaluate(page, importExpr);
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)}) };
})()`);
if (s.has) break;
await sleep(500);
}
log("预设已导入并选中: " + presetName);
const connUrl = "http://127.0.0.1:" + args.capturePort + "/v1";
const state = await evaluate(page, `(async () => {
const ctx = window.SillyTavern.getContext();
const jq = window.jQuery;
const names = ctx.getPresetManager("openai").getPresetList().preset_names || {};
const idx = names[${JSON.stringify(presetName)}];
ctx.chatCompletionSettings.bind_preset_to_connection = false;
jq("#main_api").val("openai").trigger("change");
jq("#settings_preset_openai").val(String(idx)).trigger("change");
jq("#chat_completion_source").val("custom").trigger("change");
const s = ctx.chatCompletionSettings;
s.chat_completion_source = "custom";
s.reverse_proxy = "";
s.proxy_password = "";
s.custom_url = ${JSON.stringify(connUrl)};
s.custom_model = "mock-model";
jq("#custom_url").val(s.custom_url);
jq("#custom_model").val(s.custom_model);
jq("#openai_max_context").val(2000000).trigger("input");
const list = ctx.characters || [];
const ti = list.findIndex((c) => c.name === ${JSON.stringify(cardName)});
const other = list.findIndex((c) => c.name !== ${JSON.stringify(cardName)});
for (let t = 0; t < 40 && window.SillyTavern.getContext().name2 !== ${JSON.stringify(cardName)}; t++) {
if (t === 0 && other >= 0) await ctx.selectCharacterById(other);
await ctx.selectCharacterById(ti);
await new Promise((r) => setTimeout(r, 400));
}
await window.SillyTavern.getContext().clearChat({ clearData: true });
const f = window.SillyTavern.getContext();
return {
preset: f.chatCompletionSettings.preset_settings_openai,
temp: f.chatCompletionSettings.temp_openai,
maxTokens: f.chatCompletionSettings.openai_max_tokens,
maxContext: f.chatCompletionSettings.openai_max_context,
functionCalling: f.chatCompletionSettings.function_calling,
squash: f.chatCompletionSettings.squash_system_messages,
stream: f.chatCompletionSettings.stream_openai,
charName: f.name2,
chatLen: f.chat.length,
url: f.chatCompletionSettings.custom_url,
};
})()`);
log("环境已就位: " + JSON.stringify(state));
log("=================================================");
log("浏览器已打开酒馆,你只需要:在输入框写内容 -> 点发送(或回车)。");
log("发送后我会把抓到的请求打出来。要改设置(比如关掉 function calling)也直接在页面里改。");
log("=================================================");
const seen = new Set(listCaptures(args.out, "st"));
log("开始监控抓包(Ctrl+C 结束)...");
for (;;) {
await sleep(1500);
const files = listCaptures(args.out, "st").filter((f) => !seen.has(f));
for (const f of files) {
seen.add(f);
log("抓到新请求: " + f);
const info = await evaluate(page, `(() => ({ name2: window.SillyTavern.getContext().name2, chatLen: window.SillyTavern.getContext().chat.length }))()`).catch(() => ({}));
log("页面当前: " + JSON.stringify(info));
log("→ 用 `node inspect-capture.mjs captures/" + f + "` 看逐条消息");
}
const err = await evaluate(page, "window.__lastErr || null").catch(() => null);
if (err) log("页面报错: " + err);
}
} catch (err) {
console.error("[manual-st] 失败: " + err.message);
if (stLog.length) console.error(stLog.join("").slice(-2000));
process.exitCode = 1;
} finally {
try { if (browser) browser.child.kill(); } catch (e) {}
try { st.kill("SIGKILL"); } catch (e) {}
try { cap.kill(); } catch (e) {}
}
}
await main();
+12
View File
@@ -0,0 +1,12 @@
// 看预设里某条目的正文开头与宏统计。用法: node peek-entry.mjs <preset.json> <identifier> [字数]
import { readFileSync } from "node:fs";
const [file, id, n] = process.argv.slice(2);
const raw = JSON.parse(readFileSync(file, "utf8"));
const p = (raw.prompts || []).find((x) => x.identifier === id);
if (!p) { console.log("没有这个 identifier: " + id); process.exit(1); }
const text = p.content || "";
console.log("name=" + p.name + " role=" + p.role + " 字数=" + text.length);
const macros = text.match(/\{\{[^}]*\}\}/g) || [];
console.log("宏 " + macros.length + " 个: " + macros.slice(0, 12).join(" "));
console.log("开头: " + text.slice(0, Number(n || 300)));
console.log("结尾: " + text.slice(-200));
+11
View File
@@ -0,0 +1,11 @@
// 看预设里采样参数字段的类型(有些预设存成字符串,本仓库可能因此丢掉)。
import { readFileSync } from "node:fs";
const keys = ["temperature", "top_p", "top_k", "min_p", "top_a", "frequency_penalty", "presence_penalty", "repetition_penalty", "openai_max_tokens", "openai_max_context", "seed", "n", "stream_openai"];
for (const file of process.argv.slice(2)) {
const raw = JSON.parse(readFileSync(file, "utf8"));
console.log("=== " + file.split("/").pop());
for (const k of keys) {
const v = raw[k];
console.log(" " + k.padEnd(20) + " " + (v === undefined ? "(缺)" : typeof v + " = " + JSON.stringify(v)));
}
}
@@ -0,0 +1,22 @@
// 打印预设里跟"机制"有关的顶层字段。用法: node peek-preset-flags.mjs <a.json> [b.json ...]
import { readFileSync } from "node:fs";
const keys = ["function_calling", "tool_call_recurse_limit", "tool_reasoning_mode", "squash_system_messages", "use_sysprompt", "sysprompt", "openai_max_context", "openai_max_tokens", "stream_openai", "show_thoughts", "reasoning_effort", "verbosity", "send_if_empty", "new_chat_prompt", "new_example_chat_prompt", "names_behavior", "assistant_prefill", "continue_prefill"];
for (const file of process.argv.slice(2)) {
const raw = JSON.parse(readFileSync(file, "utf8"));
console.log("=== " + file);
for (const k of keys) {
if (raw[k] === undefined) continue;
const v = JSON.stringify(raw[k]);
console.log(" " + k + ": " + (v.length > 120 ? v.slice(0, 120) + "…" : v));
}
const ext = raw.extensions;
if (ext) {
console.log(" extensions 顶层键: " + Object.keys(ext).join(", "));
const text = JSON.stringify(ext);
console.log(" extensions 里提到 tool 的片段: " + (text.match(/.{0,60}tool.{0,120}/gi) || []).slice(0, 3).join(" | "));
} else {
console.log(" extensions: 无");
}
const promptIds = (raw.prompts || []).map((p) => p.identifier);
console.log(" 含 tool 字样的条目: " + promptIds.filter((id) => String(id).includes("tool")).join(", "));
}
+99
View File
@@ -0,0 +1,99 @@
// 一条命令跑全流程:起两个假上游 -> 逐个预设驱动真酒馆与本仓库 -> 出对比报告。
// 用法: node run-all.mjs [--only all|st|wa|diff] [--headful] [--preset 路径(可多次)] [--message 你好]
import { spawn } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { waitFor } from "./cdp.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const DEFAULT_PRESETS = [
"C:/Users/zed/Downloads/Reborn2.4.json",
"C:/Users/zed/Downloads/linux_1.3.json",
];
function readArgs(argv) {
const out = { only: "all", presets: [], message: "你好", stCapturePort: 8801, waCapturePort: 8802, stPort: 8123, headful: false, card: "tokens", out: path.join(HERE, "captures") };
for (let i = 0; i < argv.length; i++) {
const k = argv[i];
const v = argv[i + 1];
if (k === "--only") { out.only = String(v); i++; }
else if (k === "--preset") { out.presets.push(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 === "--card") { out.card = String(v); i++; }
else if (k === "--headful") out.headful = true;
}
if (out.presets.length === 0) out.presets = DEFAULT_PRESETS.slice();
return out;
}
function run(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 = (chunk) => { const text = String(chunk); out += text; process.stdout.write(text); };
child.stdout.on("data", pipe);
child.stderr.on("data", pipe);
child.on("close", (code) => resolve({ code, out }));
});
}
function lastJson(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; }
}
async function main() {
const args = readArgs(process.argv.slice(2));
const wantSt = args.only === "all" || args.only === "st";
const wantWa = args.only === "all" || args.only === "wa";
const wantDiff = args.only === "all" || args.only === "diff";
const captures = [];
const startCapture = (label, port) => {
const child = spawn(process.execPath, [path.join(HERE, "capture-server.mjs"), "--label", label, "--port", String(port), "--out", args.out], { cwd: HERE, stdio: "inherit" });
captures.push(child);
};
const waitMock = (port) => waitFor(async () => (await fetch("http://127.0.0.1:" + port + "/v1/models").catch(() => null)) !== null, { timeoutMs: 15000, label: "假上游 " + port });
try {
if (wantSt || wantWa) {
startCapture("st", args.stCapturePort);
startCapture("wa", args.waCapturePort);
await waitMock(args.stCapturePort);
await waitMock(args.waCapturePort);
}
const pairs = [];
for (const preset of args.presets) {
const pair = { preset };
if (wantSt) {
const r = await run("drive-st.mjs", ["--preset", preset, "--message", args.message, "--capture-port", String(args.stCapturePort), "--st-port", String(args.stPort), "--card", args.card, "--out", args.out, ...(args.headful ? ["--headful"] : [])]);
if (r.code !== 0) throw new Error("驱动酒馆失败: " + preset);
const meta = lastJson(r.out);
if (meta) pair.st = meta.capture;
}
if (wantWa) {
const r = await run("drive-wa.mjs", ["--preset", preset, "--message", args.message, "--capture-port", String(args.waCapturePort), "--out", args.out]);
if (r.code !== 0) throw new Error("驱动本仓库失败: " + preset);
const meta = lastJson(r.out);
if (meta) pair.wa = meta.capture;
}
pairs.push(pair);
}
if (wantDiff) {
for (const pair of pairs) {
if (!pair.st || !pair.wa) continue;
const r = await run("diff.mjs", ["--st", pair.st, "--wa", pair.wa, "--preset", pair.preset]);
if (r.code !== 0) throw new Error("对比失败: " + pair.preset);
}
}
} finally {
for (const child of captures) {
try { child.kill(); } catch (e) {}
}
}
}
await main();