This commit is contained in:
Len 2024-04-26 15:36:48 +00:00 committed by GitHub
commit f2889bf715
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 7154 additions and 598 deletions

View File

@ -4025,12 +4025,48 @@
</div>
</div>
</div>
<div data-newbie-hidden class="flex-container">
<div id="reload_chat" class="menu_button whitespacenowrap" data-i18n="[title]Reload and redraw the currently open chat" title="Reload and redraw the currently open chat.">
<span data-i18n="Reload Chat">Reload Chat</span>
<div name="STscriptToggles">
<h4 data-i18n="STscript Settings">STscript Settings</h4>
<div title="Determines how STscript commands are found for autocomplete." data-i18n="[title]Determines how STscript commands are found for autocomplete.">
<label for="stscript_matching" data-i18n="Autocomplete Matching">Autocomplete Matching</label>
<select id="stscript_matching">
<option data-i18n="Starts with" value="strict">Starts with</option>
<option data-i18n="Includes" value="includes">Includes</option>
<option data-i18n="Fuzzy" value="fuzzy">Fuzzy</option>
</select>
</div>
<div id="debug_menu" class="menu_button whitespacenowrap" data-i18n="Debug Menu">
Debug Menu
<div title="Sets the style of the autocomplete for STscript." data-i18n="[title]Sets the style of the autocomplete for STscript.">
<label for="stscript_autocomplete_style" data-i18n="Autocomplete Style">Autocomplete Style</label>
<div class="flex-container flexFlowRow alignItemsBaseline">
<select id="stscript_autocomplete_style">
<option data-i18n="Follow Theme" value="theme">Follow Theme</option>
<option data-i18n="Dark" value="dark">Dark</option>
<option data-i18n="Light" value="light">Light</option>
</select>
<div class="menu_button fa-solid fa-pen-to-square" title="Customize colors"></div>
</div>
</div>
<div title="Sets default flags for the STscript parser." data-i18n="[title]Sets default flags for the STscript parser.">
<label data-i18n="Parser Flags">Parser Flags</label>
<label class="checkbox_label" title="STRICT_ESCAPING." data-i18n="[title]STRICT_ESCAPING">
<input id="stscript_parser_flag_strict_escaping" type="checkbox" />
<span data-i18n="STRICT_ESCAPING">STRICT_ESCAPING</span>
</label>
<label class="checkbox_label" title="REPLACE_GETVAR." data-i18n="[title]REPLACE_GETVAR">
<input id="stscript_parser_flag_replace_getvar" type="checkbox" />
<span data-i18n="REPLACE_GETVAR">REPLACE_GETVAR</span>
</label>
</div>
</div>
<div name="DebugToggles">
<h4 data-i18n="Debug Options">Debug Options</h4>
<div data-newbie-hidden class="flex-container">
<div id="reload_chat" class="menu_button whitespacenowrap" data-i18n="[title]Reload and redraw the currently open chat" title="Reload and redraw the currently open chat.">
<span data-i18n="Reload Chat">Reload Chat</span>
</div>
<div id="debug_menu" class="menu_button whitespacenowrap" data-i18n="Debug Menu">
Debug Menu
</div>
</div>
</div>
</div>

View File

