- 装配只认 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:真假上游抓包对比工具、三份预设的机制结论与实测报告
454 lines
15 KiB
TypeScript
454 lines
15 KiB
TypeScript
import { mkdtempSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { importSillyTavernPreset } from "../src/preset/importer.js";
|
|
import {
|
|
appendTurnMessages,
|
|
assembleDirectChatMessages,
|
|
buildChatHistory,
|
|
buildDirectChatTaskMessages,
|
|
abortReroll,
|
|
beginReroll,
|
|
buildPriorForWindow,
|
|
createConversation,
|
|
createShell,
|
|
DEFAULT_DIRECT_CHAT_SYSTEM,
|
|
duplicateConversation,
|
|
EMPTY_SHELL_SLOTS,
|
|
getConversation,
|
|
mergeTurnReplies,
|
|
switchTurnVariant,
|
|
writeRerollReplies,
|
|
getShell,
|
|
listConversations,
|
|
listShells,
|
|
promoteTemporary,
|
|
resolveDirectChatPreset,
|
|
runDirectChatTurn,
|
|
type DirectChatTurn,
|
|
} from "../src/direct-chat/index.js";
|
|
|
|
function samplePreset() {
|
|
return importSillyTavernPreset({
|
|
prompts: [
|
|
{ identifier: "main", name: "Main", role: "system", content: "MAIN" },
|
|
{
|
|
identifier: "charDescription",
|
|
name: "Desc",
|
|
role: "system",
|
|
marker: true,
|
|
},
|
|
{
|
|
identifier: "chatHistory",
|
|
name: "History",
|
|
role: "system",
|
|
marker: true,
|
|
},
|
|
{ identifier: "jailbreak", name: "PHI", role: "system", content: "PHI" },
|
|
],
|
|
prompt_order: [
|
|
{
|
|
character_id: 1,
|
|
order: [
|
|
{ identifier: "main", enabled: true },
|
|
{ identifier: "charDescription", enabled: true },
|
|
{ identifier: "chatHistory", enabled: true },
|
|
{ identifier: "jailbreak", enabled: true },
|
|
],
|
|
},
|
|
],
|
|
}).preset;
|
|
}
|
|
|
|
describe("direct-chat history assembly", () => {
|
|
it("joins seed history with prior turns", () => {
|
|
expect(
|
|
buildChatHistory("种子", [
|
|
{ role: "user", content: "你好" },
|
|
{ role: "assistant", content: "嗨" },
|
|
]),
|
|
).toBe("种子\n\n【用户】\n你好\n\n【助手】\n嗨");
|
|
});
|
|
|
|
it("fills sandwich with seed + transcript + turn", () => {
|
|
const messages = assembleDirectChatMessages(samplePreset(), {
|
|
message: "继续",
|
|
slots: {
|
|
...EMPTY_SHELL_SLOTS,
|
|
systemPrompt: "契约",
|
|
loreBefore: "设定A",
|
|
seedHistory: "旧史",
|
|
},
|
|
priorMessages: [{ role: "user", content: "上一句" }],
|
|
});
|
|
const contents = messages.map((m) => m.content);
|
|
expect(contents.some((c) => String(c).includes("设定A"))).toBe(true);
|
|
expect(contents.some((c) => String(c).includes("旧史"))).toBe(true);
|
|
expect(contents.some((c) => String(c).includes("上一句"))).toBe(true);
|
|
expect(contents).toContain("继续");
|
|
expect(contents.some((c) => String(c).includes("契约"))).toBe(true);
|
|
});
|
|
|
|
it("emits no system message when the slot is empty (酒馆没有隐式系统提示)", () => {
|
|
const task = buildDirectChatTaskMessages({
|
|
message: "hi",
|
|
slots: EMPTY_SHELL_SLOTS,
|
|
priorMessages: [],
|
|
});
|
|
expect(task.some((m) => m.role === "system")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("direct-chat store", () => {
|
|
beforeEach(() => {
|
|
process.env.WRITING_AGENT_DATA_DIR = mkdtempSync(
|
|
path.join(tmpdir(), "wa-direct-chat-"),
|
|
);
|
|
});
|
|
|
|
it("roundtrips shell and conversation", () => {
|
|
const shell = createShell({
|
|
name: "测试壳",
|
|
slots: { ...EMPTY_SHELL_SLOTS, loreBefore: "世界" },
|
|
});
|
|
expect(getShell(shell.id)?.loreBefore).toBe("世界");
|
|
const conv = createConversation({
|
|
shellId: shell.id,
|
|
name: "会话1",
|
|
messages: [
|
|
{
|
|
id: "m1",
|
|
role: "user",
|
|
content: "你好",
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
],
|
|
});
|
|
expect(listShells()[0]?.conversationCount).toBe(1);
|
|
expect(listConversations(shell.id)).toHaveLength(1);
|
|
expect(getConversation(conv.id)?.messages[0]?.content).toBe("你好");
|
|
});
|
|
|
|
it("promotes temporary messages into persistent shell+conversation", () => {
|
|
const { shell, conversation } = promoteTemporary({
|
|
shell: { name: "从临时", slots: { loreBefore: "L" } },
|
|
conversationName: "升格会话",
|
|
messages: [
|
|
{
|
|
id: "u1",
|
|
role: "user",
|
|
content: "问",
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
{
|
|
id: "a1",
|
|
role: "assistant",
|
|
content: "答",
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
],
|
|
});
|
|
expect(shell.name).toBe("从临时");
|
|
expect(shell.loreBefore).toBe("L");
|
|
expect(conversation.name).toBe("升格会话");
|
|
expect(conversation.messages).toHaveLength(2);
|
|
});
|
|
|
|
it("duplicates a conversation under the same shell", () => {
|
|
const shell = createShell({ name: "壳" });
|
|
const conv = createConversation({
|
|
shellId: shell.id,
|
|
name: "原会话 副本",
|
|
windows: [
|
|
{ id: "w1", profileId: "p1", profileName: "甲", model: "m-a" },
|
|
],
|
|
turns: [
|
|
{
|
|
id: "t1",
|
|
user: { content: "问", createdAt: "1" },
|
|
replies: [{ windowId: "w1", content: "答" }],
|
|
selectedWindowId: "w1",
|
|
createdAt: "1",
|
|
},
|
|
],
|
|
});
|
|
const copy = duplicateConversation(conv.id);
|
|
expect(copy.id).not.toBe(conv.id);
|
|
expect(copy.shellId).toBe(shell.id);
|
|
expect(copy.name).toBe("原会话 副本");
|
|
expect(copy.windows).toHaveLength(1);
|
|
expect(copy.windows[0]?.id).not.toBe("w1");
|
|
expect(copy.windows[0]?.profileId).toBe("p1");
|
|
expect(copy.turns[0]?.user.content).toBe("问");
|
|
expect(copy.turns[0]?.replies[0]?.windowId).toBe(copy.windows[0]?.id);
|
|
expect(copy.turns[0]?.selectedWindowId).toBe(copy.windows[0]?.id);
|
|
const original = getConversation(conv.id);
|
|
expect(original?.windows[0]?.id).toBe("w1");
|
|
expect(original?.turns).toHaveLength(1);
|
|
expect(listConversations(shell.id)).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe("direct-chat turn gating", () => {
|
|
it("rejects when no preset is available", async () => {
|
|
process.env.WRITING_AGENT_DATA_DIR = mkdtempSync(
|
|
path.join(tmpdir(), "wa-direct-chat-preset-"),
|
|
);
|
|
await expect(
|
|
runDirectChatTurn({
|
|
message: "你好",
|
|
slots: EMPTY_SHELL_SLOTS,
|
|
priorMessages: [],
|
|
presetId: null,
|
|
complete: false,
|
|
}),
|
|
).rejects.toThrow(/预设/);
|
|
});
|
|
|
|
it("appends user/assistant and keeps contextTrace on latest", () => {
|
|
const messages = appendTurnMessages({
|
|
prior: [],
|
|
userText: "问",
|
|
reply: "答",
|
|
contextTrace: {
|
|
caller: "direct-chat",
|
|
createdAt: new Date().toISOString(),
|
|
messages: [{ role: "user", content: "x" }],
|
|
charCount: 1,
|
|
},
|
|
keepTraceLatest: 3,
|
|
});
|
|
expect(messages).toHaveLength(2);
|
|
expect(messages[1]?.contextTrace?.caller).toBe("direct-chat");
|
|
});
|
|
});
|
|
|
|
describe("resolveDirectChatPreset", () => {
|
|
it("throws for missing explicit preset id", () => {
|
|
expect(() => resolveDirectChatPreset("missing-preset-xyz")).toThrow(
|
|
/预设不存在/,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("buildPriorForWindow", () => {
|
|
const turns = [
|
|
{
|
|
id: "t1",
|
|
user: { content: "问1", createdAt: "1" },
|
|
replies: [
|
|
{ windowId: "a", content: "A1" },
|
|
{ windowId: "b", content: "B1" },
|
|
],
|
|
createdAt: "1",
|
|
},
|
|
{
|
|
id: "t2",
|
|
user: { content: "问2", createdAt: "2" },
|
|
replies: [
|
|
{ windowId: "a", content: "A2" },
|
|
{ windowId: "b", content: "B2" },
|
|
],
|
|
selectedWindowId: "a",
|
|
createdAt: "2",
|
|
},
|
|
];
|
|
|
|
it("uses each window's own reply when nothing selected", () => {
|
|
expect(buildPriorForWindow([turns[0]!], "b")).toEqual([
|
|
{ role: "user", content: "问1" },
|
|
{ role: "assistant", content: "B1" },
|
|
]);
|
|
});
|
|
|
|
it("forces selected reply into every window's prior", () => {
|
|
expect(buildPriorForWindow(turns, "b")).toEqual([
|
|
{ role: "user", content: "问1" },
|
|
{ role: "assistant", content: "B1" },
|
|
{ role: "user", content: "问2" },
|
|
{ role: "assistant", content: "A2" },
|
|
]);
|
|
});
|
|
});
|
|
|
|
function sampleTurn(
|
|
id: string,
|
|
content: string,
|
|
reply: string,
|
|
): {
|
|
id: string;
|
|
user: { content: string; createdAt: string };
|
|
replies: Array<{ windowId: string; content: string }>;
|
|
createdAt: string;
|
|
} {
|
|
return {
|
|
id,
|
|
user: { content, createdAt: "2026-01-01T00:00:00.000Z" },
|
|
replies: [{ windowId: "w1", content: reply }],
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
};
|
|
}
|
|
|
|
describe("direct-chat turn variants", () => {
|
|
const turns = () => [sampleTurn("t1", "问1", "答1"), sampleTurn("t2", "问2", "答2")];
|
|
|
|
it("edits the original line, rerolls, and keeps later turns on the previous version", () => {
|
|
const begun = beginReroll(turns(), "t1", "问1改");
|
|
expect(begun).not.toBeNull();
|
|
expect(begun!.turns.map((t) => t.id)).toEqual(["t1"]);
|
|
expect(begun!.turns[0]?.user.content).toBe("问1改");
|
|
expect(begun!.turns[0]?.replies).toEqual([]);
|
|
expect(begun!.turns[0]?.variantIndex).toBe(1);
|
|
expect(begun!.turns[0]?.variants?.[0]?.user.content).toBe("问1");
|
|
expect(begun!.turns[0]?.variants?.[0]?.replies[0]?.content).toBe("答1");
|
|
expect(begun!.turns[0]?.variants?.[0]?.after[0]?.user.content).toBe("问2");
|
|
expect(begun!.prior).toEqual([]);
|
|
|
|
const withReply = writeRerollReplies(begun!.turns, "t1", [
|
|
{ windowId: "w1", content: "新答" },
|
|
]);
|
|
const back = switchTurnVariant(withReply, "t1", "prev");
|
|
expect(back).not.toBeNull();
|
|
expect(back!.map((t) => t.user.content)).toEqual(["问1", "问2"]);
|
|
expect(back![0]?.replies[0]?.content).toBe("答1");
|
|
|
|
const again = switchTurnVariant(back!, "t1", "next");
|
|
expect(again).not.toBeNull();
|
|
expect(again!).toHaveLength(1);
|
|
expect(again![0]?.user.content).toBe("问1改");
|
|
expect(again![0]?.replies[0]?.content).toBe("新答");
|
|
});
|
|
|
|
it("direct reroll keeps the same user text and a new version", () => {
|
|
const begun = beginReroll(turns(), "t2", "问2");
|
|
expect(begun!.turns.map((t) => t.user.content)).toEqual(["问1", "问2"]);
|
|
expect(begun!.turns[1]?.variantIndex).toBe(1);
|
|
expect(begun!.turns[1]?.variants?.[0]?.replies[0]?.content).toBe("答2");
|
|
expect(begun!.prior.map((t) => t.id)).toEqual(["t1"]);
|
|
});
|
|
|
|
it("drops an empty reroll when generation produced nothing", () => {
|
|
const begun = beginReroll(turns(), "t1", "问1");
|
|
const aborted = abortReroll(begun!.turns, "t1");
|
|
expect(aborted).not.toBeNull();
|
|
expect(aborted!.map((t) => t.user.content)).toEqual(["问1", "问2"]);
|
|
expect(aborted![0]?.replies[0]?.content).toBe("答1");
|
|
expect(aborted![0]?.variants).toHaveLength(1);
|
|
});
|
|
|
|
it("rejects blank text and unknown turns", () => {
|
|
expect(beginReroll(turns(), "t1", " ")).toBeNull();
|
|
expect(beginReroll(turns(), "missing", "x")).toBeNull();
|
|
expect(switchTurnVariant(turns(), "t1", "next")).toBeNull();
|
|
});
|
|
|
|
it("retries only the failed windows and keeps the rest in place", () => {
|
|
const base: DirectChatTurn[] = [
|
|
{
|
|
id: "t1",
|
|
user: { content: "问1", createdAt: "2026-01-01T00:00:00.000Z" },
|
|
replies: [
|
|
{ windowId: "w1", content: "好的回复" },
|
|
{ windowId: "w2", error: "上游 403" },
|
|
{ windowId: "w3", error: "超时" },
|
|
],
|
|
selectedWindowId: "w1",
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
},
|
|
];
|
|
|
|
const merged = mergeTurnReplies(base, "t1", [
|
|
{ windowId: "w2", content: "重试后的回复" },
|
|
{ windowId: "w3", error: "还是超时" },
|
|
]);
|
|
|
|
expect(merged).toHaveLength(1);
|
|
const turn = merged[0]!;
|
|
// 只重试 w2/w3:w1 原样保留,w3 的错误被新错误覆盖
|
|
expect(turn.replies.find((r) => r.windowId === "w1")?.content).toBe(
|
|
"好的回复",
|
|
);
|
|
expect(turn.replies.find((r) => r.windowId === "w2")?.content).toBe(
|
|
"重试后的回复",
|
|
);
|
|
expect(turn.replies.find((r) => r.windowId === "w2")?.error).toBeUndefined();
|
|
expect(turn.replies.find((r) => r.windowId === "w3")?.error).toBe(
|
|
"还是超时",
|
|
);
|
|
// 不新开版本,且不动「保留此回复」
|
|
expect(turn.variants ?? []).toHaveLength(0);
|
|
expect(turn.variantIndex).toBe(0);
|
|
expect(turn.selectedWindowId).toBe("w1");
|
|
// 原数据不被就地修改
|
|
expect(base[0]!.replies.find((r) => r.windowId === "w2")?.error).toBe(
|
|
"上游 403",
|
|
);
|
|
});
|
|
|
|
it("merges a retry into the active version without dropping other versions", () => {
|
|
const begun = beginReroll(turns(), "t1", "问1改");
|
|
const withReply = writeRerollReplies(begun!.turns, "t1", [
|
|
{ windowId: "w1", content: "新答" },
|
|
]);
|
|
const merged = mergeTurnReplies(withReply, "t1", [
|
|
{ windowId: "w1", content: "重试后" },
|
|
]);
|
|
expect(merged[0]?.replies[0]?.content).toBe("重试后");
|
|
expect(merged[0]?.variants).toHaveLength(2);
|
|
expect(merged[0]?.variants?.[1]?.replies[0]?.content).toBe("重试后");
|
|
expect(merged[0]?.variants?.[0]?.replies[0]?.content).toBe("答1");
|
|
});
|
|
|
|
it("leaves turns untouched when the target turn is gone", () => {
|
|
const base = turns();
|
|
expect(mergeTurnReplies(base, "missing", [{ windowId: "w1", content: "x" }])).toBe(
|
|
base,
|
|
);
|
|
});
|
|
|
|
it("roundtrips versions and remaps window ids when copying", () => {
|
|
process.env.WRITING_AGENT_DATA_DIR = mkdtempSync(
|
|
path.join(tmpdir(), "wa-direct-chat-variants-"),
|
|
);
|
|
const shell = createShell({ name: "壳" });
|
|
const conv = createConversation({
|
|
shellId: shell.id,
|
|
windows: [{ id: "w1", profileId: "p1", profileName: "甲" }],
|
|
turns: [
|
|
{
|
|
...sampleTurn("t1", "问", "新"),
|
|
variantIndex: 1,
|
|
variants: [
|
|
{
|
|
user: { content: "问", createdAt: "2026-01-01T00:00:00.000Z" },
|
|
replies: [{ windowId: "w1", content: "旧" }],
|
|
selectedWindowId: "w1",
|
|
after: [sampleTurn("t2", "后", "续")],
|
|
},
|
|
{
|
|
user: { content: "问", createdAt: "2026-01-01T00:00:00.000Z" },
|
|
replies: [{ windowId: "w1", content: "新" }],
|
|
after: [],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
const loaded = getConversation(conv.id);
|
|
expect(loaded?.turns[0]?.variantIndex).toBe(1);
|
|
expect(loaded?.turns[0]?.variants?.[0]?.replies[0]?.content).toBe("旧");
|
|
expect(loaded?.turns[0]?.variants?.[0]?.selectedWindowId).toBe("w1");
|
|
expect(loaded?.turns[0]?.variants?.[0]?.after[0]?.user.content).toBe("后");
|
|
|
|
const copy = duplicateConversation(conv.id);
|
|
const wid = copy.windows[0]?.id;
|
|
expect(wid).toBeTruthy();
|
|
expect(wid).not.toBe("w1");
|
|
expect(copy.turns[0]?.replies[0]?.windowId).toBe(wid);
|
|
expect(copy.turns[0]?.variants?.[0]?.replies[0]?.windowId).toBe(wid);
|
|
expect(copy.turns[0]?.variants?.[0]?.selectedWindowId).toBe(wid);
|
|
expect(copy.turns[0]?.variants?.[0]?.after[0]?.replies[0]?.windowId).toBe(wid);
|
|
});
|
|
});
|