宏扫描认酒馆的连带花括号写法,并补上示例对话宏。
{{{setvar::x::}} 的首个花括号在酒馆里是字面量,以前整段漏进正文;顺手把 mesExamples 接进来,导入报告也用酒馆写法显示宏名。
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
Reference in New Issue
Block a user