Files
writing-agent/tools/st-request-diff/lib.mjs
T

183 lines
6.2 KiB
JavaScript

/** 抓包对比工具共用件:参数、日志、等等,以及造一张最小角色卡。 */
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("等酒馆前端就绪超时");
}