diff --git a/src/directives/macros.ts b/src/directives/macros.ts index bbf83d3..f77ea1e 100644 --- a/src/directives/macros.ts +++ b/src/directives/macros.ts @@ -19,6 +19,8 @@ export type MacroContext = { persona?: string | null; /** `{{lastUserMessage}}` → 最近一条用户消息原文 */ lastUserMessage?: string | null; + /** `{{mesExamples}}` → 示例对话按 `` 分块后的整段文本(酒馆的 parseMesExamples) */ + mesExamples?: string | null; /** setvar / getvar 容器。同一次装配共享一份。 */ vars?: Map; /** 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 { + 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 展开成 `…` 的整段文本。 + // 没给上下文就原样保留,避免静默吞内容。 + 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> = { + 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(); - 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; diff --git a/tests/preset-macros.test.ts b/tests/preset-macros.test.ts index 3c968f6..084e2ea 100644 --- a/tests/preset-macros.test.ts +++ b/tests/preset-macros.test.ts @@ -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: "\nUser: 甲" }).text).toBe( + "[\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}}",