mirror of
				https://github.com/SillyTavern/SillyTavern.git
				synced 2025-06-05 21:59:27 +02:00 
			
		
		
		
	
		
			
				
	
	
		
			588 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			588 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| import { Handlebars, moment, seedrandom, droll } from '../lib.js';
 | ||
| import { chat, chat_metadata, main_api, getMaxContextSize, getCurrentChatId, substituteParams, eventSource, event_types } from '../script.js';
 | ||
| import { timestampToMoment, isDigitsOnly, getStringHash, escapeRegex, uuidv4 } from './utils.js';
 | ||
| import { textgenerationwebui_banned_in_macros } from './textgen-settings.js';
 | ||
| import { getInstructMacros } from './instruct-mode.js';
 | ||
| import { getVariableMacros } from './variables.js';
 | ||
| import { isMobile } from './RossAscends-mods.js';
 | ||
| 
 | ||
| /**
 | ||
|  * @typedef Macro
 | ||
|  * @property {RegExp} regex - Regular expression to match the macro
 | ||
|  * @property {(substring: string, ...args: any[]) => string} replace - Function to replace the macro
 | ||
|  */
 | ||
| 
 | ||
| // Register any macro that you want to leave in the compiled story string
 | ||
| Handlebars.registerHelper('trim', () => '{{trim}}');
 | ||
| // Catch-all helper for any macro that is not defined for story strings
 | ||
| Handlebars.registerHelper('helperMissing', function () {
 | ||
|     const options = arguments[arguments.length - 1];
 | ||
|     const macroName = options.name;
 | ||
|     return substituteParams(`{{${macroName}}}`);
 | ||
| });
 | ||
| 
 | ||
| /**
 | ||
|  * @typedef {Object<string, *>} EnvObject
 | ||
|  * @typedef {(nonce: string) => string} MacroFunction
 | ||
|  */
 | ||
| 
 | ||
| /**
 | ||
|  * @typedef {Object} CustomMacro
 | ||
|  * @property {string} key - Macro name (key)
 | ||
|  * @property {string} description - Optional description of the macro
 | ||
|  */
 | ||
| 
 | ||
