|
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'; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Handlebars.registerHelper('trim', () => '{{trim}}'); |
|
|
|
Handlebars.registerHelper('helperMissing', function () { |
|
const options = arguments[arguments.length - 1]; |
|
const macroName = options.name; |
|
return substituteParams(`{{${macroName}}}`); |
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export class MacrosParser { |
|
|
|
|
|
|
|
|
|
static #macros = new Map(); |
|
|
|
|
|
|
|
|
|
|
|
static #descriptions = new Map(); |
|
|
|
|
|
|
|
|
|
|
|
static [Symbol.iterator] = function* () { |
|
for (const macro of MacrosParser.#macros.keys()) { |
|
yield { key: macro, description: MacrosParser.#descriptions.get(macro) }; |
|
} |
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static registerMacro(key, value, description = '') { |
|
if (typeof key !== 'string') { |
|
throw new Error('Macro key must be a string'); |
|
} |
|
|
|
|
|
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); |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
static unregisterMacro(key) { |
|
if (typeof key !== 'string') { |
|
throw new Error('Macro key must be a string'); |
|
} |
|
|
|
|
|
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); |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
static populateEnv(env) { |
|
if (!env || typeof env !== 'object') { |
|
console.warn('Env object is not provided'); |
|
return; |
|
} |
|
|
|
|
|
if (this.#macros.size === 0) { |
|
return; |
|
} |
|
|
|
for (const [key, value] of this.#macros) { |
|
env[key] = 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); |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
function getChatIdHash() { |
|
const cachedIdHash = chat_metadata['chat_id_hash']; |
|
|
|
|
|
if (!cachedIdHash) { |
|
|
|
const chatId = chat_metadata['main_chat'] ?? getCurrentChatId(); |
|
const chatIdHash = getStringHash(chatId); |
|
chat_metadata['chat_id_hash'] = chatIdHash; |
|
return chatIdHash; |
|
} |
|
|
|
return cachedIdHash; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function getLastMessageId({ exclude_swipe_in_propress = true, filter = null } = {}) { |
|
for (let i = chat?.length - 1; i >= 0; i--) { |
|
let message = chat[i]; |
|
|
|
|
|
|
|
if (exclude_swipe_in_propress && message.swipes && message.swipe_id >= message.swipes.length) { |
|
continue; |
|
} |
|
|
|
|
|
if (!filter || filter(message)) { |
|
return i; |
|
} |
|
} |
|
|
|
return null; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
function getFirstIncludedMessageId() { |
|
return chat_metadata['lastInContextMessageId']; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
function getFirstDisplayedMessageId() { |
|
const mesId = Number(document.querySelector('#chat .mes')?.getAttribute('mesid')); |
|
|
|
if (!isNaN(mesId) && mesId >= 0) { |
|
return mesId; |
|
} |
|
|
|
return null; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
function getLastMessage() { |
|
const mid = getLastMessageId(); |
|
return chat[mid]?.mes ?? ''; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
function getLastUserMessage() { |
|
const mid = getLastMessageId({ filter: m => m.is_user && !m.is_system }); |
|
return chat[mid]?.mes ?? ''; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
function getLastCharMessage() { |
|
const mid = getLastMessageId({ filter: m => !m.is_user && !m.is_system }); |
|
return chat[mid]?.mes ?? ''; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
function getLastSwipeId() { |
|
|
|
const mid = getLastMessageId({ exclude_swipe_in_propress: false }); |
|
const swipes = chat[mid]?.swipes; |
|
return swipes?.length; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
function getCurrentSwipeId() { |
|
|
|
const mid = getLastMessageId({ exclude_swipe_in_propress: false }); |
|
const swipeId = chat[mid]?.swipe_id; |
|
return swipeId !== null ? swipeId + 1 : null; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
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'; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
function getRandomReplaceMacro() { |
|
const randomPattern = /{{random\s?::?([^}]+)}}/gi; |
|
const randomReplace = (match, listString) => { |
|
|
|
const list = listString.includes('::') |
|
? listString.split('::') |
|
|
|
: listString.replace(/\\,/g, '##�COMMA�##').split(',').map(item => item.trim().replace(/##�COMMA�##/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 }; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
function getPickReplaceMacro(rawContent) { |
|
|
|
|
|
const chatIdHash = getChatIdHash(); |
|
const rawContentHash = getStringHash(rawContent); |
|
|
|
const pickPattern = /{{pick\s?::?([^}]+)}}/gi; |
|
const pickReplace = (match, listString, offset) => { |
|
|
|
const list = listString.includes('::') |
|
? listString.split('::') |
|
|
|
: listString.replace(/\\,/g, '##�COMMA�##').split(',').map(item => item.trim().replace(/##�COMMA�##/g, ',')); |
|
|
|
if (list.length === 0) { |
|
return ''; |
|
} |
|
|
|
|
|
|
|
const combinedSeedString = `${chatIdHash}-${rawContentHash}-${offset}`; |
|
const finalSeed = getStringHash(combinedSeedString); |
|
|
|
const rng = seedrandom(finalSeed); |
|
const randomIndex = Math.floor(rng() * list.length); |
|
return list[randomIndex]; |
|
}; |
|
|
|
return { regex: pickPattern, replace: pickReplace }; |
|
} |
|
|
|
|
|
|
|
|
|
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 }; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
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 }; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function evaluateMacros(content, env, postProcessFn) { |
|
if (!content) { |
|
return ''; |
|
} |
|
|
|
postProcessFn = typeof postProcessFn === 'function' ? postProcessFn : (x => x); |
|
const rawContent = content; |
|
|
|
|
|
|
|
|
|
|
|
const preEnvMacros = [ |
|
|
|
{ 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()) }, |
|
]; |
|
|
|
|
|
|
|
|
|
|
|
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), |
|
]; |
|
|
|
|
|
MacrosParser.populateEnv(env); |
|
const nonce = uuidv4(); |
|
const envMacros = []; |
|
|
|
|
|
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) { |
|
|
|
if (!content) { |
|
break; |
|
} |
|
|
|
|
|
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(); |
|
} |
|
|