@ -216,6 +216,10 @@ import { currentUser, setUserControls } from './scripts/user.js';
import { callGenericPopup } from './scripts/popup.js';
import { renderTemplate, renderTemplateAsync } from './scripts/templates.js';
import { ScraperManager } from './scripts/scrapers.js';
import { SlashCommandParser } from './scripts/slash-commands/SlashCommandParser.js';
import { SlashCommand } from './scripts/slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument } from './scripts/slash-commands/SlashCommandArgument.js';
import { SlashCommandBrowser } from './scripts/slash-commands/SlashCommandBrowser.js';
//exporting functions and vars for mods
export {
@ -2398,7 +2402,7 @@ async function processCommands(message) {
}
const previousText = String($('#send_textarea').val());
const result = await executeSlashCommands(message);
const result = await executeSlashCommands(message, true, null, true);
if (!result || typeof result !== 'object') {
return false;
@ -2407,7 +2411,7 @@ async function processCommands(message) {
const currentText = String($('#send_textarea').val());
if (previousText === currentText) {
$('#send_textarea').val(result.newText).trigger('input');
$('#send_textarea').val(result.newText)[0].dispatchEvent(new Event('input', { bubbles:true }));
}
// interrupt generation if the input was nothing but a command
@ -2445,6 +2449,14 @@ function sendSystemMessage(type, text, extra = {}) {
chat.push(newMessage);
addOneMessage(newMessage);
is_send_press = false;
if (type == system_message_types.SLASH_COMMANDS) {
const browser = new SlashCommandBrowser();
const spinner = document.querySelector('#chat .last_mes .custom-slashHelp');
const parent = spinner.parentElement;
spinner.remove();
browser.renderInto(parent);
browser.search.focus();
}
}
/**
@ -2691,7 +2703,7 @@ class StreamingProcessor {
let messageId = -1;
if (this.type == 'impersonate') {
$('#send_textarea').val('').trigger('input');
$('#send_textarea').val('')[0].dispatchEvent(new Event('input', { bubbles:true }));
}
else {
await saveReply(this.type, text, true);
@ -2727,7 +2739,7 @@ class StreamingProcessor {
}
if (isImpersonate) {
$('#send_textarea').val(processedText).trigger('input');
$('#send_textarea').val(processedText)[0].dispatchEvent(new Event('input', { bubbles:true }));
}
else {
let currentTime = new Date();
@ -3074,7 +3086,7 @@ async function Generate(type, { automatic_trigger, force_name2, quiet_prompt, qu
const interruptedByCommand = await processCommands(String($('#send_textarea').val()));
if (interruptedByCommand) {
//$("#send_textarea").val('').trigger('input');
//$("#send_textarea").val('')[0].dispatchEvent(new Event('input', { bubbles:true }));
unblockGeneration(type);
return Promise.resolve();
}
@ -3160,7 +3172,7 @@ async function Generate(type, { automatic_trigger, force_name2, quiet_prompt, qu
if (type !== 'regenerate' && type !== 'swipe' && type !== 'quiet' && !isImpersonate && !dryRun) {
is_send_press = true;
textareaText = String($('#send_textarea').val());
$('#send_textarea').val('').trigger('input');
$('#send_textarea').val('')[0].dispatchEvent(new Event('input', { bubbles:true }));
} else {
textareaText = '';
if (chat.length && chat[chat.length - 1]['is_user']) {
@ -4095,7 +4107,7 @@ async function Generate(type, { automatic_trigger, force_name2, quiet_prompt, qu
if (getMessage.length > 0) {
if (isImpersonate) {
$('#send_textarea').val(getMessage).trigger('input');
$('#send_textarea').val(getMessage)[0].dispatchEvent(new Event('input', { bubbles:true }));
generatedPromptCache = '';
await eventSource.emit(event_types.IMPERSONATE_READY, getMessage);
}
@ -8602,19 +8614,137 @@ jQuery(async function () {
toastr.success('Chat and settings saved.');
}
registerSlashCommand('dupe', DupeChar, [], ' duplicates the currently selected character', true, true);
registerSlashCommand('api', connectAPISlash, [], `<span class="monospace">(${Object.keys(CONNECT_API_MAP).join(', ')})</span> connect to an API`, true, true);
registerSlashCommand('impersonate', doImpersonate, ['imp'], '<span class="monospace">[prompt]</span> calls an impersonation response, with an optional additional prompt', true, true);
registerSlashCommand('delchat', doDeleteChat, [], ' deletes the current chat', true, true);
registerSlashCommand('getchatname', doGetChatName, [], ' returns the name of the current chat file into the pipe', false, true);
registerSlashCommand('closechat', doCloseChat, [], ' closes the current chat', true, true);
registerSlashCommand('panels', doTogglePanels, ['togglepanels'], ' toggle UI panels on/off', true, true);
registerSlashCommand('forcesave', doForceSave, [], ' forces a save of the current chat and settings', true, true);
registerSlashCommand('instruct', selectInstructCallback, [], '<span class="monospace">(name)</span> selects instruct mode preset by name. Gets the current instruct if no name is provided', true, true);
registerSlashCommand('instruct-on', enableInstructCallback, [], ' enables instruct mode', true, true);
registerSlashCommand('instruct-off', disableInstructCallback, [], ' disables instruct mode', true, true);
registerSlashCommand('context', selectContextCallback, [], '<span class="monospace">(name)</span> selects context template by name. Gets the current template if no name is provided', true, true);
registerSlashCommand('chat-manager', () => $('#option_select_chat').trigger('click'), ['chat-history', 'manage-chats'], ' opens the chat manager for the current character/group', true, true);
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'dupe',
callback: DupeChar,
helpString: 'Duplicates the currently selected character.',
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'api',
callback: connectAPISlash,
namedArgumentList: [],
unnamedArgumentList: [
new SlashCommandArgument(
'API to connect to',
[ARGUMENT_TYPE.STRING],
true,
false,
null,
Object.keys(CONNECT_API_MAP),
),
],
helpString: `
<div>
Connect to an API.
</div>
<div>
<strong>Available APIs:</strong>
<pre><code>${Object.keys(CONNECT_API_MAP).join(', ')}</code></pre>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'impersonate',
callback: doImpersonate,
aliases: ['imp'],
unnamedArgumentList: [
new SlashCommandArgument(
'prompt', [ARGUMENT_TYPE.STRING], false,
),
],
helpString: `
<div>
Calls an impersonation response, with an optional additional prompt.
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code class="language-stscript">/impersonate What is the meaning of life?</code></pre>
</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'delchat',
callback: doDeleteChat,
helpString: 'Deletes the current chat.',
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'getchatname',
callback: doGetChatName,
returns: 'chat file name',
helpString: 'Returns the name of the current chat file into the pipe.',
interruptsGeneration: false,
purgeFromMessage: true,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'closechat',
callback: doCloseChat,
helpString: 'Closes the current chat.',
interruptsGeneration: true,
purgeFromMessage: true,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'panels',
callback: doTogglePanels,
aliases: ['togglepanels'],
helpString: 'Toggle UI panels on/off',
interruptsGeneration: true,
purgeFromMessage: true,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'forcesave',
callback: doForceSave,
helpString: 'Forces a save of the current chat and settings',
interruptsGeneration: true,
purgeFromMessage: true,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'instruct',
callback: selectInstructCallback,
returns: 'current preset',
namedArgumentList: [],
unnamedArgumentList: [
new SlashCommandArgument(
'name', [ARGUMENT_TYPE.STRING], false,
),
],
helpString: `
<div>
Selects instruct mode preset by name. Gets the current instruct if no name is provided.
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code class="language-stscript">/instruct creative</code></pre>
</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'instruct-on',
callback: enableInstructCallback,
helpString: 'Enables instruct mode.',
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'instruct-off',
callback: disableInstructCallback,
helpString: 'Disables instruct mode',
interruptsGeneration: true,
purgeFromMessage: true,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'context',
callback: selectContextCallback,
returns: 'template name',
unnamedArgumentList: [
new SlashCommandArgument(
'name', [ARGUMENT_TYPE.STRING], false,
),
],
helpString: 'Selects context template by name. Gets the current template if no name is provided',
interruptsGeneration: true,
purgeFromMessage: true,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'chat-manager',
callback: () => $('#option_select_chat').trigger('click'),
aliases: ['chat-history', 'manage-chats'],
helpString: 'Opens the chat manager for the current character/group.',
interruptsGeneration: true,
purgeFromMessage: true,
}));
setTimeout(function () {
$('#groupControlsToggle').trigger('click');

View File

@ -422,7 +422,7 @@ function restoreUserInput() {
const userInput = LoadLocal('userInput');
if (userInput) {
$('#send_textarea').val(userInput).trigger('input');
$('#send_textarea').val(userInput)[0].dispatchEvent(new Event('input', { bubbles:true }));
}
}

View File

@ -12,6 +12,9 @@ import { extension_settings, getContext, saveMetadataDebounced } from './extensi
import { registerSlashCommand } from './slash-commands.js';
import { getCharaFilename, debounce, delay } from './utils.js';
import { getTokenCountAsync } from './tokenizers.js';
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument } from './slash-commands/SlashCommandArgument.js';
export { MODULE_NAME as NOTE_MODULE_NAME };
const MODULE_NAME = '2_floating_prompt'; // <= Deliberate, for sorting lower than memory
@ -454,9 +457,59 @@ export function initAuthorsNote() {
});
$('#option_toggle_AN').on('click', onANMenuItemClick);
registerSlashCommand('note', setNoteTextCommand, [], '<span class=\'monospace\'>(text)</span> sets an author\'s note for the currently selected chat', true, true);
registerSlashCommand('depth', setNoteDepthCommand, [], '<span class=\'monospace\'>(number)</span> sets an author\'s note depth for in-chat positioning', true, true);
registerSlashCommand('freq', setNoteIntervalCommand, ['interval'], '<span class=\'monospace\'>(number)</span> sets an author\'s note insertion frequency', true, true);
registerSlashCommand('pos', setNotePositionCommand, ['position'], '(<span class=\'monospace\'>chat</span> or <span class=\'monospace\'>scenario</span>) sets an author\'s note position', true, true);
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'note',
callback: setNoteTextCommand,
unnamedArgumentList: [
new SlashCommandArgument(
'text', [ARGUMENT_TYPE.STRING], true,
),
],
helpString: `
<div>
Sets an author's note for the currently selected chat.
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'depth',
callback: setNoteDepthCommand,
unnamedArgumentList: [
new SlashCommandArgument(
'number', [ARGUMENT_TYPE.NUMBER], true,
),
],
helpString: `
<div>
Sets an author's note depth for in-chat positioning.
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'freq',
callback: setNoteIntervalCommand,
namedArgumentList: [],
unnamedArgumentList: [
new SlashCommandArgument(
'number', [ARGUMENT_TYPE.NUMBER], true,
),
],
helpString: `
<div>
Sets an author's note insertion frequency.
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'pos',
callback: setNotePositionCommand,
namedArgumentList: [],
unnamedArgumentList: [
new SlashCommandArgument(
'position', [ARGUMENT_TYPE.STRING], true, false, null, ['chat', 'scenario'],
),
],
helpString: `
<div>
Sets an author's note position.
</div>
`,
}));
eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
}

View File

@ -1,6 +1,8 @@
import { callPopup, chat_metadata, eventSource, event_types, generateQuietPrompt, getCurrentChatId, getRequestHeaders, getThumbnailUrl, saveSettingsDebounced } from '../script.js';
import { saveMetadataDebounced } from './extensions.js';
import { registerSlashCommand } from './slash-commands.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { stringFormat } from './utils.js';
const BG_METADATA_KEY = 'custom_background';
@ -481,7 +483,26 @@ export function initBackgrounds() {
$('#auto_background').on('click', autoBackgroundCommand);
$('#add_bg_button').on('change', onBackgroundUploadSelected);
$('#bg-filter').on('input', onBackgroundFilterInput);
registerSlashCommand('lockbg', onLockBackgroundClick, ['bglock'], ' locks a background for the currently selected chat', true, true);
registerSlashCommand('unlockbg', onUnlockBackgroundClick, ['bgunlock'], ' unlocks a background for the currently selected chat', true, true);
registerSlashCommand('autobg', autoBackgroundCommand, ['bgauto'], ' automatically changes the background based on the chat context using the AI request prompt', true, true);
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'lockbg',
callback: onLockBackgroundClick,
aliases: ['bglock'],
helpString: 'Locks a background for the currently selected chat',
interruptsGeneration: true,
purgeFromMessage: true,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'unlockbg',
callback: onUnlockBackgroundClick,
aliases: ['bgunlock'],
helpString: 'Unlocks a background for the currently selected chat',
interruptsGeneration: true,
purgeFromMessage: true,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'autobg',
callback: autoBackgroundCommand,
aliases: ['bgauto'],
helpString: 'Automatically changes the background based on the chat context using the AI request prompt',
interruptsGeneration: true,
purgeFromMessage: true,
}));
}

View File

@ -1,9 +1,18 @@
import { renderExtensionTemplateAsync } from '../../extensions.js';
import { registerSlashCommand } from '../../slash-commands.js';
import { SlashCommand } from '../../slash-commands/SlashCommand.js';
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
jQuery(async () => {
const buttons = await renderExtensionTemplateAsync('attachments', 'buttons', {});
$('#extensionsMenu').prepend(buttons);
registerSlashCommand('db', () => document.getElementById('manageAttachments')?.click(), ['databank', 'data-bank'], ' open the data bank', true, true);
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'db',
callback: () => document.getElementById('manageAttachments')?.click(),
aliases: ['databank', 'data-bank'],
helpString: 'Open the data bank',
interruptsGeneration: true,
purgeFromMessage: true,
}));
});

View File

@ -6,6 +6,9 @@ import { SECRET_KEYS, secret_state } from '../../secrets.js';
import { getMultimodalCaption } from '../shared.js';
import { textgen_types, textgenerationwebui_settings } from '../../textgen-settings.js';
import { registerSlashCommand } from '../../slash-commands.js';
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
import { SlashCommand } from '../../slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
export { MODULE_NAME };
const MODULE_NAME = 'caption';
@ -492,5 +495,29 @@ jQuery(function () {
saveSettingsDebounced();
});
registerSlashCommand('caption', captionCommandCallback, [], '<span class="monospace">quiet=true/false [prompt]</span> - caption an image with an optional prompt and passes the caption down the pipe. Only multimodal sources support custom prompts. Set the "quiet" argument to true to suppress sending a captioned message, default: false.', true, true);
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'caption',
callback: captionCommandCallback,
returns: 'caption',
namedArgumentList: [
new SlashCommandNamedArgument(
'quiet', 'suppress sending a captioned message', [ARGUMENT_TYPE.BOOLEAN], false, false, 'false', ['true', 'false'],
),
],
unnamedArgumentList: [
new SlashCommandArgument(
'prompt', [ARGUMENT_TYPE.STRING], false,
),
],
helpString: `
<div>
Caption an image with an optional prompt and passes the caption down the pipe.
</div>
<div>
Only multimodal sources support custom prompts.
</div>
<div>
Set the "quiet" argument to true to suppress sending a captioned message, default: false.
</div>
`,
}));
});

View File

@ -6,6 +6,9 @@ import { registerSlashCommand } from '../../slash-commands.js';
import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence } from '../../utils.js';
import { hideMutedSprites } from '../../group-chats.js';
import { isJsonSchemaSupported } from '../../textgen-settings.js';
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
import { SlashCommand } from '../../slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument } from '../../slash-commands/SlashCommandArgument.js';
export { MODULE_NAME };
const MODULE_NAME = 'expressions';
@ -1966,9 +1969,69 @@ function migrateSettings() {
});
eventSource.on(event_types.MOVABLE_PANELS_RESET, updateVisualNovelModeDebounced);
eventSource.on(event_types.GROUP_UPDATED, updateVisualNovelModeDebounced);
registerSlashCommand('sprite', setSpriteSlashCommand, ['emote'], '<span class="monospace">(spriteId)</span> force sets the sprite for the current character', true, true);
registerSlashCommand('spriteoverride', setSpriteSetCommand, ['costume'], '<span class="monospace">(optional folder)</span> sets an override sprite folder for the current character. If the name starts with a slash or a backslash, selects a sub-folder in the character-named folder. Empty value to reset to default.', true, true);
registerSlashCommand('lastsprite', (_, value) => lastExpression[value.trim()] ?? '', [], '<span class="monospace">(charName)</span> Returns the last set sprite / expression for the named character.', true, true);
registerSlashCommand('th', toggleTalkingHeadCommand, ['talkinghead'], ' Character Expressions: toggles <i>Image Type - talkinghead (extras)</i> on/off.', true, true);
registerSlashCommand('classify', classifyCommand, [], '<span class="monospace">(text)</span> performs an emotion classification of the given text and returns a label.', true, true);
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'sprite',
aliases: ['emote'],
callback: setSpriteSlashCommand,
unnamedArgumentList: [
new SlashCommandArgument(
'spriteId', [ARGUMENT_TYPE.STRING], true,
),
],
helpString: 'Force sets the sprite for the current character.',
interruptsGeneration: true,
purgeFromMessage: true,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'spriteoverride',
aliases: ['costume'],
callback: setSpriteSetCommand,
unnamedArgumentList: [
new SlashCommandArgument(
'optional folder', [ARGUMENT_TYPE.STRING], false,
),
],
helpString: 'Sets an override sprite folder for the current character. If the name starts with a slash or a backslash, selects a sub-folder in the character-named folder. Empty value to reset to default.',
interruptsGeneration: true,
purgeFromMessage: true,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'lastsprite',
callback: (_, value) => lastExpression[value.trim()] ?? '',
returns: 'sprite',
unnamedArgumentList: [
new SlashCommandArgument(
'charName', [ARGUMENT_TYPE.STRING], true,
),
],
helpString: 'Returns the last set sprite / expression for the named character.',
interruptsGeneration: true,
purgeFromMessage: true,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'th',
callback: toggleTalkingHeadCommand,
aliases: ['talkinghead'],
helpString: 'Character Expressions: toggles <i>Image Type - talkinghead (extras)</i> on/off.',
interruptsGeneration: true,
purgeFromMessage: true,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'classify',
callback: classifyCommand,
unnamedArgumentList: [
new SlashCommandArgument(
'text', [ARGUMENT_TYPE.STRING], true,
),
],
returns: 'emotion classification label for the given text',
helpString: `
<div>
Performs an emotion classification of the given text and returns a label.
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code>/classify I am so happy today!</code></pre>
</li>
</ul>
</div>
`,
}));
})();

View File

@ -9,6 +9,9 @@ import { loadFileToDocument, delay } from '../../utils.js';
import { loadMovingUIState } from '../../power-user.js';
import { dragElement } from '../../RossAscends-mods.js';
import { registerSlashCommand } from '../../slash-commands.js';
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
import { SlashCommand } from '../../slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
const extensionName = 'gallery';
const extensionFolderPath = `scripts/extensions/${extensionName}/`;
@ -415,8 +418,30 @@ function viewWithDragbox(items) {
// Registers a simple command for opening the char gallery.
registerSlashCommand('show-gallery', showGalleryCommand, ['sg'], ' shows the gallery', true, true);
registerSlashCommand('list-gallery', listGalleryCommand, ['lg'], '<span class="monospace">[optional char=charName] [optional group=groupName]</span> list images in the gallery of the current char / group or a specified char / group', true, true);
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'show-gallery',
aliases: ['sg'],
callback: showGalleryCommand,
helpString: 'Shows the gallery.',
interruptsGeneration: true,
purgeFromMessage: true,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'list-gallery',
aliases: ['lg'],
callback: listGalleryCommand,
returns: 'list of images',
namedArgumentList: [
new SlashCommandNamedArgument(
'char', 'character name', [ARGUMENT_TYPE.STRING], false,
),
new SlashCommandNamedArgument(
'group', 'group name', [ARGUMENT_TYPE.STRING], false,
),
],
helpString: 'List images in the gallery of the current char / group or a specified char / group.',
interruptsGeneration: true,
purgeFromMessage: true,
}));
function showGalleryCommand(args) {
showCharGallery();

View File

@ -20,6 +20,8 @@ import { registerSlashCommand } from '../../slash-commands.js';
import { loadMovingUIState } from '../../power-user.js';
import { dragElement } from '../../RossAscends-mods.js';
import { getTextTokens, getTokenCountAsync, tokenizers } from '../../tokenizers.js';
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
import { SlashCommand } from '../../slash-commands/SlashCommand.js';
export { MODULE_NAME };
const MODULE_NAME = '1_memory';
@ -864,5 +866,10 @@ jQuery(async function () {
eventSource.on(event_types.MESSAGE_EDITED, onChatEvent);
eventSource.on(event_types.MESSAGE_SWIPED, onChatEvent);
eventSource.on(event_types.CHAT_CHANGED, onChatEvent);
registerSlashCommand('summarize', forceSummarizeChat, [], ' forces the summarization of the current chat using the Main API', true, true);
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'summarize',
callback: forceSummarizeChat,
helpString: 'Forces the summarization of the current chat using the Main API.',
interruptsGeneration: true,
purgeFromMessage: true,
}));
});

View File

@ -183,14 +183,16 @@ const init = async () => {
;
if (!qr) {
let [setName, ...qrName] = name.split('.');
name = qrName.join('.');
qrName = qrName.join('.');
let qrs = QuickReplySet.get(setName);
if (qrs) {
qr = qrs.qrList.find(it=>it.label == name);
qr = qrs.qrList.find(it=>it.label == qrName);
}
}
if (qr && qr.onExecute) {
return await qr.execute(args);
} else {
throw new Error(`No Quick Reply found for "${name}".`);
}
};

View File

@ -1,4 +1,6 @@
import { POPUP_TYPE, Popup } from '../../../popup.js';
import { setSlashCommandAutoComplete } from '../../../slash-commands.js';
import { SlashCommandScope } from '../../../slash-commands/SlashCommandScope.js';
import { getSortableDelay } from '../../../utils.js';
import { log, warn } from '../index.js';
import { QuickReplyContextLink } from './QuickReplyContextLink.js';
@ -253,8 +255,9 @@ export class QuickReply {
message.addEventListener('input', () => {
this.updateMessage(message.value);
});
setSlashCommandAutoComplete(message, true);
//TODO move tab support for textarea into its own helper(?) and use for both this and .editor_maximize
message.addEventListener('keydown', (evt) => {
message.addEventListener('keydown', async(evt) => {
if (evt.key == 'Tab' && !evt.shiftKey && !evt.ctrlKey && !evt.altKey) {
evt.preventDefault();
const start = message.selectionStart;
@ -286,7 +289,15 @@ export class QuickReply {
evt.stopPropagation();
evt.preventDefault();
if (executeShortcut.checked) {
this.executeFromEditor();
const selectionStart = message.selectionStart;
const selectionEnd = message.selectionEnd;
message.blur();
await this.executeFromEditor();
if (document.activeElement != message) {
message.focus();
message.selectionStart = selectionStart;
message.selectionEnd = selectionEnd;
}
}
}
});
@ -528,10 +539,11 @@ export class QuickReply {
async execute(args = {}) {
if (this.message?.length > 0 && this.onExecute) {
const message = this.message.replace(/\{\{arg::([^}]+)\}\}/g, (_, key) => {
return args[key] ?? '';
});
return await this.onExecute(this, message, args.isAutoExecute ?? false);
const scope = new SlashCommandScope();
for (const key of Object.keys(args)) {
scope.setMacro(`arg::${key}`, args[key]);
}
return await this.onExecute(this, this.message, args.isAutoExecute ?? false, scope);
}
}

View File

@ -1,5 +1,6 @@
import { getRequestHeaders, substituteParams } from '../../../../script.js';
import { executeSlashCommands } from '../../../slash-commands.js';
import { SlashCommandScope } from '../../../slash-commands/SlashCommandScope.js';
import { debounceAsync, warn } from '../index.js';
import { QuickReply } from './QuickReply.js';
@ -102,8 +103,9 @@ export class QuickReplySet {
/**
* @param {QuickReply} qr
* @param {String} [message] - optional altered message to be used
* @param {SlashCommandScope} [scope] - optional scope to be used when running the command
*/
async execute(qr, message = null, isAutoExecute = false) {
async execute(qr, message = null, isAutoExecute = false, scope = null) {
/**@type {HTMLTextAreaElement}*/
const ta = document.querySelector('#send_textarea');
const finalMessage = message ?? qr.message;
@ -119,7 +121,7 @@ export class QuickReplySet {
}
if (input[0] == '/' && !this.disableSend) {
const result = await executeSlashCommands(input);
const result = await executeSlashCommands(input, true, scope);
return typeof result === 'object' ? result?.pipe : '';
}

View File

@ -1,4 +1,7 @@
import { registerSlashCommand } from '../../../slash-commands.js';
import { SlashCommand } from '../../../slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../../slash-commands/SlashCommandArgument.js';
import { SlashCommandParser } from '../../../slash-commands/SlashCommandParser.js';
import { isTrueBoolean } from '../../../utils.js';
// eslint-disable-next-line no-unused-vars
import { QuickReplyApi } from '../api/QuickReplyApi.js';
@ -17,46 +20,345 @@ export class SlashCommandHandler {
init() {
registerSlashCommand('qr', (_, value) => this.executeQuickReplyByIndex(Number(value)), [], '<span class="monospace">(number)</span> activates the specified Quick Reply', true, true);
registerSlashCommand('qrset', ()=>toastr.warning('The command /qrset has been deprecated. Use /qr-set, /qr-set-on, and /qr-set-off instead.'), [], '<strong>DEPRECATED</strong> The command /qrset has been deprecated. Use /qr-set, /qr-set-on, and /qr-set-off instead.', true, true);
registerSlashCommand('qr-set', (args, value)=>this.toggleGlobalSet(value, args), [], '<span class="monospace">[visible=true] (number)</span> toggle global QR set', true, true);
registerSlashCommand('qr-set-on', (args, value)=>this.addGlobalSet(value, args), [], '<span class="monospace">[visible=true] (number)</span> activate global QR set', true, true);
registerSlashCommand('qr-set-off', (_, value)=>this.removeGlobalSet(value), [], '<span class="monospace">(number)</span> deactivate global QR set', true, true);
registerSlashCommand('qr-chat-set', (args, value)=>this.toggleChatSet(value, args), [], '<span class="monospace">[visible=true] (number)</span> toggle chat QR set', true, true);
registerSlashCommand('qr-chat-set-on', (args, value)=>this.addChatSet(value, args), [], '<span class="monospace">[visible=true] (number)</span> activate chat QR set', true, true);
registerSlashCommand('qr-chat-set-off', (_, value)=>this.removeChatSet(value), [], '<span class="monospace">(number)</span> deactivate chat QR set', true, true);
registerSlashCommand('qr-set-list', (_, value)=>this.listSets(value ?? 'all'), [], '(all|global|chat) gets a list of the names of all quick reply sets', true, true);
registerSlashCommand('qr-list', (_, value)=>this.listQuickReplies(value), [], '(set name) gets a list of the names of all quick replies in this quick reply set', true, true);
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'qr',
callback: (_, value) => this.executeQuickReplyByIndex(Number(value)),
unnamedArgumentList: [
new SlashCommandArgument(
'number', [ARGUMENT_TYPE.NUMBER], true,
),
],
helpString: 'Activates the specified Quick Reply',
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'qrset',
callback: () => toastr.warning('The command /qrset has been deprecated. Use /qr-set, /qr-set-on, and /qr-set-off instead.'),
helpString: '<strong>DEPRECATED</strong> The command /qrset has been deprecated. Use /qr-set, /qr-set-on, and /qr-set-off instead.',
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'qr-set',
callback: (args, value) => this.toggleGlobalSet(value, args),
namedArgumentList: [
new SlashCommandNamedArgument(
'visible', 'set visibility', [ARGUMENT_TYPE.BOOLEAN], false, false, 'true',
),
],
unnamedArgumentList: [
new SlashCommandArgument(
'QR set name', [ARGUMENT_TYPE.STRING], true,
),
],
helpString: 'Toggle global QR set',
interruptsGeneration: true,
purgeFromMessage: true,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'qr-set-on',
callback: (args, value) => this.addGlobalSet(value, args),
namedArgumentList: [
new SlashCommandNamedArgument(
'visible', 'set visibility', [ARGUMENT_TYPE.BOOLEAN], false, false, 'true',
),
],
unnamedArgumentList: [
new SlashCommandArgument(
'QR set name', [ARGUMENT_TYPE.STRING], true,
),
],
helpString: 'Activate global QR set',
interruptsGeneration: true,
purgeFromMessage: true,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'qr-set-off',
callback: (_, value) => this.removeGlobalSet(value),
unnamedArgumentList: [
new SlashCommandArgument(
'QR set name', [ARGUMENT_TYPE.STRING], true,
),
],
helpString: 'Deactivate global QR set',
interruptsGeneration: true,
purgeFromMessage: true,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'qr-chat-set',
callback: (args, value) => this.toggleChatSet(value, args),
namedArgumentList: [
new SlashCommandNamedArgument(
'visible', 'set visibility', [ARGUMENT_TYPE.BOOLEAN], false, false, 'true',
),
],
unnamedArgumentList: [
new SlashCommandArgument(
'QR set name', [ARGUMENT_TYPE.STRING], true,
),
],
helpString: 'Toggle chat QR set',
interruptsGeneration: true,
purgeFromMessage: true,
}));
const qrArgs = `
label - string - text on the button, e.g., label=MyButton
set - string - name of the QR set, e.g., set=PresetName1
hidden - bool - whether the button should be hidden, e.g., hidden=true
startup - bool - auto execute on app startup, e.g., startup=true
user - bool - auto execute on user message, e.g., user=true
bot - bool - auto execute on AI message, e.g., bot=true
load - bool - auto execute on chat load, e.g., load=true
group - bool - auto execute on group member selection, e.g., group=true
title - string - title / tooltip to be shown on button, e.g., title="My Fancy Button"
`.trim();
const qrUpdateArgs = `
newlabel - string - new text for the button, e.g. newlabel=MyRenamedButton
${qrArgs}
`.trim();
registerSlashCommand('qr-create', (args, message)=>this.createQuickReply(args, message), [], `<span class="monospace" style="white-space:pre-line;">[arguments] (message)\n arguments:\n ${qrArgs}</span> creates a new Quick Reply, example: <tt>/qr-create set=MyPreset label=MyButton /echo 123</tt>`, true, true);
registerSlashCommand('qr-update', (args, message)=>this.updateQuickReply(args, message), [], `<span class="monospace" style="white-space:pre-line;">[arguments] (message)\n arguments:\n ${qrUpdateArgs}</span> updates Quick Reply, example: <tt>/qr-update set=MyPreset label=MyButton newlabel=MyRenamedButton /echo 123</tt>`, true, true);
registerSlashCommand('qr-delete', (args, name)=>this.deleteQuickReply(args, name), [], '<span class="monospace">set=string [label]</span> deletes Quick Reply', true, true);
registerSlashCommand('qr-contextadd', (args, name)=>this.createContextItem(args, name), [], '<span class="monospace">set=string label=string [chain=false] (preset name)</span> add context menu preset to a QR, example: <tt>/qr-contextadd set=MyPreset label=MyButton chain=true MyOtherPreset</tt>', true, true);
registerSlashCommand('qr-contextdel', (args, name)=>this.deleteContextItem(args, name), [], '<span class="monospace">set=string label=string (preset name)</span> remove context menu preset from a QR, example: <tt>/qr-contextdel set=MyPreset label=MyButton MyOtherPreset</tt>', true, true);
registerSlashCommand('qr-contextclear', (args, label)=>this.clearContextMenu(args, label), [], '<span class="monospace">set=string (label)</span> remove all context menu presets from a QR, example: <tt>/qr-contextclear set=MyPreset MyButton</tt>', true, true);
const presetArgs = `
nosend - bool - disable send / insert in user input (invalid for slash commands)
before - bool - place QR before user input
inject - bool - inject user input automatically (if disabled use {{input}})
`.trim();
registerSlashCommand('qr-set-create', (args, name)=>this.createSet(name, args), ['qr-presetadd'], `<span class="monospace" style="white-space:pre-line;">[arguments] (name)\n arguments:\n ${presetArgs}</span> create a new preset (overrides existing ones), example: <tt>/qr-set-add MyNewPreset</tt>`, true, true);
registerSlashCommand('qr-set-update', (args, name)=>this.updateSet(name, args), ['qr-presetupdate'], `<span class="monospace" style="white-space:pre-line;">[arguments] (name)\n arguments:\n ${presetArgs}</span> update an existing preset, example: <tt>/qr-set-update enabled=false MyPreset</tt>`, true, true);
registerSlashCommand('qr-set-delete', (args, name)=>this.deleteSet(name), ['qr-presetdelete'], `<span class="monospace" style="white-space:pre-line;">(name)\n arguments:\n ${presetArgs}</span> delete an existing preset, example: <tt>/qr-set-delete MyPreset</tt>`, true, true);
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'qr-chat-set-on',
callback: (args, value) => this.addChatSet(value, args),
namedArgumentList: [
new SlashCommandNamedArgument(
'visible', 'whether the QR set should be visible', [ARGUMENT_TYPE.BOOLEAN], false, false, 'true', ['true', 'false'],
),
],
unnamedArgumentList: [
new SlashCommandArgument(
'QR set name', [ARGUMENT_TYPE.STRING], true,
),
],
helpString: 'Activate chat QR set',
interruptsGeneration: true,
purgeFromMessage: true,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'qr-chat-set-off',
callback: (_, value) => this.removeChatSet(value),
unnamedArgumentList: [
new SlashCommandArgument(
'QR set name', [ARGUMENT_TYPE.STRING], true,
),
],
helpString: 'Deactivate chat QR set',
interruptsGeneration: true,
purgeFromMessage: true,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'qr-set-list',
callback: (_, value) => this.listSets(value ?? 'all'),
returns: 'list of QR sets',
namedArgumentList: [],
unnamedArgumentList: [
new SlashCommandArgument(
'set type', [ARGUMENT_TYPE.STRING], false, false, null, ['all', 'global', 'chat'],
),
],
helpString: 'Gets a list of the names of all quick reply sets.',
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'qr-list',
callback: (_, value) => this.listQuickReplies(value),
returns: 'list of QRs',
namedArgumentList: [],
unnamedArgumentList: [
new SlashCommandArgument(
'set name', [ARGUMENT_TYPE.STRING], true,
),
],
helpString: 'Gets a list of the names of all quick replies in this quick reply set.',
}));
const qrArgs = [
new SlashCommandNamedArgument('label', 'text on the button, e.g., label=MyButton', [ARGUMENT_TYPE.STRING]),
new SlashCommandNamedArgument('set', 'name of the QR set, e.g., set=PresetName1', [ARGUMENT_TYPE.STRING]),
new SlashCommandNamedArgument('hidden', 'whether the button should be hidden, e.g., hidden=true', [ARGUMENT_TYPE.BOOLEAN], false, false, 'false'),
new SlashCommandNamedArgument('startup', 'auto execute on app startup, e.g., startup=true', [ARGUMENT_TYPE.BOOLEAN], false, false, 'false'),
new SlashCommandNamedArgument('user', 'auto execute on user message, e.g., user=true', [ARGUMENT_TYPE.BOOLEAN], false, false, 'false'),
new SlashCommandNamedArgument('bot', 'auto execute on AI message, e.g., bot=true', [ARGUMENT_TYPE.BOOLEAN], false, false, 'false'),
new SlashCommandNamedArgument('load', 'auto execute on chat load, e.g., load=true', [ARGUMENT_TYPE.BOOLEAN], false, false, 'false'),
new SlashCommandNamedArgument('group', 'auto execute on group member selection, e.g., group=true', [ARGUMENT_TYPE.BOOLEAN], false, false, 'false'),
new SlashCommandNamedArgument('title', 'title / tooltip to be shown on button, e.g., title="My Fancy Button"', [ARGUMENT_TYPE.STRING], false),
];
const qrUpdateArgs = [
new SlashCommandNamedArgument('newlabel', 'new text for the button', [ARGUMENT_TYPE.STRING], false),
];
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'qr-create',
callback: (args, message) => this.createQuickReply(args, message),
namedArgumentList: qrArgs,
unnamedArgumentList: [
new SlashCommandArgument(
'command', [ARGUMENT_TYPE.STRING], true,
),
],
helpString: `
<div>Creates a new Quick Reply.</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code>/qr-create set=MyPreset label=MyButton /echo 123</code></pre>
</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'qr-update',
callback: (args, message) => this.updateQuickReply(args, message),
returns: 'updated quick reply',
namedArgumentList: [...qrUpdateArgs, ...qrArgs],
helpString: `
<div>
Updates Quick Reply.
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code>/qr-update set=MyPreset label=MyButton newlabel=MyRenamedButton /echo 123</code></pre>
</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'qr-delete',
callback: (args, name) => this.deleteQuickReply(args, name),
namedArgumentList: [
new SlashCommandNamedArgument(
'set', 'Quick Reply set', [ARGUMENT_TYPE.STRING], true,
),
new SlashCommandNamedArgument(
'label', 'Quick Reply label', [ARGUMENT_TYPE.STRING], false,
),
],
helpString: 'Deletes a Quick Reply from the specified set. If no label is provided, the entire set is deleted.',
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'qr-contextadd',
callback: (args, name) => this.createContextItem(args, name),
namedArgumentList: [
new SlashCommandNamedArgument(
'set', 'string', [ARGUMENT_TYPE.STRING], true,
),
new SlashCommandNamedArgument(
'label', 'string', [ARGUMENT_TYPE.STRING], true,
),
new SlashCommandNamedArgument(
'chain', 'boolean', [ARGUMENT_TYPE.BOOLEAN], false, false, 'false',
),
],
unnamedArgumentList: [
new SlashCommandArgument(
'preset name', [ARGUMENT_TYPE.STRING], true,
),
],
helpString: `
<div>
Add context menu preset to a QR.
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code>/qr-contextadd set=MyPreset label=MyButton chain=true MyOtherPreset</code></pre>
</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'qr-contextdel',
callback: (args, name) => this.deleteContextItem(args, name),
namedArgumentList: [
new SlashCommandNamedArgument(
'set', 'string', [ARGUMENT_TYPE.STRING], true,
),
new SlashCommandNamedArgument(
'label', 'string', [ARGUMENT_TYPE.STRING], true,
),
],
unnamedArgumentList: [
new SlashCommandArgument(
'preset name', [ARGUMENT_TYPE.STRING], true,
),
],
helpString: `
<div>
Remove context menu preset from a QR.
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code>/qr-contextdel set=MyPreset label=MyButton MyOtherPreset</code></pre>
</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'qr-contextclear',
callback: (args, label) => this.clearContextMenu(args, label),
namedArgumentList: [
new SlashCommandNamedArgument(
'set', 'context menu preset name', [ARGUMENT_TYPE.STRING], true,
),
],
unnamedArgumentList: [
new SlashCommandArgument(
'label', [ARGUMENT_TYPE.STRING], true,
),
],
helpString: `
<div>
Remove all context menu presets from a QR.
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code>/qr-contextclear set=MyPreset MyButton</code></pre>
</li>
</ul>
</div>
`,
purgeFromMessage: true,
interruptsGeneration: true,
}));
const presetArgs = [
new SlashCommandNamedArgument('nosend', 'disable send / insert in user input (invalid for slash commands)', [ARGUMENT_TYPE.BOOLEAN], false),
new SlashCommandNamedArgument('before', 'place QR before user input', [ARGUMENT_TYPE.BOOLEAN], false),
new SlashCommandNamedArgument('inject', 'inject user input automatically (if disabled use {{input}})', [ARGUMENT_TYPE.BOOLEAN], false),
];
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'qr-set-create',
callback: (args, name) => this.createSet(name, args),
aliases: ['qr-presetadd'],
namedArgumentList: presetArgs,
unnamedArgumentList: [
new SlashCommandArgument(
'name', [ARGUMENT_TYPE.STRING], true,
),
],
helpString: `
<div>
Create a new preset (overrides existing ones).
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code>/qr-set-add MyNewPreset</code></pre>
</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'qr-set-update',
callback: (args, name) => this.updateSet(name, args),
aliases: ['qr-presetupdate'],
namedArgumentList: presetArgs,
unnamedArgumentList: [
new SlashCommandArgument('name', [ARGUMENT_TYPE.STRING], true),
],
helpString: `
<div>
Update an existing preset.
</div>
<div>
<strong>Example:</strong>
<pre><code>/qr-set-update enabled=false MyPreset</code></pre>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'qr-set-delete',
callback: (args, name) => this.deleteSet(name),
aliases: ['qr-presetdelete'],
unnamedArgumentList: [
new SlashCommandArgument('name', [ARGUMENT_TYPE.STRING], true),
],
helpString: `
<div>
Delete an existing preset.
</div>
<div>
<strong>Example:</strong>
<pre><code>/qr-set-delete MyPreset</code></pre>
</div>
`,
}));
}

View File

@ -1,6 +1,9 @@
import { callPopup, getCurrentChatId, reloadCurrentChat, saveSettingsDebounced } from '../../../script.js';
import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js';
import { registerSlashCommand } from '../../slash-commands.js';
import { SlashCommand } from '../../slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
import { download, getFileText, getSortableDelay, uuidv4 } from '../../utils.js';
import { resolveVariable } from '../../variables.js';
import { regex_placement, runRegexScript } from './engine.js';
@ -353,5 +356,20 @@ jQuery(async () => {
await loadRegexScripts();
$('#saved_regex_scripts').sortable('enable');
registerSlashCommand('regex', runRegexCallback, [], '(name=scriptName [input]) runs a Regex extension script by name on the provided string. The script must be enabled.', true, true);
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'regex',
callback: runRegexCallback,
returns: 'replaced text',
namedArgumentList: [
new SlashCommandNamedArgument(
'name', 'script name', [ARGUMENT_TYPE.STRING], true,
),
],
unnamedArgumentList: [
new SlashCommandArgument(
'input', [ARGUMENT_TYPE.STRING], false,
),
],
helpString: 'Runs a Regex extension script by name on the provided string. The script must be enabled.',
}));
});