| export class MacrosParser {
 | ||
|     /**
 | ||
|      * A map of registered macros.
 | ||
|      * @type {Map<string, string|MacroFunction>}
 | ||
|      */
 | ||
|     static #macros = new Map();
 | ||
| 
 | ||
|     /**
 | ||
|      * A map of macro descriptions.
 | ||
|      * @type {Map<string, string>}
 | ||
|      */
 | ||
|     static #descriptions = new Map();
 | ||
| 
 | ||
|     /**
 | ||
|      * Returns an iterator over all registered macros.
 | ||
|      * @returns {IterableIterator<CustomMacro>}
 | ||
|      */
 | ||
|     static [Symbol.iterator] = function* () {
 | ||
|         for (const macro of MacrosParser.#macros.keys()) {
 | ||
|             yield { key: macro, description: MacrosParser.#descriptions.get(macro) };
 | ||
|         }
 | ||
|     };
 | ||
| 
 | ||
|     /**
 | ||
|      * Registers a global macro that can be used anywhere where substitution is allowed.
 | ||
|      * @param {string} key Macro name (key)
 | ||
|      * @param {string|MacroFunction} value A string or a function that returns a string
 | ||
|      * @param {string} [description] Optional description of the macro
 | ||
|      */
 | ||
|     static registerMacro(key, value, description = '') {
 | ||
|         if (typeof key !== 'string') {
 | ||
|             throw new Error('Macro key must be a string');
 | ||
|         }
 | ||
| 
 | ||
|         // Allowing surrounding whitespace would just create more confusion...
 | ||
|         key = key.trim();
 | ||
| 
 | ||
|         if (!key) {
 | ||
|             throw new Error('Macro key must not be empty or whitespace only');
 | ||
|         }
 | ||
| 
 | ||
|         if (key.startsWith('{{') || key.endsWith('}}')) {
 | ||
|             throw new Error('Macro key must not include the surrounding braces');
 | ||
|         }
 | ||
| 
 | ||
|         if (typeof value !== 'string' && typeof value !== 'function') {
 | ||
|             console.warn(`Macro value for "${key}" will be converted to a string`);
 | ||
|             value = this.sanitizeMacroValue(value);
 | ||
|         }
 | ||
| 
 | ||
|         if (this.#macros.has(key)) {
 | ||
|             console.warn(`Macro ${key} is already registered`);
 | ||
|         }
 | ||
| 
 | ||
|         this.#macros.set(key, value);
 | ||
| 
 | ||
|         if (typeof description === 'string' && description) {
 | ||
|             this.#descriptions.set(key, description);
 | ||
|         }
 | ||
|     }
 | ||
| 
 | ||
|     /**
 | ||
|      * Unregisters a global macro with the given key
 | ||
|      *
 | ||
|      * @param {string} key Macro name (key)
 | ||
|      */
 | ||
|     static unregisterMacro(key) {
 | ||
|         if (typeof key !== 'string') {
 | ||
|             throw new Error('Macro key must be a string');
 | ||
|         }
 | ||
| 
 | ||
|         // Allowing surrounding whitespace would just create more confusion...
 | ||
|         key = key.trim();
 | ||
| 
 | ||
|         if (!key) {
 | ||
|             throw new Error('Macro key must not be empty or whitespace only');
 | ||
|         }
 | ||
| 
 | ||
|         const deleted = this.#macros.delete(key);
 | ||
| 
 | ||
|         if (!deleted) {
 | ||
|             console.warn(`Macro ${key} was not registered`);
 | ||
|         }
 | ||
| 
 | ||
|         this.#descriptions.delete(key);
 | ||
|     }
 | ||
| 
 | ||
|     /**
 | ||
|      * Populate the env object with macro values from the current context.
 | ||
|      * @param {EnvObject} env Env object for the current evaluation context
 | ||
|      * @returns {void}
 | ||
|      */
 | ||
|     static populateEnv(env) {
 | ||
|         if (!env || typeof env !== 'object') {
 | ||
|             console.warn('Env object is not provided');
 | ||
|             return;
 | ||
|         }
 | ||
| 
 | ||
|         // No macros are registered
 | ||
|         if (this.#macros.size === 0) {
 | ||
|             return;
 | ||
|         }
 | ||
| 
 | ||
|         for (const [key, value] of this.#macros) {
 | ||
|             env[key] = value;
 | ||
|         }
 | ||
|     }
 | ||
| 
 | ||
|     /**
 | ||
|      * Performs a type-check on the macro value and returns a sanitized version of it.
 | ||
|      * @param {any} value Value returned by a macro
 | ||
|      * @returns {string} Sanitized value
 | ||
|      */
 | ||
|     static sanitizeMacroValue(value) {
 | ||
|         if (typeof value === 'string') {
 | ||
|             return value;
 | ||
|         }
 | ||
| 
 | ||
|         if (value === null || value === undefined) {
 | ||
|             return '';
 | ||
|         }
 | ||
| 
 | ||
|         if (value instanceof Promise) {
 | ||
|             console.warn('Promises are not supported as macro values');
 | ||
|             return '';
 | ||
|         }
 | ||
| 
 | ||
|         if (typeof value === 'function') {
 | ||
|             console.warn('Functions are not supported as macro values');
 | ||
|             return '';
 | ||
|         }
 | ||
| 
 | ||
|         if (value instanceof Date) {
 | ||
|             return value.toISOString();
 | ||
|         }
 | ||
| 
 | ||
|         if (typeof value === 'object') {
 | ||
|             return JSON.stringify(value);
 | ||
|         }
 | ||
| 
 | ||
|         return String(value);
 | ||
|     }
 | ||
| }
 | ||
| 
 | ||
| /**
 | ||
|  * Gets a hashed id of the current chat from the metadata.
 | ||
|  * If no metadata exists, creates a new hash and saves it.
 | ||
|  * @returns {number} The hashed chat id
 | ||
|  */
 | ||
| function getChatIdHash() {
 | ||
|     const cachedIdHash = chat_metadata['chat_id_hash'];
 | ||
| 
 | ||
|     // If chat_id_hash is not already set, calculate it
 | ||
|     if (!cachedIdHash) {
 | ||
|         // Use the main_chat if it's available, otherwise get the current chat ID
 | ||
|         const chatId = chat_metadata['main_chat'] ?? getCurrentChatId();
 | ||
|         const chatIdHash = getStringHash(chatId);
 | ||
|         chat_metadata['chat_id_hash'] = chatIdHash;
 | ||
|         return chatIdHash;
 | ||
|     }
 | ||
| 
 | ||
|     return cachedIdHash;
 | ||
| }
 | ||
| 
 | ||
| /**
 | ||
|  * Returns the ID of the last message in the chat
 | ||
|  *
 | ||
|  * Optionally can only choose specific messages, if a filter is provided.
 | ||
|  *
 | ||
|  * @param {object} param0 - Optional arguments
 | ||
|  * @param {boolean} [param0.exclude_swipe_in_propress=true] - Whether a message that is currently being swiped should be ignored
 | ||
|  * @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.
 | ||
|  * @returns {number|null} The message id, or null if none was found
 | ||
|  */
 | ||
| export function getLastMessageId({ exclude_swipe_in_propress = true, filter = null } = {}) {
 | ||
|     for (let i = chat?.length - 1; i >= 0; i--) {
 | ||
|         let message = chat[i];
 | ||
| 
 | ||
|         // If ignoring swipes and the message is being swiped, continue
 | ||
|         // 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
 | ||
|         if (exclude_swipe_in_propress && message.swipes && message.swipe_id >= message.swipes.length) {
 | ||
|             continue;
 | ||
|         }
 | ||
| 
 | ||
|         // Check if no filter is provided, or if the message passes the filter
 | ||
|         if (!filter || filter(message)) {
 | ||
|             return i;
 | ||
|         }
 | ||
|     }
 | ||
| 
 | ||
|     return null;
 | ||
| }
 | ||
| 
 | ||
| /**
 | ||
|  * Returns the ID of the first message included in the context
 | ||
|  *
 | ||
|  * @returns {number|null} The ID of the first message in the context
 | ||
|  */
 | ||
| function getFirstIncludedMessageId() {
 | ||
|     return chat_metadata['lastInContextMessageId'];
 | ||
| }
 | ||
| 
 | ||
| /**
 | ||
|  * Returns the ID of the first displayed message in the chat.
 | ||
|  *
 | ||
|  * @returns {number|null} The ID of the first displayed message
 | ||
|  */
 | ||
| function getFirstDisplayedMessageId() {
 | ||
|     const mesId = Number(document.querySelector('#chat .mes')?.getAttribute('mesid'));
 | ||
| 
 | ||
|     if (!isNaN(mesId) && mesId >= 0) {
 | ||
|         return mesId;
 | ||
|     }
 | ||
| 
 | ||
|     return null;
 | ||
| }
 | ||
| 
 | ||
| /**
 | ||
|  * Returns the last message in the chat
 | ||
|  *
 | ||
|  * @returns {string} The last message in the chat
 | ||
|  */
 | ||
| function getLastMessage() {
 | ||
|     const mid = getLastMessageId();
 | ||
|     return chat[mid]?.mes ?? '';
 | ||
| }
 | ||
| 
 | ||
| /**
 | ||
|  * Returns the last message from the user
 | ||
|  *
 | ||
|  * @returns {string} The last message from the user
 | ||
|  */
 | ||
| function getLastUserMessage() {
 | ||
|     const mid = getLastMessageId({ filter: m => m.is_user && !m.is_system });
 | ||
|     return chat[mid]?.mes ?? '';
 | ||
| }
 | ||
| 
 | ||
| /**
 | ||
|  * Returns the last message from the bot
 | ||
|  *
 | ||
|  * @returns {string} The last message from the bot
 | ||
|  */
 | ||
| function getLastCharMessage() {
 | ||
|     const mid = getLastMessageId({ filter: m => !m.is_user && !m.is_system });
 | ||
|     return chat[mid]?.mes ?? '';
 | ||
| }
 | ||
| 
 | ||
| /**
 | ||
|  * Returns the 1-based ID (number) of the last swipe
 | ||
|  *
 | ||
|  * @returns {number|null} The 1-based ID of the last swipe
 | ||
|  */
 | ||
| function getLastSwipeId() {
 | ||
|     // For swipe macro, we are accepting using the message that is currently being swiped
 | ||
|     const mid = getLastMessageId({ exclude_swipe_in_propress: false });
 | ||
|     const swipes = chat[mid]?.swipes;
 | ||
|     return swipes?.length;
 | ||
| }
 | ||
| 
 | ||
| /**
 | ||
|  * Returns the 1-based ID (number) of the current swipe
 | ||
|  *
 | ||
|  * @returns {number|null} The 1-based ID of the current swipe
 | ||
|  */
 | ||
| function getCurrentSwipeId() {
 | ||
|     // For swipe macro, we are accepting using the message that is currently being swiped
 | ||
|     const mid = getLastMessageId({ exclude_swipe_in_propress: false });
 | ||
|     const swipeId = chat[mid]?.swipe_id;
 | ||
|     return swipeId !== null ? swipeId + 1 : null;
 | ||
| }
 | ||
| 
 | ||
| /**
 | ||
|  * Replaces banned words in macros with an empty string.
 | ||
|  * Adds them to textgenerationwebui ban list.
 | ||
|  * @returns {Macro}
 | ||
|  */
 | ||
| function getBannedWordsMacro() {
 | ||
|     const banPattern = /{{banned "(.*)"}}/gi;
 | ||
|     const banReplace = (match, bannedWord) => {
 | ||
|         if (main_api == 'textgenerationwebui') {
 | ||
|             console.log('Found banned word in macros: ' + bannedWord);
 | ||
|             textgenerationwebui_banned_in_macros.push(bannedWord);
 | ||
|         }
 | ||
|         return '';
 | ||
|     };
 | ||
| 
 | ||
|     return { regex: banPattern, replace: banReplace };
 | ||
| }
 | ||
| 
 | ||
| function getTimeSinceLastMessage() {
 | ||
|     const now = moment();
 | ||
| 
 | ||
|     if (Array.isArray(chat) && chat.length > 0) {
 | ||
|         let lastMessage;
 | ||
|         let takeNext = false;
 | ||
| 
 | ||
|         for (let i = chat.length - 1; i >= 0; i--) {
 | ||
|             const message = chat[i];
 | ||
| 
 | ||
|             if (message.is_system) {
 | ||
|                 continue;
 | ||
|             }
 | ||
| 
 | ||
|             if (message.is_user && takeNext) {
 | ||
|                 lastMessage = message;
 | ||
|                 break;
 | ||
|             }
 | ||
| 
 | ||
|             takeNext = true;
 | ||
|         }
 | ||
| 
 | ||
|         if (lastMessage?.send_date) {
 | ||
|             const lastMessageDate = timestampToMoment(lastMessage.send_date);
 | ||
|             const duration = moment.duration(now.diff(lastMessageDate));
 | ||
|             return duration.humanize();
 | ||
|         }
 | ||
|     }
 | ||
| 
 | ||
|     return 'just now';
 | ||
| }
 | ||
| 
 | ||
| /**
 | ||
|  * Returns a macro that picks a random item from a list.
 | ||
|  * @returns {Macro} The random replace macro
 | ||
|  */
 | ||
| function getRandomReplaceMacro() {
 | ||
|     const randomPattern = /{{random\s?::?([^}]+)}}/gi;
 | ||
|     const randomReplace = (match, listString) => {
 | ||
|         // Split on either double colons or comma. If comma is the separator, we are also trimming all items.
 | ||
|         const list = listString.includes('::')
 | ||
|             ? listString.split('::')
 | ||
|             // Replaced escaped commas with a placeholder to avoid splitting on them
 | ||
|             : listString.replace(/\\,/g, '##<23>COMMA<4D>##').split(',').map(item => item.trim().replace(/##<23>COMMA<4D>##/g, ','));
 | ||
| 
 | ||
|         if (list.length === 0) {
 | ||
|             return '';
 | ||
|         }
 | ||
|         const rng = seedrandom('added entropy.', { entropy: true });
 | ||
|         const randomIndex = Math.floor(rng() * list.length);
 | ||
|         return list[randomIndex];
 | ||
|     };
 | ||
| 
 | ||
|     return { regex: randomPattern, replace: randomReplace };
 | ||
| }
 | ||
| 
 | ||
| /**
 | ||
|  * Returns a macro that picks a random item from a list with a consistent seed.
 | ||
|  * @param {string} rawContent The raw content of the string
 | ||
|  * @returns {Macro} The pick replace macro
 | ||
|  */
 | ||
| function getPickReplaceMacro(rawContent) {
 | ||
|     // We need to have a consistent chat hash, otherwise we'll lose rolls on chat file rename or branch switches
 | ||
|     // 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
 | ||
|     const chatIdHash = getChatIdHash();
 | ||
|     const rawContentHash = getStringHash(rawContent);
 | ||
| 
 | ||
|     const pickPattern = /{{pick\s?::?([^}]+)}}/gi;
 | ||
|     const pickReplace = (match, listString, offset) => {
 | ||
|         // Split on either double colons or comma. If comma is the separator, we are also trimming all items.
 | ||
|         const list = listString.includes('::')
 | ||
|             ? listString.split('::')
 | ||
|             // Replaced escaped commas with a placeholder to avoid splitting on them
 | ||
|             : listString.replace(/\\,/g, '##<23>COMMA<4D>##').split(',').map(item => item.trim().replace(/##<23>COMMA<4D>##/g, ','));
 | ||
| 
 | ||
|         if (list.length === 0) {
 | ||
|             return '';
 | ||
|         }
 | ||
| 
 | ||
|         // We build a hash seed based on: unique chat file, raw content, and the placement inside this content
 | ||
|         // This allows us to get unique but repeatable picks in nearly all cases
 | ||
|         const combinedSeedString = `${chatIdHash}-${rawContentHash}-${offset}`;
 | ||
|         const finalSeed = getStringHash(combinedSeedString);
 | ||
|         // @ts-ignore - have to use numbers for legacy picks
 | ||
|         const rng = seedrandom(finalSeed);
 | ||
|         const randomIndex = Math.floor(rng() * list.length);
 | ||
|         return list[randomIndex];
 | ||
|     };
 | ||
| 
 | ||
|     return { regex: pickPattern, replace: pickReplace };
 | ||
| }
 | ||
| 
 | ||
| /**
 | ||
|  * @returns {Macro} The dire roll macro
 | ||
|  */
 | ||
| function getDiceRollMacro() {
 | ||
|     const rollPattern = /{{roll[ : ]([^}]+)}}/gi;
 | ||
|     const rollReplace = (match, matchValue) => {
 | ||
|         let formula = matchValue.trim();
 | ||
| 
 | ||
|         if (isDigitsOnly(formula)) {
 | ||
|             formula = `1d${formula}`;
 | ||
|         }
 | ||
| 
 | ||
|         const isValid = droll.validate(formula);
 | ||
| 
 | ||
|         if (!isValid) {
 | ||
|             console.debug(`Invalid roll formula: ${formula}`);
 | ||
|             return '';
 | ||
|         }
 | ||
| 
 | ||
|         const result = droll.roll(formula);
 | ||
|         if (result === false) return '';
 | ||
|         return String(result.total);
 | ||
|     };
 | ||
| 
 | ||
|     return { regex: rollPattern, replace: rollReplace };
 | ||
| }
 | ||
| 
 | ||
| /**
 | ||
|  * Returns the difference between two times. Works with any time format acceptable by moment().
 | ||
|  * Can work with {{date}} {{time}} macros
 | ||
|  * @returns {Macro} The time difference macro
 | ||
|  */
 | ||
| function getTimeDiffMacro() {
 | ||
|     const timeDiffPattern = /{{timeDiff::(.*?)::(.*?)}}/gi;
 | ||
|     const timeDiffReplace = (_match, matchPart1, matchPart2) => {
 | ||
|         const time1 = moment(matchPart1);
 | ||
|         const time2 = moment(matchPart2);
 | ||
| 
 | ||
|         const timeDifference = moment.duration(time1.diff(time2));
 | ||
|         return timeDifference.humanize(true);
 | ||
|     };
 | ||
| 
 | ||
|     return { regex: timeDiffPattern, replace: timeDiffReplace };
 | ||
| }
 | ||
| 
 | ||
| /**
 | ||
|  * Substitutes {{macro}} parameters in a string.
 | ||
|  * @param {string} content - The string to substitute parameters in.
 | ||
|  * @param {EnvObject} env - Map of macro names to the values they'll be substituted with. If the param
 | ||
|  * values are functions, those functions will be called and their return values are used.
 | ||
|  * @param {function(string): string} postProcessFn - Function to run on the macro value before replacing it.
 | ||
|  * @returns {string} The string with substituted parameters.
 | ||
|  */
 | ||
| export function evaluateMacros(content, env, postProcessFn) {
 | ||
|     if (!content) {
 | ||
|         return '';
 | ||
|     }
 | ||
| 
 | ||
|     postProcessFn = typeof postProcessFn === 'function' ? postProcessFn : (x => x);
 | ||
|     const rawContent = content;
 | ||
| 
 | ||
|     /**
 | ||
|      * Built-ins running before the env variables
 | ||
|      * @type {Macro[]}
 | ||
|      * */
 | ||
|     const preEnvMacros = [
 | ||
|         // Legacy non-curly macros
 | ||
|         { regex: /<USER>/gi, replace: () => typeof env.user === 'function' ? env.user() : env.user },
 | ||
|         { regex: /<BOT>/gi, replace: () => typeof env.char === 'function' ? env.char() : env.char },
 | ||
|         { regex: /<CHAR>/gi, replace: () => typeof env.char === 'function' ? env.char() : env.char },
 | ||
|         { regex: /<CHARIFNOTGROUP>/gi, replace: () => typeof env.group === 'function' ? env.group() : env.group },
 | ||
|         { regex: /<GROUP>/gi, replace: () => typeof env.group === 'function' ? env.group() : env.group },
 | ||
|         getDiceRollMacro(),
 | ||
|         ...getInstructMacros(env),
 | ||
|         ...getVariableMacros(),
 | ||
|         { regex: /{{newline}}/gi, replace: () => '\n' },
 | ||
|         { regex: /(?:\r?\n)*{{trim}}(?:\r?\n)*/gi, replace: () => '' },
 | ||
|         { regex: /{{noop}}/gi, replace: () => '' },
 | ||
|         { regex: /{{input}}/gi, replace: () => String($('#send_textarea').val()) },
 | ||
|     ];
 | ||
| 
 | ||
|     /**
 | ||
|      * Built-ins running after the env variables
 | ||
|      * @type {Macro[]}
 | ||
|     */
 | ||
|     const postEnvMacros = [
 | ||
|         { regex: /{{maxPrompt}}/gi, replace: () => String(getMaxContextSize()) },
 | ||
|         { regex: /{{lastMessage}}/gi, replace: () => getLastMessage() },
 | ||
|         { regex: /{{lastMessageId}}/gi, replace: () => String(getLastMessageId() ?? '') },
 | ||
|         { regex: /{{lastUserMessage}}/gi, replace: () => getLastUserMessage() },
 | ||
|         { regex: /{{lastCharMessage}}/gi, replace: () => getLastCharMessage() },
 | ||
|         { regex: /{{firstIncludedMessageId}}/gi, replace: () => String(getFirstIncludedMessageId() ?? '') },
 | ||
|         { regex: /{{firstDisplayedMessageId}}/gi, replace: () => String(getFirstDisplayedMessageId() ?? '') },
 | ||
|         { regex: /{{lastSwipeId}}/gi, replace: () => String(getLastSwipeId() ?? '') },
 | ||
|         { regex: /{{currentSwipeId}}/gi, replace: () => String(getCurrentSwipeId() ?? '') },
 | ||
|         { regex: /{{reverse:(.+?)}}/gi, replace: (_, str) => Array.from(str).reverse().join('') },
 | ||
|         { regex: /\{\{\/\/([\s\S]*?)\}\}/gm, replace: () => '' },
 | ||
|         { regex: /{{time}}/gi, replace: () => moment().format('LT') },
 | ||
|         { regex: /{{date}}/gi, replace: () => moment().format('LL') },
 | ||
|         { regex: /{{weekday}}/gi, replace: () => moment().format('dddd') },
 | ||
|         { regex: /{{isotime}}/gi, replace: () => moment().format('HH:mm') },
 | ||
|         { regex: /{{isodate}}/gi, replace: () => moment().format('YYYY-MM-DD') },
 | ||
|         { regex: /{{datetimeformat +([^}]*)}}/gi, replace: (_, format) => moment().format(format) },
 | ||
|         { regex: /{{idle_duration}}/gi, replace: () => getTimeSinceLastMessage() },
 | ||
|         { regex: /{{time_UTC([-+]\d+)}}/gi, replace: (_, offset) => moment().utc().utcOffset(parseInt(offset, 10)).format('LT') },
 | ||
|         getTimeDiffMacro(),
 | ||
|         getBannedWordsMacro(),
 | ||
|         getRandomReplaceMacro(),
 | ||
|         getPickReplaceMacro(rawContent),
 | ||
|     ];
 | ||
| 
 | ||
|     // Add all registered macros to the env object
 | ||
|     MacrosParser.populateEnv(env);
 | ||
|     const nonce = uuidv4();
 | ||
|     const envMacros = [];
 | ||
| 
 | ||
|     // Substitute passed-in variables
 | ||
|     for (const varName in env) {
 | ||
|         if (!Object.hasOwn(env, varName)) continue;
 | ||
| 
 | ||
|         const envRegex = new RegExp(`{{${escapeRegex(varName)}}}`, 'gi');
 | ||
|         const envReplace = () => {
 | ||
|             const param = env[varName];
 | ||
|             const value = MacrosParser.sanitizeMacroValue(typeof param === 'function' ? param(nonce) : param);
 | ||
|             return value;
 | ||
|         };
 | ||
| 
 | ||
|         envMacros.push({ regex: envRegex, replace: envReplace });
 | ||
|     }
 | ||
| 
 | ||
|     const macros = [...preEnvMacros, ...envMacros, ...postEnvMacros];
 | ||
| 
 | ||
|     for (const macro of macros) {
 | ||
|         // Stop if the content is empty
 | ||
|         if (!content) {
 | ||
|             break;
 | ||
|         }
 | ||
| 
 | ||
|         // Short-circuit if no curly braces are found
 | ||
|         if (!macro.regex.source.startsWith('<') && !content.includes('{{')) {
 | ||
|             break;
 | ||
|         }
 | ||
| 
 | ||
|         try {
 | ||
|             content = content.replace(macro.regex, (...args) => postProcessFn(macro.replace(...args)));
 | ||
|         } catch (e) {
 | ||
|             console.warn(`Macro content can't be replaced: ${macro.regex} in ${content}`, e);
 | ||
|         }
 | ||
|     }
 | ||
| 
 | ||
|     return content;
 | ||
| }
 | ||
| 
 | ||
| export function initMacros() {
 | ||
|     function initLastGenerationType() {
 | ||
|         let lastGenerationType = '';
 | ||
| 
 | ||
|         MacrosParser.registerMacro('lastGenerationType', () => lastGenerationType);
 | ||
| 
 | ||
|         eventSource.on(event_types.GENERATION_STARTED, (type, _params, isDryRun) => {
 | ||
|             if (isDryRun) return;
 | ||
|             lastGenerationType = type || 'normal';
 | ||
|         });
 | ||
| 
 | ||
|         eventSource.on(event_types.CHAT_CHANGED, () => {
 | ||
|             lastGenerationType = '';
 | ||
|         });
 | ||
|     }
 | ||
| 
 | ||
|     MacrosParser.registerMacro('isMobile', () => String(isMobile()));
 | ||
|     initLastGenerationType();
 | ||
| }
 |