Blame Raw
Cohee · e3f41666 · · 481 lines (20.8 KB)
1 contributor
1import { seedrandom, droll } from '../../../lib.js';
2import { chat_metadata, main_api, getMaxPromptTokens, getMaxContextTokens, getMaxResponseTokens, extension_prompts, getCurrentChatId } from '../../../script.js';
3import { getStringHash, isFalseBoolean } from '../../utils.js';
4import { textgenerationwebui_banned_in_macros } from '../../textgen-settings.js';
5import { inject_ids } from '../../constants.js';
6import { MacroRegistry, MacroCategory, MacroValueType } from '../engine/MacroRegistry.js';
7import { MACRO_VARIABLE_SHORTHAND_PATTERN } from '../engine/MacroLexer.js';
8import { MacroParser } from '../engine/MacroParser.js';
9import { MacroCstWalker } from '../engine/MacroCstWalker.js';
10
11/**
12 * Marker used by {{else}} to split content in {{if}} blocks.
13 * Uses control characters to minimize collision with real content.
14 *
15 * This marker is used internally by the macro engine to separate if/else branches.
16 * It should never appear in user-generated content.
17 *
18 * @type {string}
19 */
20export const ELSE_MARKER = '\u0000\u001FELSE\u001F\u0000';
21
22/**
23 * Registers SillyTavern's core built-in macros in the MacroRegistry.
24 *
25 * These macros correspond to the main {{...}} macros that are available
26 * in prompts (time/date/chat info, utility macros, etc.). They are
27 * intended to preserve the behavior of the existing regex-based macros
28 * in macros.js while using the new MacroRegistry/MacroEngine pipeline.
29 */
30export function registerCoreMacros() {
31 // {{space}} -> ' '
32 MacroRegistry.registerMacro('space', {
33 category: MacroCategory.UTILITY,
34 unnamedArgs: [
35 {
36 name: 'count',
37 optional: true,
38 defaultValue: '1',
39 type: MacroValueType.INTEGER,
40 description: 'Number of spaces to insert.',
41 },
42 ],
43 description: 'Returns one or more spaces. One space by default, more if the count argument is specified.',
44 returns: 'One or more spaces.',
45 exampleUsage: ['{{space}}', '{{space::4}}'],
46 handler: ({ unnamedArgs: [count] }) => ' '.repeat(Number(count ?? 1)),
47 });
48
49 // {{newline}} -> '\n'
50 MacroRegistry.registerMacro('newline', {
51 category: MacroCategory.UTILITY,
52 unnamedArgs: [
53 {
54 name: 'count',
55 optional: true,
56 defaultValue: '1',
57 type: MacroValueType.INTEGER,
58 description: 'Number of newlines to insert.',
59 },
60 ],
61 description: 'Inserts one or more newlines. One newline by default, more if the count argument is specified.',
62 returns: 'One or more \\n.',
63 exampleUsage: ['{{newline}}', '{{newline::2}}'],
64 handler: ({ unnamedArgs: [count] }) => '\n'.repeat(Number(count ?? 1)),
65 });
66
67 // {{noop}} -> ''
68 MacroRegistry.registerMacro('noop', {
69 category: MacroCategory.UTILITY,
70 description: 'Does nothing and produces an empty string.',
71 returns: '',
72 handler: () => '',
73 });
74
75 // {{trim}} -> macro will currently replace itself with itself. Trimming is handled in post-processing.
76 // Scoped: {{trim}}content{{/trim}} -> trims whitespace from content (handled by engine auto-trim)
77 MacroRegistry.registerMacro('trim', {
78 category: MacroCategory.UTILITY,
79 description: 'Trims whitespace. Non-scoped: trims newlines around the macro (post-processing). Scoped: returns the content (auto-trimmed by the engine).',
80 unnamedArgs: [
81 {
82 name: 'content',
83 description: 'Content to trim (when used as scoped macro)',
84 optional: true,
85 },
86 ],
87 returns: '',
88 handler: ({ unnamedArgs: [content], isScoped }) => {
89 // Scoped usage: return content (already auto-trimmed by the engine)
90 if (isScoped) return content ?? '';
91 // Non-scoped: return marker for post-processing regex
92 return '{{trim}}';
93 },
94 });
95
96 /**
97 * Splits raw content on the first {{else}} macro at nesting depth 0.
98 * Tracks scoped {{if}}/{{/if}} pairs to find the correct top-level else.
99 * Only {{if}} with 1 argument (condition only) are considered scoped blocks.
100 *
101 * @param {string} content - The raw content to split
102 * @returns {{ thenBranch: string, elseBranch: string | undefined }}
103 */
104 function splitOnTopLevelElse(content) {
105 const { cst } = MacroParser.parseDocument(content);
106 const macroNodes = /** @type {import('chevrotain').CstNode[]} */ (cst?.children?.macro || []);
107
108 let depth = 0;
109 for (const macroNode of macroNodes) {
110 const info = MacroCstWalker.extractMacroInfo(macroNode);
111 if (!info) continue;
112
113 // Only track scoped {{if}} blocks (1 arg = condition only, expects {{/if}})
114 // Inline {{if condition::content}} has 2 args and doesn't affect depth
115 if (info.name === 'if' && !info.isClosing && info.argCount === 1) {
116 depth++;
117 } else if (info.name === 'if' && info.isClosing) {
118 depth--;
119 } else if (info.name === 'else' && depth === 0) {
120 return {
121 thenBranch: content.slice(0, info.startOffset),
122 elseBranch: content.slice(info.endOffset + 1),
123 };
124 }
125 }
126
127 return { thenBranch: content, elseBranch: undefined };
128 }
129
130 // {{if condition}}content{{/if}} -> conditional content
131 // {{if condition}}then-content{{else}}else-content{{/if}} -> conditional with else branch
132 // {{if !condition}}content{{/if}} -> inverted conditional (negated)
133 // Condition can be a macro name (resolved automatically), variable shorthand (.var or $var), or any value
134 MacroRegistry.registerMacro('if', {
135 category: MacroCategory.UTILITY,
136 description: 'Conditional macro. Returns the content if the condition is truthy, otherwise returns nothing (or the else branch if present). Prefix the condition with ! to invert. If the condition is a registered macro name (without braces), it will be resolved first. Variable shorthands (.varname for local, $varname for global) are also supported.',
137 unnamedArgs: [
138 {
139 name: 'condition',
140 description: 'The condition to evaluate. Prefix with ! to invert. Can be a macro name (auto-resolved), variable shorthand (.var or $var), or a value. Falsy: empty string, "false", "off", "0".',
141 },
142 {
143 name: 'content',
144 description: 'The content to return if condition is truthy (typically provided as scoped content). May contain {{else}} to define an else branch.',
145 },
146 ],
147 displayOverride: '{{if condition}}then{{else}}other{{/if}}',
148 exampleUsage: [
149 '{{if description}}# Description\n{{description}}{{/if}}',
150 '{{if charVersion}}{{charVersion}}{{else}}No version{{/if}}',
151 '{{if !personality}}No personality defined{{/if}}',
152 '{{if {{getvar::showHeader}}}}# Header{{/if}}',
153 '{{if .myvar}}Local var exists{{/if}}',
154 '{{if $globalFlag}}Global flag is set{{/if}}',
155 ],
156 returns: 'The content if condition is truthy, else branch or empty string otherwise.',
157 // Delay argument resolution so nested macros are only evaluated in the chosen branch
158 delayArgResolution: true,
159 handler: ({ unnamedArgs: [rawCondition, rawContent], flags, resolve, trimContent }) => {
160 // With delayArgResolution: true, args contain raw (unresolved) text.
161 // We resolve the condition first, then only resolve the chosen branch.
162
163 // Check if the condition starts with ! for inversion
164 let inverted = false;
165 let condition = rawCondition;
166 if (/^\s*!/.test(rawCondition)) {
167 inverted = true;
168 condition = rawCondition.replace(/^\s*!\s*/, '');
169 }
170
171 // Resolve the condition (may contain nested macros like {{getvar::x}})
172 condition = resolve(condition);
173
174 // Check if condition is a variable shorthand (.varname or $varname)
175 // If so, resolve it using the appropriate variable macro
176 const varShorthandRegex = new RegExp(`^([.$])(${MACRO_VARIABLE_SHORTHAND_PATTERN.source})$`);
177 const varShorthandMatch = condition.match(varShorthandRegex);
178 if (varShorthandMatch) {
179 const [, prefix, varName] = varShorthandMatch;
180 const varMacro = prefix === '.' ? 'getvar' : 'getglobalvar';
181 condition = resolve(`{{${varMacro}::${varName}}}`);
182 } else {
183 // Check if condition is a registered macro name (without braces)
184 // If so, resolve it first (only for macros that accept 0 required args)
185 const macroDef = MacroRegistry.getPrimaryMacro(condition);
186 if (macroDef && macroDef.minArgs === 0) {
187 condition = resolve(`{{${condition}}}`);
188 }
189 }
190
191 // Check if condition is falsy: empty string or isFalseBoolean
192 let isFalsy = condition === '' || isFalseBoolean(condition);
193 if (inverted) isFalsy = !isFalsy;
194
195 // Split raw content on {{else}} macro at the top nesting level
196 // We need to track nesting depth to find the correct {{else}} for this if
197 const { thenBranch, elseBranch } = splitOnTopLevelElse(rawContent);
198
199 // Only resolve the chosen branch
200 const chosenBranch = !isFalsy ? thenBranch : elseBranch;
201 if (chosenBranch === undefined) {
202 return '';
203 }
204
205 // Resolve nested macros in the chosen branch
206 // Trim result unless # flag is set (preserveWhitespace)
207 let result = resolve(chosenBranch);
208 if (!flags.preserveWhitespace) {
209 result = trimContent(result);
210 }
211 return result;
212 },
213 });
214
215 // {{else}} -> marker for else branch inside {{if}} blocks
216 // Only meaningful inside a scoped {{if}} macro
217 MacroRegistry.registerMacro('else', {
218 category: MacroCategory.UTILITY,
219 description: 'Marks the else branch inside a scoped {{if}} block. Only works inside {{if}}...{{/if}}. If used outside, returns an invisible marker.',
220 exampleUsage: [
221 '{{if condition}}true branch{{else}}false branch{{/if}}',
222 ],
223 returns: 'Invisible marker (consumed by the enclosing {{if}} macro).',
224 handler: () => ELSE_MARKER,
225 });
226
227 // {{input}} -> current textarea content
228 MacroRegistry.registerMacro('input', {
229 category: MacroCategory.UTILITY,
230 description: 'Current text from the send textarea.',
231 returns: 'Current text from the send textarea.',
232 handler: () => (/** @type {HTMLTextAreaElement} */(document.querySelector('#send_textarea')))?.value ?? '',
233 });
234
235 // {{maxPrompt}} -> max context size (context minus response)
236 MacroRegistry.registerMacro('maxPrompt', {
237 aliases: [{ alias: 'maxPromptTokens', visible: true }],
238 category: MacroCategory.STATE,
239 description: 'Maximum prompt context size.',
240 returns: 'Maximum prompt context size.',
241 returnType: MacroValueType.INTEGER,
242 handler: () => String(getMaxPromptTokens()),
243 });
244
245 // {{maxContext}} -> max context token limit
246 MacroRegistry.registerMacro('maxContext', {
247 aliases: [{ alias: 'maxContextTokens', visible: true }],
248 category: MacroCategory.STATE,
249 description: 'Maximum context token limit.',
250 returns: 'Maximum context token limit.',
251 returnType: MacroValueType.INTEGER,
252 handler: () => String(getMaxContextTokens()),
253 });
254
255 // {{maxResponse}} -> max response token limit
256 MacroRegistry.registerMacro('maxResponse', {
257 aliases: [{ alias: 'maxResponseTokens', visible: true }],
258 category: MacroCategory.STATE,
259 description: 'Maximum response token limit.',
260 returns: 'Maximum response token limit.',
261 returnType: MacroValueType.INTEGER,
262 handler: () => String(getMaxResponseTokens()),
263 });
264
265 // String utilities
266 MacroRegistry.registerMacro('reverse', {
267 category: MacroCategory.UTILITY,
268 unnamedArgs: [
269 {
270 name: 'value',
271 type: MacroValueType.STRING,
272 description: 'The string to reverse.',
273 },
274 ],
275 description: 'Reverses the characters of the argument provided.',
276 returns: 'Reversed string.',
277 exampleUsage: ['{{reverse::I am Lana}}'],
278 handler: ({ unnamedArgs: [value] }) => Array.from(value).reverse().join(''),
279 });
280
281 // Comment macro: {{// ...}} -> '' (consumes any arguments)
282 MacroRegistry.registerMacro('//', {
283 aliases: [{ alias: 'comment', visible: false }],
284 category: MacroCategory.UTILITY,
285 unnamedArgs: [
286 {
287 name: 'comment',
288 type: MacroValueType.STRING,
289 description: 'Any kind of text as comment. If you want multiline comments, consider using a scoped macro like {{//}}First\nSecond{{///}}.',
290 },
291 ],
292 list: true, // We consume any arguments as if this is a list, but we'll ignore them in the handler anyway
293 strictArgs: false, // and we also always remove it, even if the parsing might say it's invalid
294 description: 'Comment macro that produces an empty string. Can be used for writing into prompt definitions, without being passed to the context.',
295 returns: '',
296 displayOverride: '{{// ...}}',
297 exampleUsage: ['{{// This is a comment}}'],
298 handler: () => '',
299 });
300
301 // Time and date macros
302 // Dice roll macro: {{roll 1d6}} or {{roll: 1d6}}
303 MacroRegistry.registerMacro('roll', {
304 category: MacroCategory.RANDOM,
305 unnamedArgs: [
306 {
307 name: 'formula',
308 sampleValue: '1d20',
309 description: 'Dice roll formula using droll syntax (e.g. 1d20).',
310 type: 'string',
311 },
312 ],
313 description: 'Rolls dice using droll syntax (e.g. {{roll 1d20}}).',
314 returns: 'Dice roll result.',
315 returnType: MacroValueType.INTEGER,
316 exampleUsage: [
317 '{{roll::1d20}}',
318 '{{roll::6}}',
319 '{{roll::3d6+4}}',
320 ],
321 handler: ({ unnamedArgs: [formula], warn }) => {
322 // If only digits were provided, treat it as `1dX`.
323 if (/^\d+$/.test(formula)) {
324 formula = `1d${formula}`;
325 }
326
327 const isValid = droll.validate(formula);
328 if (!isValid) {
329 warn(`Invalid roll formula: ${formula}`);
330 return '';
331 }
332
333 const result = droll.roll(formula);
334 if (result === false) return '';
335 return String(result.total);
336 },
337 });
338
339 // Random choice macro: {{random::a::b}} or {{random a,b}}
340 MacroRegistry.registerMacro('random', {
341 category: MacroCategory.RANDOM,
342 list: true,
343 description: 'Picks a random item from a list. Will be re-rolled every time macros are resolved.',
344 returns: 'Randomly selected item from the list.',
345 exampleUsage: ['{{random::blonde::brown::red::black::blue}}'],
346 handler: ({ list }) => {
347 // Handle old legacy cases, where we have to split the list manually
348 if (list.length === 1) {
349 list = readSingleArgsRandomList(list[0]);
350 }
351
352 if (list.length === 0) {
353 return '';
354 }
355
356 const rng = seedrandom('added entropy.', { entropy: true });
357 const randomIndex = Math.floor(rng() * list.length);
358 return list[randomIndex];
359 },
360 });
361
362 // Deterministic choice macro: {{pick::a::b}} or {{pick a,b}}
363 MacroRegistry.registerMacro('pick', {
364 category: MacroCategory.RANDOM,
365 list: true,
366 description: 'Picks a random item from a list, but keeps the choice stable for a given chat and macro position. Can be rerolled via /reroll-pick slash command.',
367 // TODO: add expanded documentation once HTML details are supported
368 // descriptionDetails: `
369 // <p>Picks a random item from a list, but keeps the choice stable for a given chat and macro position.</p>
370 // <p>The choice can be reset per chat using the <code>/reroll-pick</code> slash command.</p>
371 // `,
372 returns: 'Stable randomly selected item from the list.',
373 exampleUsage: ['{{pick::blonde::brown::red::black::blue}}'],
374 handler: ({ list, globalOffset, env }) => {
375 // Handle old legacy cases, where we have to split the list manually
376 if (list.length === 1) {
377 list = readSingleArgsRandomList(list[0]);
378 }
379
380 if (!list.length) {
381 return '';
382 }
383
384 // NOTE:
385 // When changing the hashing logic, make sure to update unit test functionality
386 // in registerTestablePick() to be identical.
387
388 const chatIdHash = getChatIdHash();
389
390 // Use the full original input string for deterministic behavior
391 const rawContentHash = env.contentHash;
392
393 // Use globalOffset for deterministic seeding - this ensures identical macros
394 // at different positions in the document produce different results, even when
395 // nested inside arguments or scoped content
396 const offset = globalOffset;
397
398 // Reroll seed allows users to reset all picks in the chat via /reroll-pick command
399 const rerollSeed = chat_metadata.pick_reroll_seed || null;
400
401 const combinedSeedString = [chatIdHash, rawContentHash, offset, rerollSeed].filter(it => it !== null).join('-');
402 const finalSeed = getStringHash(combinedSeedString);
403 const rng = seedrandom(String(finalSeed));
404 const randomIndex = Math.floor(rng() * list.length);
405 return list[randomIndex];
406 },
407 });
408
409 /** @param {string} listString @return {string[]} */
410 function readSingleArgsRandomList(listString) {
411 // If it contains double colons, those will have precedence over comma-separated lists.
412 // This can only happen if the macro only had a single colon to introduce the list...
413 // like, {{random:a::b::c}}
414 if (listString.includes('::')) {
415 return listString.split('::').map((/** @type {string} */ item) => item.trim());
416 }
417 // Otherwise, we fall back and split by commas that may be present
418 return listString
419 .replace(/\\,/g, '##�COMMA�##')
420 .split(',')
421 .map((/** @type {string} */ item) => item.trim().replace(/##�COMMA�##/g, ','));
422 }
423
424 // Banned words macro: {{banned "word"}}
425 MacroRegistry.registerMacro('banned', {
426 category: MacroCategory.UTILITY,
427 unnamedArgs: [
428 {
429 name: 'word',
430 sampleValue: 'word',
431 description: 'Word to ban for Text Completion backend.',
432 type: 'string',
433 },
434 ],
435 description: 'Bans a word for Text Completion backend. (Strips quotes surrounding the banned word, if present)',
436 returns: '',
437 exampleUsage: ['{{banned::delve}}'],
438 handler: ({ unnamedArgs: [bannedWord] }) => {
439 // Strip quotes via regex, which were allowed in legacy syntax
440 bannedWord = bannedWord.replace(/^"|"$/g, '');
441 if (main_api === 'textgenerationwebui') {
442 console.log('Found banned word in macros: ' + bannedWord);
443 textgenerationwebui_banned_in_macros.push(bannedWord);
444 }
445 return '';
446 },
447 });
448
449 // Outlet macro: {{outlet::key}}
450 MacroRegistry.registerMacro('outlet', {
451 category: MacroCategory.UTILITY,
452 unnamedArgs: [
453 {
454 name: 'key',
455 sampleValue: 'my-outlet-key',
456 description: 'Outlet key.',
457 type: 'string',
458 },
459 ],
460 description: 'Returns the world info outlet prompt for a given outlet key.',
461 returns: 'World info outlet prompt.',
462 exampleUsage: ['{{outlet::character-achievements}}'],
463 handler: ({ unnamedArgs: [outlet] }) => {
464 if (!outlet) return '';
465 const value = extension_prompts[inject_ids.CUSTOM_WI_OUTLET(outlet)]?.value;
466 return value || '';
467 },
468 });
469}
470
471function getChatIdHash() {
472 const cachedIdHash = chat_metadata.chat_id_hash;
473 if (typeof cachedIdHash === 'number') {
474 return cachedIdHash;
475 }
476
477 const chatId = chat_metadata.main_chat ?? getCurrentChatId();
478 const chatIdHash = getStringHash(chatId);
479 chat_metadata.chat_id_hash = chatIdHash;
480 return chatIdHash;
481}