View File

@ -26,6 +26,9 @@ import { SECRET_KEYS, secret_state } from '../../secrets.js';
import { getNovelUnlimitedImageGeneration, getNovelAnlas, loadNovelSubscriptionData } from '../../nai-settings.js';
import { getMultimodalCaption } from '../shared.js';
import { registerSlashCommand } from '../../slash-commands.js';
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
import { SlashCommand } from '../../slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
export { MODULE_NAME };
// Wraps a string into monospace font-face span
@ -3025,8 +3028,42 @@ $('#sd_dropdown [id]').on('click', function () {
});
jQuery(async () => {
registerSlashCommand('imagine', generatePicture, ['sd', 'img', 'image'], helpString, true, true);
registerSlashCommand('imagine-comfy-workflow', changeComfyWorkflow, ['icw'], '(workflowName) - change the workflow to be used for image generation with ComfyUI, e.g. <tt>/imagine-comfy-workflow MyWorkflow</tt>');
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'imagine',
callback: generatePicture,
aliases: ['sd', 'img', 'image'],
namedArgumentList: [
new SlashCommandNamedArgument(
'quiet', 'whether to post the generated image to chat', [ARGUMENT_TYPE.BOOLEAN], false, false, 'false', ['false', 'true'],
),
],
unnamedArgumentList: [
new SlashCommandArgument(
'argument', [ARGUMENT_TYPE.STRING], false, false, null, Object.values(triggerWords).flat(),
),
],
interruptsGeneration: true,
purgeFromMessage: true,
helpString: `
<div>
Requests to generate an image and posts it to chat (unless quiet=true argument is specified). Supported arguments: <code>${Object.values(triggerWords).flat().join(', ')}</code>.
</div>
<div>
Anything else would trigger a "free mode" to make generate whatever you prompted. Example: <code>/imagine apple tree</code> would generate a picture of an apple tree. Returns a link to the generated image.
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'imagine-comfy-workflow',
callback: changeComfyWorkflow,
aliases: ['icw'],
unnamedArgumentList: [
new SlashCommandArgument(
'workflowName', [ARGUMENT_TYPE.STRING], true,
),
],
helpString: '(workflowName) - change the workflow to be used for image generation with ComfyUI, e.g. <pre><code>/imagine-comfy-workflow MyWorkflow</code></pre>',
}));
const template = await renderExtensionTemplateAsync('stable-diffusion', 'settings', defaultSettings);
$('#extensions_settings').append(template);

View File

@ -1,6 +1,8 @@
import { callPopup, main_api } from '../../../script.js';
import { getContext } from '../../extensions.js';
import { registerSlashCommand } from '../../slash-commands.js';
import { SlashCommand } from '../../slash-commands/SlashCommand.js';
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
import { getFriendlyTokenizerName, getTextTokens, getTokenCountAsync, tokenizers } from '../../tokenizers.js';
import { resetScrollHeight, debounce } from '../../utils.js';
@ -131,5 +133,12 @@ jQuery(() => {
</div>`;
$('#extensionsMenu').prepend(buttonHtml);
$('#token_counter').on('click', doTokenCounter);
registerSlashCommand('count', doCount, [], ' counts the number of tokens in the current chat', true, false);
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'count',
callback: doCount,
returns: 'number of tokens',
helpString: 'Counts the number of tokens in the current chat.',
interruptsGeneration: true,
purgeFromMessage: false,
}));
});

View File

@ -13,6 +13,9 @@ import { OpenAITtsProvider } from './openai.js';
import { XTTSTtsProvider } from './xtts.js';
import { AllTalkTtsProvider } from './alltalk.js';
import { SpeechT5TtsProvider } from './speecht5.js';
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
import { SlashCommand } from '../../slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
export { talkingAnimation };
const UPDATE_INTERVAL = 1000;
@ -1063,6 +1066,36 @@ $(document).ready(function () {
eventSource.on(event_types.GROUP_UPDATED, onChatChanged);
eventSource.on(event_types.MESSAGE_SENT, onMessageEvent);
eventSource.on(event_types.MESSAGE_RECEIVED, onMessageEvent);
registerSlashCommand('speak', onNarrateText, ['narrate', 'tts'], '<span class="monospace">(text)</span> narrate any text using currently selected character\'s voice. Use voice="Character Name" argument to set other voice from the voice map, example: <tt>/speak voice="Donald Duck" Quack!</tt>', true, true);
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'speak',
callback: onNarrateText,
aliases: ['narrate', 'tts'],
namedArgumentList: [
new SlashCommandNamedArgument(
'voice', 'character voice name', [ARGUMENT_TYPE.STRING], false,
),
],
unnamedArgumentList: [
new SlashCommandArgument(
'text', [ARGUMENT_TYPE.STRING], true,
),
],
helpString: `
<div>
Narrate any text using currently selected character's voice.
</div>
<div>
Use <code>voice="Character Name"</code> argument to set other voice from the voice map.
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code>/speak voice="Donald Duck" Quack!</code></pre>
</li>
</ul>
</div>
`,
}));
document.body.appendChild(audioElement);
});

View File

@ -804,7 +804,7 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) {
const bias = getBiasStrings(userInput, type);
await sendMessageAsUser(userInput, bias.messageBias);
await saveChatConditional();
$('#send_textarea').val('').trigger('input');
$('#send_textarea').val('')[0].dispatchEvent(new Event('input', { bubbles:true }));
}
// now the real generation begins: cycle through every activated character

View File

