From 860eff59b289dda86b202b45708875753dc9bc06 Mon Sep 17 00:00:00 2001 From: moranzhi Date: Mon, 26 Oct 2026 01:09:29 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A2=84=E8=AE=BE=E5=8F=AF=E4=BB=A5=E5=B8=A6?= =?UTF-8?q?=E4=B8=80=E6=AE=B5=E6=89=8B=E5=86=99=E7=9A=84=E5=81=87=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=EF=BC=8C=E6=8C=89=E7=9C=9F=E5=A4=9A=E8=BD=AE=E5=8F=91?= =?UTF-8?q?=E5=87=BA=E5=8E=BB=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 它进对话历史区、占真实轮次,跟酒馆示例对话(只做示范、不占轮次)不是一回事,所以要分开存:有它时排在真实历史之前,宏照样展开。 --- src/preset/play-frame.ts | 12 +- src/preset/pseudo-history.ts | 41 ++++++ src/types/preset.ts | 12 ++ tests/preset-pseudo-history.test.ts | 188 ++++++++++++++++++++++++++++ 4 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 src/preset/pseudo-history.ts create mode 100644 tests/preset-pseudo-history.test.ts diff --git a/src/preset/play-frame.ts b/src/preset/play-frame.ts index 212129c..9538309 100644 --- a/src/preset/play-frame.ts +++ b/src/preset/play-frame.ts @@ -23,6 +23,7 @@ import { SCENARIO_MARKER, } from "./markers.js"; import { parseExampleDialogue } from "./examples.js"; +import { mergePseudoHistory, readPseudoHistory } from "./pseudo-history.js"; import { applyRegexScripts, readRegexScripts, @@ -144,8 +145,15 @@ export function assemblePlayWorkerMessages(params: { ] : []; const scripts = readRegexScripts(params.preset.raw); - const history = regexHistoryMessages(pack.history, scripts, (text) => - expandPresetDirectives(text, dirCtx).text, + // 假历史:作者手写的前情 → 真多轮,排在真实历史之前(一样要展开宏)。 + const pseudo: ChatMessage[] = readPseudoHistory(params.preset).map((turn) => ({ + role: turn.role, + content: expandPresetDirectives(turn.content, dirCtx).text, + })); + const history = regexHistoryMessages( + mergePseudoHistory(pseudo, pack.history), + scripts, + (text) => expandPresetDirectives(text, dirCtx).text, ); const all = head.length > 0 ? [...head, ...history] : history; return all.length > 0 ? all : null; diff --git a/src/preset/pseudo-history.ts b/src/preset/pseudo-history.ts new file mode 100644 index 0000000..30ca4cf --- /dev/null +++ b/src/preset/pseudo-history.ts @@ -0,0 +1,41 @@ +import type { ChatMessage } from "../llm/client.js"; +import type { PresetChatTurn, PresetPackage } from "../types/preset.js"; + +/** + * 假历史:预设作者手写的前情,按真多轮(user / assistant)进「对话.历史」区。 + * 属于历史,不属于示例对话(`mes_example` → system + example_user/example_assistant)。 + * + * 只认 user / assistant:这里没有 system 的语义,作者想加系统提示就写一条普通模块。 + * 脏数据(角色不认、内容非字符串或空白)一律丢掉,不抛。 + */ +export function normalizePseudoHistory(value: unknown): PresetChatTurn[] { + if (!Array.isArray(value)) return []; + const out: PresetChatTurn[] = []; + for (const item of value) { + if (!item || typeof item !== "object") continue; + const { role, content } = item as Partial; + if (role !== "user" && role !== "assistant") continue; + if (typeof content !== "string" || !content.trim()) continue; + out.push({ role, content }); + } + return out; +} + +/** 读预设上的假历史。 */ +export function readPseudoHistory( + preset: PresetPackage | null | undefined, +): PresetChatTurn[] { + return normalizePseudoHistory(preset?.pseudoHistory); +} + +/** + * 假历史在前(作者写的前情),真实轮次在后。 + * 返回的都是普通 message,交给「对话.历史」洞展开时再打历史区记号。 + */ +export function mergePseudoHistory( + pseudo: ReadonlyArray, + history: ChatMessage[], +): ChatMessage[] { + if (pseudo.length === 0) return history; + return [...pseudo, ...history]; +} diff --git a/src/types/preset.ts b/src/types/preset.ts index 1c7bd6d..1038249 100644 --- a/src/types/preset.ts +++ b/src/types/preset.ts @@ -148,6 +148,16 @@ export type PresetChatPrompts = { continueNudge?: string; }; +/** + * 预设的「假历史」:作者手写的前情,按真多轮(user / assistant)发出。 + * 与酒馆示例对话(`mes_example` → system + example_user/example_assistant)是两回事,互不替代: + * 假历史进「对话.历史」区,会占真实轮次;示例对话不占轮次、只做 few-shot。 + */ +export type PresetChatTurn = { + role: "user" | "assistant"; + content: string; +}; + /** 酒馆的几条包裹模板:`personality_format` / `scenario_format` / `wi_format`。 */ export type PresetFormats = { personality?: string; @@ -168,6 +178,8 @@ export type PresetPackage = { chatPrompts?: PresetChatPrompts; /** 角色卡字段的包裹模板;缺省原样发。 */ formats?: PresetFormats; + /** 假历史:手写前情,按真多轮进历史区;缺省不加。 */ + pseudoHistory?: PresetChatTurn[]; unsupported: UnsupportedPresetSection[]; importedAt: string; raw?: unknown; diff --git a/tests/preset-pseudo-history.test.ts b/tests/preset-pseudo-history.test.ts new file mode 100644 index 0000000..b38e53e --- /dev/null +++ b/tests/preset-pseudo-history.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "vitest"; +import { importSillyTavernPreset } from "../src/preset/importer.js"; +import { PRESET_HISTORY_MARK } from "../src/preset/assembler.js"; +import { + assemblePlayWorkerMessages, + shapeFramedMessages, +} from "../src/preset/play-frame.js"; +import { normalizePseudoHistory } from "../src/preset/pseudo-history.js"; +import { buildCaseMessages } from "../src/preset/test-run.js"; +import type { PresetChatTurn } from "../src/types/preset.js"; + +const rawPreset = { + prompts: [ + { identifier: "main", name: "main", role: "system", content: "MAIN" }, + { + identifier: "dialogueExamples", + name: "examples", + role: "system", + marker: true, + content: "", + }, + { + identifier: "chatHistory", + name: "history", + role: "system", + marker: true, + content: "", + }, + ], + prompt_order: [ + { + character_id: 100001, + order: [ + { identifier: "main", enabled: true }, + { identifier: "dialogueExamples", enabled: true }, + { identifier: "chatHistory", enabled: true }, + ], + }, + ], +}; + +function assemble(input: { + pseudoHistory?: PresetChatTurn[]; + history?: Array<{ role: "user" | "assistant"; content: string }>; + exampleDialogue?: string; +}) { + const report = importSillyTavernPreset(rawPreset, { name: "t" }); + const preset = { + ...report.preset, + ...(input.pseudoHistory ? { pseudoHistory: input.pseudoHistory } : {}), + }; + return assemblePlayWorkerMessages({ + systemPrompt: "", + preset, + pack: { + worldBookBefore: [], + history: input.history ?? [], + worldBookAfter: [], + turn: "", + postTurn: [], + ...(input.exampleDialogue ? { exampleDialogue: input.exampleDialogue } : {}), + }, + persona: null, + charName: "林昭", + }); +} + +type Marked = { role: string; content: string }; + +/** 只取历史区(打了 PRESET_HISTORY_MARK 的那一段)。 */ +function historyTurns(messages: Array>): Marked[] { + return messages + .filter((m) => Boolean((m as Record)[PRESET_HISTORY_MARK])) + .map((m) => ({ role: String(m.role), content: String(m.content) })); +} + +describe("预设的假历史:真多轮,不与示例对话混用", () => { + it("每条假历史各占一条 user / assistant 消息", () => { + const messages = assemble({ + pseudoHistory: [ + { role: "user", content: "我推开门" }, + { role: "assistant", content: "屋里没有人。" }, + ], + }); + expect(historyTurns(messages as never)).toEqual([ + { role: "user", content: "我推开门" }, + { role: "assistant", content: "屋里没有人。" }, + ]); + // 不能伪装成示例对话(system + example_* 的 few-shot) + expect(messages.some((m) => m.name)).toBe(false); + }); + + it("假历史排在真实历史之前,真实轮次原样保留", () => { + const messages = assemble({ + pseudoHistory: [ + { role: "user", content: "前情-问" }, + { role: "assistant", content: "前情-答" }, + ], + history: [ + { role: "user", content: "真-问" }, + { role: "assistant", content: "真-答" }, + ], + }); + expect(historyTurns(messages as never).map((m) => m.content)).toEqual([ + "前情-问", + "前情-答", + "真-问", + "真-答", + ]); + }); + + it("正文里的换行是真换行,不会被当成新的一条", () => { + const messages = assemble({ + pseudoHistory: [ + { role: "assistant", content: "第一段\n\n第二段\nassistant: 这不是新的一条" }, + ], + }); + const turns = historyTurns(messages as never); + expect(turns).toHaveLength(1); + expect(turns[0]!.content).toContain("\n\n"); + expect(turns[0]!.content).toContain("assistant: 这不是新的一条"); + }); + + it("合体后的历史区不被 squash 揉成一段", () => { + const shaped = shapeFramedMessages( + assemble({ + pseudoHistory: [ + { role: "user", content: "前情-问" }, + { role: "assistant", content: "前情-答" }, + ], + history: [{ role: "user", content: "真-问" }], + }), + "strict", + ); + const contents = historyTurns(shaped as never).map((m) => m.content); + expect(contents).toEqual(["前情-问", "前情-答", "真-问"]); + }); + + it("示例对话仍是酒馆的 system + example_* 形状,与假历史并存", () => { + const messages = assemble({ + pseudoHistory: [{ role: "user", content: "前情" }], + exampleDialogue: "\nUser: 你好\n林昭: 你也好", + }); + const examples = messages.filter((m) => m.name?.startsWith("example_")); + expect(examples.map((m) => m.name)).toEqual([ + "example_user", + "example_assistant", + ]); + expect(examples.every((m) => m.role === "system")).toBe(true); + // 假历史照旧是真多轮 + expect(historyTurns(messages as never)).toEqual([ + { role: "user", content: "前情" }, + ]); + }); + + it("工作台用例预览走同一条路:预设假历史 + 用例历史 + 本轮输入", () => { + const report = importSillyTavernPreset(rawPreset, { name: "t" }); + const preset = { ...report.preset, pseudoHistory: [{ role: "user" as const, content: "预设前情" }] }; + const messages = buildCaseMessages(preset, { + id: "c1", + name: "c1", + charName: "林昭", + charDescription: "", + worldBook: "", + userInput: "本轮输入", + history: [{ role: "assistant", content: "用例前情" }], + }); + const contents = messages.map((m) => String(m.content)); + const at = (text: string) => contents.findIndex((c) => c.includes(text)); + expect(at("预设前情")).toBeGreaterThanOrEqual(0); + expect(at("预设前情")).toBeLessThan(at("用例前情")); + expect(at("用例前情")).toBeLessThan(at("本轮输入")); + }); + + it("非法条目被丢掉,不会变成空消息", () => { + expect( + normalizePseudoHistory([ + { role: "user", content: "留着" }, + { role: "system", content: "丢掉" }, + { role: "assistant", content: " " }, + { role: "assistant" }, + "垃圾", + null, + ]), + ).toEqual([{ role: "user", content: "留着" }]); + expect(normalizePseudoHistory("user: 我")).toEqual([]); + }); +});