Blame Raw
Cohee · 51ad27fb · · 747 lines (26.5 KB)
2 contributors
1import { Handlebars, moment, seedrandom, droll } from '../lib.js';
2import { chat, chat_metadata, main_api, getMaxPromptTokens, getMaxContextTokens, getMaxResponseTokens, getCurrentChatId, substituteParams, eventSource, event_types, extension_prompts } from '../script.js';
3import { timestampToMoment, isDigitsOnly, getStringHash, escapeRegex, uuidv4 } from './utils.js';
4import { textgenerationwebui_banned_in_macros } from './textgen-settings.js';
5import { getInstructMacros } from './instruct-mode.js';
6import { getVariableMacros } from './variables.js';
7import { isMobile } from './RossAscends-mods.js';
8import { inject_ids } from './constants.js';
9import { initRegisterMacros, macros as macroSystem } from './macros/macro-system.js';
10import { power_user } from './power-user.js';
11
12/**
13 * @typedef Macro
14 * @property {RegExp} regex - Regular expression to match the macro
15 * @property {(substring: string, ...args: any[]) => string} replace - Function to replace the macro
16 */
17
18// Register any macro that you want to leave in the compiled story string
19Handlebars.registerHelper('trim', () => '{{trim}}');
20// Catch-all helper for any macro that is not defined for story strings
21Handlebars.registerHelper('helperMissing', function () {
22 const options = arguments[arguments.length - 1];
23 const macroName = options.name;
24 return substituteParams(`{{${macroName}}}`);
25});
26
27/**
28 * @typedef {Object<string, *>} EnvObject
29 * @typedef {(nonce: string) => string} MacroFunction
30 */
31
32/**
33 * @typedef {Object} CustomMacro
34 * @property {string} key - Macro name (key)
35 * @property {string} description - Optional description of the macro
36 */
37
38/**
39 * @deprecated Use macros.registry.registerMacro (from scripts/macros/macro-system.js)
40 * or substituteParams({ dynamicMacros }) with the new macro engine.
41 */
42export class MacrosParser {
43 /**
44 * A map of registered macros.
45 * @type {Map<string, string|MacroFunction>}
46 */
47 static #macros = new Map();
48
49 /**
50 * A map of macro descriptions.
51 * @type {Map<string, string>}
52 */
53 static #descriptions = new Map();
54
55 /**
56 * Logs a deprecation warning for MacrosParser APIs, pointing callers to
57 * the new macro engine registration surface.
58 *
59 * @param {string} method
60 * @param {string} replacement
61 * @param {IArguments} [methodArgs=null]
62 * @returns {void}
63 */
64 static #logDeprecated(method, replacement, methodArgs = null) {
65 console.warn(`[DEPRECATED] MacrosParser.${method} is deprecated and will be removed in a future version. Use ${replacement} instead. Arguments:`, (methodArgs ?? 'none'));
66 }
67
68 /**
69 * Bridges a legacy MacrosParser macro registration into the new macro
70 * engine when the experimental macro engine flag is enabled.
71 *
72 * This mirrors the simple "{{key}}" replacement behavior by registering
73 * a 0-arg macro in MacroRegistry that does not take arguments and returns
74 * the sanitized value from the legacy registry.
75 *
76 * @param {string} key
77 * @param {string|MacroFunction} value
78 * @param {string} description
79 * @returns {void}
80 */
81 static #registerMacroInNewEngine(key, value, description) {
82 if (!power_user.experimental_macro_engine) {
83 return;
84 }
85
86 // Like the old MacrosParser, we explicitly allow overriding macros, and only warn
87 if (macroSystem.registry.hasMacro(key)) {
88 console.warn(`Macro ${key} is already registered`);
89 }
90
91 const legacyValue = value;
92
93 macroSystem.registry.registerMacro(key, {
94 // Legacy MacrosParser macros never took arguments; keep the
95 // contract that only {{key}} without arguments is valid.
96 category: 'legacy',
97 description: typeof description === 'string' ? description : 'Automatically registered macro from MacrosParser',
98 handler: () => {
99 /** @type {string|MacroFunction|undefined} */
100 let stored = legacyValue;
101
102 if (typeof stored === 'function') {
103 try {
104 const nonce = uuidv4();
105 stored = stored(nonce);
106 } catch (e) {
107 console.warn(`Macro "${key}" function threw an error.`, e);
108 stored = '';
109 }
110 }
111
112 // Let the new macro engine's normalizeMacroResult handle type
113 // normalization for the returned value.
114 return stored;
115 },
116 });
117 }
118
119 /**
120 * Bridges a legacy MacrosParser macro unregistration into the new macro
121 * engine when the experimental macro engine flag is enabled.
122 *
123 * @param {string} key
124 * @returns {void}
125 */
126 static #unregisterMacroInNewEngine(key) {
127 if (!power_user.experimental_macro_engine) {
128 return;
129 }
130
131 macroSystem.registry.unregisterMacro(key);
132 }
133
134 /**
135 * Returns an iterator over all registered macros.
136 * @returns {IterableIterator<CustomMacro>}
137 */
138 static [Symbol.iterator] = function* () {
139 // When experimental macro engine is active, yield from the new registry
140 if (power_user.experimental_macro_engine) {
141 // Exclude hidden aliases for consistency with autocomplete behavior
142 for (const def of macroSystem.registry.getAllMacros({ excludeHiddenAliases: true })) {
143 yield { key: def.name, description: def.description || '' };
144 }
145 return;
146 }
147
148 for (const macro of MacrosParser.#macros.keys()) {
149 yield { key: macro, description: MacrosParser.#descriptions.get(macro) };
150 }
151 };
152
153 /**
154 * Access a macro by its name.
155 * @param {string} key Macro name (key)
156 * @returns {string|MacroFunction|undefined} The macro value
157 */
158 static get(key) {
159 MacrosParser.#logDeprecated('get', 'macros.registry.getMacro (from scripts/macros/macro-system.js)', arguments);
160 return MacrosParser.#macros.get(key);
161 }
162
163 /**
164 * Checks if a macro is registered.
165 * @param {string} key Macro name (key)
166 * @returns {boolean} True if the macro is registered, false otherwise
167 */
168 static has(key) {
169 MacrosParser.#logDeprecated('has', 'macros.registry.hasMacro (from scripts/macros/macro-system.js)', arguments);
170 if (power_user.experimental_macro_engine) {
171 return macroSystem.registry.hasMacro(key);
172 }
173
174 return MacrosParser.#macros.has(key);
175 }
176
177 /**
178 * Registers a global macro that can be used anywhere where substitution is allowed.
179 * @param {string} key Macro name (key)
180 * @param {string|MacroFunction} value A string or a function that returns a string
181 * @param {string} [description] Optional description of the macro
182 */
183 static registerMacro(key, value, description = '') {
184 MacrosParser.#logDeprecated('registerMacro', 'macros.registry.registerMacro (from scripts/macros/macro-system.js) or substituteParams({ dynamicMacros })', arguments);
185 if (typeof key !== 'string') {
186 throw new Error('Macro key must be a string');
187 }
188
189 // Allowing surrounding whitespace would just create more confusion...
190 key = key.trim();
191
192 if (!key) {
193 throw new Error('Macro key must not be empty or whitespace only');
194 }
195
196 if (key.startsWith('{{') || key.endsWith('}}')) {
197 throw new Error('Macro key must not include the surrounding braces');
198 }
199
200 if (typeof value !== 'string' && typeof value !== 'function') {
201 console.warn(`Macro value for "${key}" will be converted to a string`);
202 value = this.sanitizeMacroValue(value);
203 }
204
205 MacrosParser.#registerMacroInNewEngine(key, value, description);
206 if (power_user.experimental_macro_engine) {
207 return;
208 }
209
210 if (this.#macros.has(key)) {
211 console.warn(`Macro ${key} is already registered`);
212 }
213
214 this.#macros.set(key, value);
215
216 if (typeof description === 'string' && description) {
217 this.#descriptions.set(key, description);
218 }
219 }
220
221 /**
222 * Unregisters a global macro with the given key
223 *
224 * @param {string} key Macro name (key)
225 */
226 static unregisterMacro(key) {
227 MacrosParser.#logDeprecated('unregisterMacro', 'macros.registry.unregisterMacro (from scripts/macros/macro-system.js)', arguments);
228 if (typeof key !== 'string') {
229 throw new Error('Macro key must be a string');
230 }
231
232 // Allowing surrounding whitespace would just create more confusion...
233 key = key.trim();
234
235 if (!key) {
236 throw new Error('Macro key must not be empty or whitespace only');
237 }
238
239 if (power_user.experimental_macro_engine) {
240 MacrosParser.#unregisterMacroInNewEngine(key);
241 return;
242 }
243
244 const deleted = this.#macros.delete(key);
245
246 if (!deleted) {
247 console.warn(`Macro ${key} was not registered`);
248 }
249
250 this.#descriptions.delete(key);
251 }
252
253 /**
254 * Populate the env object with macro values from the current context.
255 * @param {EnvObject} env Env object for the current evaluation context
256 * @returns {void}
257 */
258 static populateEnv(env) {
259 if (!env || typeof env !== 'object') {
260 console.warn('Env object is not provided');
261 return;
262 }
263
264 // No macros are registered
265 if (this.#macros.size === 0) {
266 return;
267 }
268
269 for (const [key, value] of this.#macros) {
270 env[key] = value;
271 }
272 }
273
274 /**
275 * Performs a type-check on the macro value and returns a sanitized version of it.
276 * @param {any} value Value returned by a macro
277 * @returns {string} Sanitized value
278 */
279 static sanitizeMacroValue(value) {
280 if (typeof value === 'string') {
281 return value;
282 }
283
284 if (value === null || value === undefined) {
285 return '';
286 }
287
288 if (value instanceof Promise) {
289 console.warn('Promises are not supported as macro values');
290 return '';
291 }
292
293 if (typeof value === 'function') {
294 console.warn('Functions are not supported as macro values');
295 return '';
296 }
297
298 if (value instanceof Date) {
299 return value.toISOString();
300 }
301
302 if (typeof value === 'object') {
303 return JSON.stringify(value);
304 }
305
306 return String(value);
307 }
308}
309
310/**
311 * Gets a hashed id of the current chat from the metadata.
312 * If no metadata exists, creates a new hash and saves it.
313 * @returns {number} The hashed chat id
314 */
315function getChatIdHash() {
316 const cachedIdHash = chat_metadata.chat_id_hash;
317
318 // If chat_id_hash is not already set, calculate it
319 if (!cachedIdHash) {
320 // Use the main_chat if it's available, otherwise get the current chat ID
321 const chatId = chat_metadata.main_chat ?? getCurrentChatId();
322 const chatIdHash = getStringHash(chatId);
323 chat_metadata.chat_id_hash = chatIdHash;
324 return chatIdHash;
325 }
326
327 return cachedIdHash;
328}
329
330/**
331 * Returns the ID of the last message in the chat
332 *
333 * Optionally can only choose specific messages, if a filter is provided.
334 *
335 * @param {object} param0 - Optional arguments
336 * @param {boolean} [param0.exclude_swipe_in_propress=true] - Whether a message that is currently being swiped should be ignored
337 * @param {function(object):boolean} [param0.filter] - A filter applied to the search, ignoring all messages that don't match the criteria. For example to only find user messages, etc.
338 * @returns {number|null} The message id, or null if none was found
339 */
340export function getLastMessageId({ exclude_swipe_in_propress = true, filter = null } = {}) {
341 for (let i = chat?.length - 1; i >= 0; i--) {
342 let message = chat[i];
343
344 // If ignoring swipes and the message is being swiped, continue
345 // We can check if a message is being swiped by checking whether the current swipe id is not in the list of finished swipes yet
346 if (exclude_swipe_in_propress && message.swipes && message.swipe_id >= message.swipes.length) {
347 continue;
348 }
349
350 // Check if no filter is provided, or if the message passes the filter
351 if (!filter || filter(message)) {
352 return i;
353 }
354 }
355
356 return null;
357}
358
359/**
360 * Returns the ID of the first message included in the context
361 *
362 * @returns {number|null} The ID of the first message in the context
363 */
364function getFirstIncludedMessageId() {
365 return chat_metadata.lastInContextMessageId;
366}
367
368/**
369 * Returns the ID of the first displayed message in the chat.
370 *
371 * @returns {number|null} The ID of the first displayed message
372 */
373function getFirstDisplayedMessageId() {
374 const mesId = Number(document.querySelector('#chat .mes')?.getAttribute('mesid'));
375
376 if (!isNaN(mesId) && mesId >= 0) {
377 return mesId;
378 }
379
380 return null;
381}
382
383/**
384 * Returns the last message in the chat
385 *
386 * @returns {string} The last message in the chat
387 */
388function getLastMessage() {
389 const mid = getLastMessageId();
390 return chat[mid]?.mes ?? '';
391}
392
393/**
394 * Returns the last message from the user
395 *
396 * @returns {string} The last message from the user
397 */
398function getLastUserMessage() {
399 const mid = getLastMessageId({ filter: m => m.is_user && !m.is_system });
400 return chat[mid]?.mes ?? '';
401}
402
403/**
404 * Returns the last message from the bot
405 *
406 * @returns {string} The last message from the bot
407 */
408function getLastCharMessage() {
409 const mid = getLastMessageId({ filter: m => !m.is_user && !m.is_system });
410 return chat[mid]?.mes ?? '';
411}
412
413/**
414 * Returns the 1-based ID (number) of the last swipe
415 *
416 * @returns {number|null} The 1-based ID of the last swipe
417 */
418function getLastSwipeId() {
419 // For swipe macro, we are accepting using the message that is currently being swiped
420 const mid = getLastMessageId({ exclude_swipe_in_propress: false });
421 const swipes = chat[mid]?.swipes;
422 return swipes?.length;
423}
424
425/**
426 * Returns the 1-based ID (number) of the current swipe
427 *
428 * @returns {number|null} The 1-based ID of the current swipe
429 */
430function getCurrentSwipeId() {
431 // For swipe macro, we are accepting using the message that is currently being swiped
432 const mid = getLastMessageId({ exclude_swipe_in_propress: false });
433 const swipeId = chat[mid]?.swipe_id;
434 return swipeId !== null ? swipeId + 1 : null;
435}
436
437/**
438 * Replaces banned words in macros with an empty string.
439 * Adds them to textgenerationwebui ban list.
440 * @returns {Macro}
441 */
442function getBannedWordsMacro() {
443 const banPattern = /{{banned "(.*)"}}/gi;
444 const banReplace = (match, bannedWord) => {
445 if (main_api == 'textgenerationwebui') {
446 console.log('Found banned word in macros: ' + bannedWord);
447 textgenerationwebui_banned_in_macros.push(bannedWord);
448 }
449 return '';
450 };
451
452 return { regex: banPattern, replace: banReplace };
453}
454
455function getTimeSinceLastMessage() {
456 const now = moment();
457
458 if (Array.isArray(chat) && chat.length > 0) {
459 let lastMessage;
460 let takeNext = false;
461
462 for (let i = chat.length - 1; i >= 0; i--) {
463 const message = chat[i];
464
465 if (message.is_system) {
466 continue;
467 }
468
469 if (message.is_user && takeNext) {
470 lastMessage = message;
471 break;
472 }
473
474 takeNext = true;
475 }
476
477 if (lastMessage?.send_date) {
478 const lastMessageDate = timestampToMoment(lastMessage.send_date);
479 const duration = moment.duration(now.diff(lastMessageDate));
480 return duration.humanize();
481 }
482 }
483
484 return 'just now';
485}
486
487/**
488 * Returns a macro that picks a random item from a list.
489 * @returns {Macro} The random replace macro
490 */
491function getRandomReplaceMacro() {
492 const randomPattern = /{{random\s?::?([^}]+)}}/gi;
493 const randomReplace = (match, listString) => {
494 // Split on either double colons or comma. If comma is the separator, we are also trimming all items.
495 const list = listString.includes('::')
496 ? listString.split('::')
497 // Replaced escaped commas with a placeholder to avoid splitting on them
498 : listString.replace(/\\,/g, '##�COMMA�##').split(',').map(item => item.trim().replace(/##�COMMA�##/g, ','));
499
500 if (list.length === 0) {
501 return '';
502 }
503 const rng = seedrandom('added entropy.', { entropy: true });
504 const randomIndex = Math.floor(rng() * list.length);
505 return list[randomIndex];
506 };
507
508 return { regex: randomPattern, replace: randomReplace };
509}
510
511/**
512 * Returns a macro that picks a random item from a list with a consistent seed.
513 * @param {string} rawContent The raw content of the string
514 * @returns {Macro} The pick replace macro
515 */
516function getPickReplaceMacro(rawContent) {
517 // We need to have a consistent chat hash, otherwise we'll lose rolls on chat file rename or branch switches
518 // No need to save metadata here - branching and renaming will implicitly do the save for us, and until then loading it like this is consistent
519 const chatIdHash = getChatIdHash();
520 const rawContentHash = getStringHash(rawContent);
521
522 const pickPattern = /{{pick\s?::?([^}]+)}}/gi;
523 const pickReplace = (match, listString, offset) => {
524 // Split on either double colons or comma. If comma is the separator, we are also trimming all items.
525 const list = listString.includes('::')
526 ? listString.split('::')
527 // Replaced escaped commas with a placeholder to avoid splitting on them
528 : listString.replace(/\\,/g, '##�COMMA�##').split(',').map(item => item.trim().replace(/##�COMMA�##/g, ','));
529
530 if (list.length === 0) {
531 return '';
532 }
533
534 // We build a hash seed based on: unique chat file, raw content, and the placement inside this content
535 // This allows us to get unique but repeatable picks in nearly all cases
536 const combinedSeedString = `${chatIdHash}-${rawContentHash}-${offset}`;
537 const finalSeed = getStringHash(combinedSeedString);
538 // @ts-ignore - have to use numbers for legacy picks
539 const rng = seedrandom(finalSeed);
540 const randomIndex = Math.floor(rng() * list.length);
541 return list[randomIndex];
542 };
543
544 return { regex: pickPattern, replace: pickReplace };
545}
546
547/**
548 * @returns {Macro} The dire roll macro
549 */
550function getDiceRollMacro() {
551 const rollPattern = /{{roll[ : ]([^}]+)}}/gi;
552 const rollReplace = (match, matchValue) => {
553 let formula = matchValue.trim();
554
555 if (isDigitsOnly(formula)) {
556 formula = `1d${formula}`;
557 }
558
559 const isValid = droll.validate(formula);
560
561 if (!isValid) {
562 console.debug(`Invalid roll formula: ${formula}`);
563 return '';
564 }
565
566 const result = droll.roll(formula);
567 if (result === false) return '';
568 return String(result.total);
569 };
570
571 return { regex: rollPattern, replace: rollReplace };
572}
573
574/**
575 * Returns the difference between two times. Works with any time format acceptable by moment().
576 * Can work with {{date}} {{time}} macros
577 * @returns {Macro} The time difference macro
578 */
579function getTimeDiffMacro() {
580 const timeDiffPattern = /{{timeDiff::(.*?)::(.*?)}}/gi;
581 const timeDiffReplace = (_match, matchPart1, matchPart2) => {
582 const time1 = moment(matchPart1);
583 const time2 = moment(matchPart2);
584
585 const timeDifference = moment.duration(time1.diff(time2));
586 return timeDifference.humanize(true);
587 };
588
589 return { regex: timeDiffPattern, replace: timeDiffReplace };
590}
591
592/**
593 * Returns the outlet prompt for a given outlet key.
594 * @param {string} key - The outlet key
595 * @returns {string} The outlet prompt
596 */
597function getOutletPrompt(key) {
598 const value = extension_prompts[inject_ids.CUSTOM_WI_OUTLET(key)]?.value;
599 return value || '';
600}
601
602/**
603 * Substitutes {{macro}} parameters in a string.
604 * @param {string} content - The string to substitute parameters in.
605 * @param {EnvObject} env - Map of macro names to the values they'll be substituted with. If the param
606 * values are functions, those functions will be called and their return values are used.
607 * @param {function(string): string} postProcessFn - Function to run on the macro value before replacing it.
608 * @returns {string} The string with substituted parameters.
609 */
610export function evaluateMacros(content, env, postProcessFn) {
611 if (!content) {
612 return '';
613 }
614
615 postProcessFn = typeof postProcessFn === 'function' ? postProcessFn : (x => x);
616 const rawContent = content;
617
618 /**
619 * Built-ins running before the env variables
620 * @type {Macro[]}
621 * */
622 const preEnvMacros = [
623 // Legacy non-curly macros
624 { regex: /<USER>/gi, replace: () => typeof env.user === 'function' ? env.user() : env.user },
625 { regex: /<BOT>/gi, replace: () => typeof env.char === 'function' ? env.char() : env.char },
626 { regex: /<CHAR>/gi, replace: () => typeof env.char === 'function' ? env.char() : env.char },
627 { regex: /<CHARIFNOTGROUP>/gi, replace: () => typeof env.group === 'function' ? env.group() : env.group },
628 { regex: /<GROUP>/gi, replace: () => typeof env.group === 'function' ? env.group() : env.group },
629 getDiceRollMacro(),
630 ...getInstructMacros(env),
631 ...getVariableMacros(),
632 { regex: /{{newline}}/gi, replace: () => '\n' },
633 { regex: /(?:\r?\n)*{{trim}}(?:\r?\n)*/gi, replace: () => '' },
634 { regex: /{{noop}}/gi, replace: () => '' },
635 { regex: /{{input}}/gi, replace: () => String($('#send_textarea').val()) },
636 ];
637
638 /**
639 * Built-ins running after the env variables
640 * @type {Macro[]}
641 */
642 const postEnvMacros = [
643 { regex: /{{maxPrompt}}/gi, replace: () => String(getMaxPromptTokens()) },
644 { regex: /{{maxPromptTokens}}/gi, replace: () => String(getMaxPromptTokens()) },
645 { regex: /{{maxContext}}/gi, replace: () => String(getMaxContextTokens()) },
646 { regex: /{{maxContextTokens}}/gi, replace: () => String(getMaxContextTokens()) },
647 { regex: /{{maxResponse}}/gi, replace: () => String(getMaxResponseTokens()) },
648 { regex: /{{maxResponseTokens}}/gi, replace: () => String(getMaxResponseTokens()) },
649 { regex: /{{lastMessage}}/gi, replace: () => getLastMessage() },
650 { regex: /{{lastMessageId}}/gi, replace: () => String(getLastMessageId() ?? '') },
651 { regex: /{{lastUserMessage}}/gi, replace: () => getLastUserMessage() },
652 { regex: /{{lastCharMessage}}/gi, replace: () => getLastCharMessage() },
653 { regex: /{{firstIncludedMessageId}}/gi, replace: () => String(getFirstIncludedMessageId() ?? '') },
654 { regex: /{{firstDisplayedMessageId}}/gi, replace: () => String(getFirstDisplayedMessageId() ?? '') },
655 { regex: /{{lastSwipeId}}/gi, replace: () => String(getLastSwipeId() ?? '') },
656 { regex: /{{currentSwipeId}}/gi, replace: () => String(getCurrentSwipeId() ?? '') },
657 { regex: /{{allChatRange}}/gi, replace: () => chat.length === 0 ? '' : `0-${chat.length - 1}` },
658 { regex: /{{reverse:(.+?)}}/gi, replace: (_, str) => Array.from(str).reverse().join('') },
659 { regex: /\{\{\/\/([\s\S]*?)\}\}/gm, replace: () => '' },
660 { regex: /{{time}}/gi, replace: () => moment().format('LT') },
661 { regex: /{{date}}/gi, replace: () => moment().format('LL') },
662 { regex: /{{weekday}}/gi, replace: () => moment().format('dddd') },
663 { regex: /{{isotime}}/gi, replace: () => moment().format('HH:mm') },
664 { regex: /{{isodate}}/gi, replace: () => moment().format('YYYY-MM-DD') },
665 { regex: /{{datetimeformat +([^}]*)}}/gi, replace: (_, format) => moment().format(format) },
666 { regex: /{{idle_duration}}/gi, replace: () => getTimeSinceLastMessage() },
667 { regex: /{{time_UTC([-+]\d+)}}/gi, replace: (_, offset) => moment().utc().utcOffset(parseInt(offset, 10)).format('LT') },
668 { regex: /{{outlet::(.+?)}}/gi, replace: (_, key) => getOutletPrompt(key.trim()) || '' },
669 getTimeDiffMacro(),
670 getBannedWordsMacro(),
671 getRandomReplaceMacro(),
672 getPickReplaceMacro(rawContent),
673 ];
674
675 // Add all registered macros to the env object
676 MacrosParser.populateEnv(env);
677 const nonce = uuidv4();
678 const envMacros = [];
679
680 // Substitute passed-in variables
681 for (const varName in env) {
682 if (!Object.hasOwn(env, varName)) continue;
683
684 const envRegex = new RegExp(`{{${escapeRegex(varName)}}}`, 'gi');
685 const envReplace = () => {
686 const param = env[varName];
687 const value = MacrosParser.sanitizeMacroValue(typeof param === 'function' ? param(nonce) : param);
688 return value;
689 };
690
691 envMacros.push({ regex: envRegex, replace: envReplace });
692 }
693
694 const macros = [...preEnvMacros, ...envMacros, ...postEnvMacros];
695
696 for (const macro of macros) {
697 // Stop if the content is empty
698 if (!content) {
699 break;
700 }
701
702 // Short-circuit if no curly braces are found
703 if (!macro.regex.source.startsWith('<') && !content.includes('{{')) {
704 break;
705 }
706
707 try {
708 content = content.replace(macro.regex, (...args) => postProcessFn(macro.replace(...args)));
709 } catch (e) {
710 console.warn(`Macro content can't be replaced: ${macro.regex} in ${content}`, e);
711 }
712 }
713
714 return content;
715}
716
717export function initMacros() {
718 // Only manually register those is new macro engine is not on. In the new one, they are already registered automatically
719 if (!power_user.experimental_macro_engine) {
720 function initLastGenerationType() {
721 let lastGenerationType = '';
722
723 MacrosParser.registerMacro('lastGenerationType',
724 () => lastGenerationType,
725 'Returns the type of the last generation (e.g., "normal", "swipe", "continue", "impersonate", "quiet").',
726 );
727
728 eventSource.on(event_types.GENERATION_STARTED, (type, _params, isDryRun) => {
729 if (isDryRun) return;
730 lastGenerationType = type || 'normal';
731 });
732
733 eventSource.on(event_types.CHAT_CHANGED, () => {
734 lastGenerationType = '';
735 });
736 }
737
738 MacrosParser.registerMacro('isMobile',
739 () => String(isMobile()),
740 'Returns "true" if the user is on a mobile device, "false" otherwise.',
741 );
742 initLastGenerationType();
743 }
744
745 // TODO: Needs to be moved once old macros are deprecated and removed
746 initRegisterMacros();
747}