From 294358e3126ea6aee272bd19adf41efca2ff64a7 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Thu, 17 Sep 2026 00:38:53 +0800 Subject: [PATCH] fix(stage-ui): preserve macro-looking character and user names --- .../airi-card/runtime.browser.test.ts | 12 +++++ .../src/services/airi-card/runtime.ts | 46 +++++++++++-------- 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/packages/stage-ui/src/services/airi-card/runtime.browser.test.ts b/packages/stage-ui/src/services/airi-card/runtime.browser.test.ts index 438702b1c5..f96b56c09d 100644 --- a/packages/stage-ui/src/services/airi-card/runtime.browser.test.ts +++ b/packages/stage-ui/src/services/airi-card/runtime.browser.test.ts @@ -482,3 +482,15 @@ it('reuses one real Worker for all regex checks in a compilation', async () => { vi.unstubAllGlobals() } }) + +it('preserves macro-looking names without interpreting inserted text again', async () => { + const card = createCard({ + name: '{{user}} {{reverse:abc}}', + greetings: ['{{char}} / {{user}} / / / {{reverse:abc}}'], + systemPrompt: '{{char}} / {{user}} / {{reverse:abc}}', + }) + const userName = '{{roll:d6}} {{comment:keep}}' + expect(compileCharacterCardGreeting(card, { userName })).toBe(`${card.name} / ${userName} / ${card.name} / ${userName} / cba`) + const result = await compileCharacterCardMessages(card, [{ role: 'system', content: compileCharacterCardSystemPrompt(card) }], { userName }) + expect(result[0]?.content).toContain(`${card.name} / ${userName} / cba`) +}) diff --git a/packages/stage-ui/src/services/airi-card/runtime.ts b/packages/stage-ui/src/services/airi-card/runtime.ts index 3179b6f9b5..99ffdfca75 100644 --- a/packages/stage-ui/src/services/airi-card/runtime.ts +++ b/packages/stage-ui/src/services/airi-card/runtime.ts @@ -506,26 +506,32 @@ function expandCharacterCardMacros( const userName = options.userName?.trim() || 'User' const random = options.random ?? Math.random - return source - .replace(/\{\{char\}\}||/gi, () => characterName) - .replace(/\{\{user\}\}|/gi, () => userName) - .replace(/\{\{\/\/[^}]*\}\}/g, '') - .replace(/\{\{(?:hidden_key|comment):[^}]*\}\}/gi, '') - .replace(/\{\{reverse:([^}]*)\}\}/gi, (_, value: string) => [...value].reverse().join('')) - .replace(/\{\{roll:d?(\d+)\}\}/gi, (_, rawSides: string) => { - const sides = Number.parseInt(rawSides, 10) - if (sides < 1) - return '' - return String(Math.floor(random() * sides) + 1) - }) - .replace(/\{\{random:([^}]*)\}\}/gi, (_, rawValues: string) => { - const values = splitEscapedCommaList(rawValues) - return values[Math.floor(random() * values.length)] ?? '' - }) - .replace(/\{\{pick:([^}]*)\}\}/gi, (_, rawValues: string) => { - const values = splitEscapedCommaList(rawValues) - return values[stableStringHash(rawValues) % values.length] ?? '' - }) + // Replace source tokens once; inserted names are literal, even if they look like macros. + return source.replace(/\{\{(char|user|\/\/[^}]*|(?:hidden_key|comment|reverse|random|pick):[^}]*|roll:d?\d+)\}\}|<(char|bot|user)>/gi, (_match, macro: string | undefined, legacy: string | undefined) => { + const token = macro ?? legacy! + const name = token.toLowerCase() + if (name === 'char' || name === 'bot') + return characterName + if (name === 'user') + return userName + if (token.startsWith('//')) + return '' + + const separator = token.indexOf(':') + const kind = token.slice(0, separator).toLowerCase() + const value = token.slice(separator + 1) + if (kind === 'hidden_key' || kind === 'comment') + return '' + if (kind === 'reverse') + return [...value].reverse().join('') + if (kind === 'roll') { + const sides = Number.parseInt(value.replace(/^d/i, ''), 10) + return sides < 1 ? '' : String(Math.floor(random() * sides) + 1) + } + const values = splitEscapedCommaList(value) + const index = kind === 'pick' ? stableStringHash(value) % values.length : Math.floor(random() * values.length) + return values[index] ?? '' + }) } function splitEscapedCommaList(source: string): string[] {