预设兼容:按真实抓包对齐装配与包装层,补上声明式工具与预设正则。

- 装配只认 prompt_order.enabled;squash_system_messages 只合并相邻 system(新增 system 档,连接符按酒馆用单换行)
- 包装层:new_chat_prompt、示例对话(new_example_chat_prompt + <START> 切段 + name)、角色卡三块与 personality_format/scenario_format、{{user}}/{{char}} 取值、任务契约并进预设条目
- 工具协议:按 ToolBindings / 伪工具协议数据声明真 tools(presetToolPolicy=preset),工具描述用到的条目不再重复作为消息发出
- 正则:promptOnly 作用于对话历史(含 depth 过滤)、写入时脚本改写回复;{{//}} 注释按配平扫描
- tools/st-request-diff:真假上游抓包对比工具、三份预设的机制结论与实测报告
This commit is contained in:
2026-10-23 00:13:08 +08:00
parent ed09e5c831
commit 9dc927282f
94 changed files with 11558 additions and 413 deletions
+35 -2
View File
@@ -53,15 +53,48 @@
## 已确认的差距(按优先级)
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 合成一条。
2. **`prompts[].enabled` 双闸**(已修:装配只认 `prompt_order.enabled`):酒馆只用 `prompt_order.enabled`(`openai.js` 从不读 `prompts[].enabled`),本仓库要求两者都开 → linux_1.3 少发 `<世界设定与背景>`、`作者简介 古典武侠`、`结尾` 共约 1 万字。
3. **`squash_system_messages` 语义**(已修:新增 `system` 档,只合并相邻 system):酒馆只合并**相邻 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}`。
## 工具协议:声明式映射(不跑脚本)
预设里的「伪工具协议」有两种数据形态,`src/preset/tool-declaration.ts` 都直接读数据翻译成真 `tools`:
1. `extensions.SPreset.ToolBindings[].form` → name / description / parameters(Reborn2.4 的 `game_content`);
2. 只有协议条目、没有 ToolBindings 时 → 从条目正文取工具名(`dispatch_stream_payload_…`)、
「工具说明」条目当 description、「字段说明」条目当参数的 description(linux_1.3 的 `display_stream`)。
开关:设置页「预设工具协议」= `preset`(只发预设要求的工具)。默认仍是 `adapt`,不动现有行为。
命中后 `complete` / `completeStream` 会改用带 tools 的请求,并把工具调用里的正文按声明字段折回正文。
实测(linux_1.3):酒馆与我们的 `tool_choice` 都是 `auto`、都只有 1 个工具、参数都是 `display_stream`;
差别只剩工具名后缀(酒馆每次导入随机生成 hash)与 description 覆盖范围(酒馆把「系统声明」也并进去了)。
## 正则脚本:三类,只做数据驱动的三类
酒馆 `regex_scripts` 的语义(`extensions/regex/engine.js` + `getRegexedString` 调用点):
| 类别 | 条件 | 作用对象 | 我们做了吗 |
| --- | --- | --- | --- |
| ① 发送前 | `promptOnly === true` | **对话历史**(user=placement 1、assistant=2、思维链=6),带 `minDepth/maxDepth` | ✅ `src/preset/regex-scripts.ts` + `play-frame` 的 chatHistory 装配 |
| ② 展示 | `markdownOnly === true` | 只影响界面渲染 | ❌ 暂不做(纯 UI) |
| ③ 写入时 | 两个 flag 都不是 true | 消息进 chat 那一刻改写文本 | ✅ 回复落盘前(`direct-chat/turn.ts`) |
支持的脚本字段:`findRegex`、`replaceString`(含 `{{match}}`、`$1`、`$<name>`)、`trimStrings`、
`placement`、`promptOnly`/`markdownOnly`、`minDepth`/`maxDepth`、`disabled`、`substituteRegex`(0/1;
2 按 RAW 处理并标记)。
实测(linux_1.3,两轮对话):上一轮助手回复 `回答正文<choices>A、B、C</choices>` →
发出的请求里变成 `回答正文`(预设的「去<choices>」promptOnly 正则生效);
上一轮用户消息被预设的「userinput」正则改写成 `【HISTORICAL_USER_INPUT】【LAST_USER_INPUT】…`。
## 决策记录
- **采样参数跟默认走**:预设缺 `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,54 @@
// TavernHelper 只对"白名单里的预设"执行预设内置脚本。这个脚本把指定预设加进白名单并重载页面。
// 用法: node allow-preset-scripts.mjs --presets linux_1.3,Reborn2.4,NVWA-Galaxia-ST [--reload]
import { CdpSession, evaluate, sleep } from "./cdp.mjs";
import { waitStReady } from "./lib.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 presets = arg("presets", "").split(",").map((s) => s.trim()).filter(Boolean);
const reload = argv.includes("--reload");
const browserPort = Number(arg("browser-port", "8223"));
const stHint = arg("st-url", "127.0.0.1:8123");
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 before = await evaluate(page, `(() => {
const s = window.SillyTavern.getContext().extensionSettings.tavern_helper.script;
return s.enabled;
})()`);
console.log("[scripts] 改前: " + JSON.stringify(before));
await evaluate(page, `(() => {
const ctx = window.SillyTavern.getContext();
const s = ctx.extensionSettings.tavern_helper.script;
const list = new Set(s.enabled.presets || []);
for (const name of ${JSON.stringify(presets)}) list.add(name);
s.enabled = { global: true, presets: [...list], characters: s.enabled.characters || [] };
ctx.saveSettingsDebounced();
return s.enabled;
})()`);
await sleep(1200);
const after = await evaluate(page, `(() => window.SillyTavern.getContext().extensionSettings.tavern_helper.script.enabled)()`);
console.log("[scripts] 改后: " + JSON.stringify(after));
if (reload) {
await cdp.send("Page.enable", {}, sessionId);
await cdp.send("Page.reload", {}, sessionId);
await sleep(1500);
await waitStReady(page, 120000, (msg) => console.log("[scripts] " + msg));
console.log("[scripts] 页面已重载并就绪");
const now = await evaluate(page, `(() => window.SillyTavern.getContext().extensionSettings.tavern_helper.script.enabled)()`);
console.log("[scripts] 重载后: " + JSON.stringify(now));
}
cdp.close();
+14
View File
@@ -29,6 +29,20 @@ const out = await evaluate(page, `(async () => {
jq("#settings_preset_openai").val(String(names[want])).trigger("change");
await new Promise((r) => setTimeout(r, 900));
}
// 页面重载后可能没选角色:先把探针卡选上
if (window.SillyTavern.getContext().name2 !== "ProbeCard") {
await window.SillyTavern.getContext().getCharacters();
const list = window.SillyTavern.getContext().characters || [];
const idx = list.findIndex((c) => c.name === "ProbeCard");
const other = list.findIndex((c) => c.name !== "ProbeCard");
if (idx >= 0) {
for (let t = 0; t < 40 && window.SillyTavern.getContext().name2 !== "ProbeCard"; t++) {
if (t === 0 && other >= 0) await window.SillyTavern.getContext().selectCharacterById(other);
await window.SillyTavern.getContext().selectCharacterById(idx);
await new Promise((r) => setTimeout(r, 400));
}
}
}
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 };
+12
View File
@@ -0,0 +1,12 @@
// 逐字对比两份抓包里指定下标的消息,打印差异位置。
import { readFileSync } from "node:fs";
const [a, b, idx] = process.argv.slice(2);
const get = (file) => JSON.parse(JSON.parse(readFileSync(file, "utf8")).body).messages[Number(idx)].content;
const left = get(a);
const right = get(b);
console.log("ST " + left.length + " 字 / WA " + right.length + " 字");
let i = 0;
while (i < Math.min(left.length, right.length) && left[i] === right[i]) i++;
console.log("首个差异在 " + i);
console.log("ST: " + JSON.stringify(left.slice(Math.max(0, i - 40), i + 60)));
console.log("WA: " + JSON.stringify(right.slice(Math.max(0, i - 40), i + 60)));
+92 -1
View File
@@ -86,7 +86,20 @@ 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 (Array.isArray(v)) {
const tools = v.filter((x) => x && x.function && x.function.name);
if (tools.length) {
return tools
.map((t) => {
const fn = t.function;
const props = Object.keys((fn.parameters && fn.parameters.properties) || {});
return fn.name + "(参数: " + (props.join(",") || "-") + "; 描述 " + String(fn.description || "").length + " 字)";
})
.join("; ");
}
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);
};
@@ -166,6 +179,72 @@ function unattributed(entries, messages) {
return out.length ? out.join("\n") : "(没有)";
}
function onlyOnOneSide(stMsgs, waMsgs) {
const key = (m) => stripWs(m.content).slice(0, 40);
const stKeys = new Set(stMsgs.map(key));
const waKeys = new Set(waMsgs.map(key));
const onlySt = stMsgs.filter((m) => !waKeys.has(key(m)));
const onlyWa = waMsgs.filter((m) => !stKeys.has(key(m)));
const fmtList = (list) =>
list.length
? list.map((m, i) => "- [" + m.role + "] " + m.content.length + " 字:" + stripWs(m.content).slice(0, 60)).join("\n")
: "(没有)";
return { onlySt, onlyWa, text: "**只在酒馆出现**:" + onlySt.length + " 条\n\n" + fmtList(onlySt) + "\n\n**只在本仓库出现**:" + onlyWa.length + " 条\n\n" + fmtList(onlyWa) };
}
/** 把某一侧的最终 messages 原文落盘,方便逐字比对。 */
function writeMessageDump(outDir, label, msgs) {
mkdirSync(outDir, { recursive: true });
const lines = [];
msgs.forEach((m, i) => {
const name = m.name ? " name=" + m.name : "";
lines.push("#" + i + " [" + m.role + "]" + name + " " + m.content.length + " 字");
lines.push(m.content);
lines.push("");
});
writeFileSync(outDir + "/messages-" + label + ".txt", lines.join("\n"), "utf8");
}
/** 按正文前 40 字把两边消息配对,并排打印。 */
function sideBySide(stMsgs, waMsgs) {
const key = (m) => stripWs(m.content).slice(0, 40);
const waIndex = new Map();
const used = new Set();
waMsgs.forEach((m, j) => {
const k = key(m);
if (!waIndex.has(k)) waIndex.set(k, []);
waIndex.get(k).push(j);
});
const rows = [];
const pairedWa = new Set();
stMsgs.forEach((m, i) => {
const candidates = (waIndex.get(key(m)) || []).filter((j) => !pairedWa.has(j));
const j = candidates.length ? candidates[0] : null;
if (j !== null) pairedWa.add(j);
const left = "#" + i + " [" + m.role + "] " + m.content.length + " 字: " + stripWs(m.content).slice(0, 50);
const right =
j === null
? "—"
: "#" + j + " [" + waMsgs[j].role + "] " + waMsgs[j].content.length + " 字: " + stripWs(waMsgs[j].content).slice(0, 50);
let verdict = "对上了";
if (j === null) verdict = "**只在酒馆**";
else if (stMsgs[i].role !== waMsgs[j].role) verdict = "**角色不同**";
else if (stMsgs[i].content.trim() !== waMsgs[j].content.trim()) verdict = "内容有差";
rows.push("| " + left + " | " + right + " | " + verdict + " |");
});
waMsgs.forEach((m, j) => {
if (pairedWa.has(j)) return;
rows.push(
"| — | #" + j + " [" + m.role + "] " + m.content.length + " 字: " + stripWs(m.content).slice(0, 50) + " | **只在本仓库** |",
);
});
return [
"| 酒馆 | 本仓库 | 结果 |",
"| --- | --- | --- |",
...rows,
].join("\n");
}
function main() {
const args = readArgs(process.argv.slice(2));
if (!args.st || !args.wa) throw new Error("缺少 --st / --wa");
@@ -209,6 +288,13 @@ function main() {
lines.push("");
lines.push(messageTable("本仓库", waMsgs, entries));
lines.push("");
const side = onlyOnOneSide(stMsgs, waMsgs);
writeMessageDump(args.out, "st", stMsgs);
writeMessageDump(args.out, "wa", waMsgs);
lines.push("## 4. 只在一侧出现的消息(按正文前 40 字配对)");
lines.push("");
lines.push(side.text);
lines.push("");
lines.push("## 4. 开头对不上任何预设条目的消息");
lines.push("");
lines.push("酒馆:");
@@ -218,6 +304,10 @@ function main() {
lines.push("本仓库:");
lines.push("");
lines.push(unattributed(entries, waMsgs));
lines.push("## 5. 最终 messages 并排(全文见同目录 messages-st.txt / messages-wa.txt)");
lines.push("");
lines.push(sideBySide(stMsgs, waMsgs));
lines.push("");
lines.push("");
mkdirSync(args.out, { recursive: true });
@@ -232,6 +322,7 @@ function main() {
console.log("[diff] 报告: " + reportFile);
console.log("[diff] 字段: " + fields.length + " 项, 仅酒馆 " + onlySt + ", 仅本仓库 " + onlyWa + ", 不同 " + diffCount);
console.log("[diff] 消息: 酒馆 " + stMsgs.length + " 条 / 本仓库 " + waMsgs.length + " 条");
console.log("[diff] 只在酒馆 " + side.onlySt.length + " 条,只在本仓库 " + side.onlyWa.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("、"));
}
+47 -2
View File
@@ -19,6 +19,14 @@ const DEFAULTS = {
port: 23377,
out: path.join(HERE, "captures"),
dataRoot: path.join(HERE, ".wa-data"),
charName: "ProbeCard",
toolPolicy: "adapt",
exampleDialogue:
"<START>\n{{user}}: EX_USER_TOKEN 今天做什么?\n{{char}}: EX_CHAR_TOKEN 读书。",
// 跟酒馆那张探针卡的三块字段一致
charDescription: "DESC_TOKEN 这是一张测试角色卡的描述,用来确认酒馆把它放在哪一段。",
charPersonality: "PERS_TOKEN 冷静、话少、只答问题。",
scenario: "SCEN_TOKEN 一间安静的书房,只有两个人在说话。",
timeoutMs: 120000,
};
@@ -33,6 +41,10 @@ async function postJson(url, body) {
async function main() {
const args = parseArgs(process.argv.slice(2), DEFAULTS);
const priorIndex = process.argv.indexOf("--prior");
if (priorIndex > 0) args.prior = String(process.argv[priorIndex + 1] ?? "");
const policyIndex = process.argv.indexOf("--tool-policy");
if (policyIndex > 0) args.toolPolicy = String(process.argv[policyIndex + 1] ?? "adapt");
if (!args.preset) throw new Error("缺少 --preset");
const presetName = path.basename(args.preset, ".json");
const raw = JSON.parse(readFileSync(args.preset, "utf8"));
@@ -56,8 +68,26 @@ async function main() {
],
};
writeFileSync(path.join(args.dataRoot, "profiles.json"), JSON.stringify(profiles, null, 2), "utf8");
// 预设工具协议:preset = 按预设数据声明真 tools
writeFileSync(
path.join(args.dataRoot, "settings.json"),
JSON.stringify(
{
version: 1,
activeProfileId: null,
activePresetId: null,
messageSquash: "follow-preset",
presetToolPolicy: args.toolPolicy,
},
null,
2,
),
"utf8",
);
try { rmSync(path.join(args.dataRoot, "presets"), { recursive: true, force: true }); } catch (e) {} const known = new Set(listCaptures(args.out, "wa"));
try { rmSync(path.join(args.dataRoot, "presets"), { recursive: true, force: true }); } catch (e) {}
// 人设也用默认的(名字 User,跟酒馆一致),免得复用上一次跑留下的「玩家」
try { rmSync(path.join(args.dataRoot, "personas.json"), { 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 = [];
@@ -77,13 +107,28 @@ async function main() {
text: args.message,
presetId,
complete: true,
turns: [],
turns: args.prior
? [
{
id: "t1",
user: { content: "上一轮用户", createdAt: now },
replies: [{ windowId: "w1", content: args.prior }],
createdAt: now,
},
]
: [],
windows: [{ id: "w1", profileId: "probe-mock" }],
systemPrompt: "",
loreBefore: "",
seedHistory: "",
loreAfter: "",
postTurn: "",
// 跟酒馆那张探针卡对齐:角色名 + 卡里的示例对话原文
charName: args.charName,
exampleDialogue: args.exampleDialogue,
charDescription: args.charDescription,
charPersonality: args.charPersonality,
scenario: args.scenario,
};
const result = await postJson(base + "/api/direct-chat/ephemeral/complete", body);
const first = result.multi && result.multi.results && result.multi.results[0];
@@ -0,0 +1,27 @@
// 用本仓库的声明式工具代码看某个预设会声明出什么 tools(不发请求)。
// 用法: node node_modules/tsx/dist/cli.mjs tools/st-request-diff/inspect-tools-decl.mjs <preset.json>
import { readFileSync } from "node:fs";
import path from "node:path";
import { importSillyTavernPreset } from "../../src/preset/importer.js";
import { declarePresetTools } from "../../src/preset/tool-declaration.js";
const file = process.argv[2];
const raw = JSON.parse(readFileSync(file, "utf8"));
const report = importSillyTavernPreset(raw, { name: path.basename(file, ".json") });
const declared = declarePresetTools(report.preset);
if (!declared) {
console.log("没有声明工具(declarePresetTools = null)");
process.exit(0);
}
console.log("来源: " + declared.source + ",toolChoice: " + declared.toolChoice + ",正文参数: " + declared.payloadFields.join(", "));
for (const tool of declared.tools) {
const fn = tool.function;
const params = fn.parameters;
console.log(" 工具名: " + fn.name);
console.log(" 描述字数: " + fn.description.length + " 开头: " + fn.description.replace(/\s+/g, " ").slice(0, 60));
console.log(" 参数: " + JSON.stringify(Object.keys(params.properties ?? {})) + " required=" + JSON.stringify(params.required ?? []));
for (const [key, value] of Object.entries(params.properties ?? {})) {
const desc = String(value.description ?? "");
console.log(" - " + key + " (" + (value.type ?? "") + ") 说明字数 " + desc.length + " 开头: " + desc.replace(/\s+/g, " ").slice(0, 50));
}
}
+14
View File
@@ -0,0 +1,14 @@
// 看工具模式下两边的消息条数/字数,以及"工具说明/字段说明"有没有既进消息又进 tools 描述(重复)。
import { readFileSync } from "node:fs";
for (const file of process.argv.slice(2)) {
const body = JSON.parse(JSON.parse(readFileSync(file, "utf8")).body);
const msgs = body.messages || [];
const total = msgs.reduce((n, m) => n + String(m.content ?? "").length, 0);
const desc = (body.tools || []).map((t) => String(t.function?.description ?? "").length);
const paramDesc = (body.tools || []).flatMap((t) => Object.values(t.function?.parameters?.properties ?? {}).map((p) => String(p.description ?? "").length));
const hitDesc = msgs.some((m) => String(m.content ?? "").includes("POSIX Terminal Screen Kernel Configuration"));
const hitParam = msgs.some((m) => String(m.content ?? "").includes("terminal_payload_contract"));
console.log(file.split("/").pop());
console.log(" messages=" + msgs.length + " 正文合计=" + total + " 字; tools=" + (body.tools || []).length + " 工具描述=" + desc.join(",") + " 参数描述=" + paramDesc.join(","));
console.log(" 工具说明也在 messages 里? " + hitDesc + "; 字段说明也在 messages 里? " + hitParam);
}
+10
View File
@@ -0,0 +1,10 @@
// 看预设里的 personality_format / scenario_format / wi_format 长什么样。
import { readFileSync } from "node:fs";
for (const file of process.argv.slice(2)) {
const raw = JSON.parse(readFileSync(file, "utf8"));
console.log("=== " + file.split("/").pop());
for (const key of ["personality_format", "scenario_format", "wi_format"]) {
const value = raw[key];
console.log(" " + key + ": " + (value === undefined ? "(缺)" : JSON.stringify(value)));
}
}
@@ -0,0 +1,6 @@
import { readFileSync } from "node:fs";
const body = JSON.parse(JSON.parse(readFileSync(process.argv[2], "utf8")).body);
const t = (body.tools || [])[0];
console.log("name=" + t?.function?.name);
console.log("parameters=" + JSON.stringify(t?.function?.parameters, null, 1));
console.log("description 字数=" + (t?.function?.description || "").length);
@@ -0,0 +1,18 @@
// 看酒馆工具模式请求里的 tools 定义全文,以及 Reborn 预设里的 ToolBindings 结构。
import { readFileSync } from "node:fs";
const capture = process.argv[2];
const body = JSON.parse(JSON.parse(readFileSync(capture, "utf8")).body);
console.log("=== 抓包里的 tools ===");
console.log(JSON.stringify(body.tools, null, 1).slice(0, 3000));
console.log("=== tool_choice: " + JSON.stringify(body.tool_choice));
const presetFile = process.argv[3];
if (presetFile) {
const raw = JSON.parse(readFileSync(presetFile, "utf8"));
const spreset = raw.extensions?.SPreset ?? raw.extensions?.spreset ?? null;
console.log("=== 预设 extensions 键: " + Object.keys(raw.extensions || {}).join(", "));
const bindings = spreset?.ToolBindings ?? raw.extensions?.ToolBindings ?? null;
console.log("=== ToolBindings ===");
console.log(bindings ? JSON.stringify(bindings, null, 1).slice(0, 2000) : "(无)");
}
@@ -0,0 +1,31 @@
// 一个预设到底有多少内容、哪些进了请求:启用条目正文总量 / 最大条目 / 会改请求的正则。
// 用法: node node_modules/tsx/dist/cli.mjs tools/st-request-diff/preset-accounting.mjs <preset.json>
import { readFileSync } from "node:fs";
import path from "node:path";
import { importSillyTavernPreset } from "../../src/preset/importer.js";
import { buildPresetDiagnostics } from "../../src/preset/diagnostics.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;
const orderEnabled = new Map(preset.promptOrder.map((o) => [o.promptId, o.enabled]));
const entries = preset.prompts
.filter((p) => orderEnabled.get(p.id) === true)
.map((p) => ({ id: p.id, name: p.name, marker: p.marker, len: p.content.length }))
.sort((a, b) => b.len - a.len);
const total = entries.reduce((n, e) => n + e.len, 0);
console.log("=== " + path.basename(file) + ":order 启用条目 " + entries.length + " 条,正文合计 " + total + " 字");
console.log(" 最大的 8 条:");
for (const entry of entries.slice(0, 8)) {
console.log(" " + String(entry.len).padStart(6) + " 字 " + (entry.marker ? "[洞] " : "") + entry.name + " (" + entry.id.slice(0, 12) + ")");
}
const diagnostics = buildPresetDiagnostics(preset, preset.raw);
const promptRegexes = diagnostics.regexes.filter((r) => r.promptOnly || r.markdownOnly === false);
console.log("=== 正则 " + diagnostics.regexes.length + " 条,其中 promptOnly=" + diagnostics.regexes.filter((r) => r.promptOnly).length + " 条,会影响发出的请求");
for (const regex of diagnostics.regexes.slice(0, 8)) {
console.log(" " + regex.name + " placement=" + JSON.stringify(regex.placement) + " promptOnly=" + regex.promptOnly + " markdownOnly=" + regex.markdownOnly + " disabled=" + regex.disabled);
}
console.log("=== 脚本 " + diagnostics.scripts.length + " 个:" + diagnostics.scripts.map((s) => s.name + "(" + Math.round(s.bytes / 1024) + "KB" + (s.isUiPanel ? ",界面" : "") + ")").join("、"));
console.log("=== 工具协议 bindings=" + diagnostics.toolProtocol.bindings.length + " entries=" + diagnostics.toolProtocol.entries.length + " placeholders=" + diagnostics.toolProtocol.placeholders.length);
+8
View File
@@ -0,0 +1,8 @@
import { readFileSync } from "node:fs";
const body = JSON.parse(JSON.parse(readFileSync(process.argv[2], "utf8")).body);
const from = Number(process.argv[3] ?? 0);
const to = Number(process.argv[4] ?? body.messages.length);
body.messages.slice(from, to).forEach((m, i) => {
const text = String(m.content ?? "");
console.log("#" + (from + i) + " [" + m.role + "] " + text.length + " 字: " + JSON.stringify(text.slice(0, 90)));
});