预设可以带一段手写的假历史,按真多轮发出去。
它进对话历史区、占真实轮次,跟酒馆示例对话(只做示范、不占轮次)不是一回事,所以要分开存:有它时排在真实历史之前,宏照样展开。
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<PresetChatTurn>;
|
||||
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<ChatMessage>,
|
||||
history: ChatMessage[],
|
||||
): ChatMessage[] {
|
||||
if (pseudo.length === 0) return history;
|
||||
return [...pseudo, ...history];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Record<string, unknown>>): Marked[] {
|
||||
return messages
|
||||
.filter((m) => Boolean((m as Record<symbol, unknown>)[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: "<START>\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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user