@ -66,6 +66,9 @@ import {
} from './instruct-mode.js';
import { isMobile } from './RossAscends-mods.js';
import { saveLogprobsForActiveMessage } from './logprobs.js';
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument } from './slash-commands/SlashCommandArgument.js';
export {
openai_messages_count,
@ -4294,7 +4297,18 @@ function runProxyCallback(_, value) {
return foundName;
}
registerSlashCommand('proxy', runProxyCallback, [], '<span class="monospace">(name)</span> sets a proxy preset by name');
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'proxy',
callback: runProxyCallback,
returns: 'current proxy',
namedArgumentList: [],
unnamedArgumentList: [
new SlashCommandArgument(
'name', [ARGUMENT_TYPE.STRING], true,
),
],
helpString: 'Sets a proxy preset by name.',
}));
$(document).ready(async function () {
$('#test_api_button').on('click', testApiConnection);

View File

@ -41,6 +41,9 @@ import { BIAS_CACHE } from './logit-bias.js';
import { renderTemplateAsync } from './templates.js';
import { countOccurrences, debounce, delay, download, getFileText, isOdd, resetScrollHeight, shuffle, sortMoments, stringToRange, timestampToMoment } from './utils.js';
import { PARSER_FLAG, SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument } from './slash-commands/SlashCommandArgument.js';
export {
loadPowerUserSettings,
@ -251,6 +254,14 @@ let power_user = {
zoomed_avatar_magnification: false,
show_tag_filters: false,
aux_field: 'character_version',
stscript: {
matching: 'fuzzy',
autocomplete_style: 'theme',
parser: {
/**@type {Object.<PARSER_FLAG,boolean>} */
flags: {},
},
},
restore_user_input: true,
reduced_motion: false,
compact_input_area: true,
@ -1428,11 +1439,20 @@ function getExampleMessagesBehavior() {
}
function loadPowerUserSettings(settings, data) {
const defaultStscript = JSON.parse(JSON.stringify(power_user.stscript));
// Load from settings.json
if (settings.power_user !== undefined) {
Object.assign(power_user, settings.power_user);
}
if (power_user.stscript === undefined) {
power_user.stscript = defaultStscript;
} else if (power_user.stscript.parser === undefined) {
power_user.stscript.parser = defaultStscript.parser;
} else if (power_user.stscript.parser.flags === undefined) {
power_user.stscript.parser.flags = defaultStscript.parser.flags;
}
if (data.themes !== undefined) {
themes = data.themes;
}
@ -1574,6 +1594,13 @@ function loadPowerUserSettings(settings, data) {
$('#chat_width_slider').val(power_user.chat_width);
$('#token_padding').val(power_user.token_padding);
$('#aux_field').val(power_user.aux_field);
$('#stscript_matching').val(power_user.stscript.matching ?? 'fuzzy');
$('#stscript_autocomplete_style').val(power_user.stscript.autocomplete_style ?? 'theme');
document.body.setAttribute('data-stscript-style', power_user.stscript.autocomplete_style);
$('#stscript_parser_flag_strict_escaping').prop('checked', power_user.stscript.parser.flags[PARSER_FLAG.STRICT_ESCAPING] ?? false);
$('#stscript_parser_flag_replace_getvar').prop('checked', power_user.stscript.parser.flags[PARSER_FLAG.REPLACE_GETVAR] ?? false);
$('#restore_user_input').prop('checked', power_user.restore_user_input);
$('#chat_truncation').val(power_user.chat_truncation);
@ -3530,6 +3557,31 @@ $(document).ready(() => {
saveSettingsDebounced();
});
$('#stscript_matching').on('change', function () {
const value = $(this).find(':selected').val();
power_user.stscript.matching = String(value);
saveSettingsDebounced();
});
$('#stscript_autocomplete_style').on('change', function () {
const value = $(this).find(':selected').val();
power_user.stscript.autocomplete_style = String(value);
document.body.setAttribute('data-stscript-style', power_user.stscript.autocomplete_style);
saveSettingsDebounced();
});
$('#stscript_parser_flag_strict_escaping').on('click', function () {
const value = $(this).prop('checked');
power_user.stscript.parser.flags[PARSER_FLAG.STRICT_ESCAPING] = value;
saveSettingsDebounced();
});
$('#stscript_parser_flag_replace_getvar').on('click', function () {
const value = $(this).prop('checked');
power_user.stscript.parser.flags[PARSER_FLAG.REPLACE_GETVAR] = value;
saveSettingsDebounced();
});
$('#restore_user_input').on('input', function () {
power_user.restore_user_input = !!$(this).prop('checked');
saveSettingsDebounced();
@ -3608,13 +3660,96 @@ $(document).ready(() => {
browser_has_focus = false;
});
registerSlashCommand('vn', toggleWaifu, [], ' swaps Visual Novel Mode On/Off', false, true);
registerSlashCommand('newchat', doNewChat, [], ' start a new chat with current character', true, true);
registerSlashCommand('random', doRandomChat, [], '<span class="monospace">(optional tag name)</span> start a new chat with a random character. If an argument is provided, only considers characters that have the specified tag.', true, true);
registerSlashCommand('delmode', doDelMode, ['del'], '<span class="monospace">(optional number)</span> enter message deletion mode, and auto-deletes last N messages if numeric argument is provided', true, true);
registerSlashCommand('cut', doMesCut, [], '<span class="monospace">(number or range)</span> cuts the specified message or continuous chunk from the chat, e.g. <tt>/cut 0-10</tt>. Ranges are inclusive! Returns the text of cut messages separated by a newline.', true, true);
registerSlashCommand('resetpanels', doResetPanels, ['resetui'], ' resets UI panels to original state.', true, true);
registerSlashCommand('bgcol', setAvgBG, [], ' WIP test of auto-bg avg coloring', true, true);
registerSlashCommand('theme', setThemeCallback, [], '<span class="monospace">(name)</span> sets a UI theme by name', true, true);
registerSlashCommand('movingui', setmovingUIPreset, [], '<span class="monospace">(name)</span> activates a movingUI preset by name', true, true);
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'vn',
callback: toggleWaifu,
helpString: 'Swaps Visual Novel Mode On/Off',
interruptsGeneration: false,
purgeFromMessage: true,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'newchat',
callback: doNewChat,
helpString: 'Start a new chat with the current character',
interruptsGeneration: true,
purgeFromMessage: true,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'random',
callback: doRandomChat,
interruptsGeneration: true,
purgeFromMessage: true,
unnamedArgumentList: [
new SlashCommandArgument(
'optional tag name', [ARGUMENT_TYPE.STRING], false,
),
],
helpString: 'Start a new chat with a random character. If an argument is provided, only considers characters that have the specified tag.',
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'delmode',
callback: doDelMode,
aliases: ['del'],
interruptsGeneration: true,
purgeFromMessage: true,
unnamedArgumentList: [
new SlashCommandArgument(
'optional number', [ARGUMENT_TYPE.NUMBER], false,
),
],
helpString: 'Enter message deletion mode, and auto-deletes last N messages if numeric argument is provided.',
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'cut',
callback: doMesCut,
returns: 'the text of cut messages separated by a newline',
unnamedArgumentList: [
new SlashCommandArgument(
'number or range', [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.RANGE], true,
),
],
helpString: `
<div>
Cuts the specified message or continuous chunk from the chat.
</div>
<div>
Ranges are inclusive!
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code>/cut 0-10</code></pre>
</li>
</ul>
</div>
`,
aliases: [],
interruptsGeneration: true,
purgeFromMessage: true,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'resetpanels',
callback: doResetPanels,
helpString: 'resets UI panels to original state',
aliases: ['resetui'],
interruptsGeneration: true,
purgeFromMessage: true,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'bgcol',
callback: setAvgBG,
helpString: ' WIP test of auto-bg avg coloring',
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'theme',
callback: setThemeCallback,
unnamedArgumentList: [
new SlashCommandArgument(
'name', [ARGUMENT_TYPE.STRING], true,
),
],
helpString: 'sets a UI theme by name',
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'movingui',
callback: setmovingUIPreset,
unnamedArgumentList: [
new SlashCommandArgument(
'name', [ARGUMENT_TYPE.STRING], true,
),
],
helpString: 'activates a movingUI preset by name',
}));
});

View File

@ -21,6 +21,9 @@ import { instruct_presets } from './instruct-mode.js';
import { kai_settings } from './kai-settings.js';
import { context_presets, getContextSettings, power_user } from './power-user.js';
import { registerSlashCommand } from './slash-commands.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument } from './slash-commands/SlashCommandArgument.js';
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import {
textgenerationwebui_preset_names,
textgenerationwebui_presets,
@ -470,7 +473,33 @@ async function waitForConnection() {
export async function initPresetManager() {
eventSource.on(event_types.CHAT_CHANGED, autoSelectPreset);
registerPresetManagers();
registerSlashCommand('preset', presetCommandCallback, [], '<span class="monospace">(name)</span> sets a preset by name for the current API. Gets the current preset if no name is provided', true, true);
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'preset',
callback: presetCommandCallback,
returns: 'current preset',
namedArgumentList: [],
unnamedArgumentList: [
new SlashCommandArgument(
'name', [ARGUMENT_TYPE.STRING], false,
),
],
helpString: `
<div>
Sets a preset by name for the current API. Gets the current preset if no name is provided.
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code>/preset myPreset</code></pre>
</li>
<li>
<pre><code>/preset</code></pre>
</li>
</ul>
</div>
`,
}));
$(document).on('click', '[data-preset-manager-update]', async function () {
const apiId = $(this).data('preset-manager-update');

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,359 @@
import { SlashCommandArgument, SlashCommandNamedArgument } from './SlashCommandArgument.js';
import { SlashCommandClosure } from './SlashCommandClosure.js';
export class SlashCommand {
/**
* Creates a SlashCommand from a properties object.
* @param {Object} props
* @param {string} [props.name]
* @param {(namedArguments:Object<string, string|SlashCommandClosure>, unnamedArguments:string|SlashCommandClosure|(string|SlashCommandClosure)[])=>string|SlashCommandClosure|Promise<string|SlashCommandClosure>} [props.callback]
* @param {string} [props.helpString]
* @param {boolean} [props.interruptsGeneration]
* @param {boolean} [props.purgeFromMessage]
* @param {string[]} [props.aliases]
* @param {string} [props.returns]
* @param {SlashCommandNamedArgument[]} [props.namedArgumentList]
* @param {SlashCommandArgument[]} [props.unnamedArgumentList]
*/
static fromProps(props) {
const instance = Object.assign(new this(), props);
return instance;
}
/**@type {string}*/ name;
/**@type {(namedArguments:Object<string, string|SlashCommandClosure>, unnamedArguments:string|SlashCommandClosure|(string|SlashCommandClosure)[])=>string|SlashCommandClosure|Promise<string|SlashCommandClosure>}*/ callback;
/**@type {string}*/ helpString;
/**@type {boolean}*/ interruptsGeneration = true;
/**@type {boolean}*/ purgeFromMessage = true;
/**@type {string[]}*/ aliases = [];
/**@type {string}*/ returns;
/**@type {SlashCommandNamedArgument[]}*/ namedArgumentList = [];
/**@type {SlashCommandArgument[]}*/ unnamedArgumentList = [];
/**@type {Object.<string, HTMLElement>}*/ helpCache = {};
/**@type {Object.<string, DocumentFragment>}*/ helpDetailsCache = {};
renderHelpItem(key = null) {
key = key ?? this.name;
if (!this.helpCache[key]) {
const typeIcon = '/';
const li = document.createElement('li'); {
li.classList.add('item');
const type = document.createElement('span'); {
type.classList.add('type');
type.classList.add('monospace');
type.textContent = typeIcon;
li.append(type);
}
const specs = document.createElement('span'); {
specs.classList.add('specs');
const name = document.createElement('span'); {
name.classList.add('name');
name.classList.add('monospace');
name.textContent = '/';
key.split('').forEach(char=>{
const span = document.createElement('span'); {
span.textContent = char;
name.append(span);
}
});
specs.append(name);
}
const body = document.createElement('span'); {
body.classList.add('body');
const args = document.createElement('span'); {
args.classList.add('arguments');
for (const arg of this.namedArgumentList) {
const argItem = document.createElement('span'); {
argItem.classList.add('argument');
argItem.classList.add('namedArgument');
if (!arg.isRequired || (arg.defaultValue ?? false)) argItem.classList.add('optional');
if (arg.acceptsMultiple) argItem.classList.add('multiple');
const name = document.createElement('span'); {
name.classList.add('argument-name');
name.textContent = arg.name;
argItem.append(name);
}
if (arg.enumList.length > 0) {
const enums = document.createElement('span'); {
enums.classList.add('argument-enums');
for (const e of arg.enumList) {
const enumItem = document.createElement('span'); {
enumItem.classList.add('argument-enum');
enumItem.textContent = e;
enums.append(enumItem);
}
}
argItem.append(enums);
}
} else {
const types = document.createElement('span'); {
types.classList.add('argument-types');
for (const t of arg.typeList) {
const type = document.createElement('span'); {
type.classList.add('argument-type');
type.textContent = t;
types.append(type);
}
}
argItem.append(types);
}
}
args.append(argItem);
}
}
for (const arg of this.unnamedArgumentList) {
const argItem = document.createElement('span'); {
argItem.classList.add('argument');
argItem.classList.add('unnamedArgument');
if (!arg.isRequired || (arg.defaultValue ?? false)) argItem.classList.add('optional');
if (arg.acceptsMultiple) argItem.classList.add('multiple');
if (arg.enumList.length > 0) {
const enums = document.createElement('span'); {
enums.classList.add('argument-enums');
for (const e of arg.enumList) {
const enumItem = document.createElement('span'); {
enumItem.classList.add('argument-enum');
enumItem.textContent = e;
enums.append(enumItem);
}
}
argItem.append(enums);
}
} else {
const types = document.createElement('span'); {
types.classList.add('argument-types');
for (const t of arg.typeList) {
const type = document.createElement('span'); {
type.classList.add('argument-type');
type.textContent = t;
types.append(type);
}
}
argItem.append(types);
}
}
args.append(argItem);
}
}
body.append(args);
}
const returns = document.createElement('span'); {
returns.classList.add('returns');
returns.textContent = this.returns ?? 'void';
body.append(returns);
}
specs.append(body);
}
li.append(specs);
}
const help = document.createElement('span'); {
help.classList.add('help');
const content = document.createElement('span'); {
content.classList.add('helpContent');
content.innerHTML = this.helpString;
const text = content.textContent;
content.innerHTML = '';
content.textContent = text;
help.append(content);
}
li.append(help);
}
if (this.aliases.length > 0) {
const aliases = document.createElement('span'); {
aliases.classList.add('aliases');
aliases.append(' (alias: ');
for (const aliasName of this.aliases) {
const alias = document.createElement('span'); {
alias.classList.add('monospace');
alias.textContent = `/${aliasName}`;
aliases.append(alias);
}
}
aliases.append(')');
// li.append(aliases);
}
}
}
this.helpCache[key] = li;
}
return this.helpCache[key];
}
renderHelpDetails(key = null) {
key = key ?? this.name;
if (!this.helpDetailsCache[key]) {
const frag = document.createDocumentFragment();
const cmd = this;
const namedArguments = cmd.namedArgumentList ?? [];
const unnamedArguments = cmd.unnamedArgumentList ?? [];
const returnType = cmd.returns ?? 'void';
const helpString = cmd.helpString ?? 'NO DETAILS';
const aliasList = [cmd.name, ...(cmd.aliases ?? [])].filter(it=>it != key);
const specs = document.createElement('div'); {
specs.classList.add('specs');
const name = document.createElement('div'); {
name.classList.add('name');
name.classList.add('monospace');
name.title = 'command name';
name.textContent = `/${key}`;
specs.append(name);
}
const body = document.createElement('div'); {
body.classList.add('body');
const args = document.createElement('ul'); {
args.classList.add('arguments');
for (const arg of namedArguments) {
const listItem = document.createElement('li'); {
listItem.classList.add('argumentItem');
const argSpec = document.createElement('div'); {
argSpec.classList.add('argumentSpec');
const argItem = document.createElement('div'); {
argItem.classList.add('argument');
argItem.classList.add('namedArgument');
argItem.title = `${arg.isRequired ? '' : 'optional '}named argument`;
if (!arg.isRequired || (arg.defaultValue ?? false)) argItem.classList.add('optional');
if (arg.acceptsMultiple) argItem.classList.add('multiple');
const name = document.createElement('span'); {
name.classList.add('argument-name');
name.title = `${argItem.title} - name`;
name.textContent = arg.name;
argItem.append(name);
}
if (arg.enumList.length > 0) {
const enums = document.createElement('span'); {
enums.classList.add('argument-enums');
enums.title = `${argItem.title} - accepted values`;
for (const e of arg.enumList) {
const enumItem = document.createElement('span'); {
enumItem.classList.add('argument-enum');
enumItem.textContent = e;
enums.append(enumItem);
}
}
argItem.append(enums);
}
} else {
const types = document.createElement('span'); {
types.classList.add('argument-types');
types.title = `${argItem.title} - accepted types`;
for (const t of arg.typeList) {
const type = document.createElement('span'); {
type.classList.add('argument-type');
type.textContent = t;
types.append(type);
}
}
argItem.append(types);
}
}
argSpec.append(argItem);
}
if (arg.defaultValue !== null) {
const argDefault = document.createElement('div'); {
argDefault.classList.add('argument-default');
argDefault.title = 'default value';
argDefault.textContent = arg.defaultValue.toString();
argSpec.append(argDefault);
}
}
listItem.append(argSpec);
}
const desc = document.createElement('div'); {
desc.classList.add('argument-description');
desc.innerHTML = arg.description;
listItem.append(desc);
}
args.append(listItem);
}
}
for (const arg of unnamedArguments) {
const listItem = document.createElement('li'); {
listItem.classList.add('argumentItem');
const argItem = document.createElement('div'); {
argItem.classList.add('argument');
argItem.classList.add('unnamedArgument');
argItem.title = `${arg.isRequired ? '' : 'optional '}unnamed argument`;
if (!arg.isRequired || (arg.defaultValue ?? false)) argItem.classList.add('optional');
if (arg.acceptsMultiple) argItem.classList.add('multiple');
if (arg.enumList.length > 0) {
const enums = document.createElement('span'); {
enums.classList.add('argument-enums');
enums.title = `${argItem.title} - accepted values`;
for (const e of arg.enumList) {
const enumItem = document.createElement('span'); {
enumItem.classList.add('argument-enum');
enumItem.textContent = e;
enums.append(enumItem);
}
}
argItem.append(enums);
}
} else {
const types = document.createElement('span'); {
types.classList.add('argument-types');
types.title = `${argItem.title} - accepted types`;
for (const t of arg.typeList) {
const type = document.createElement('span'); {
type.classList.add('argument-type');
type.textContent = t;
types.append(type);
}
}
argItem.append(types);
}
}
listItem.append(argItem);
}
const desc = document.createElement('div'); {
desc.classList.add('argument-description');
desc.innerHTML = arg.description;
listItem.append(desc);
}
args.append(listItem);
}
}
body.append(args);
}
const returns = document.createElement('span'); {
returns.classList.add('returns');
returns.title = [null, undefined, 'void'].includes(returnType) ? 'command does not return anything' : 'return value';
returns.textContent = returnType ?? 'void';
body.append(returns);
}
specs.append(body);
}
frag.append(specs);
}
const help = document.createElement('span'); {
help.classList.add('help');
help.innerHTML = helpString;
for (const code of help.querySelectorAll('pre > code')) {
code.classList.add('language-stscript');
hljs.highlightElement(code);
}
frag.append(help);
}
if (aliasList.length > 0) {
const aliases = document.createElement('span'); {
aliases.classList.add('aliases');
for (const aliasName of aliasList) {
const alias = document.createElement('span'); {
alias.classList.add('alias');
alias.textContent = `/${aliasName}`;
aliases.append(alias);
}
}
frag.append(aliases);
}
}
this.helpDetailsCache[key] = frag;
}
return this.helpDetailsCache[key].cloneNode(true);
}
}

View File

@ -0,0 +1,117 @@
import { SlashCommandClosure } from './SlashCommandClosure.js';
/**@readonly*/
/**@enum {string}*/
export const ARGUMENT_TYPE = {
'STRING': 'string',
'NUMBER': 'number',
'RANGE': 'range',
'BOOLEAN': 'bool',
'VARIABLE_NAME': 'varname',
'CLOSURE': 'closure',
'SUBCOMMAND': 'subcommand',
'LIST': 'list',
'DICTIONARY': 'dictionary',
};
export class SlashCommandArgument {
/**
* Creates an unnamed argument from a poperties object.
* @param {Object} props
* @param {string} props.description description of the argument
* @param {ARGUMENT_TYPE|ARGUMENT_TYPE[]} props.typeList default: ARGUMENT_TYPE.STRING - list of accepted types (from ARGUMENT_TYPE)
* @param {boolean} [props.isRequired] default: false - whether the argument is required (false = optional argument)
* @param {boolean} [props.acceptsMultiple] default: false - whether argument accepts multiple values
* @param {string|SlashCommandClosure} [props.defaultValue] default value if no value is provided
* @param {string|string[]} [props.enumList] list of accepted values
*/
static fromProps(props) {
return new SlashCommandArgument(
props.description,
props.typeList ?? [ARGUMENT_TYPE.STRING],
props.isRequired ?? false,
props.acceptsMultiple ?? false,
props.defaultValue ?? null,
props.enumList ?? [],
);
}
/**@type {string}*/ description;
/**@type {ARGUMENT_TYPE[]}*/ typeList = [];
/**@type {boolean}*/ isRequired = false;
/**@type {boolean}*/ acceptsMultiple = false;
/**@type {string|SlashCommandClosure}*/ defaultValue;
/**@type {string[]}*/ enumList = [];
/**
* @param {string} description
* @param {ARGUMENT_TYPE|ARGUMENT_TYPE[]} types
* @param {string|SlashCommandClosure} defaultValue
* @param {string|string[]} enums
*/
constructor(description, types, isRequired = false, acceptsMultiple = false, defaultValue = null, enums = []) {
this.description = description;
this.typeList = types ? Array.isArray(types) ? types : [types] : [];
this.isRequired = isRequired ?? false;
this.acceptsMultiple = acceptsMultiple ?? false;
this.defaultValue = defaultValue;
this.enumList = enums ? Array.isArray(enums) ? enums : [enums] : [];
}
}
export class SlashCommandNamedArgument extends SlashCommandArgument {
/**
* Creates an unnamed argument from a poperties object.
* @param {Object} props
* @param {string} props.name the argument's name
* @param {string[]} [props.aliasList] list of aliases
* @param {string} props.description description of the argument
* @param {ARGUMENT_TYPE|ARGUMENT_TYPE[]} props.typeList default: ARGUMENT_TYPE.STRING - list of accepted types (from ARGUMENT_TYPE)
* @param {boolean} [props.isRequired] default: false - whether the argument is required (false = optional argument)
* @param {boolean} [props.acceptsMultiple] default: false - whether argument accepts multiple values
* @param {string|SlashCommandClosure} [props.defaultValue] default value if no value is provided
* @param {string|string[]} [props.enumList] list of accepted values
*/
static fromProps(props) {
return new SlashCommandNamedArgument(
props.name,
props.description,
props.typeList ?? [ARGUMENT_TYPE.STRING],
props.isRequired ?? false,
props.acceptsMultiple ?? false,
props.defaultValue ?? null,
props.enumList ?? [],
props.aliasList ?? [],
);
}
/**@type {string}*/ name;
/**@type {string[]}*/ aliasList = [];
/**
* @param {string} name
* @param {string} description
* @param {ARGUMENT_TYPE|ARGUMENT_TYPE[]} types
* @param {string|SlashCommandClosure} defaultValue
* @param {string|string[]} enums
*/
constructor(name, description, types, isRequired = false, acceptsMultiple = false, defaultValue = null, enums = [], aliases = []) {
super(description, types, isRequired, acceptsMultiple, defaultValue, enums);
this.name = name;
this.aliasList = aliases ? Array.isArray(aliases) ? aliases : [aliases] : [];
}
}

View File

@ -0,0 +1,701 @@
import { power_user } from '../power-user.js';
import { debounce, escapeRegex } from '../utils.js';
import { SlashCommandAutoCompleteOption, SlashCommandFuzzyScore } from './SlashCommandAutoCompleteOption.js';
import { SlashCommandBlankAutoCompleteOption } from './SlashCommandBlankAutoCompleteOption.js';
// eslint-disable-next-line no-unused-vars
import { SlashCommandParserNameResult } from './SlashCommandParserNameResult.js';
export class SlashCommandAutoComplete {
/**@type {HTMLTextAreaElement}*/ textarea;
/**@type {boolean}*/ isFloating = false;
/**@type {()=>boolean}*/ checkIfActivate;
/**@type {(text:string, index:number) => Promise<SlashCommandParserNameResult>}*/ getNameAt;
/**@type {boolean}*/ isActive = false;
/**@type {boolean}*/ isReplaceable = false;
/**@type {boolean}*/ isShowingDetails = false;
/**@type {string}*/ text;
/**@type {SlashCommandParserNameResult}*/ parserResult;
/**@type {string}*/ name;
/**@type {boolean}*/ startQuote;
/**@type {boolean}*/ endQuote;
/**@type {number}*/ selectionStart;
/**@type {RegExp}*/ fuzzyRegex;
/**@type {Object.<string,HTMLElement>}*/ items = {};
/**@type {boolean}*/ hasCache = false;
/**@type {SlashCommandAutoCompleteOption[]}*/ result = [];
/**@type {SlashCommandAutoCompleteOption}*/ selectedItem = null;
/**@type {Promise}*/ pointerup = Promise.resolve();
/**@type {HTMLElement}*/ clone;
/**@type {HTMLElement}*/ domWrap;
/**@type {HTMLElement}*/ dom;
/**@type {HTMLElement}*/ detailsWrap;
/**@type {HTMLElement}*/ detailsDom;
/**@type {function}*/ renderDebounced;
/**@type {function}*/ renderDetailsDebounced;
/**@type {function}*/ updatePositionDebounced;
/**@type {function}*/ updateDetailsPositionDebounced;
/**@type {function}*/ updateFloatingPositionDebounced;
get matchType() {
return power_user.stscript.matching ?? 'fuzzy';
}
/**
* @param {HTMLTextAreaElement} textarea The textarea to receive autocomplete.
* @param {() => boolean} checkIfActivate
* @param {(text: string, index: number) => Promise<SlashCommandParserNameResult>} getNameAt
* @param {boolean} isFloating Whether autocomplete should float at the keyboard cursor.
*/
constructor(textarea, checkIfActivate, getNameAt, isFloating = false) {
this.textarea = textarea;
this.checkIfActivate = checkIfActivate;
this.getNameAt = getNameAt;
this.isFloating = isFloating;
this.domWrap = document.createElement('div'); {
this.domWrap.classList.add('slashCommandAutoComplete-wrap');
if (isFloating) this.domWrap.classList.add('isFloating');
}
this.dom = document.createElement('ul'); {
this.dom.classList.add('slashCommandAutoComplete');
this.domWrap.append(this.dom);
}
this.detailsWrap = document.createElement('div'); {
this.detailsWrap.classList.add('slashCommandAutoComplete-detailsWrap');
if (isFloating) this.detailsWrap.classList.add('isFloating');
}
this.detailsDom = document.createElement('div'); {
this.detailsDom.classList.add('slashCommandAutoComplete-details');
this.detailsWrap.append(this.detailsDom);
}
this.renderDebounced = debounce(this.render.bind(this), 10);
this.renderDetailsDebounced = debounce(this.renderDetails.bind(this), 10);
this.updatePositionDebounced = debounce(this.updatePosition.bind(this), 10);
this.updateDetailsPositionDebounced = debounce(this.updateDetailsPosition.bind(this), 10);
this.updateFloatingPositionDebounced = debounce(this.updateFloatingPosition.bind(this), 10);
textarea.addEventListener('input', ()=>this.show(true));
textarea.addEventListener('keydown', (evt)=>this.handleKeyDown(evt));
textarea.addEventListener('click', ()=>this.isActive ? this.show() : null);
textarea.addEventListener('selectionchange', ()=>this.show());
textarea.addEventListener('blur', ()=>this.hide());
if (isFloating) {
textarea.addEventListener('scroll', ()=>this.updateFloatingPositionDebounced());
}
window.addEventListener('resize', ()=>this.updatePositionDebounced());
}
/**
*
* @param {SlashCommandAutoCompleteOption} option
*/
makeItem(option) {
const li = option.renderItem();
// gotta listen to pointerdown (happens before textarea-blur)
li.addEventListener('pointerdown', ()=>{
// gotta catch pointerup to restore focus to textarea (blurs after pointerdown)
this.pointerup = new Promise(resolve=>{
const resolver = ()=>{
window.removeEventListener('pointerup', resolver);
resolve();
};
window.addEventListener('pointerup', resolver);
});
this.selectedItem = this.result.find(it=>it.name == li.getAttribute('data-name'));
this.select();
});
return li;
}
/**
*
* @param {SlashCommandAutoCompleteOption} item
*/
updateName(item) {
const chars = Array.from(item.dom.querySelector('.name').children);
switch (this.matchType) {
case 'strict': {
chars.forEach((it, idx)=>{
if (idx < item.name.length) {
it.classList.add('matched');
} else {
it.classList.remove('matched');
}
});
break;
}
case 'includes': {
const start = item.name.toLowerCase().search(this.name);
chars.forEach((it, idx)=>{
if (idx < start) {
it.classList.remove('matched');
} else if (idx < start + item.name.length) {
it.classList.add('matched');
} else {
it.classList.remove('matched');
}
});
break;
}
case 'fuzzy': {
item.name.replace(this.fuzzyRegex, (_, ...parts)=>{
parts.splice(-2, 2);
if (parts.length == 2) {
chars.forEach(c=>c.classList.remove('matched'));
} else {
let cIdx = 0;
parts.forEach((it, idx)=>{
if (it === null || it.length == 0) return '';
if (idx % 2 == 1) {
chars.slice(cIdx, cIdx + it.length).forEach(c=>c.classList.add('matched'));
} else {
chars.slice(cIdx, cIdx + it.length).forEach(c=>c.classList.remove('matched'));
}
cIdx += it.length;
});
}
return '';
});
}
}
return item;
}
/**
* Calculate score for the fuzzy match.
* @param {SlashCommandAutoCompleteOption} option
* @returns The option.
*/
fuzzyScore(option) {
const parts = this.fuzzyRegex.exec(option.name).slice(1, -1);
let start = null;
let consecutive = [];
let current = '';
let offset = 0;
parts.forEach((part, idx) => {
if (idx % 2 == 0) {
if (part.length > 0) {
if (current.length > 0) {
consecutive.push(current);
}
current = '';
}
} else {
if (start === null) {
start = offset;
}
current += part;
}
offset += part.length;
});
if (current.length > 0) {
consecutive.push(current);
}
consecutive.sort((a,b)=>b.length - a.length);
option.score = new SlashCommandFuzzyScore(start, consecutive[0]?.length ?? 0);
return option;
}
/**
* Compare two auto complete options by their fuzzy score.
* @param {SlashCommandAutoCompleteOption} a
* @param {SlashCommandAutoCompleteOption} b
*/
fuzzyScoreCompare(a, b) {
if (a.score.start < b.score.start) return -1;
if (a.score.start > b.score.start) return 1;
if (a.score.longestConsecutive > b.score.longestConsecutive) return -1;
if (a.score.longestConsecutive < b.score.longestConsecutive) return 1;
return a.name.localeCompare(b.name);
}
/**
* Show the autocomplete.
* @param {boolean} isInput Whether triggered by input.
* @param {boolean} isForced Whether force-showing (ctrl+space).
*/
async show(isInput = false, isForced = false) {
//TODO check if isInput and isForced are both required
this.text = this.textarea.value;
// only show with textarea in focus
if (document.activeElement != this.textarea) return this.hide();
// only show if provider wants to
if (!this.checkIfActivate()) return this.hide();
// request provider to get name result (potentially "incomplete", i.e. not an actual existing name) for
// cursor position
this.parserResult = await this.getNameAt(this.text, this.textarea.selectionStart);
// don't show if no executor found, i.e. cursor's area is not a command
if (!this.parserResult) return this.hide();
// need to know if name *can* be inside quotes, and then check if quotes are already there
if (this.parserResult.canBeQuoted) {
this.startQuote = this.text[this.parserResult.start] == '"';
this.endQuote = this.startQuote && this.text[this.parserResult.start + this.parserResult.name.length + 1] == '"';
} else {
this.startQuote = false;
this.endQuote = false;
}
// use lowercase name for matching
this.name = this.parserResult?.name?.toLowerCase() ?? '';
// do autocomplete if triggered by a user input and we either don't have an executor or the cursor is at the end
// of the name part of the command
this.isReplaceable = isInput && (!this.parserResult ? true : this.textarea.selectionStart == this.parserResult.start + this.parserResult.name.length + (this.startQuote ? 1 : 0));
// if [forced (ctrl+space) or user input] and cursor is in the middle of the name part (not at the end)
if (isForced || isInput) {
if (this.textarea.selectionStart >= this.parserResult.start && this.textarea.selectionStart <= this.parserResult.start + this.parserResult.name.length + (this.startQuote ? 1 : 0)) {
this.name = this.name.slice(0, this.textarea.selectionStart - (this.parserResult.start) - (this.startQuote ? 1 : 0));
this.parserResult.name = this.name;
this.isReplaceable = true;
}
}
// only build the fuzzy regex if match type is set to fuzzy
if (this.matchType == 'fuzzy') {
this.fuzzyRegex = new RegExp(`^(.*?)${this.name.split('').map(char=>`(${escapeRegex(char)})`).join('(.*?)')}(.*?)$`, 'i');
}
//TODO maybe move the matchers somewhere else; a single match function? matchType is available as property
const matchers = {
'strict': (name) => name.toLowerCase().startsWith(this.name),
'includes': (name) => name.toLowerCase().includes(this.name),
'fuzzy': (name) => this.fuzzyRegex.test(name),
};
this.result = this.parserResult.optionList
// filter the list of options by the partial name according to the matching type
.filter(it => this.isReplaceable || it.name == '' ? matchers[this.matchType](it.name) : it.name.toLowerCase() == this.name)
// remove aliases
.filter((it,idx,list) => list.findIndex(opt=>opt.value == it.value) == idx)
// update remaining options
.map(option => {
// build element
option.dom = this.makeItem(option);
// update replacer and add quotes if necessary
if (this.parserResult.canBeQuoted) {
option.replacer = option.name.includes(' ') || this.startQuote || this.endQuote ? `"${option.name}"` : `${option.name}`;
} else {
option.replacer = option.name;
}
// calculate fuzzy score if matching is fuzzy
if (this.matchType == 'fuzzy') this.fuzzyScore(option);
// update the name to highlight the matched chars
this.updateName(option);
return option;
})
// sort by fuzzy score or alphabetical
.toSorted(this.matchType == 'fuzzy' ? this.fuzzyScoreCompare : (a, b) => a.name.localeCompare(b.name))
;
if (this.result.length == 0) {
// no result and no input? hide autocomplete
if (!isInput) {
return this.hide();
}
// otherwise add "no match" notice
const option = new SlashCommandBlankAutoCompleteOption(
this.name.length ?
this.parserResult.makeNoMatchText()
: this.parserResult.makeNoOptionstext()
,
);
this.result.push(option);
} else if (this.result.length == 1 && this.parserResult && this.result[0].name == this.parserResult.name) {
// only one result that is exactly the current value? just show hint, no autocomplete
this.isReplaceable = false;
this.isShowingDetails = false;
} else if (!this.isReplaceable && this.result.length > 1) {
return this.hide();
}
this.selectedItem = this.result[0];
this.isActive = true;
this.renderDebounced();
}
/**
* Hide autocomplete.
*/
hide() {
this.domWrap?.remove();
this.detailsWrap?.remove();
this.isActive = false;
this.isShowingDetails = false;
}
/**
* Create updated DOM.
*/
render() {
// render autocomplete list
if (this.isReplaceable) {
this.dom.innerHTML = '';
const frag = document.createDocumentFragment();
for (const item of this.result) {
if (item == this.selectedItem) {
item.dom.classList.add('selected');
} else {
item.dom.classList.remove('selected');
}
frag.append(item.dom);
}
this.dom.append(frag);
this.updatePosition();
document.body.append(this.domWrap);
} else {
this.domWrap.remove();
}
this.renderDetailsDebounced();
}
/**
* Create updated DOM for details.
*/
renderDetails() {
if (!this.isShowingDetails && this.isReplaceable) return this.detailsWrap.remove();
this.detailsDom.innerHTML = '';
this.detailsDom.append(this.selectedItem?.renderDetails() ?? 'NO ITEM');
document.body.append(this.detailsWrap);
this.updateDetailsPositionDebounced();
}
/**
* Update position of DOM.
*/
updatePosition() {
if (this.isFloating) {
this.updateFloatingPosition();
} else {
const rect = this.textarea.getBoundingClientRect();
this.domWrap.style.setProperty('--bottom', `${window.innerHeight - rect.top}px`);
this.dom.style.setProperty('--bottom', `${window.innerHeight - rect.top}px`);
this.domWrap.style.bottom = `${window.innerHeight - rect.top}px`;
if (this.isShowingDetails) {
this.domWrap.style.setProperty('--leftOffset', '1vw');
} else {
this.domWrap.style.setProperty('--leftOffset', `${rect.left}px`);
}
this.domWrap.style.setProperty('--rightOffset', `calc(1vw + ${this.isShowingDetails ? 25 : 0}vw)`);
this.updateDetailsPosition();
}
}
/**
* Update position of details DOM.
*/
updateDetailsPosition() {
if (this.isShowingDetails || !this.isReplaceable) {
if (this.isFloating) {
this.updateFloatingDetailsPosition();
} else {
const rect = this.textarea.getBoundingClientRect();
if (this.isReplaceable) {
const selRect = this.selectedItem.dom.children[0].getBoundingClientRect();
this.detailsWrap.style.setProperty('--targetOffset', `${selRect.top}`);
this.detailsWrap.style.bottom = `${window.innerHeight - rect.top}px`;
this.detailsWrap.style.left = `calc(100vw - calc(1vw + ${this.isShowingDetails ? 25 : 0}vw))`;
this.detailsWrap.style.right = '1vw';
this.detailsWrap.style.top = '5vh';
} else {
this.detailsWrap.style.setProperty('--targetOffset', `${rect.top}`);
this.detailsWrap.style.bottom = `${window.innerHeight - rect.top}px`;
this.detailsWrap.style.left = `${rect.left}px`;
this.detailsWrap.style.right = `calc(100vw - ${rect.right}px)`;
this.detailsWrap.style.top = '5vh';
}
}
}
}
/**
* Update position of floating autocomplete.
*/
updateFloatingPosition() {
const location = this.getCursorPosition();
const rect = this.textarea.getBoundingClientRect();
// cursor is out of view -> hide
if (location.bottom < rect.top || location.top > rect.bottom || location.left < rect.left || location.left > rect.right) {
return this.hide();
}
const left = Math.max(rect.left, location.left);
this.domWrap.style.setProperty('--targetOffset', `${left}`);
if (location.top <= window.innerHeight / 2) {
// if cursor is in lower half of window, show list above line
this.domWrap.style.top = `${location.bottom}px`;
this.domWrap.style.bottom = 'auto';
this.domWrap.style.maxHeight = `calc(${location.bottom}px - 1vh)`;
} else {
// if cursor is in upper half of window, show list below line
this.domWrap.style.top = 'auto';
this.domWrap.style.bottom = `calc(100vh - ${location.top}px)`;
this.domWrap.style.maxHeight = `calc(${location.top}px - 1vh)`;
}
}
updateFloatingDetailsPosition(location = null) {
if (!location) location = this.getCursorPosition();
const rect = this.textarea.getBoundingClientRect();
if (location.bottom < rect.top || location.top > rect.bottom || location.left < rect.left || location.left > rect.right) {
return this.hide();
}
const left = Math.max(rect.left, location.left);
this.detailsWrap.style.setProperty('--targetOffset', `${left}`);
if (this.isReplaceable) {
this.detailsWrap.classList.remove('full');
if (left < window.innerWidth / 4) {
// if cursor is in left part of screen, show details on right of list
this.detailsWrap.classList.add('right');
this.detailsWrap.classList.remove('left');
} else {
// if cursor is in right part of screen, show details on left of list
this.detailsWrap.classList.remove('right');
this.detailsWrap.classList.add('left');
}
} else {
this.detailsWrap.classList.remove('left');
this.detailsWrap.classList.remove('right');
this.detailsWrap.classList.add('full');
}
if (location.top <= window.innerHeight / 2) {
// if cursor is in lower half of window, show list above line
this.detailsWrap.style.top = `${location.bottom}px`;
this.detailsWrap.style.bottom = 'auto';
this.detailsWrap.style.maxHeight = `calc(${location.bottom}px - 1vh)`;
} else {
// if cursor is in upper half of window, show list below line
this.detailsWrap.style.top = 'auto';
this.detailsWrap.style.bottom = `calc(100vh - ${location.top}px)`;
this.detailsWrap.style.maxHeight = `calc(${location.top}px - 1vh)`;
}
}
/**
* Calculate (keyboard) cursor coordinates within textarea.
* @returns {{left:number, top:number, bottom:number}}
*/
getCursorPosition() {
const inputRect = this.textarea.getBoundingClientRect();
const style = window.getComputedStyle(this.textarea);
if (!this.clone) {
this.clone = document.createElement('div');
for (const key of style) {
this.clone.style[key] = style[key];
}
this.clone.style.position = 'fixed';
this.clone.style.visibility = 'hidden';
document.body.append(this.clone);
const mo = new MutationObserver(muts=>{
if (muts.find(it=>Array.from(it.removedNodes).includes(this.textarea))) {
this.clone.remove();
}
});
mo.observe(this.textarea.parentElement, { childList:true });
}
this.clone.style.height = `${inputRect.height}px`;
this.clone.style.left = `${inputRect.left}px`;
this.clone.style.top = `${inputRect.top}px`;
this.clone.style.whiteSpace = style.whiteSpace;
this.clone.style.tabSize = style.tabSize;
const text = this.textarea.value;
const before = text.slice(0, this.textarea.selectionStart);
this.clone.textContent = before;
const locator = document.createElement('span');
locator.textContent = text[this.textarea.selectionStart];
this.clone.append(locator);
this.clone.append(text.slice(this.textarea.selectionStart + 1));
this.clone.scrollTop = this.textarea.scrollTop;
this.clone.scrollLeft = this.textarea.scrollLeft;
const locatorRect = locator.getBoundingClientRect();
const location = {
left: locatorRect.left,
top: locatorRect.top,
bottom: locatorRect.bottom,
};
return location;
}
/**
* Toggle details view alongside autocomplete list.
*/
toggleDetails() {
this.isShowingDetails = !this.isShowingDetails;
this.renderDetailsDebounced();
this.updatePosition();
}
/**
* Select an item for autocomplete and put text into textarea.
*/
async select() {
if (this.isReplaceable && this.selectedItem.value !== null) {
this.textarea.value = `${this.text.slice(0, this.parserResult.start)}${this.selectedItem.replacer}${this.text.slice(this.parserResult.start + this.parserResult.name.length + (this.startQuote ? 1 : 0) + (this.endQuote ? 1 : 0))}`;
await this.pointerup;
this.textarea.focus();
this.textarea.selectionStart = this.parserResult.start + this.selectedItem.replacer.length;
this.textarea.selectionEnd = this.textarea.selectionStart;
this.show();
} else {
const selectionStart = this.textarea.selectionStart;
const selectionEnd = this.textarea.selectionDirection;
await this.pointerup;
this.textarea.focus();
this.textarea.selectionStart = selectionStart;
this.textarea.selectionDirection = selectionEnd;
}
}
/**
* Mark the item at newIdx in the autocomplete list as selected.
* @param {number} newIdx
*/
selectItemAtIndex(newIdx) {
this.selectedItem.dom.classList.remove('selected');
this.selectedItem = this.result[newIdx];
this.selectedItem.dom.classList.add('selected');
const rect = this.selectedItem.dom.children[0].getBoundingClientRect();
const rectParent = this.dom.getBoundingClientRect();
if (rect.top < rectParent.top || rect.bottom > rectParent.bottom ) {
this.dom.scrollTop += rect.top < rectParent.top ? rect.top - rectParent.top : rect.bottom - rectParent.bottom;
}
this.renderDetailsDebounced();
}
/**
* Handle keyboard events.
* @param {KeyboardEvent} evt The event.
*/
async handleKeyDown(evt) {
// autocomplete is shown and cursor at end of current command name (or inside name and typed or forced)
if (this.isActive && this.isReplaceable) {
// actions in the list
switch (evt.key) {
case 'ArrowUp': {
// select previous item
if (evt.ctrlKey || evt.altKey || evt.shiftKey) return;
evt.preventDefault();
evt.stopPropagation();
const idx = this.result.indexOf(this.selectedItem);
let newIdx;
if (idx == 0) newIdx = this.result.length - 1;
else newIdx = idx - 1;
this.selectItemAtIndex(newIdx);
return;
}
case 'ArrowDown': {
// select next item
if (evt.ctrlKey || evt.altKey || evt.shiftKey) return;
evt.preventDefault();
evt.stopPropagation();
const idx = this.result.indexOf(this.selectedItem);
const newIdx = (idx + 1) % this.result.length;
this.selectItemAtIndex(newIdx);
return;
}
case 'Enter': {
// pick the selected item to autocomplete
if (evt.ctrlKey || evt.altKey || evt.shiftKey || this.selectedItem.value == '') break;
if (this.selectedItem.name == this.name) break;
evt.preventDefault();
evt.stopImmediatePropagation();
this.select();
return;
}
case 'Tab': {
// pick the selected item to autocomplete
if (evt.ctrlKey || evt.altKey || evt.shiftKey || this.selectedItem.value == '') break;
evt.preventDefault();
evt.stopImmediatePropagation();
this.select();
return;
}
}
}
// details are shown, cursor can be anywhere
if (this.isActive) {
switch (evt.key) {
case 'Escape': {
// close autocomplete
if (evt.ctrlKey || evt.altKey || evt.shiftKey) return;
evt.preventDefault();
evt.stopPropagation();
this.hide();
return;
}
case 'Enter': {
// hide autocomplete on enter (send, execute, ...)
if (!evt.shiftKey) {
this.hide();
return;
}
break;
}
}
}
// autocomplete shown or not, cursor anywhere
switch (evt.key) {
case ' ': {
if (evt.ctrlKey) {
if (this.isActive && this.isReplaceable) {
// ctrl-space to toggle details for selected item
this.toggleDetails();
} else {
// ctrl-space to force show autocomplete
this.show(true, true);
}
return;
}
break;
}
}
if (['Control', 'Shift', 'Alt'].includes(evt.key)) {
// ignore keydown on modifier keys
return;
}
switch (evt.key) {
default:
case 'ArrowUp':
case 'ArrowDown':
case 'ArrowRight':
case 'ArrowLeft': {
if (this.isActive) {
// keyboard navigation, wait for keyup to complete cursor move
const oldText = this.textarea.value;
await new Promise(resolve=>{
window.addEventListener('keyup', resolve, { once:true });
});
if (this.selectionStart != this.textarea.selectionStart) {
this.selectionStart = this.textarea.selectionStart;
this.show(this.isReplaceable || oldText != this.textarea.value);
}
}
}
}
}
}

View File

@ -0,0 +1,195 @@
import { SlashCommand } from './SlashCommand.js';
export class SlashCommandFuzzyScore {
/**@type {number}*/ start;
/**@type {number}*/ longestConsecutive;
/**
* @param {number} start
* @param {number} longestConsecutive
*/
constructor(start, longestConsecutive) {
this.start = start;
this.longestConsecutive = longestConsecutive;
}
}
export class SlashCommandAutoCompleteOption {
/**@type {string|SlashCommand}*/ value;
/**@type {string}*/ name;
/**@type {SlashCommandFuzzyScore}*/ score;
/**@type {string}*/ replacer;
/**@type {HTMLElement}*/ dom;
/**
* @param {string|SlashCommand} value
* @param {string} name
*/
constructor(value, name) {
this.value = value;
this.name = name;
}
makeItem(key, typeIcon, noSlash, namedArguments = [], unnamedArguments = [], returnType = 'void', helpString = '', aliasList = []) {
const li = document.createElement('li'); {
li.classList.add('item');
const type = document.createElement('span'); {
type.classList.add('type');
type.classList.add('monospace');
type.textContent = typeIcon;
li.append(type);
}
const specs = document.createElement('span'); {
specs.classList.add('specs');
const name = document.createElement('span'); {
name.classList.add('name');
name.classList.add('monospace');
name.textContent = noSlash ? '' : '/';
key.split('').forEach(char=>{
const span = document.createElement('span'); {
span.textContent = char;
name.append(span);
}
});
specs.append(name);
}
const body = document.createElement('span'); {
body.classList.add('body');
const args = document.createElement('span'); {
args.classList.add('arguments');
for (const arg of namedArguments) {
const argItem = document.createElement('span'); {
argItem.classList.add('argument');
argItem.classList.add('namedArgument');
if (!arg.isRequired || (arg.defaultValue ?? false)) argItem.classList.add('optional');
if (arg.acceptsMultiple) argItem.classList.add('multiple');
const name = document.createElement('span'); {
name.classList.add('argument-name');
name.textContent = arg.name;
argItem.append(name);
}
if (arg.enumList.length > 0) {
const enums = document.createElement('span'); {
enums.classList.add('argument-enums');
for (const e of arg.enumList) {
const enumItem = document.createElement('span'); {
enumItem.classList.add('argument-enum');
enumItem.textContent = e;
enums.append(enumItem);
}
}
argItem.append(enums);
}
} else {
const types = document.createElement('span'); {
types.classList.add('argument-types');
for (const t of arg.typeList) {
const type = document.createElement('span'); {
type.classList.add('argument-type');
type.textContent = t;
types.append(type);
}
}
argItem.append(types);
}
}
args.append(argItem);
}
}
for (const arg of unnamedArguments) {
const argItem = document.createElement('span'); {
argItem.classList.add('argument');
argItem.classList.add('unnamedArgument');
if (!arg.isRequired || (arg.defaultValue ?? false)) argItem.classList.add('optional');
if (arg.acceptsMultiple) argItem.classList.add('multiple');
if (arg.enumList.length > 0) {
const enums = document.createElement('span'); {
enums.classList.add('argument-enums');
for (const e of arg.enumList) {
const enumItem = document.createElement('span'); {
enumItem.classList.add('argument-enum');
enumItem.textContent = e;
enums.append(enumItem);
}
}
argItem.append(enums);
}
} else {
const types = document.createElement('span'); {
types.classList.add('argument-types');
for (const t of arg.typeList) {
const type = document.createElement('span'); {
type.classList.add('argument-type');
type.textContent = t;
types.append(type);
}
}
argItem.append(types);
}
}
args.append(argItem);
}
}
body.append(args);
}
const returns = document.createElement('span'); {
returns.classList.add('returns');
returns.textContent = returnType ?? 'void';
// body.append(returns);
}
specs.append(body);
}
li.append(specs);
}
const help = document.createElement('span'); {
help.classList.add('help');
const content = document.createElement('span'); {
content.classList.add('helpContent');
content.innerHTML = helpString;
const text = content.textContent;
content.innerHTML = '';
content.textContent = text;
help.append(content);
}
li.append(help);
}
if (aliasList.length > 0) {
const aliases = document.createElement('span'); {
aliases.classList.add('aliases');
aliases.append(' (alias: ');
for (const aliasName of aliasList) {
const alias = document.createElement('span'); {
alias.classList.add('monospace');
alias.textContent = `/${aliasName}`;
aliases.append(alias);
}
}
aliases.append(')');
// li.append(aliases);
}
}
}
return li;
}
/**
* @returns {HTMLElement}
*/
renderItem() {
throw new Error(`${this.constructor.name}.renderItem() is not implemented`);
}
/**
* @returns {DocumentFragment}
*/
renderDetails() {
throw new Error(`${this.constructor.name}.renderDetails() is not implemented`);
}
}

View File

@ -0,0 +1,27 @@
import { SlashCommandAutoCompleteOption } from './SlashCommandAutoCompleteOption.js';
export class SlashCommandBlankAutoCompleteOption extends SlashCommandAutoCompleteOption {
/**
* @param {string} value
*/
constructor(value) {
super(value, value);
this.dom = this.renderItem();
}
renderItem() {
const li = document.createElement('li'); {
li.classList.add('item');
li.classList.add('blank');
li.textContent = this.name;
}
return li;
}
renderDetails() {
const frag = document.createDocumentFragment();
return frag;
}
}

View File

@ -0,0 +1,148 @@
import { escapeRegex } from '../utils.js';
import { SlashCommand } from './SlashCommand.js';
import { SlashCommandParser } from './SlashCommandParser.js';
export class SlashCommandBrowser {
/**@type {SlashCommand[]}*/ cmdList;
/**@type {HTMLElement}*/ dom;
/**@type {HTMLElement}*/ search;
/**@type {HTMLElement}*/ details;
/**@type {Object.<string,HTMLElement>}*/ itemMap = {};
/**@type {MutationObserver}*/ mo;
renderInto(parent) {
if (!this.dom) {
const queryRegex = /(?:(?:^|\s+)([^\s"][^\s]*?)(?:\s+|$))|(?:(?:^|\s+)"(.*?)(?:"|$)(?:\s+|$))/;
const root = document.createElement('div'); {
this.dom = root;
const search = document.createElement('div'); {
search.classList.add('search');
const lbl = document.createElement('label'); {
lbl.classList.add('searchLabel');
lbl.textContent = 'Search: ';
const inp = document.createElement('input'); {
this.search = inp;
inp.classList.add('searchInput');
inp.classList.add('text_pole');
inp.type = 'search';
inp.placeholder = 'Search slash commands - use quotes to search "literal" instead of fuzzy';
inp.addEventListener('input', ()=>{
this.details?.remove();
this.details = null;
let query = inp.value.trim();
if (query.slice(-1) == '"' && !/(?:^|\s+)"/.test(query)) {
query = `"${query}`;
}
let fuzzyList = [];
let quotedList = [];
while (query.length > 0) {
const match = queryRegex.exec(query);
if (!match) break;
if (match[1] !== undefined) {
fuzzyList.push(new RegExp(`^(.*?)${match[1].split('').map(char=>`(${escapeRegex(char)})`).join('(.*?)')}(.*?)$`, 'i'));
} else if (match[2] !== undefined) {
quotedList.push(match[2]);
}
query = query.slice(match.index + match[0].length);
}
for (const cmd of this.cmdList) {
const targets = [
cmd.name,
...cmd.namedArgumentList.map(it=>it.name),
...cmd.namedArgumentList.map(it=>it.description),
...cmd.namedArgumentList.map(it=>it.enumList).flat(),
...cmd.namedArgumentList.map(it=>it.typeList).flat(),
...cmd.unnamedArgumentList.map(it=>it.description),
...cmd.unnamedArgumentList.map(it=>it.enumList).flat(),
...cmd.unnamedArgumentList.map(it=>it.typeList).flat(),
...cmd.aliases,
cmd.helpString,
];
const find = ()=>targets.find(t=>(fuzzyList.find(f=>f.test(t)) ?? quotedList.find(q=>t.includes(q))) !== undefined) !== undefined;
if (fuzzyList.length + quotedList.length == 0 || find()) {
this.itemMap[cmd.name].classList.remove('isFiltered');
} else {
this.itemMap[cmd.name].classList.add('isFiltered');
}
}
});
lbl.append(inp);
}
search.append(lbl);
}
root.append(search);
}
const container = document.createElement('div'); {
container.classList.add('commandContainer');
const list = document.createElement('div'); {
list.classList.add('slashCommandAutoComplete');
this.cmdList = Object
.keys(SlashCommandParser.commands)
.filter(key => SlashCommandParser.commands[key].name == key) // exclude aliases
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))
.map(key => SlashCommandParser.commands[key])
;
for (const cmd of this.cmdList) {
const item = cmd.renderHelpItem();
this.itemMap[cmd.name] = item;
let details;
item.addEventListener('click', ()=>{
if (!details) {
details = document.createElement('div'); {
details.classList.add('slashCommandAutoComplete-detailsWrap');
const inner = document.createElement('div'); {
inner.classList.add('slashCommandAutoComplete-details');
inner.append(cmd.renderHelpDetails());
details.append(inner);
}
}
}
if (this.details != details) {
Array.from(list.querySelectorAll('.selected')).forEach(it=>it.classList.remove('selected'));
item.classList.add('selected');
this.details?.remove();
container.append(details);
this.details = details;
const pRect = list.getBoundingClientRect();
const rect = item.children[0].getBoundingClientRect();
details.style.setProperty('--targetOffset', rect.top - pRect.top);
} else {
item.classList.remove('selected');
details.remove();
this.details = null;
}
});
list.append(item);
}
container.append(list);
}
root.append(container);
}
root.classList.add('slashCommandBrowser');
}
}
parent.append(this.dom);
this.mo = new MutationObserver(muts=>{
if (muts.find(mut=>Array.from(mut.removedNodes).find(it=>it == this.dom || it.contains(this.dom)))) {
this.mo.disconnect();
window.removeEventListener('keydown', boundHandler);
}
});
this.mo.observe(document.querySelector('#chat'), { childList:true, subtree:true });
const boundHandler = this.handleKeyDown.bind(this);
window.addEventListener('keydown', boundHandler);
return this.dom;
}
handleKeyDown(evt) {
if (!evt.shiftKey && !evt.altKey && evt.ctrlKey && evt.key.toLowerCase() == 'f') {
if (!this.dom.closest('body')) return;
if (this.dom.closest('.mes') && !this.dom.closest('.last_mes')) return;
evt.preventDefault();
evt.stopPropagation();
evt.stopImmediatePropagation();
this.search.focus();
}
}
}

View File

@ -0,0 +1,235 @@
import { substituteParams } from '../../script.js';
import { escapeRegex } from '../utils.js';
import { SlashCommandClosureExecutor } from './SlashCommandClosureExecutor.js';
import { SlashCommandClosureResult } from './SlashCommandClosureResult.js';
import { SlashCommandExecutor } from './SlashCommandExecutor.js';
import { SlashCommandScope } from './SlashCommandScope.js';
export class SlashCommandClosure {
/**@type {SlashCommandScope}*/ scope;
/**@type {Boolean}*/ executeNow = false;
// @ts-ignore
/**@type {Object.<string,string|SlashCommandClosure>}*/ arguments = {};
// @ts-ignore
/**@type {Object.<string,string|SlashCommandClosure>}*/ providedArguments = {};
/**@type {SlashCommandExecutor[]}*/ executorList = [];
/**@type {String}*/ keptText;
constructor(parent) {
this.scope = new SlashCommandScope(parent);
}
toString() {
return '[Closure]';
}
/**
*
* @param {string} text
* @param {SlashCommandScope} scope
* @returns
*/
substituteParams(text, scope = null) {
let isList = false;
let listValues = [];
scope = scope ?? this.scope;
const macros = scope.macroList.map(it=>escapeRegex(it.key)).join('|');
const re = new RegExp(`({{pipe}})|(?:{{var::([^\\s]+?)(?:::((?!}}).+))?}})|(?:{{(${macros})}})`);
let done = '';
let remaining = text;
while (re.test(remaining)) {
const match = re.exec(remaining);
const before = substituteParams(remaining.slice(0, match.index));
const after = remaining.slice(match.index + match[0].length);
const replacer = match[1] ? scope.pipe : match[2] ? scope.getVariable(match[2], match[3]) : scope.macroList.find(it=>it.key == match[4])?.value;
if (replacer instanceof SlashCommandClosure) {
isList = true;
if (match.index > 0) {
listValues.push(before);
}
listValues.push(replacer);
if (match.index + match[0].length + 1 < remaining.length) {
const rest = this.substituteParams(after, scope);
listValues.push(...(Array.isArray(rest) ? rest : [rest]));
}
break;
} else {
done = `${done}${before}${replacer}`;
remaining = after;
}
}
if (!isList) {
text = `${done}${substituteParams(remaining)}`;
}
if (isList) {
if (listValues.length > 1) return listValues;
return listValues[0];
}
return text;
}
getCopy() {
const closure = new SlashCommandClosure();
closure.scope = this.scope.getCopy();
closure.executeNow = this.executeNow;
closure.arguments = this.arguments;
closure.providedArguments = this.providedArguments;
closure.executorList = this.executorList;
closure.keptText = this.keptText;
return closure;
}
/**
*
* @returns Promise<SlashCommandClosureResult>
*/
async execute() {
const closure = this.getCopy();
return await closure.executeDirect();
}
async executeDirect() {
let interrupt = false;
// closure arguments
for (const key of Object.keys(this.arguments)) {
let v = this.arguments[key];
if (v instanceof SlashCommandClosure) {
/**@type {SlashCommandClosure}*/
const closure = v;
closure.scope.parent = this.scope;
if (closure.executeNow) {
v = (await closure.execute())?.pipe;
} else {
v = closure;
}
} else {
v = this.substituteParams(v);
}
// unescape value
if (typeof v == 'string') {
v = v
?.replace(/\\\{/g, '{')
?.replace(/\\\}/g, '}')
;
}
this.scope.letVariable(key, v);
}
for (const key of Object.keys(this.providedArguments)) {
let v = this.providedArguments[key];
if (v instanceof SlashCommandClosure) {
/**@type {SlashCommandClosure}*/
const closure = v;
closure.scope.parent = this.scope;
if (closure.executeNow) {
v = (await closure.execute())?.pipe;
} else {
v = closure;
}
} else {
v = this.substituteParams(v, this.scope.parent);
}
// unescape value
if (typeof v == 'string') {
v = v
?.replace(/\\\{/g, '{')
?.replace(/\\\}/g, '}')
;
}
this.scope.setVariable(key, v);
}
for (const executor of this.executorList) {
if (executor instanceof SlashCommandClosureExecutor) {
const closure = this.scope.getVariable(executor.name);
if (!closure || !(closure instanceof SlashCommandClosure)) throw new Error(`${executor.name} is not a closure.`);
closure.scope.parent = this.scope;
closure.providedArguments = executor.providedArguments;
const result = await closure.execute();
this.scope.pipe = result.pipe;
interrupt = result.interrupt;
} else {
interrupt = executor.command.interruptsGeneration;
let args = {
_scope: this.scope,
_parserFlags: executor.parserFlags,
};
let value;
// substitute named arguments
for (const key of Object.keys(executor.args)) {
if (executor.args[key] instanceof SlashCommandClosure) {
/**@type {SlashCommandClosure}*/
const closure = executor.args[key];
closure.scope.parent = this.scope;
if (closure.executeNow) {
args[key] = (await closure.execute())?.pipe;
} else {
args[key] = closure;
}
} else {
args[key] = this.substituteParams(executor.args[key]);
}
// unescape named argument
if (typeof args[key] == 'string') {
args[key] = args[key]
?.replace(/\\\{/g, '{')
?.replace(/\\\}/g, '}')
;
}
}
// substitute unnamed argument
if (executor.value === undefined) {
if (executor.injectPipe) {
value = this.scope.pipe;
}
} else if (executor.value instanceof SlashCommandClosure) {
/**@type {SlashCommandClosure}*/
const closure = executor.value;
closure.scope.parent = this.scope;
if (closure.executeNow) {
value = (await closure.execute())?.pipe;
} else {
value = closure;
}
} else if (Array.isArray(executor.value)) {
value = [];
for (let i = 0; i < executor.value.length; i++) {
let v = executor.value[i];
if (v instanceof SlashCommandClosure) {
/**@type {SlashCommandClosure}*/
const closure = v;
closure.scope.parent = this.scope;
if (closure.executeNow) {
v = (await closure.execute())?.pipe;
} else {
v = closure;
}
} else {
v = this.substituteParams(v);
}
value[i] = v;
}
if (!value.find(it=>it instanceof SlashCommandClosure)) {
value = value.join(' ');
}
} else {
value = this.substituteParams(executor.value);
}
// unescape unnamed argument
if (typeof value == 'string') {
value = value
?.replace(/\\\{/g, '{')
?.replace(/\\\}/g, '}')
;
}
this.scope.pipe = await executor.command.callback(args, value ?? '');
}
}
/**@type {SlashCommandClosureResult} */
const result = Object.assign(new SlashCommandClosureResult(), { interrupt, newText: this.keptText, pipe: this.scope.pipe });
return result;
}
}

View File

@ -0,0 +1,5 @@
export class SlashCommandClosureExecutor {
/**@type {String}*/ name = '';
// @ts-ignore
/**@type {Object.<string,string|SlashCommandClosure>}*/ providedArguments = {};
}

View File

@ -0,0 +1,5 @@
export class SlashCommandClosureResult {
/**@type {Boolean}*/ interrupt = false;
/**@type {String}*/ newText = '';
/**@type {String}*/ pipe;
}

View File

@ -0,0 +1,30 @@
import { SlashCommand } from './SlashCommand.js';
import { SlashCommandAutoCompleteOption } from './SlashCommandAutoCompleteOption.js';
export class SlashCommandCommandAutoCompleteOption extends SlashCommandAutoCompleteOption {
/**@type {SlashCommand} */
get cmd() {
// @ts-ignore
return this.value;
}
/**
* @param {SlashCommand} value
* @param {string} name
*/
constructor(value, name) {
super(value, name);
}
renderItem() {
let li;
li = this.cmd.renderHelpItem(this.name);
li.setAttribute('data-name', this.name);
return li;
}
renderDetails() {
return this.cmd.renderHelpDetails(this.name);
}
}

View File

@ -0,0 +1,21 @@
// eslint-disable-next-line no-unused-vars
import { SlashCommand } from './SlashCommand.js';
// eslint-disable-next-line no-unused-vars
import { SlashCommandClosure } from './SlashCommandClosure.js';
import { PARSER_FLAG } from './SlashCommandParser.js';
export class SlashCommandExecutor {
/**@type {Boolean}*/ injectPipe = true;
/**@type {Number}*/ start;
/**@type {Number}*/ end;
/**@type {String}*/ name = '';
/**@type {SlashCommand}*/ command;
// @ts-ignore
/**@type {Object.<string,String|SlashCommandClosure>}*/ args = {};
/**@type {String|SlashCommandClosure|(String|SlashCommandClosure)[]}*/ value;
/**@type {Object<PARSER_FLAG,boolean>} */ parserFlags;
constructor(start) {
this.start = start;
}
}

View File

@ -0,0 +1,860 @@
import { power_user } from '../power-user.js';
import { isTrueBoolean, uuidv4 } from '../utils.js';
import { SlashCommand } from './SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument } from './SlashCommandArgument.js';
import { SlashCommandClosure } from './SlashCommandClosure.js';
import { SlashCommandCommandAutoCompleteOption } from './SlashCommandCommandAutoCompleteOption.js';
import { SlashCommandExecutor } from './SlashCommandExecutor.js';
import { SlashCommandParserError } from './SlashCommandParserError.js';
import { NAME_RESULT_TYPE, SlashCommandParserNameResult } from './SlashCommandParserNameResult.js';
import { SlashCommandQuickReplyAutoCompleteOption } from './SlashCommandQuickReplyAutoCompleteOption.js';
// eslint-disable-next-line no-unused-vars
import { SlashCommandScope } from './SlashCommandScope.js';
import { SlashCommandVariableAutoCompleteOption } from './SlashCommandVariableAutoCompleteOption.js';
/**@readonly*/
/**@enum {Number}*/
export const PARSER_FLAG = {
'STRICT_ESCAPING': 1,
'REPLACE_GETVAR': 2,
};
export class SlashCommandParser {
// @ts-ignore
/**@type {Object.<string, SlashCommand>}*/ static commands = {};
static addCommand(command, callback, aliases, helpString = '', interruptsGeneration = false, purgeFromMessage = true) {
const reserved = ['/', '#', ':', 'parser-flag'];
for (const start of reserved) {
if (command.toLowerCase().startsWith(start) || (aliases ?? []).find(a=>a.toLowerCase().startsWith(start))) {
throw new Error(`Illegal Name. Slash command name cannot begin with "${start}".`);
}
}
this.addCommandUnsafe(command, callback, aliases, helpString, interruptsGeneration, purgeFromMessage);
}
static addCommandUnsafe(command, callback, aliases, helpString = '', interruptsGeneration = false, purgeFromMessage = true) {
const fnObj = Object.assign(new SlashCommand(), { name:command, callback, helpString, interruptsGeneration, purgeFromMessage, aliases });
if ([command, ...aliases].some(x => Object.hasOwn(this.commands, x))) {
console.trace('WARN: Duplicate slash command registered!', [command, ...aliases]);
}
this.commands[command] = fnObj;
if (Array.isArray(aliases)) {
aliases.forEach((alias) => {
this.commands[alias] = fnObj;
});
}
}
/**
*
* @param {SlashCommand} command
*/
static addCommandObject(command) {
const reserved = ['/', '#', ':', 'parser-flag'];
for (const start of reserved) {
if (command.name.toLowerCase().startsWith(start) || (command.aliases ?? []).find(a=>a.toLowerCase().startsWith(start))) {
throw new Error(`Illegal Name. Slash command name cannot begin with "${start}".`);
}
}
this.addCommandObjectUnsafe(command);
}
/**
*
* @param {SlashCommand} command
*/
static addCommandObjectUnsafe(command) {
if ([command.name, ...command.aliases].some(x => Object.hasOwn(this.commands, x))) {
console.trace('WARN: Duplicate slash command registered!', [command.name, ...command.aliases]);
}
this.commands[command.name] = command;
if (Array.isArray(command.aliases)) {
command.aliases.forEach((alias) => {
this.commands[alias] = command;
});
}
}
get commands() {
return SlashCommandParser.commands;
}
// @ts-ignore
/**@type {Object.<string, string>}*/ helpStrings = {};
/**@type {boolean}*/ verifyCommandNames = true;
/**@type {string}*/ text;
/**@type {string}*/ keptText;
/**@type {number}*/ index;
/**@type {SlashCommandScope}*/ scope;
/**@type {SlashCommandClosure}*/ closure;
/**@type {Object.<PARSER_FLAG,boolean>}*/ flags = {};
/**@type {boolean}*/ jumpedEscapeSequence = false;
/**@type {{start:number, end:number}[]}*/ closureIndex;
/**@type {SlashCommandExecutor[]}*/ commandIndex;
/**@type {SlashCommandScope[]}*/ scopeIndex;
get userIndex() { return this.index - 2; }
get ahead() {
return this.text.slice(this.index + 1);
}
get behind() {
return this.text.slice(0, this.index);
}
get char() {
return this.text[this.index];
}
get endOfText() {
return this.index >= this.text.length || /^\s+$/.test(this.ahead);
}
constructor() {
// add dummy commands for help strings / autocomplete
const parserFlagCmd = new SlashCommand();
parserFlagCmd.name = 'parser-flag';
parserFlagCmd.unnamedArgumentList.push(new SlashCommandArgument(
'The parser flag to modify.',
ARGUMENT_TYPE.STRING,
true,
false,
null,
Object.keys(PARSER_FLAG),
));
parserFlagCmd.unnamedArgumentList.push(new SlashCommandArgument(
'The state of the parser flag to set.',
ARGUMENT_TYPE.BOOLEAN,
false,
false,
'on',
['on', 'off'],
));
parserFlagCmd.helpString = 'Set a parser flag.';
SlashCommandParser.addCommandObjectUnsafe(parserFlagCmd);
const commentCmd = new SlashCommand();
commentCmd.name = '/';
commentCmd.aliases.push('#');
commentCmd.unnamedArgumentList.push(new SlashCommandArgument(
'commentary',
ARGUMENT_TYPE.STRING,
));
commentCmd.helpString = 'Write a comment.';
SlashCommandParser.addCommandObjectUnsafe(commentCmd);
this.registerLanguage();
}
registerLanguage() {
// NUMBER mode is copied from highlightjs's own implementation for JavaScript
// https://tc39.es/ecma262/#sec-literals-numeric-literals
const decimalDigits = '[0-9](_?[0-9])*';
const frac = `\\.(${decimalDigits})`;
// DecimalIntegerLiteral, including Annex B NonOctalDecimalIntegerLiteral
// https://tc39.es/ecma262/#sec-additional-syntax-numeric-literals
const decimalInteger = '0|[1-9](_?[0-9])*|0[0-7]*[89][0-9]*';
const NUMBER = {
className: 'number',
variants: [
// DecimalLiteral
{ begin: `(\\b(${decimalInteger})((${frac})|\\.)?|(${frac}))` +
`[eE][+-]?(${decimalDigits})\\b` },
{ begin: `\\b(${decimalInteger})\\b((${frac})\\b|\\.)?|(${frac})\\b` },
// DecimalBigIntegerLiteral
{ begin: '\\b(0|[1-9](_?[0-9])*)n\\b' },
// NonDecimalIntegerLiteral
{ begin: '\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*n?\\b' },
{ begin: '\\b0[bB][0-1](_?[0-1])*n?\\b' },
{ begin: '\\b0[oO][0-7](_?[0-7])*n?\\b' },
// LegacyOctalIntegerLiteral (does not include underscore separators)
// https://tc39.es/ecma262/#sec-additional-syntax-numeric-literals
{ begin: '\\b0[0-7]+n?\\b' },
],
relevance: 0,
};
const COMMENT = {
scope: 'comment',
begin: /\/[/#]/,
end: /\||$|:}/,
contains: [],
};
const LET = {
begin: [
/\/(let|var)\s+/,
],
beginScope: {
1: 'variable',
},
end: /\||$|:}/,
contains: [],
};
const SETVAR = {
begin: /\/(setvar|setglobalvar)\s+/,
beginScope: 'variable',
end: /\||$|:}/,
excludeEnd: true,
contains: [],
};
const GETVAR = {
begin: /\/(getvar|getglobalvar)\s+/,
beginScope: 'variable',
end: /\||$|:}/,
excludeEnd: true,
contains: [],
};
const RUN = {
match: [
/\/:/,
/(".+?(?<!\\)") |(\S+?) /,
],
className: {
1: 'variable.language',
2: 'title.function.invoke',
},
contains: [], // defined later
};
const COMMAND = {
scope: 'command',
begin: /\/\S+/,
beginScope: 'title.function',
end: /\||$|(?=:})/,
excludeEnd: true,
contains: [], // defined later
};
const CLOSURE = {
scope: 'closure',
begin: /{:/,
end: /:}(\(\))?/,
beginScope: 'punctuation',
endScope: 'punctuation',
contains: [], // defined later
};
const NAMED_ARG = {
scope: 'property',
begin: /\w+=/,
end: '',
};
const MACRO = {
scope: 'variable',
begin: /{{/,
end: /}}/,
};
RUN.contains.push(
hljs.BACKSLASH_ESCAPE,
NAMED_ARG,
hljs.QUOTE_STRING_MODE,
NUMBER,
MACRO,
CLOSURE,
);
LET.contains.push(
hljs.BACKSLASH_ESCAPE,
NAMED_ARG,
hljs.QUOTE_STRING_MODE,
NUMBER,
MACRO,
CLOSURE,
);
SETVAR.contains.push(
hljs.BACKSLASH_ESCAPE,
NAMED_ARG,
hljs.QUOTE_STRING_MODE,
NUMBER,
MACRO,
CLOSURE,
);
GETVAR.contains.push(
hljs.BACKSLASH_ESCAPE,
NAMED_ARG,
hljs.QUOTE_STRING_MODE,
NUMBER,
MACRO,
CLOSURE,
);
COMMAND.contains.push(
hljs.BACKSLASH_ESCAPE,
NAMED_ARG,
hljs.QUOTE_STRING_MODE,
NUMBER,
MACRO,
CLOSURE,
);
CLOSURE.contains.push(
hljs.BACKSLASH_ESCAPE,
COMMENT,
NAMED_ARG,
hljs.QUOTE_STRING_MODE,
NUMBER,
MACRO,
RUN,
LET,
GETVAR,
SETVAR,
COMMAND,
'self',
);
hljs.registerLanguage('stscript', ()=>({
case_insensitive: false,
keywords: ['|'],
contains: [
hljs.BACKSLASH_ESCAPE,
COMMENT,
RUN,
LET,
GETVAR,
SETVAR,
COMMAND,
CLOSURE,
],
}));
}
addCommand(command, callback, aliases, helpString = '', interruptsGeneration = false, purgeFromMessage = true) {
SlashCommandParser.addCommand(command, callback, aliases, helpString, interruptsGeneration, purgeFromMessage);
}
addDummyCommand(command, aliases, helpString) {
SlashCommandParser.addCommandUnsafe(command, null, aliases, helpString, true, true);
}
getHelpString() {
return '<div class="slashHelp">Loading...</div>';
}
getHelpStringX() {
const listItems = Object
.keys(this.commands)
.filter(key=>this.commands[key].name == key)
.map(key=>this.commands[key])
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
.map(x => x.helpStringFormatted)
.map(x => `<li>${x}</li>`)
.join('\n');
return `<p>Slash commands:</p><ol>${listItems}</ol>
<small>Slash commands can be batched into a single input by adding a pipe character | at the end, and then writing a new slash command.</small>
<ul><li><small>Example:</small><code>/cut 1 | /sys Hello, | /continue</code></li>
<li>This will remove the first message in chat, send a system message that starts with 'Hello,', and then ask the AI to continue the message.</li></ul>`;
}
/**
*
* @param {*} text The text to parse.
* @param {*} index Index to check for names (cursor position).
*/
async getNameAt(text, index) {
if (this.text != `{:${text}:}`) {
try {
this.parse(text, false);
} catch (e) {
// do nothing
console.warn(e);
}
}
index += 2;
const executor = this.commandIndex
.filter(it=>it.start <= index && (it.end >= index || it.end == null))
.slice(-1)[0]
?? null
;
if (executor) {
const childClosure = this.closureIndex
.find(it=>it.start <= index && (it.end >= index || it.end == null) && it.start > executor.start)
?? null
;
if (childClosure !== null) return null;
if (executor.name == ':') {
const options = this.scopeIndex[this.commandIndex.indexOf(executor)]
?.allVariableNames
?.map(it=>new SlashCommandVariableAutoCompleteOption(it))
?? []
;
try {
const qrApi = (await import('../extensions/quick-reply/index.js')).quickReplyApi;
options.push(...qrApi.listSets()
.map(set=>qrApi.listQuickReplies(set).map(qr=>`${set}.${qr}`))
.flat()
.map(qr=>new SlashCommandQuickReplyAutoCompleteOption(qr)),
);
} catch { /* empty */ }
const result = new SlashCommandParserNameResult(
NAME_RESULT_TYPE.CLOSURE,
executor.value.toString(),
executor.start - 2,
options,
true,
()=>`No matching variables in scope and no matching Quick Replies for "${result.name}"`,
()=>'No variables in scope and no Quick Replies found.',
);
return result;
}
const result = new SlashCommandParserNameResult(
NAME_RESULT_TYPE.COMMAND,
executor.name,
executor.start - 2,
Object
.keys(this.commands)
.map(key=>new SlashCommandCommandAutoCompleteOption(this.commands[key], key))
,
false,
()=>`No matching slash commands for "/${result.name}"`,
()=>'No slash commands found!',
);
return result;
}
return null;
}
/**
* Moves the index <length> number of characters forward and returns the last character taken.
* @param {number} length Number of characters to take.
* @param {boolean} keep Whether to add the characters to the kept text.
* @returns The last character taken.
*/
take(length = 1, keep = false) {
this.jumpedEscapeSequence = false;
let content = this.char;
this.index++;
if (keep) this.keptText += content;
if (length > 1) {
content = this.take(length - 1, keep);
}
return content;
}
discardWhitespace() {
while (/\s/.test(this.char)) {
this.take(); // discard whitespace
this.jumpedEscapeSequence = false;
}
}
/**
* Tests if the next characters match a symbol.
* Moves the index forward if the next characters are backslashes directly followed by the symbol.
* Expects that the current char is taken after testing.
* @param {string|RegExp} sequence Sequence of chars or regex character group that is the symbol.
* @param {number} offset Offset from the current index (won't move the index if offset != 0).
* @returns Whether the next characters are the indicated symbol.
*/
testSymbol(sequence, offset = 0) {
if (!this.flags[PARSER_FLAG.STRICT_ESCAPING]) return this.testSymbolLooseyGoosey(sequence, offset);
// /echo abc | /echo def
// -> TOAST: abc
// -> TOAST: def
// /echo abc \| /echo def
// -> TOAST: abc | /echo def
// /echo abc \\| /echo def
// -> TOAST: abc \
// -> TOAST: def
// /echo abc \\\| /echo def
// -> TOAST: abc \| /echo def
// /echo abc \\\\| /echo def
// -> TOAST: abc \\
// -> TOAST: def
// /echo title=\:} \{: | /echo title=\{: \:}
// -> TOAST: *:}* {:
// -> TOAST: *{:* :}
const escapeOffset = this.jumpedEscapeSequence ? -1 : 0;
const escapes = this.text.slice(this.index + offset + escapeOffset).replace(/^(\\*).*$/s, '$1').length;
const test = (sequence instanceof RegExp) ?
(text) => new RegExp(`^${sequence.source}`).test(text) :
(text) => text.startsWith(sequence)
;
if (test(this.text.slice(this.index + offset + escapeOffset + escapes))) {
// no backslashes before sequence
// -> sequence found
if (escapes == 0) return true;
// uneven number of backslashes before sequence
// = the final backslash escapes the sequence
// = every preceding pair is one literal backslash
// -> move index forward to skip the backslash escaping the first backslash or the symbol
// even number of backslashes before sequence
// = every pair is one literal backslash
// -> move index forward to skip the backslash escaping the first backslash
if (!this.jumpedEscapeSequence && offset == 0) {
this.index++;
this.jumpedEscapeSequence = true;
}
return false;
}
}
testSymbolLooseyGoosey(sequence, offset = 0) {
const escapeOffset = this.jumpedEscapeSequence ? -1 : 0;
const escapes = this.text[this.index + offset + escapeOffset] == '\\' ? 1 : 0;
const test = (sequence instanceof RegExp) ?
(text) => new RegExp(`^${sequence.source}`).test(text) :
(text) => text.startsWith(sequence)
;
if (test(this.text.slice(this.index + offset + escapeOffset + escapes))) {
// no backslashes before sequence
// -> sequence found
if (escapes == 0) return true;
// otherwise
// -> sequence found
if (!this.jumpedEscapeSequence && offset == 0) {
this.index++;
this.jumpedEscapeSequence = true;
}
return false;
}
}
replaceGetvar(value) {
return value.replace(/{{(get(?:global)?var)::([^}]+)}}/gi, (_, cmd, name) => {
name = name.trim();
// store pipe
const pipeName = `_PARSER_${uuidv4()}`;
const storePipe = new SlashCommandExecutor(null);
storePipe.command = this.commands['let'];
storePipe.name = 'let';
storePipe.value = `${pipeName} {{pipe}}`;
this.closure.executorList.push(storePipe);
// getvar / getglobalvar
const getvar = new SlashCommandExecutor(null);
getvar.command = this.commands[cmd];
getvar.name = 'cmd';
getvar.value = name;
this.closure.executorList.push(getvar);
// set to temp scoped var
const varName = `_PARSER_${uuidv4()}`;
const setvar = new SlashCommandExecutor(null);
setvar.command = this.commands['let'];
setvar.name = 'let';
setvar.value = `${varName} {{pipe}}`;
this.closure.executorList.push(setvar);
// return pipe
const returnPipe = new SlashCommandExecutor(null);
returnPipe.command = this.commands['return'];
returnPipe.name = 'return';
returnPipe.value = `{{var::${pipeName}}}`;
this.closure.executorList.push(returnPipe);
return `{{var::${varName}}}`;
});
}
parse(text, verifyCommandNames = true, flags = null) {
this.verifyCommandNames = verifyCommandNames;
for (const key of Object.keys(PARSER_FLAG)) {
this.flags[PARSER_FLAG[key]] = flags?.[PARSER_FLAG[key]] ?? power_user.stscript.parser.flags[PARSER_FLAG[key]] ?? false;
}
this.text = `{:${text}:}`;
this.keptText = '';
this.index = 0;
this.scope = null;
this.closureIndex = [];
this.commandIndex = [];
this.scopeIndex = [];
const closure = this.parseClosure();
closure.keptText = this.keptText;
return closure;
}
testClosure() {
return this.testSymbol('{:');
}
testClosureEnd() {
if (this.ahead.length < 1) throw new SlashCommandParserError(`Unclosed closure at position ${this.userIndex}`, this.text, this.index);
return this.testSymbol(':}');
}
parseClosure() {
const closureIndexEntry = { start:this.index + 1, end:null };
this.closureIndex.push(closureIndexEntry);
let injectPipe = true;
this.take(2); // discard opening {:
let closure = new SlashCommandClosure(this.scope);
this.scope = closure.scope;
this.closure = closure;
this.discardWhitespace();
while (this.testNamedArgument()) {
const arg = this.parseNamedArgument();
closure.arguments[arg.key] = arg.value;
this.scope.variableNames.push(arg.key);
this.discardWhitespace();
}
while (!this.testClosureEnd()) {
if (this.testComment()) {
this.parseComment();
} else if (this.testParserFlag()) {
this.parseParserFlag();
} else if (this.testRunShorthand()) {
const cmd = this.parseRunShorthand();
closure.executorList.push(cmd);
injectPipe = true;
} else if (this.testCommand()) {
const cmd = this.parseCommand();
cmd.injectPipe = injectPipe;
closure.executorList.push(cmd);
injectPipe = true;
} else {
while (!this.testCommandEnd()) this.take(); // discard plain text and comments
}
this.discardWhitespace();
// first pipe marks end of command
if (this.testSymbol('|')) {
this.take(); // discard first pipe
// second pipe indicates no pipe injection for the next command
if (this.testSymbol('|')) {
injectPipe = false;
this.take(); // discard second pipe
}
}
this.discardWhitespace(); // discard further whitespace
}
this.take(2); // discard closing :}
if (this.testSymbol('()')) {
this.take(2); // discard ()
closure.executeNow = true;
}
closureIndexEntry.end = this.index - 1;
this.discardWhitespace(); // discard trailing whitespace
this.scope = closure.scope.parent;
return closure;
}
testComment() {
return this.testSymbol(/\/[/#]/);
}
testCommentEnd() {
return this.testCommandEnd();
}
parseComment() {
const start = this.index + 1;
const cmd = new SlashCommandExecutor(start);
this.commandIndex.push(cmd);
this.scopeIndex.push(this.scope.getCopy());
this.take(); // discard "/"
cmd.name = this.take(); // set second "/" or "#" as name
while (!this.testCommentEnd()) this.take();
cmd.end = this.index;
}
testParserFlag() {
return this.testSymbol('/parser-flag ');
}
testParserFlagEnd() {
return this.testCommandEnd();
}
parseParserFlag() {
const start = this.index + 1;
const cmd = new SlashCommandExecutor(start);
cmd.name = 'parser-flag';
cmd.value = '';
this.commandIndex.push(cmd);
this.scopeIndex.push(this.scope.getCopy());
this.take(13); // discard "/parser-flag "
const [flag, state] = this.parseUnnamedArgument()?.split(/\s+/) ?? [null, null];
if (Object.keys(PARSER_FLAG).includes(flag)) {
this.flags[PARSER_FLAG[flag]] = isTrueBoolean(state ?? 'on');
}
cmd.end = this.index;
}
testRunShorthand() {
return this.testSymbol('/:') && !this.testSymbol(':}', 1);
}
testRunShorthandEnd() {
return this.testCommandEnd();
}
parseRunShorthand() {
const start = this.index + 2;
const cmd = new SlashCommandExecutor(start);
cmd.name = ':';
cmd.value = '';
cmd.command = this.commands['run'];
this.commandIndex.push(cmd);
this.scopeIndex.push(this.scope.getCopy());
this.take(2); //discard "/:"
if (this.testQuotedValue()) cmd.value = this.parseQuotedValue();
else cmd.value = this.parseValue();
this.discardWhitespace();
while (this.testNamedArgument()) {
const arg = this.parseNamedArgument();
cmd.args[arg.key] = arg.value;
this.discardWhitespace();
}
this.discardWhitespace();
// /run shorthand does not take unnamed arguments (the command name practically *is* the unnamed argument)
if (this.testRunShorthandEnd()) {
cmd.end = this.index;
if (!cmd.command?.purgeFromMessage) this.keptText += this.text.slice(cmd.start, cmd.end);
return cmd;
} else {
console.warn(this.behind, this.char, this.ahead);
throw new SlashCommandParserError(`Unexpected end of command at position ${this.userIndex}: "/${cmd.name}"`, this.text, this.index);
}
}
testCommand() {
return this.testSymbol('/');
}
testCommandEnd() {
return this.testClosureEnd() || this.testSymbol('|');
}
parseCommand() {
const start = this.index + 1;
const cmd = new SlashCommandExecutor(start);
cmd.parserFlags = Object.assign({}, this.flags);
this.commandIndex.push(cmd);
this.scopeIndex.push(this.scope.getCopy());
this.take(); // discard "/"
while (!/\s/.test(this.char) && !this.testCommandEnd()) cmd.name += this.take(); // take chars until whitespace or end
this.discardWhitespace();
if (this.verifyCommandNames && !this.commands[cmd.name]) throw new SlashCommandParserError(`Unknown command at position ${this.index - cmd.name.length - 2}: "/${cmd.name}"`, this.text, this.index - cmd.name.length);
cmd.command = this.commands[cmd.name];
while (this.testNamedArgument()) {
const arg = this.parseNamedArgument();
cmd.args[arg.key] = arg.value;
this.discardWhitespace();
}
this.discardWhitespace();
if (this.testUnnamedArgument()) {
cmd.value = this.parseUnnamedArgument();
if (cmd.name == 'let') {
if (Array.isArray(cmd.value)) {
if (typeof cmd.value[0] == 'string') {
this.scope.variableNames.push(cmd.value[0]);
}
} else if (typeof cmd.value == 'string') {
this.scope.variableNames.push(cmd.value.split(/\s+/)[0]);
}
}
}
if (this.testCommandEnd()) {
cmd.end = this.index;
if (!cmd.command?.purgeFromMessage) this.keptText += this.text.slice(cmd.start, cmd.end);
return cmd;
} else {
console.warn(this.behind, this.char, this.ahead);
throw new SlashCommandParserError(`Unexpected end of command at position ${this.userIndex}: "/${cmd.name}"`, this.text, this.index);
}
}
testNamedArgument() {
return /^(\w+)=/.test(`${this.char}${this.ahead}`);
}
parseNamedArgument() {
let key = '';
while (/\w/.test(this.char)) key += this.take(); // take chars
this.take(); // discard "="
let value;
if (this.testClosure()) {
value = this.parseClosure();
} else if (this.testQuotedValue()) {
value = this.parseQuotedValue();
} else if (this.testListValue()) {
value = this.parseListValue();
} else if (this.testValue()) {
value = this.parseValue();
}
return { key, value };
}
testUnnamedArgument() {
return !this.testCommandEnd();
}
testUnnamedArgumentEnd() {
return this.testCommandEnd();
}
parseUnnamedArgument() {
/**@type {SlashCommandClosure|String}*/
let value = this.jumpedEscapeSequence ? this.take() : ''; // take the first, already tested, char if it is an escaped one
let isList = false;
let listValues = [];
while (!this.testUnnamedArgumentEnd()) {
if (this.testClosure()) {
isList = true;
if (value.length > 0) {
listValues.push(value.trim());
value = '';
}
listValues.push(this.parseClosure());
} else if (this.testQuotedValue()) {
isList = true;
if (value.length > 0) {
listValues.push(value.trim());
value = '';
}
listValues.push(this.parseQuotedValue());
} else {
value += this.take();
}
}
if (isList && value.trim().length > 0) {
listValues.push(value.trim());
}
if (isList) {
if (listValues.length == 1) return listValues[0];
return listValues;
}
value = value.trim();
if (this.flags[PARSER_FLAG.REPLACE_GETVAR]) {
value = this.replaceGetvar(value);
}
return value;
}
testQuotedValue() {
return this.testSymbol('"');
}
testQuotedValueEnd() {
if (this.endOfText) {
if (this.verifyCommandNames) throw new SlashCommandParserError(`Unexpected end of quoted value at position ${this.index}`, this.text, this.index);
else return true;
}
if (!this.verifyCommandNames && this.testClosureEnd()) return true;
if (this.verifyCommandNames && !this.flags[PARSER_FLAG.STRICT_ESCAPING] && this.testCommandEnd()) {
throw new SlashCommandParserError(`Unexpected end of quoted value at position ${this.index}`, this.text, this.index);
}
return this.testSymbol('"') || (!this.flags[PARSER_FLAG.STRICT_ESCAPING] && this.testCommandEnd());
}
parseQuotedValue() {
this.take(); // discard opening quote
let value = '';
while (!this.testQuotedValueEnd()) value += this.take(); // take all chars until closing quote
this.take(); // discard closing quote
if (this.flags[PARSER_FLAG.REPLACE_GETVAR]) {
value = this.replaceGetvar(value);
}
return value;
}
testListValue() {
return this.testSymbol('[');
}
testListValueEnd() {
if (this.endOfText) throw new SlashCommandParserError(`Unexpected end of list value at position ${this.index}`, this.text, this.index);
return this.testSymbol(']');
}
parseListValue() {
let value = this.take(); // take the already tested opening bracket
while (!this.testListValueEnd()) value += this.take(); // take all chars until closing bracket
value += this.take(); // take closing bracket
if (this.flags[PARSER_FLAG.REPLACE_GETVAR]) {
value = this.replaceGetvar(value);
}
return value;
}
testValue() {
return !this.testSymbol(/\s/);
}
testValueEnd() {
if (this.testSymbol(/\s/)) return true;
return this.testCommandEnd();
}
parseValue() {
let value = this.jumpedEscapeSequence ? this.take() : ''; // take the first, already tested, char if it is an escaped one
while (!this.testValueEnd()) value += this.take(); // take all chars until value end
if (this.flags[PARSER_FLAG.REPLACE_GETVAR]) {
value = this.replaceGetvar(value);
}
return value;
}
}

View File

@ -0,0 +1,47 @@
export class SlashCommandParserError extends Error {
/**@type {String}*/ text;
/**@type {Number}*/ index;
get line() {
return this.text.slice(0, this.index).replace(/[^\n]/g, '').length;
}
get column() {
return this.text.slice(0, this.index).split('\n').pop().length;
}
get hint() {
let lineOffset = this.line.toString().length;
let lineStart = this.index;
let start = this.index;
let end = this.index;
let offset = 0;
let lineCount = 0;
while (offset < 10000 && lineCount < 3 && start >= 0) {
if (this.text[start] == '\n') lineCount++;
if (lineCount == 0) lineStart--;
offset++;
start--;
}
if (this.text[start + 1] == '\n') start++;
offset = 0;
while (offset < 10000 && this.text[end] != '\n') {
offset++;
end++;
}
let hint = [];
let lines = this.text.slice(start + 1, end - 1).split('\n');
let lineNum = this.line - lines.length + 1;
for (const line of lines) {
const num = `${' '.repeat(lineOffset - lineNum.toString().length)}${lineNum}`;
lineNum++;
hint.push(`${num}: ${line}`);
}
hint.push(`${' '.repeat(this.index - lineStart + lineOffset + 1)}^^^^^`);
return hint.join('\n');
}
constructor(message, text, index) {
super(message);
this.text = text.slice(2, -2);
this.index = index - 2;
}
}

View File

@ -0,0 +1,41 @@
import { SlashCommandAutoCompleteOption } from './SlashCommandAutoCompleteOption.js';
/**@readonly*/
/**@enum {number}*/
export const NAME_RESULT_TYPE = {
'COMMAND': 1,
'CLOSURE': 2,
};
export class SlashCommandParserNameResult {
/**@type {NAME_RESULT_TYPE} */ type;
/**@type {string} */ name;
/**@type {number} */ start;
/**@type {SlashCommandAutoCompleteOption[]} */ optionList = [];
/**@type {boolean} */ canBeQuoted = false;
/**@type {()=>string} */ makeNoMatchText = ()=>`No matches found for "${this.name}"`;
/**@type {()=>string} */ makeNoOptionstext = ()=>'No options';
/**
* @param {NAME_RESULT_TYPE} type Type of the name at the requested index.
* @param {string} name Name (potentially partial) of the name at the requested index.
* @param {number} start Index where the name starts.
* @param {SlashCommandAutoCompleteOption[]} optionList A list of autocomplete options found in the current scope.
* @param {boolean} canBeQuoted Whether the name can be inside quotes.
* @param {()=>string} makeNoMatchText Function that returns text to show when no matches where found.
* @param {()=>string} makeNoOptionsText Function that returns text to show when no options are available to match against.
*/
constructor(type, name, start, optionList = [], canBeQuoted = false, makeNoMatchText = null, makeNoOptionsText = null) {
this.type = type;
this.name = name;
this.start = start;
this.optionList = optionList;
this.canBeQuoted = canBeQuoted;
this.noMatchText = makeNoMatchText ?? this.makeNoMatchText;
this.noOptionstext = makeNoOptionsText ?? this.makeNoOptionstext;
}
}

View File

@ -0,0 +1,39 @@
import { SlashCommandAutoCompleteOption } from './SlashCommandAutoCompleteOption.js';
export class SlashCommandQuickReplyAutoCompleteOption extends SlashCommandAutoCompleteOption {
/**
* @param {string} value
*/
constructor(value) {
super(value, value);
}
renderItem() {
let li;
li = this.makeItem(this.name, 'QR', true);
li.setAttribute('data-name', this.name);
return li;
}
renderDetails() {
const frag = document.createDocumentFragment();
const specs = document.createElement('div'); {
specs.classList.add('specs');
const name = document.createElement('div'); {
name.classList.add('name');
name.classList.add('monospace');
name.textContent = this.value.toString();
specs.append(name);
}
frag.append(specs);
}
const help = document.createElement('span'); {
help.classList.add('help');
help.textContent = 'Quick Reply';
frag.append(help);
}
return frag;
}
}

View File

@ -0,0 +1,114 @@
import { SlashCommandClosure } from './SlashCommandClosure.js';
export class SlashCommandScope {
/**@type {string[]}*/ variableNames = [];
get allVariableNames() {
const names = [...this.variableNames, ...(this.parent?.allVariableNames ?? [])];
return names.filter((it,idx)=>idx == names.indexOf(it));
}
// @ts-ignore
/**@type {object.<string, string|SlashCommandClosure>}*/ variables = {};
// @ts-ignore
/**@type {object.<string, string|SlashCommandClosure>}*/ macros = {};
/**@type {{key:string, value:string|SlashCommandClosure}[]} */
get macroList() {
return [...Object.keys(this.macros).map(key=>({ key, value:this.macros[key] })), ...(this.parent?.macroList ?? [])];
}
/**@type {SlashCommandScope}*/ parent;
/**@type {string}*/ #pipe;
get pipe() {
return this.#pipe ?? this.parent?.pipe;
}
set pipe(value) {
this.#pipe = value;
}
constructor(parent) {
this.parent = parent;
}
getCopy() {
const scope = new SlashCommandScope(this.parent);
scope.variableNames = [...this.variableNames];
scope.variables = Object.assign({}, this.variables);
scope.macros = Object.assign({}, this.macros);
scope.#pipe = this.#pipe;
return scope;
}
setMacro(key, value) {
this.macros[key] = value;
}
existsVariableInScope(key) {
return Object.keys(this.variables).includes(key);
}
existsVariable(key) {
return Object.keys(this.variables).includes(key) || this.parent?.existsVariable(key);
}
letVariable(key, value = undefined) {
if (this.existsVariableInScope(key)) throw new SlashCommandScopeVariableExistsError(`Variable named "${key}" already exists.`);
this.variables[key] = value;
}
setVariable(key, value, index = null) {
if (this.existsVariableInScope(key)) {
if (index !== null && index !== undefined) {
let v = this.variables[key];
try {
v = JSON.parse(v);
const numIndex = Number(index);
if (Number.isNaN(numIndex)) {
v[index] = value;
} else {
v[numIndex] = value;
}
v = JSON.stringify(v);
} catch {
v[index] = value;
}
this.variables[key] = v;
} else {
this.variables[key] = value;
}
return value;
}
if (this.parent) {
return this.parent.setVariable(key, value, index);
}
throw new SlashCommandScopeVariableNotFoundError(`No such variable: "${key}"`);
}
getVariable(key, index = null) {
if (this.existsVariableInScope(key)) {
if (index !== null && index !== undefined) {
let v = this.variables[key];
try { v = JSON.parse(v); } catch { /* empty */ }
const numIndex = Number(index);
if (Number.isNaN(numIndex)) {
v = v[index];
} else {
v = v[numIndex];
}
if (typeof v == 'object') return JSON.stringify(v);
return v;
} else {
const value = this.variables[key];
return (value === '' || isNaN(Number(value))) ? (value || '') : Number(value);
}
}
if (this.parent) {
return this.parent.getVariable(key, index);
}
throw new SlashCommandScopeVariableNotFoundError(`No such variable: "${key}"`);
}
}
export class SlashCommandScopeVariableExistsError extends Error {}
export class SlashCommandScopeVariableNotFoundError extends Error {}

View File

@ -0,0 +1,39 @@
import { SlashCommandAutoCompleteOption } from './SlashCommandAutoCompleteOption.js';
export class SlashCommandVariableAutoCompleteOption extends SlashCommandAutoCompleteOption {
/**
* @param {string} value
*/
constructor(value) {
super(value, value);
}
renderItem() {
let li;
li = this.makeItem(this.name, '𝑥', true);
li.setAttribute('data-name', this.name);
return li;
}
renderDetails() {
const frag = document.createDocumentFragment();
const specs = document.createElement('div'); {
specs.classList.add('specs');
const name = document.createElement('div'); {
name.classList.add('name');
name.classList.add('monospace');
name.textContent = this.value.toString();
specs.append(name);
}
frag.append(specs);
}
const help = document.createElement('span'); {
help.classList.add('help');
help.textContent = 'scoped variable';
frag.append(help);
}
return frag;
}
}

View File

@ -72,6 +72,7 @@
</div>
<div><small>Local variables = unique to the current chat</small></div>
<div><small>Global variables = works in any chat for any character</small></div>
<div><small>Scoped variables = works in STscript</small></div>
<ul>
<li><tt>&lcub;&lcub;getvar::name&rcub;&rcub;</tt> replaced with the value of the local variable "name"</li>
<li><tt>&lcub;&lcub;setvar::name::value&rcub;&rcub;</tt> replaced with empty string, sets the local variable "name" to "value"</li>
@ -83,4 +84,6 @@
<li><tt>&lcub;&lcub;addglobalvar::name::value&rcub;&rcub;</tt> replaced with empty string, adds a numeric value of "increment" to the global variable "name"</li>
<li><tt>&lcub;&lcub;incglobalvar::name&rcub;&rcub;</tt> replaced with the result of the increment of value of the global variable "name" by 1</li>
<li><tt>&lcub;&lcub;decglobalvar::name&rcub;&rcub;</tt> replaced with the result of the decrement of value of the global variable "name" by 1</li>
<li><tt>&lcub;&lcub;var::name&rcub;&rcub;</tt> replaced with the value of the scoped variable "name"</li>
<li><tt>&lcub;&lcub;var::name::index&rcub;&rcub;</tt> replaced with the value of item at index (for arrays / lists or objects / dictionaries) of the scoped variable "name"</li>
</ul>

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,9 @@ import { getTokenCountAsync } from './tokenizers.js';
import { power_user } from './power-user.js';
import { getTagKeyForEntity } from './tags.js';
import { resolveVariable } from './variables.js';
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
export {
world_info,
@ -590,12 +593,169 @@ function registerWorldInfoSlashCommands() {
return '';
}
registerSlashCommand('world', onWorldInfoChange, [], '<span class="monospace">[optional state=off|toggle] [optional silent=true] (optional name)</span> sets active World, or unsets if no args provided, use <code>state=off</code> and <code>state=toggle</code> to deactivate or toggle a World, use <code>silent=true</code> to suppress toast messages', true, true);
registerSlashCommand('getchatbook', getChatBookCallback, ['getchatlore', 'getchatwi'], ' get a name of the chat-bound lorebook or create a new one if was unbound, and pass it down the pipe', true, true);
registerSlashCommand('findentry', findBookEntryCallback, ['findlore', 'findwi'], '<span class="monospace">(file=bookName field=field [texts])</span> find a UID of the record from the specified book using the fuzzy match of a field value (default: key) and pass it down the pipe, e.g. <tt>/findentry file=chatLore field=key Shadowfang</tt>', true, true);
registerSlashCommand('getentryfield', getEntryFieldCallback, ['getlorefield', 'getwifield'], '<span class="monospace">(file=bookName field=field [UID])</span> get a field value (default: content) of the record with the UID from the specified book and pass it down the pipe, e.g. <tt>/getentryfield file=chatLore field=content 123</tt>', true, true);
registerSlashCommand('createentry', createEntryCallback, ['createlore', 'createwi'], '<span class="monospace">(file=bookName key=key [content])</span> create a new record in the specified book with the key and content (both are optional) and pass the UID down the pipe, e.g. <tt>/createentry file=chatLore key=Shadowfang The sword of the king</tt>', true, true);
registerSlashCommand('setentryfield', setEntryFieldCallback, ['setlorefield', 'setwifield'], '<span class="monospace">(file=bookName uid=UID field=field [value])</span> set a field value (default: content) of the record with the UID from the specified book. To set multiple values for key fields, use comma-delimited list as a value, e.g. <tt>/setentryfield file=chatLore uid=123 field=key Shadowfang,sword,weapon</tt>', true, true);
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'world',
callback: onWorldInfoChange,
namedArgumentList: [
new SlashCommandNamedArgument(
'state', 'set world state', [ARGUMENT_TYPE.STRING], false, false, null, ['off', 'toggle'],
),
new SlashCommandNamedArgument(
'silent', 'suppress toast messages', [ARGUMENT_TYPE.BOOLEAN], false,
),
],
unnamedArgumentList: [
new SlashCommandArgument(
'name', [ARGUMENT_TYPE.STRING], false,
),
],
helpString: `
<div>
Sets active World, or unsets if no args provided, use <code>state=off</code> and <code>state=toggle</code> to deactivate or toggle a World, use <code>silent=true</code> to suppress toast messages.
</div>
`,
aliases: [],
interruptsGeneration: true,
purgeFromMessage: true,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'getchatbook',
callback: getChatBookCallback,
returns: 'lorebook name',
helpString: 'Get a name of the chat-bound lorebook or create a new one if was unbound, and pass it down the pipe.',
aliases: ['getchatlore', 'getchatwi'],
interruptsGeneration: true,
purgeFromMessage: true,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'findentry',
aliases: ['findlore', 'findwi'],
returns: 'UID',
callback: findBookEntryCallback,
namedArgumentList: [
new SlashCommandNamedArgument(
'file', 'bookName', ARGUMENT_TYPE.STRING, true,
),
new SlashCommandNamedArgument(
'field', 'field value for fuzzy match (default: key)', ARGUMENT_TYPE.STRING, false, false, 'key',
),
],
unnamedArgumentList: [
new SlashCommandArgument(
'texts', ARGUMENT_TYPE.STRING, true, true,
),
],
helpString: `
<div>
Find a UID of the record from the specified book using the fuzzy match of a field value (default: key) and pass it down the pipe.
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code>/findentry file=chatLore field=key Shadowfang</code></pre>
</li>
</ul>
</div>
`,
interruptsGeneration: true,
purgeFromMessage: true,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'getentryfield',
aliases: ['getlorefield', 'getwifield'],
callback: getEntryFieldCallback,
returns: 'field value',
namedArgumentList: [
new SlashCommandNamedArgument(
'file', 'bookName', ARGUMENT_TYPE.STRING, true,
),
new SlashCommandNamedArgument(
'field', 'field to retrieve (default: content)', ARGUMENT_TYPE.STRING, false, false, 'content',
),
],
unnamedArgumentList: [
new SlashCommandArgument(
'UID', ARGUMENT_TYPE.STRING, true,
),
],
helpString: `
<div>
Get a field value (default: content) of the record with the UID from the specified book and pass it down the pipe.
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code>/getentryfield file=chatLore field=content 123</code></pre>
</li>
</ul>
</div>
`,
interruptsGeneration: true,
purgeFromMessage: true,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'createentry',
callback: createEntryCallback,
aliases: ['createlore', 'createwi'],
returns: 'UID of the new record',
namedArgumentList: [
new SlashCommandNamedArgument(
'file', 'book name', [ARGUMENT_TYPE.STRING], true,
),
new SlashCommandNamedArgument(
'key', 'record key', [ARGUMENT_TYPE.STRING], false,
),
],
unnamedArgumentList: [
new SlashCommandArgument(
'content', [ARGUMENT_TYPE.STRING], false,
),
],
helpString: `
<div>
Create a new record in the specified book with the key and content (both are optional) and pass the UID down the pipe.
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code>/createentry file=chatLore key=Shadowfang The sword of the king</code></pre>
</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'setentryfield',
callback: setEntryFieldCallback,
aliases: ['setlorefield', 'setwifield'],
namedArgumentList: [
new SlashCommandNamedArgument(
'file', 'book name', [ARGUMENT_TYPE.STRING], true,
),
new SlashCommandNamedArgument(
'uid', 'record UID', [ARGUMENT_TYPE.STRING], true,
),
new SlashCommandNamedArgument(
'field', 'field name', [ARGUMENT_TYPE.STRING], true, false, 'content',
),
],
unnamedArgumentList: [
new SlashCommandArgument(
'value', [ARGUMENT_TYPE.STRING], true,
),
],
helpString: `
<div>
Set a field value (default: content) of the record with the UID from the specified book. To set multiple values for key fields, use comma-delimited list as a value.
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code>/setentryfield file=chatLore uid=123 field=key Shadowfang,sword,weapon</code></pre>
</li>
</ul>
</div>
`,
}));
}
// World Info Editor

View File

@ -76,6 +76,7 @@
/*base variable calculated in rems*/
--fontScale: 1;
--mainFontSize: calc(var(--fontScale) * 15px);
--mainFontFamily: "Noto Sans", "Noto Color Emoji", sans-serif;
/* base variable for blur strength slider calculations */
--blurStrength: 10;
@ -133,7 +134,7 @@ body {
background-repeat: no-repeat;
background-attachment: fixed;
background-size: cover;
font-family: "Noto Sans", "Noto Color Emoji", sans-serif;
font-family: var(--mainFontFamily);
font-size: var(--mainFontSize);
color: var(--SmartThemeBodyColor);
overflow: hidden;
@ -1064,6 +1065,603 @@ select {
order: 3;
}
.slashCommandAutoComplete-wrap {
--targetOffset: 0;
--direction: column;
--leftOffset: 1vw;
--rightOffset: 1vw;
display: flex;
flex-direction: var(--direction);
position: absolute;
left: var(--leftOffset);
right: var(--rightOffset);
z-index: 10000;
&.isFloating {
--direction: row;
left: 0;
right: 0;
&:before {
content: "";
flex: 0 1 calc(var(--targetOffset) * 1px);
display: block;
pointer-events: none;
}
.slashCommandAutoComplete {
flex: 0 0 auto;
width: 50vw;
pointer-events: all;
}
&:after {
content: "";
flex: 1 1 0;
display: block;
pointer-events: none;
}
}
}
.slashCommandAutoComplete-detailsWrap {
--targetOffset: 0;
display: flex;
flex-direction: column;
position: absolute;
z-index: 10000;
&:before {
content: "";
flex: 0 1 calc(var(--targetOffset) * 1px - 5vh);
display: block;
pointer-events: none;
}
.slashCommandAutoComplete-details {
flex: 0 0 auto;
max-height: 80vh;
pointer-events: all;
}
&:after {
content: "";
flex: 1 1 0;
display: block;
pointer-events: none;
}
&.isFloating {
flex-direction: row;
left: 0;
right: 0;
.slashCommandAutoComplete-details {
max-height: unset;
width: 25vw;
}
&.left {
&:before {
flex: 0 1 calc(var(--targetOffset) * 1px - 25vw);
}
&:after {
flex: 1 0 auto;
max-width: 50vw;
}
}
&.right {
&:before {
flex: 0 0 calc(var(--targetOffset) * 1px + 50vw);
}
}
&.full {
&:before {
content: "";
flex: 0 1 calc(var(--targetOffset) * 1px);
display: block;
}
.slashCommandAutoComplete-details {
flex: 0 0 auto;
max-width: 50vw;
width: unset;
}
&:after {
content: "";
flex: 1 1 0;
display: block;
}
}
}
}
body[data-stscript-style="dark"] {
--ac-style-color-border: rgba(69 69 69 / 1);
--ac-style-color-background: rgba(32 32 32 / 1);
--ac-style-color-text: rgba(204 204 204 / 1);
--ac-style-color-matchedBackground: rgba(0 0 0 / 0);
--ac-style-color-matchedText: rgba(108 171 251 / 1);
--ac-style-color-selectedBackground: rgba(32 57 92 / 1);
--ac-style-color-selectedText: rgba(255 255 255 / 1);
--ac-style-color-hoveredBackground: rgba(43 45 46 / 1);
--ac-style-color-hoveredText: rgba(204 204 204 / 1);
--ac-style-color-argName: rgba(171 209 239 / 1);
--ac-style-color-type: rgba(131 199 177 / 1);
--ac-style-color-cmd: rgba(219 219 173 / 1);
--ac-style-color-symbol: rgba(115 156 211 / 1);
--ac-style-color-string: rgba(190 146 122 / 1);
--ac-style-color-number: rgba(188 205 170 / 1);
--ac-style-color-variable: rgba(131 193 252 / 1);
--ac-style-color-variableLanguage: rgba(98 160 251 / 1);
--ac-style-color-punctuation: rgba(242 214 48 / 1);
--ac-style-color-punctuationL1: rgba(195 118 210 / 1);
--ac-style-color-punctuationL2: rgba(98 160 251 / 1);
--ac-style-color-currentParenthesis: rgba(195 118 210 / 1);
--ac-style-color-comment: rgba(122 151 90 / 1);
}
body[data-stscript-style="light"] {
--ac-style-color-border: rgba(200 200 200 / 1);
--ac-style-color-background: rgba(248 248 248 / 1);
--ac-style-color-text: rgba(59 59 59 / 1);
--ac-style-color-matchedBackground: rgba(0 0 0 / 0);
--ac-style-color-matchedText: rgba(61 104 188 / 1);
--ac-style-color-selectedBackground: rgba(232 232 232 / 1);
--ac-style-color-selectedText: rgba(0 0 0 / 1);
--ac-style-color-hoveredBackground: rgba(242 242 242 / 1);
--ac-style-color-hoveredText: rgba(59 59 59 / 1);
--ac-style-color-argName: rgba(16 24 125 / 1);
--ac-style-color-type: rgba(80 127 152 / 1);
--ac-style-color-cmd: rgba(113 94 43 / 1);
--ac-style-color-symbol: rgba(36 37 249 / 1);
--ac-style-color-string: rgba(139 31 24 / 1);
--ac-style-color-number: rgba(76 132 91 / 1);
--ac-style-color-variable: rgba(16 24 125 / 1);
--ac-style-color-currentParenthesis: rgba(195 118 210 / 1);
--ac-style-color-comment: rgba(70 126 26 / 1);
}
body[data-stscript-style="theme"] {
--ac-style-color-border: var(--SmartThemeBorderColor);
--ac-style-color-background: var(--SmartThemeBlurTintColor);
--ac-style-color-text: var(--SmartThemeEmColor);
--ac-style-color-matchedBackground: rgba(0 0 0 / 0);
--ac-style-color-matchedText: var(--SmartThemeQuoteColor);
--ac-style-color-selectedBackground: color-mix(in srgb, rgb(128 128 128) 75%, var(--SmartThemeChatTintColor));
--ac-style-color-selectedText: var(--SmartThemeBodyColor);
--ac-style-color-hoveredBackground: color-mix(in srgb, rgb(128 128 128) 30%, var(--SmartThemeChatTintColor));
--ac-style-color-hoveredText: var(--SmartThemeEmColor);
--ac-style-color-argName: rgba(171 209 239 / 1);
--ac-style-color-type: rgba(131 199 177 / 1);
--ac-style-color-cmd: rgba(219 219 173 / 1);
--ac-style-color-symbol: rgba(115 156 211 / 1);
--ac-style-color-string: rgba(190 146 122 / 1);
--ac-style-color-variable: rgba(131 193 252 / 1);
--ac-style-color-currentParenthesis: rgba(195 118 210 / 1);
--ac-style-color-comment: rgba(122 151 90 / 1);
}
.slashCommandAutoComplete, .slashCommandAutoComplete-details {
--ac-color-border: var(--ac-style-color-border, rgba(69 69 69 / 1));
--ac-color-background: var(--ac-style-color-background, rgba(32 32 32 / 1));
--ac-color-text: var(--ac-style-color-text, rgba(204 204 204 / 1));
--ac-color-matchedBackground: var(--ac-style-color-matchedBackground, rgba(0 0 0 / 0));
--ac-color-matchedText: var(--ac-style-color-matchedText, rgba(108 171 251 / 1));
--ac-color-selectedBackground: var(--ac-style-color-selectedBackground, rgba(32 57 92 / 1));
--ac-color-selectedText: var(--ac-style-color-selectedText, rgba(255 255 255 / 1));
--ac-color-hoveredBackground: var(--ac-style-color-hoveredBackground, rgba(43 45 46 / 1));
--ac-color-hoveredText: var(--ac-style-color-hoveredText, rgba(204 204 204 / 1));
--ac-color-argName: var(--ac-style-color-argName, rgba(171 209 239 / 1));
--ac-color-type: var(--ac-style-color-type, rgba(131 199 177 / 1));
--ac-color-cmd: var(--ac-style-color-cmd, rgba(219 219 173 / 1));
--ac-color-symbol: var(--ac-style-color-symbol, rgba(115 156 211 / 1));
--ac-color-string: var(--ac-style-color-string, rgba(190 146 122 / 1));
--ac-color-number: var(--ac-style-color-number, rgba(188 205 170 / 1));
--ac-color-variable: var(--ac-style-color-variable, rgba(131 193 252 / 1));
--ac-color-variableLanguage: var(--ac-style-color-variableLanguage, rgba(98 160 251 / 1));
--ac-color-punctuation: var(--ac-style-color-punctuation, rgba(242 214 48 / 1));
--ac-color-punctuationL1: var(--ac-style-color-punctuationL1, rgba(195 118 210 / 1));
--ac-color-punctuationL2: var(--ac-style-color-punctuationL2, rgba(98 160 251 / 1));
--ac-color-currentParenthesis: var(--ac-style-color-currentParenthesis, rgba(195 118 210 / 1));
--ac-color-comment: var(--ac-style-color-comment, rgba(122 151 90 / 1));
--bottom: 50vh;
background: var(--ac-color-background);
backdrop-filter: blur(var(--SmartThemeBlurStrength));
border: 1px solid var(--ac-color-border);
border-radius: 3px;
color: var(--ac-color-text);
max-height: calc(95vh - var(--bottom));
list-style: none;
margin: 0px;
overflow: auto;
padding: 0px;
padding-bottom: 1px;
line-height: 1.2;
text-align: left;
z-index: 10000;
}
body[data-stscript-style] .hljs.language-stscript {
* { text-shadow: none !important; }
text-shadow: none !important;
background-color: var(--ac-style-color-background);
color: var(--ac-style-color-text);
.hljs-title.function_ { color: var(--ac-style-color-cmd); }
.hljs-title.function_.invoke__ { color: var(--ac-style-color-cmd); }
.hljs-string { color: var(--ac-style-color-string); }
.hljs-number { color: var(--ac-style-color-number); }
.hljs-variable { color: var(--ac-style-color-variable); }
.hljs-variable.language_ { color: var(--ac-style-color-variableLanguage); }
.hljs-property { color: var(--ac-style-color-argName); }
.hljs-punctuation { color: var(--ac-style-color-punctuation); }
.hljs-keyword { color: var(--ac-style-color-variableLanguage); }
.hljs-comment { color: var(--ac-style-color-comment); }
.hljs-closure {
> .hljs-punctuation { color: var(--ac-style-color-punctuation); }
.hljs-closure {
> .hljs-punctuation { color: var(--ac-style-color-punctuationL1); }
.hljs-closure {
> .hljs-punctuation { color: var(--ac-style-color-punctuationL2); }
.hljs-closure {
> .hljs-punctuation { color: var(--ac-style-color-punctuation); }
.hljs-closure {
> .hljs-punctuation { color: var(--ac-style-color-punctuationL1); }
.hljs-closure {
> .hljs-punctuation { color: var(--ac-style-color-punctuationL2); }
}
}
}
}
}
}
}
.slashCommandAutoComplete {
padding-bottom: 1px;
/* position: absolute; */
display: grid;
grid-template-columns: 0fr auto minmax(50%, 1fr);
align-items: center;
max-height: calc(95vh - var(--bottom));
/* gap: 0.5em; */
> .item {
cursor: pointer;
padding: 3px;
text-shadow: none;
display: flex;
gap: 0.5em;
font-size: 0.8em;
display: contents;
&.blank {
display: block;
grid-column: 1 / 4;
}
&:hover > * {
background-color: var(--ac-color-hoveredBackground);
color: var(--ac-color-hoveredText);
}
&.selected > * {
background-color: var(--ac-color-selectedBackground);
color: var(--ac-color-selectedText);
}
> * {
height: 100%;
}
> *+* {
padding-left: 0.5em;
}
> .type {
flex: 0 0 auto;
display: inline-block;
width: 2.25em;
/* font-size: 0.8em; */
text-align: center;
opacity: 0.6;
white-space: nowrap;
font-family: monospace;
&:before { content: "["; }
&:after { content: "]"; }
}
> .specs {
align-items: flex-start;
> .name {
> .matched {
background-color: var(--ac-color-matchedBackground);
color: var(--ac-color-matchedText);
font-weight: bold;
}
}
> .body {
flex-wrap: wrap;
column-gap: 0.5em;
> .arguments {
display: contents;
height: 100%;
}
}
}
> .help {
height: 100%;
> .helpContent {
&:before { content: " "; }
text-overflow: ellipsis;
overflow: hidden;
font-size: 0.9em;white-space: nowrap;
line-height: 1.2;
height: 1.2em;
display: block;
> * {
display: contents;
}
}
}
}
}
.slashCommandAutoComplete-details {
font-size: 0.8em;
display: flex;
flex-direction: column;
gap: 0.5em;
> .specs {
cursor: default;
flex-direction: column;
padding: 0.25em 0.25em 0.5em 0.25em;
border-bottom: 1px solid var(--ac-color-border);
> .name {
font-weight: bold;
color: var(--ac-color-text);
cursor: help;
&:hover {
text-decoration: 1px dotted underline;
}
}
> .body {
flex-direction: column;
gap: 0.5em;
> .arguments {
margin: 0;
padding-left: 1.25em;
> .argumentItem::marker {
color: color-mix(in srgb, var(--ac-color-text), var(--ac-style-color-background));
}
.argumentSpec {
display: flex;
gap: 0.5em;
.argument-default {
&:before {
content: " = ";
color: var(--ac-color-text);
}
color: var(--ac-color-string);
}
}
.argument {
cursor: help;
&:hover:not(:has(.argument-name:hover, .argument-types:hover, .argument-enums:hover)) {
text-decoration: 1px dotted underline;
}
}
.argument-name,
.argument-types,
.argument-enums,
.argument-default
{
cursor: help;
&:hover {
text-decoration: 1px dotted underline;
}
}
.argument.optional + .argument-description:before,
.argumentSpec:has(.argument.optional) + argument.description:before
{
content: "(optional) ";
color: var(--ac-color-text);
opacity: 0.5;
font-size: 0.8em;
}
.argument-description {
margin-left: 0.5em;
font-family: var(--mainFontFamily);
font-size: 0.9em;
}
}
.returns {
cursor: help;
&:hover {
text-decoration: 1px dotted underline;
}
}
}
}
> .help {
padding: 0 0.5em 0.5em 0.5em;
div {
margin-block-end: 1em;
}
ul {
margin: 0;
padding-left: 1.5em;
}
pre {
margin: 0;
> code {
display: block;
padding: 0;
}
}
}
> .aliases {
padding: 0 0.5em 0.5em 0.5em;
&:before { content: '(alias: '; }
> .alias {
font-family: monospace;
&+.alias:before { content: ', '; }
}
&:after { content: ')'; }
}
}
.slashCommandAutoComplete > .item, .slashCommandAutoComplete-details {
> .specs {
display: flex;
gap: 0.5em;
> .name {
font-family: monospace;
white-space: nowrap;
/* color: var(--ac-color-text); */
}
> .body {
display: flex;
> .arguments {
font-family: monospace;
.argument {
white-space: nowrap;
&.namedArgument {
&:before {
content: "[";
color: var(--ac-color-text);
}
&:after {
content: "]";
color: var(--ac-color-text);
}
&.optional:after {
content: "]?";
color: var(--ac-color-text);
}
> .argument-name {
color: var(--ac-color-argName);
}
}
&.unnamedArgument {
&:before {
content: "(";
color: var(--ac-color-text);
}
&.multiple:before {
content: "...(";
color: var(--ac-color-text);
}
&:after {
content: ")";
color: var(--ac-color-text);
}
&.optional:after {
content: ")?";
color: var(--ac-color-text);
}
}
> .argument-name + .argument-types:before {
content: "=";
color: var(--ac-color-text);
}
> .argument-types {
color: var(--ac-color-type);
word-break: break-all;
white-space: break-spaces;
> .argument-type + .argument-type:before {
content: "|";
color: var(--ac-color-text);
};
}
> .argument-types + .argument-enums,
> .argument-name + .argument-enums
{
&:before {
content: "=";
color: var(--ac-color-text);
}
}
> .argument-enums {
color: var(--ac-color-string);
word-break: break-all;
white-space: break-spaces;
> .argument-enum + .argument-enum:before {
content: "|";
color: var(--ac-color-text);
};
}
}
}
> .returns {
font-family: monospace;
color: var(--ac-color-text);
&:before {
content: "=> ";
color: var(--ac-color-symbol);
}
}
}
}
}
@media screen and (max-width: 1000px) {
.slashCommandAutoComplete-wrap {
left: 1vw;
> .slashCommandAutoComplete {
.specs {
grid-column: 2 / 4;
}
.help {
grid-column: 2 / 4;
padding-left: 1em;
opacity: 0.75;
}
}
}
}
.slashCommandBrowser {
> .search {
display: flex;
gap: 1em;
align-items: baseline;
white-space: nowrap;
> .searchLabel {
flex: 1 1 auto;
display: flex;
gap: 0.5em;
align-items: baseline;
> .searchInput {
flex: 1 1 auto;
}
}
> .searchOptions {
display: flex;
gap: 1em;
align-items: baseline;
}
}
> .commandContainer {
display: flex;
align-items: flex-start;
> .slashCommandAutoComplete {
flex: 1 1 auto;
max-height: unset;
> .isFiltered {
display: none;
}
.specs {
grid-column: 2 / 4;
}
.help {
grid-column: 2 / 4;
padding-left: 1em;
opacity: 0.75;
}
}
> .slashCommandAutoComplete-detailsWrap {
flex: 0 0 auto;
align-self: stretch;
width: 30%;
position: static;
&:before {
flex: 0 1 calc(var(--targetOffset) * 1px);
}
> .slashCommandAutoComplete-details {
max-height: 50vh;
}
}
}
}
#character_popup .editor_maximize {
cursor: pointer;
margin: 5px;