Merge pull request #2903 from SillyTavern/extensions-slash-commands Slash commands to manage Extensions

ff56cb9c2e7f4be17523fd32906f88eee1a4a704

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
3 files changed, +333 -6Showing whitespace changes
public/script.js+4 -1
@@ -158,7 +158,7 @@ import {
158158} from './scripts/utils.js';
159159import { debounce_timeout } from './scripts/constants.js';
160160
161161import { ModuleWorkerWrapper, doDailyExtensionUpdatesCheck, extension_settings, getContext, initExtensions, loadExtensionSettings, renderExtensionTemplate, renderExtensionTemplateAsync, runGenerationInterceptors, saveMetadataDebounced, writeExtensionField } from './scripts/extensions.js';
162162import { COMMENT_NAME_DEFAULT, executeSlashCommands, executeSlashCommandsOnChatInput, executeSlashCommandsWithOptions, getSlashCommandsHelp, initDefaultSlashCommands, isExecutingCommandsFromChatInput, pauseScriptExecution, processChatSlashCommands, registerSlashCommand, stopScriptExecution } from './scripts/slash-commands.js';
163163import {
164164 tag_map,
@@ -244,6 +244,7 @@ import { commonEnumProviders, enumIcons } from './scripts/slash-commands/SlashCo
244244import { initInputMarkdown } from './scripts/input-md-formatting.js';
245245import { AbortReason } from './scripts/util/AbortReason.js';
246246import { initSystemPrompts } from './scripts/sysprompt.js';
247+import { registerExtensionSlashCommands as initExtensionSlashCommands } from './scripts/extensions-slashcommands.js';
247248
248249//exporting functions and vars for mods
249250export {
@@ -956,6 +957,8 @@ async function firstLoadInit() {
956957 initCfg();
957958 initLogprobs();
958959 initInputMarkdown();
960+ initExtensions();
961+ initExtensionSlashCommands();
959962 doDailyExtensionUpdatesCheck();
960963 await hideLoader();
961964 await fixViewport();
public/scripts/extensions-slashcommands.js+320 -0
@@ -0,0 +1,320 @@
1+import { disableExtension, enableExtension, extension_settings, extensionNames } from './extensions.js';
2+import { SlashCommand } from './slash-commands/SlashCommand.js';
3+import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
4+import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
5+import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js';
6+import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
7+import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
8+import { equalsIgnoreCaseAndAccents, isFalseBoolean, isTrueBoolean } from './utils.js';
9+
10+/**
11+ * @param {'enable' | 'disable' | 'toggle'} action - The action to perform on the extension
12+ * @returns {(args: {[key: string]: string | SlashCommandClosure}, extensionName: string | SlashCommandClosure) => Promise<string>}
13+ */
14+function getExtensionActionCallback(action) {
15+ return async (args, extensionName) => {
16+ if (args?.reload instanceof SlashCommandClosure) throw new Error('\'reload\' argument cannot be a closure.');
17+ if (typeof extensionName !== 'string') throw new Error('Extension name must be a string. Closures or arrays are not allowed.');
18+ if (!extensionName) {
19+ toastr.warning(`Extension name must be provided as an argument to ${action} this extension.`);
20+ return '';
21+ }
22+
23+ const reload = !isFalseBoolean(args?.reload);
24+ const internalExtensionName = findExtension(extensionName);
25+ if (!internalExtensionName) {
26+ toastr.warning(`Extension ${extensionName} does not exist.`);
27+ return '';
28+ }
29+
30+ const isEnabled = !extension_settings.disabledExtensions.includes(internalExtensionName);
31+
32+ if (action === 'enable' && isEnabled) {
33+ toastr.info(`Extension ${extensionName} is already enabled.`);
34+ return internalExtensionName;
35+ }
36+
37+ if (action === 'disable' && !isEnabled) {
38+ toastr.info(`Extension ${extensionName} is already disabled.`);
39+ return internalExtensionName;
40+ }
41+
42+ if (action === 'toggle') {
43+ action = isEnabled ? 'disable' : 'enable';
44+ }
45+
46+ if (reload) {
47+ toastr.info(`${action.charAt(0).toUpperCase() + action.slice(1)}ing extension ${extensionName} and reloading...`);
48+
49+ // Clear input, so it doesn't stay because the command didn't "finish",
50+ // and wait for a bit to both show the toast and let the clear bubble through.
51+ $('#send_textarea').val('')[0].dispatchEvent(new Event('input', { bubbles: true }));
52+ await new Promise(resolve => setTimeout(resolve, 100));
53+ }
54+
55+ if (action === 'enable') {
56+ await enableExtension(internalExtensionName, reload);
57+ } else {
58+ await disableExtension(internalExtensionName, reload);
59+ }
60+
61+ toastr.success(`Extension ${extensionName} ${action}d.`);
62+
63+
64+ console.info(`Extension ${action}ed: ${extensionName}`);
65+ if (!reload) {
66+ console.info('Reload not requested, so page needs to be reloaded manually for changes to take effect.');
67+ }
68+
69+ return internalExtensionName;
70+ };
71+}
72+
73+/**
74+ * Finds an extension by name, allowing omission of the "third-party/" prefix.
75+ *
76+ * @param {string} name - The name of the extension to find
77+ * @returns {string?} - The matched extension name or undefined if not found
78+ */
79+function findExtension(name) {
80+ return extensionNames.find(extName => {
81+ return equalsIgnoreCaseAndAccents(extName, name) || equalsIgnoreCaseAndAccents(extName, `third-party/${name}`);
82+ });
83+}
84+
85+/**
86+ * Provides an array of SlashCommandEnumValue objects based on the extension names.
87+ * Each object contains the name of the extension and a description indicating if it is a third-party extension.
88+ *
89+ * @returns {SlashCommandEnumValue[]} An array of SlashCommandEnumValue objects
90+ */
91+const extensionNamesEnumProvider = () => extensionNames.map(name => {
92+ const isThirdParty = name.startsWith('third-party/');
93+ if (isThirdParty) name = name.slice('third-party/'.length);
94+
95+ const description = isThirdParty ? 'third party extension' : null;
96+
97+ return new SlashCommandEnumValue(name, description, !isThirdParty ? enumTypes.name : enumTypes.enum);
98+});
99+
100+export function registerExtensionSlashCommands() {
101+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
102+ name: 'extension-enable',
103+ callback: getExtensionActionCallback('enable'),
104+ returns: 'The internal extension name',
105+ namedArgumentList: [
106+ SlashCommandNamedArgument.fromProps({
107+ name: 'reload',
108+ description: 'Whether to reload the page after enabling the extension',
109+ typeList: [ARGUMENT_TYPE.BOOLEAN],
110+ defaultValue: 'true',
111+ enumList: commonEnumProviders.boolean('trueFalse')(),
112+ }),
113+ ],
114+ unnamedArgumentList: [
115+ SlashCommandArgument.fromProps({
116+ description: 'Extension name',
117+ typeList: [ARGUMENT_TYPE.STRING],
118+ isRequired: true,
119+ enumProvider: extensionNamesEnumProvider,
120+ forceEnum: true,
121+ }),
122+ ],
123+ helpString: `
124+ <div>
125+ Enables a specified extension.
126+ </div>
127+ <div>
128+ By default, the page will be reloaded automatically, stopping any further commands.<br />
129+ If <code>reload=false</code> named argument is passed, the page will not be reloaded, and the extension will stay disabled until refreshed.
130+ The page either needs to be refreshed, or <code>/reload-page</code> has to be called.
131+ </div>
132+ <div>
133+ <strong>Example:</strong>
134+ <ul>
135+ <li>
136+ <pre><code class="language-stscript">/extension-enable Summarize</code></pre>
137+ </li>
138+ </ul>
139+ </div>
140+ `,
141+ }));
142+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
143+ name: 'extension-disable',
144+ callback: getExtensionActionCallback('disable'),
145+ returns: 'The internal extension name',
146+ namedArgumentList: [
147+ SlashCommandNamedArgument.fromProps({
148+ name: 'reload',
149+ description: 'Whether to reload the page after disabling the extension',
150+ typeList: [ARGUMENT_TYPE.BOOLEAN],
151+ defaultValue: 'true',
152+ enumList: commonEnumProviders.boolean('trueFalse')(),
153+ }),
154+ ],
155+ unnamedArgumentList: [
156+ SlashCommandArgument.fromProps({
157+ description: 'Extension name',
158+ typeList: [ARGUMENT_TYPE.STRING],
159+ isRequired: true,
160+ enumProvider: extensionNamesEnumProvider,
161+ forceEnum: true,
162+ }),
163+ ],
164+ helpString: `
165+ <div>
166+ Disables a specified extension.
167+ </div>
168+ <div>
169+ By default, the page will be reloaded automatically, stopping any further commands.<br />
170+ If <code>reload=false</code> named argument is passed, the page will not be reloaded, and the extension will stay enabled until refreshed.
171+ The page either needs to be refreshed, or <code>/reload-page</code> has to be called.
172+ </div>
173+ <div>
174+ <strong>Example:</strong>
175+ <ul>
176+ <li>
177+ <pre><code class="language-stscript">/extension-disable Summarize</code></pre>
178+ </li>
179+ </ul>
180+ </div>
181+ `,
182+ }));
183+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
184+ name: 'extension-toggle',
185+ callback: async (args, extensionName) => {
186+ if (args?.state instanceof SlashCommandClosure) throw new Error('\'state\' argument cannot be a closure.');
187+ if (typeof extensionName !== 'string') throw new Error('Extension name must be a string. Closures or arrays are not allowed.');
188+
189+ const action = isTrueBoolean(args?.state) ? 'enable' :
190+ isFalseBoolean(args?.state) ? 'disable' :
191+ 'toggle';
192+
193+ return await getExtensionActionCallback(action)(args, extensionName);
194+ },
195+ returns: 'The internal extension name',
196+ namedArgumentList: [
197+ SlashCommandNamedArgument.fromProps({
198+ name: 'reload',
199+ description: 'Whether to reload the page after toggling the extension',
200+ typeList: [ARGUMENT_TYPE.BOOLEAN],
201+ defaultValue: 'true',
202+ enumList: commonEnumProviders.boolean('trueFalse')(),
203+ }),
204+ SlashCommandNamedArgument.fromProps({
205+ name: 'state',
206+ description: 'Explicitly set the state of the extension (true to enable, false to disable). If not provided, the state will be toggled to the opposite of the current state.',
207+ typeList: [ARGUMENT_TYPE.BOOLEAN],
208+ enumList: commonEnumProviders.boolean('trueFalse')(),
209+ }),
210+ ],
211+ unnamedArgumentList: [
212+ SlashCommandArgument.fromProps({
213+ description: 'Extension name',
214+ typeList: [ARGUMENT_TYPE.STRING],
215+ isRequired: true,
216+ enumProvider: extensionNamesEnumProvider,
217+ forceEnum: true,
218+ }),
219+ ],
220+ helpString: `
221+ <div>
222+ Toggles the state of a specified extension.
223+ </div>
224+ <div>
225+ By default, the page will be reloaded automatically, stopping any further commands.<br />
226+ If <code>reload=false</code> named argument is passed, the page will not be reloaded, and the extension will stay in its current state until refreshed.
227+ The page either needs to be refreshed, or <code>/reload-page</code> has to be called.
228+ </div>
229+ <div>
230+ <strong>Example:</strong>
231+ <ul>
232+ <li>
233+ <pre><code class="language-stscript">/extension-toggle Summarize</code></pre>
234+ </li>
235+ <li>
236+ <pre><code class="language-stscript">/extension-toggle Summarize state=true</code></pre>
237+ </li>
238+ </ul>
239+ </div>
240+ `,
241+ }));
242+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
243+ name: 'extension-state',
244+ callback: async (_, extensionName) => {
245+ if (typeof extensionName !== 'string') throw new Error('Extension name must be a string. Closures or arrays are not allowed.');
246+ const internalExtensionName = findExtension(extensionName);
247+ if (!internalExtensionName) {
248+ toastr.warning(`Extension ${extensionName} does not exist.`);
249+ return '';
250+ }
251+
252+ const isEnabled = !extension_settings.disabledExtensions.includes(internalExtensionName);
253+ return String(isEnabled);
254+ },
255+ returns: 'The state of the extension, whether it is enabled.',
256+ unnamedArgumentList: [
257+ SlashCommandArgument.fromProps({
258+ description: 'Extension name',
259+ typeList: [ARGUMENT_TYPE.STRING],
260+ isRequired: true,
261+ enumProvider: extensionNamesEnumProvider,
262+ forceEnum: true,
263+ }),
264+ ],
265+ helpString: `
266+ <div>
267+ Returns the state of a specified extension (true if enabled, false if disabled).
268+ </div>
269+ <div>
270+ <strong>Example:</strong>
271+ <ul>
272+ <li>
273+ <pre><code class="language-stscript">/extension-state Summarize</code></pre>
274+ </li>
275+ </ul>
276+ </div>
277+ `,
278+ }));
279+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
280+ name: 'extension-exists',
281+ aliases: ['extension-installed'],
282+ callback: async (_, extensionName) => {
283+ if (typeof extensionName !== 'string') throw new Error('Extension name must be a string. Closures or arrays are not allowed.');
284+ const exists = findExtension(extensionName) !== undefined;
285+ return exists ? 'true' : 'false';
286+ },
287+ returns: 'Whether the extension exists and is installed.',
288+ unnamedArgumentList: [
289+ SlashCommandArgument.fromProps({
290+ description: 'Extension name',
291+ typeList: [ARGUMENT_TYPE.STRING],
292+ isRequired: true,
293+ enumProvider: extensionNamesEnumProvider,
294+ }),
295+ ],
296+ helpString: `
297+ <div>
298+ Checks if a specified extension exists.
299+ </div>
300+ <div>
301+ <strong>Example:</strong>
302+ <ul>
303+ <li>
304+ <pre><code class="language-stscript">/extension-exists SillyTavern-LALib</code></pre>
305+ </li>
306+ </ul>
307+ </div>
308+ `,
309+ }));
310+
311+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
312+ name: 'reload-page',
313+ callback: async () => {
314+ toastr.info('Reloading the page...');
315+ location.reload();
316+ return '';
317+ },
318+ helpString: 'Reloads the current page. All further commands will not be processed.',
319+ }));
320+}
public/scripts/extensions.js+9 -5
@@ -1,5 +1,5 @@
11import { eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, animation_duration } from '../script.js';
22import { hideLoader, showLoader } from './loader.js';
33import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
44import { renderTemplate, renderTemplateAsync } from './templates.js';
55import { isSubsetOf, setValueByPath } from './utils.js';
@@ -14,7 +14,9 @@ export {
1414 ModuleWorkerWrapper,
1515};
1616
17+/** @type {string[]} */
1718export let extensionNames = [];
19+
1820let manifests = {};
1921const defaultUrl = 'http://localhost:5100';
2022
@@ -241,7 +243,7 @@ function onEnableExtensionClick() {
241243 enableExtension(name, false);
242244}
243245
244246export async function enableExtension(name, reload = true) {
245247 extension_settings.disabledExtensions = extension_settings.disabledExtensions.filter(x => x !== name);
246248 stateChanged = true;
247249 await saveSettings();
@@ -252,7 +254,7 @@ async function enableExtension(name, reload = true) {
252254 }
253255}
254256
255257export async function disableExtension(name, reload = true) {
256258 extension_settings.disabledExtensions.push(name);
257259 stateChanged = true;
258260 await saveSettings();
@@ -1041,7 +1043,9 @@ export async function openThirdPartyExtensionMenu(suggestUrl = '') {
10411043 await installExtension(url);
10421044}
10431045
1044-jQuery(async function () {
1046+
1047+
1048+export async function initExtensions() {
10451049 await addExtensionsButtonAndMenu();
10461050 $('#extensionsMenuButton').css('display', 'flex');
10471051
@@ -1060,4 +1064,4 @@ jQuery(async function () {
10601064 * @listens #third_party_extension_button#click - The click event of the '#third_party_extension_button' element.
10611065 */
10621066 $('#third_party_extension_button').on('click', () => openThirdPartyExtensionMenu());
10631067});