加一套抓真实请求做预设对比的工具,并把三份预设的机制差异记进 README。
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# 抓包对比工具的临时产物,不进库
|
||||
.st-data/
|
||||
.wa-data/
|
||||
captures/
|
||||
presets/
|
||||
*.log
|
||||
*.err
|
||||
@@ -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']` 等硬编码决定),不是位置规则;当前三份抓包里两边位置一致 → 不是当前核心差距。
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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 + ")");
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)"));
|
||||
}
|
||||
@@ -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("等酒馆前端就绪超时");
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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));
|
||||
@@ -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(", "));
|
||||
}
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user