预设可以带一段手写的假历史,按真多轮发出去。
它进对话历史区、占真实轮次,跟酒馆示例对话(只做示范、不占轮次)不是一回事,所以要分开存:有它时排在真实历史之前,宏照样展开。
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
|||||||
SCENARIO_MARKER,
|
SCENARIO_MARKER,
|
||||||
} from "./markers.js";
|
} from "./markers.js";
|
||||||
import { parseExampleDialogue } from "./examples.js";
|
import { parseExampleDialogue } from "./examples.js";
|
||||||
|
import { mergePseudoHistory, readPseudoHistory } from "./pseudo-history.js";
|
||||||
import {
|
import {
|
||||||
applyRegexScripts,
|
applyRegexScripts,
|
||||||
readRegexScripts,
|
readRegexScripts,
|
||||||
@@ -144,8 +145,15 @@ export function assemblePlayWorkerMessages(params: {
|
|||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
const scripts = readRegexScripts(params.preset.raw);
|
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;
|
const all = head.length > 0 ? [...head, ...history] : history;
|
||||||
return all.length > 0 ? all : null;
|
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;
|
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`。 */
|
/** 酒馆的几条包裹模板:`personality_format` / `scenario_format` / `wi_format`。 */
|
||||||
export type PresetFormats = {
|
export type PresetFormats = {
|
||||||
personality?: string;
|
personality?: string;
|
||||||
@@ -168,6 +178,8 @@ export type PresetPackage = {
|
|||||||
chatPrompts?: PresetChatPrompts;
|
chatPrompts?: PresetChatPrompts;
|
||||||
/** 角色卡字段的包裹模板;缺省原样发。 */
|
/** 角色卡字段的包裹模板;缺省原样发。 */
|
||||||
formats?: PresetFormats;
|
formats?: PresetFormats;
|
||||||
|
/** 假历史:手写前情,按真多轮进历史区;缺省不加。 */
|
||||||
|
pseudoHistory?: PresetChatTurn[];
|
||||||
unsupported: UnsupportedPresetSection[];
|
unsupported: UnsupportedPresetSection[];
|
||||||
importedAt: string;
|
importedAt: string;
|
||||||
raw?: unknown;
|
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