281 lines
11 KiB
JavaScript
281 lines
11 KiB
JavaScript
import { seedrandom, droll } from '../../../lib.js';
|
||
import { chat_metadata, main_api, getMaxContextSize, extension_prompts, getCurrentChatId } from '../../../script.js';
|
||
import { getStringHash } from '../../utils.js';
|
||
import { textgenerationwebui_banned_in_macros } from '../../textgen-settings.js';
|
||
import { inject_ids } from '../../constants.js';
|
||
import { MacroRegistry, MacroCategory, MacroValueType } from '../engine/MacroRegistry.js';
|
||
|
||
/**
|
||
* Registers SillyTavern's core built-in macros in the MacroRegistry.
|
||
*
|
||
* These macros correspond to the main {{...}} macros that are available
|
||
* in prompts (time/date/chat info, utility macros, etc.). They are
|
||
* intended to preserve the behavior of the existing regex-based macros
|
||
* in macros.js while using the new MacroRegistry/MacroEngine pipeline.
|
||
*/
|
||
export function registerCoreMacros() {
|
||
// {{space}} -> ' '
|
||
MacroRegistry.registerMacro('space', {
|
||
category: MacroCategory.UTILITY,
|
||
unnamedArgs: [
|
||
{
|
||
name: 'count',
|
||
optional: true,
|
||
defaultValue: '1',
|
||
type: MacroValueType.INTEGER,
|
||
description: 'Number of spaces to insert.',
|
||
},
|
||
],
|
||
description: 'Returns one or more spaces. One space by default, more if the count argument is specified.',
|
||
returns: 'One or more spaces.',
|
||
exampleUsage: ['{{space}}', '{{space::4}}'],
|
||
handler: ({ unnamedArgs: [count] }) => ' '.repeat(Number(count ?? 1)),
|
||
});
|
||
|
||
// {{newline}} -> '\n'
|
||
MacroRegistry.registerMacro('newline', {
|
||
category: MacroCategory.UTILITY,
|
||
unnamedArgs: [
|
||
{
|
||
name: 'count',
|
||
optional: true,
|
||
defaultValue: '1',
|
||
type: MacroValueType.INTEGER,
|
||
description: 'Number of newlines to insert.',
|
||
},
|
||
],
|
||
description: 'Inserts one or more newlines. One newline by default, more if the count argument is specified.',
|
||
returns: 'One or more \\n.',
|
||
exampleUsage: ['{{newline}}', '{{newline::2}}'],
|
||
handler: ({ unnamedArgs: [count] }) => '\n'.repeat(Number(count ?? 1)),
|
||
});
|
||
|
||
// {{noop}} -> ''
|
||
MacroRegistry.registerMacro('noop', {
|
||
category: MacroCategory.UTILITY,
|
||
description: 'Does nothing and produces an empty string.',
|
||
returns: '',
|
||
handler: () => '',
|
||
});
|
||
|
||
// {{trim}} -> macro will currently replace itself with itself. Trimming is handled in post-processing.
|
||
MacroRegistry.registerMacro('trim', {
|
||
category: MacroCategory.UTILITY,
|
||
description: 'Trims all whitespaces around the trim macro.',
|
||
returns: '',
|
||
handler: () => '{{trim}}',
|
||
});
|
||
|
||
// {{input}} -> current textarea content
|
||
MacroRegistry.registerMacro('input', {
|
||
category: MacroCategory.UTILITY,
|
||
description: 'Current text from the send textarea.',
|
||
returns: 'Current text from the send textarea.',
|
||
handler: () => (/** @type {HTMLTextAreaElement} */(document.querySelector('#send_textarea')))?.value ?? '',
|
||
});
|
||
|
||
// {{maxPrompt}} -> max context size
|
||
MacroRegistry.registerMacro('maxPrompt', {
|
||
category: MacroCategory.STATE,
|
||
description: 'Maximum prompt context size.',
|
||
returns: 'Maximum prompt context size.',
|
||
returnType: MacroValueType.INTEGER,
|
||
handler: () => String(getMaxContextSize()),
|
||
});
|
||
|
||
// String utilities
|
||
MacroRegistry.registerMacro('reverse', {
|
||
category: MacroCategory.UTILITY,
|
||
unnamedArgs: [
|
||
{
|
||
name: 'value',
|
||
type: MacroValueType.STRING,
|
||
description: 'The string to reverse.',
|
||
},
|
||
],
|
||
description: 'Reverses the characters of the argument provided.',
|
||
returns: 'Reversed string.',
|
||
exampleUsage: ['{{reverse::I am Lana}}'],
|
||
handler: ({ unnamedArgs: [value] }) => Array.from(value).reverse().join(''),
|
||
});
|
||
|
||
// Comment macro: {{// ...}} -> '' (consumes any arguments)
|
||
MacroRegistry.registerMacro('//', {
|
||
aliases: [{ alias: 'comment' }],
|
||
category: MacroCategory.UTILITY,
|
||
list: true, // We consume any arguments as if this is a list, but we'll ignore them in the handler anyway
|
||
strictArgs: false, // and we also always remove it, even if the parsing might say it's invalid
|
||
description: 'Comment macro that produces an empty string. Can be used for writing into prompt definitions, without being passed to the context.',
|
||
returns: '',
|
||
displayOverride: '{{// ...}}',
|
||
exampleUsage: ['{{// This is a comment}}'],
|
||
handler: () => '',
|
||
});
|
||
|
||
// Time and date macros
|
||
// Dice roll macro: {{roll 1d6}} or {{roll: 1d6}}
|
||
MacroRegistry.registerMacro('roll', {
|
||
category: MacroCategory.RANDOM,
|
||
unnamedArgs: [
|
||
{
|
||
name: 'formula',
|
||
sampleValue: '1d20',
|
||
description: 'Dice roll formula using droll syntax (e.g. 1d20).',
|
||
type: 'string',
|
||
},
|
||
],
|
||
description: 'Rolls dice using droll syntax (e.g. {{roll 1d20}}).',
|
||
returns: 'Dice roll result.',
|
||
returnType: MacroValueType.INTEGER,
|
||
exampleUsage: [
|
||
'{{roll::1d20}}',
|
||
'{{roll::6}}',
|
||
'{{roll::3d6+4}}',
|
||
],
|
||
handler: ({ unnamedArgs: [formula] }) => {
|
||
// If only digits were provided, treat it as `1dX`.
|
||
if (/^\d+$/.test(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);
|
||
},
|
||
});
|
||
|
||
// Random choice macro: {{random::a::b}} or {{random a,b}}
|
||
MacroRegistry.registerMacro('random', {
|
||
category: MacroCategory.RANDOM,
|
||
list: true,
|
||
description: 'Picks a random item from a list. Will be re-rolled every time macros are resolved.',
|
||
returns: 'Randomly selected item from the list.',
|
||
exampleUsage: ['{{random::blonde::brown::red::black::blue}}'],
|
||
handler: ({ list }) => {
|
||
// Handle old legacy cases, where we have to split the list manually
|
||
if (list.length === 1) {
|
||
list = readSingleArgsRandomList(list[0]);
|
||
}
|
||
|
||
if (list.length === 0) {
|
||
return '';
|
||
}
|
||
|
||
const rng = seedrandom('added entropy.', { entropy: true });
|
||
const randomIndex = Math.floor(rng() * list.length);
|
||
return list[randomIndex];
|
||
},
|
||
});
|
||
|
||
// Deterministic choice macro: {{pick::a::b}} or {{pick a,b}}
|
||
MacroRegistry.registerMacro('pick', {
|
||
category: MacroCategory.RANDOM,
|
||
list: true,
|
||
description: 'Picks a random item from a list, but keeps the choice stable for a given chat and macro position.',
|
||
returns: 'Stable randomly selected item from the list.',
|
||
exampleUsage: ['{{pick::blonde::brown::red::black::blue}}'],
|
||
handler: ({ list, range, env }) => {
|
||
// Handle old legacy cases, where we have to split the list manually
|
||
if (list.length === 1) {
|
||
list = readSingleArgsRandomList(list[0]);
|
||
}
|
||
|
||
if (!list.length) {
|
||
return '';
|
||
}
|
||
|
||
const chatIdHash = getChatIdHash();
|
||
|
||
// Use the full original input string for deterministic behavior
|
||
const rawContentHash = env.contentHash;
|
||
|
||
const offset = typeof range?.startOffset === 'number' ? range.startOffset : 0;
|
||
|
||
const combinedSeedString = `${chatIdHash}-${rawContentHash}-${offset}`;
|
||
const finalSeed = getStringHash(combinedSeedString);
|
||
const rng = seedrandom(String(finalSeed));
|
||
const randomIndex = Math.floor(rng() * list.length);
|
||
return list[randomIndex];
|
||
},
|
||
});
|
||
|
||
/** @param {string} listString @return {string[]} */
|
||
function readSingleArgsRandomList(listString) {
|
||
// If it contains double colons, those will have precedence over comma-seperated lists.
|
||
// This can only happen if the macro only had a single colon to introduce the list...
|
||
// like, {{random:a::b::c}}
|
||
if (listString.includes('::')) {
|
||
return listString.split('::').map((/** @type {string} */ item) => item.trim());
|
||
}
|
||
// Otherwise, we fall back and split by commas that may be present
|
||
return listString
|
||
.replace(/\\,/g, '##<23>COMMA<4D>##')
|
||
.split(',')
|
||
.map((/** @type {string} */ item) => item.trim().replace(/##<23>COMMA<4D>##/g, ','));
|
||
}
|
||
|
||
// Banned words macro: {{banned "word"}}
|
||
MacroRegistry.registerMacro('banned', {
|
||
category: MacroCategory.UTILITY,
|
||
unnamedArgs: [
|
||
{
|
||
name: 'word',
|
||
sampleValue: 'word',
|
||
description: 'Word to ban for textgenerationwebui backend.',
|
||
type: 'string',
|
||
},
|
||
],
|
||
description: 'Bans a word for textgenerationwebui backend. (Strips quotes surrounding the banned word, if present)',
|
||
returns: '',
|
||
exampleUsage: ['{{banned::delve}}'],
|
||
handler: ({ unnamedArgs: [bannedWord] }) => {
|
||
// Strip quotes via regex, which were allowed in legacy syntax
|
||
bannedWord = bannedWord.replace(/^"|"$/g, '');
|
||
if (main_api === 'textgenerationwebui') {
|
||
console.log('Found banned word in macros: ' + bannedWord);
|
||
textgenerationwebui_banned_in_macros.push(bannedWord);
|
||
}
|
||
return '';
|
||
},
|
||
});
|
||
|
||
// Outlet macro: {{outlet::key}}
|
||
MacroRegistry.registerMacro('outlet', {
|
||
category: MacroCategory.UTILITY,
|
||
unnamedArgs: [
|
||
{
|
||
name: 'key',
|
||
sampleValue: 'my-outlet-key',
|
||
description: 'Outlet key.',
|
||
type: 'string',
|
||
},
|
||
],
|
||
description: 'Returns the world info outlet prompt for a given outlet key.',
|
||
returns: 'World info outlet prompt.',
|
||
exampleUsage: ['{{outlet::character-achievements}}'],
|
||
handler: ({ unnamedArgs: [outlet] }) => {
|
||
if (!outlet) return '';
|
||
const value = extension_prompts[inject_ids.CUSTOM_WI_OUTLET(outlet)]?.value;
|
||
return value || '';
|
||
},
|
||
});
|
||
}
|
||
|
||
function getChatIdHash() {
|
||
const cachedIdHash = chat_metadata['chat_id_hash'];
|
||
if (typeof cachedIdHash === 'number') {
|
||
return cachedIdHash;
|
||
}
|
||
|
||
const chatId = chat_metadata['main_chat'] ?? getCurrentChatId();
|
||
const chatIdHash = getStringHash(chatId);
|
||
chat_metadata['chat_id_hash'] = chatIdHash;
|
||
return chatIdHash;
|
||
}
|