宏扫描认酒馆的连带花括号写法,并补上示例对话宏。

{{{setvar::x::}} 的首个花括号在酒馆里是字面量,以前整段漏进正文;顺手把 mesExamples 接进来,导入报告也用酒馆写法显示宏名。
This commit is contained in:
2026-10-26 01:09:23 +08:00
parent b4c83e706b
commit 7e7b9c5083
2 changed files with 104 additions and 9 deletions
+87 -9
View File
@@ -19,6 +19,8 @@ export type MacroContext = {
persona?: string | null;
/** `{{lastUserMessage}}` → 最近一条用户消息原文 */
lastUserMessage?: string | null;
/** `{{mesExamples}}` → 示例对话按 `<START>` 分块后的整段文本(酒馆的 parseMesExamples) */
mesExamples?: string | null;
/** setvar / getvar 容器。同一次装配共享一份。 */
vars?: Map<string, string>;
/** false 时 `{{roll}}` 原样保留。默认 true。 */
@@ -32,6 +34,7 @@ export type MacroKind =
| "char"
| "persona"
| "last_user_message"
| "mes_examples"
| "getvar"
| "setvar"
| "addvar"
@@ -50,13 +53,60 @@ export type MacroResult = {
trim: boolean;
};
/** 非贪婪:同一行多个宏能逐个匹配,跨行注释也能吃下。 */
const MACRO_RE = /\{\{([\s\S]*?)\}\}/g;
/**
* 逐个扫 `{{...}}`(非贪婪,等价于 /\{\{([\s\S]*?)\}\}/g),
* 外加酒馆的一条规则:`{{{...}}` 里第一个 `{` 是**字面量**,宏从第二个 `{` 起算。
* 少了这条,`{{{setvar::SEMOD1::}}`(kunlun / 简单预设里都有)会整段原样漏进正文。
*/
type MacroToken = { start: number; end: number; raw: string; inner: string };
function* macroTokens(input: string): Generator<MacroToken> {
if (input.indexOf("{{") < 0) return;
let i = 0;
while (i < input.length) {
const at = input.indexOf("{{", i);
if (at < 0) return;
if (input[at + 2] === "{") {
i = at + 1;
continue;
}
const end = input.indexOf("}}", at + 2);
if (end < 0) return;
yield {
start: at,
end: end + 2,
raw: input.slice(at, end + 2),
inner: input.slice(at + 2, end),
};
i = end + 2;
}
}
/**
* 扫描到一个永远等不到 `}}` 的 `{{...` 就返回它(截前 40 字)。
* 与展开器同一套规则:`{{{...}}` 的首个 `{` 是字面量,不算宏起点。
*/
export function findUnclosedMacro(input: string): string | null {
if (input.indexOf("{{") < 0) return null;
let i = 0;
while (i < input.length) {
const at = input.indexOf("{{", i);
if (at < 0) return null;
if (input[at + 2] === "{") {
i = at + 1;
continue;
}
const end = input.indexOf("}}", at + 2);
if (end < 0) return input.slice(at, at + 40);
i = end + 2;
}
return null;
}
const COMMENT_HEAD_RE = /^\{\{\s*\/\//;
/**
* 先整体啃掉 `{{// ... }}` 注释。
* 酒馆的注释里常嵌 `{{user}}` 这类宏,非贪婪的 MACRO_RE 会在内层 `}}` 处提前收尾,
* 酒馆的注释里常嵌 `{{user}}` 这类宏,非贪婪扫描会在内层 `}}` 处提前收尾,
* 把注释尾巴漏成正文(Reborn2.4 那条 57 字残片就是这么来的)。这里按 {{ / }} 配平扫描。
*/
export function stripCommentMacros(input: string): string {
@@ -135,7 +185,16 @@ function evalRoll(expression: string): string | null {
}
type Parsed =
| { kind: "comment" | "trim" | "user" | "char" | "persona" | "last_user_message" }
| {
kind:
| "comment"
| "trim"
| "user"
| "char"
| "persona"
| "last_user_message"
| "mes_examples";
}
| { kind: "getvar"; key: string }
| { kind: "setvar" | "addvar"; key: string; value: string }
| { kind: "roll"; expression: string }
@@ -155,6 +214,7 @@ function parseInner(inner: string): Parsed {
// 酒馆的 `{{persona}}` = 当前用户角色的详细人设正文(我们的 `@人设`)
if (lower === "persona") return { kind: "persona" };
if (lower === "lastusermessage") return { kind: "last_user_message" };
if (lower === "mesexamples") return { kind: "mes_examples" };
if (lower.startsWith("getvar::")) {
return { kind: "getvar", key: text.slice("getvar::".length).trim() };
@@ -199,7 +259,7 @@ export function expandMacros(
for (const comment of stripped.comments) {
hits.push({ raw: comment, kind: "comment", replacement: "" });
}
const text = stripped.text.replace(MACRO_RE, (raw: string, inner: string) => {
const expandOne = (raw: string, inner: string): string => {
const parsed = parseInner(inner);
const push = (kind: MacroKind, replacement: string): string => {
hits.push({ raw: raw, kind, replacement });
@@ -220,6 +280,10 @@ export function expandMacros(
return push("persona", ctx.persona?.trim() ?? "");
case "last_user_message":
return push("last_user_message", ctx.lastUserMessage ?? "");
case "mes_examples":
// 酒馆里 `{{mesExamples}}` 由 parseMesExamples 展开成 `<START>…` 的整段文本。
// 没给上下文就原样保留,避免静默吞内容。
return ctx.mesExamples == null ? raw : push("mes_examples", ctx.mesExamples);
case "getvar":
return push("getvar", vars.get(parsed.key) ?? "");
case "setvar":
@@ -239,11 +303,25 @@ export function expandMacros(
default:
return raw;
}
});
};
let text = "";
let cursor = 0;
for (const token of macroTokens(stripped.text)) {
text += stripped.text.slice(cursor, token.start);
text += expandOne(token.raw, token.inner);
cursor = token.end;
}
text += stripped.text.slice(cursor);
return { text, hits, trim };
}
/** 导入报告里给个别宏用酒馆的写法,别把内部类型名漏给用户。 */
const KIND_LABEL: Partial<Record<MacroKind, string>> = {
mes_examples: "{{mesExamples}}",
};
/** 只列出文本里出现过的宏名(含未知),用于导入报告。 */
export function collectMacroNames(text: string): string[] {
return collectMacroUsages(text).map((u) => u.name);
@@ -259,12 +337,12 @@ export type MacroUsage = {
/** 统计文本里的宏。已知宏按类型归一,未知宏按原文归类。 */
export function collectMacroUsages(text: string): MacroUsage[] {
const byName = new Map<string, MacroUsage>();
for (const match of text.matchAll(MACRO_RE)) {
const inner = match[1] ?? "";
for (const token of macroTokens(text)) {
const inner = token.inner;
const parsed = parseInner(inner);
const known = parsed.kind !== "unknown";
const name = known
? `{{${parsed.kind}}}`
? (KIND_LABEL[parsed.kind] ?? `{{${parsed.kind}}}`)
: `{{${inner.trim().slice(0, 40)}}}`;
const existing = byName.get(name);
if (existing) existing.count += 1;
+17
View File
@@ -106,6 +106,23 @@ describe("expandMacros", () => {
expect(r.hits).toEqual([]);
});
it("{{{setvar::x::}} 里第一个花括号是字面量(酒馆语义)", () => {
const vars = createMacroVars();
const r = expandMacros("{{{setvar::SEMOD1::}}\n[RESET]", { vars });
expect(r.text).toBe("{\n[RESET]");
expect(vars.get("SEMOD1")).toBe("");
});
it("{{mesExamples}} 有上下文就展开,没有就原样保留", () => {
expect(expandMacros("[{{mesExamples}}]", { mesExamples: "<START>\nUser: 甲" }).text).toBe(
"[<START>\nUser: 甲]",
);
expect(expandMacros("[{{mesExamples}}]").text).toBe("[{{mesExamples}}]");
});
it("三花括号里的宏也进导入报告(按 setvar 计)", () => {
expect(collectMacroNames("{{{setvar::a::}}")).toContain("{{setvar}}");
});
it("collectMacroNames reports unknown macros by raw text", () => {
const names = collectMacroNames(
"{{// c}}{{user}}{{setvar::a::b}}{{turnPlus1}}{{roll 9}}",