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 -6Ignore whitespace
public/script.js+4 -1
@@ -158,7 +158,7 @@ import {
158} from './scripts/utils.js';158} from './scripts/utils.js';
159import { debounce_timeout } from './scripts/constants.js';159import { debounce_timeout } from './scripts/constants.js';
160160
161import { ModuleWorkerWrapper, doDailyExtensionUpdatesCheck, extension_settings, getContext, loadExtensionSettings, renderExtensionTemplate, renderExtensionTemplateAsync, runGenerationInterceptors, saveMetadataDebounced, writeExtensionField } from './scripts/extensions.js';161import { ModuleWorkerWrapper, doDailyExtensionUpdatesCheck, extension_settings, getContext, initExtensions, loadExtensionSettings, renderExtensionTemplate, renderExtensionTemplateAsync, runGenerationInterceptors, saveMetadataDebounced, writeExtensionField } from './scripts/extensions.js';
162import { COMMENT_NAME_DEFAULT, executeSlashCommands, executeSlashCommandsOnChatInput, executeSlashCommandsWithOptions, getSlashCommandsHelp, initDefaultSlashCommands, isExecutingCommandsFromChatInput, pauseScriptExecution, processChatSlashCommands, registerSlashCommand, stopScriptExecution } from './scripts/slash-commands.js';162import { COMMENT_NAME_DEFAULT, executeSlashCommands, executeSlashCommandsOnChatInput, executeSlashCommandsWithOptions, getSlashCommandsHelp, initDefaultSlashCommands, isExecutingCommandsFromChatInput, pauseScriptExecution, processChatSlashCommands, registerSlashCommand, stopScriptExecution } from './scripts/slash-commands.js';
163import {163import {
164 tag_map,164 tag_map,
@@ -244,6 +244,7 @@ import { commonEnumProviders, enumIcons } from './scripts/slash-commands/SlashCo
244import { initInputMarkdown } from './scripts/input-md-formatting.js';244import { initInputMarkdown } from './scripts/input-md-formatting.js';
245import { AbortReason } from './scripts/util/AbortReason.js';245import { AbortReason } from './scripts/util/AbortReason.js';
246import { initSystemPrompts } from './scripts/sysprompt.js';246import { initSystemPrompts } from './scripts/sysprompt.js';
247import { registerExtensionSlashCommands as initExtensionSlashCommands } from './scripts/extensions-slashcommands.js';
247248
248//exporting functions and vars for mods249//exporting functions and vars for mods
249export {250export {
@@ -956,6 +957,8 @@ async function firstLoadInit() {
956 initCfg();957 initCfg();
957 initLogprobs();958 initLogprobs();
958 initInputMarkdown();959 initInputMarkdown();
960 initExtensions();
961 initExtensionSlashCommands();
959 doDailyExtensionUpdatesCheck();962 doDailyExtensionUpdatesCheck();
960 await hideLoader();963 await hideLoader();
961 await fixViewport();964 await fixViewport();
public/scripts/extensions-slashcommands.js+320 -0
@@ -0,0 +1,320 @@
1import { disableExtension, enableExtension, extension_settings, extensionNames } from './extensions.js';
2import { SlashCommand } from './slash-commands/SlashCommand.js';
3import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
4import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
5import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js';
6import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
7import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
8import { 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 */
14function 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 */
79function 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 */
91const 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
100export 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 @@
1import { eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, animation_duration } from '../script.js';1import { eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, animation_duration } from '../script.js';
2import { hideLoader, showLoader } from './loader.js';2import { showLoader } from './loader.js';
3import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';3import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
4import { renderTemplate, renderTemplateAsync } from './templates.js';4import { renderTemplate, renderTemplateAsync } from './templates.js';
5import { isSubsetOf, setValueByPath } from './utils.js';5import { isSubsetOf, setValueByPath } from './utils.js';
@@ -14,7 +14,9 @@ export {
14 ModuleWorkerWrapper,14 ModuleWorkerWrapper,
15};15};
1616
17/** @type {string[]} */
17export let extensionNames = [];18export let extensionNames = [];
19
18let manifests = {};20let manifests = {};
19const defaultUrl = 'http://localhost:5100';21const defaultUrl = 'http://localhost:5100';
2022
@@ -241,7 +243,7 @@ function onEnableExtensionClick() {
241 enableExtension(name, false);243 enableExtension(name, false);
242}244}
243245
244async function enableExtension(name, reload = true) {246export async function enableExtension(name, reload = true) {
245 extension_settings.disabledExtensions = extension_settings.disabledExtensions.filter(x => x !== name);247 extension_settings.disabledExtensions = extension_settings.disabledExtensions.filter(x => x !== name);
246 stateChanged = true;248 stateChanged = true;
247 await saveSettings();249 await saveSettings();
@@ -252,7 +254,7 @@ async function enableExtension(name, reload = true) {
252 }254 }
253}255}
254256
255async function disableExtension(name, reload = true) {257export async function disableExtension(name, reload = true) {
256 extension_settings.disabledExtensions.push(name);258 extension_settings.disabledExtensions.push(name);
257 stateChanged = true;259 stateChanged = true;
258 await saveSettings();260 await saveSettings();
@@ -1041,7 +1043,9 @@ export async function openThirdPartyExtensionMenu(suggestUrl = '') {
1041 await installExtension(url);1043 await installExtension(url);
1042}1044}
10431045
1044jQuery(async function () {1046
1047
1048export async function initExtensions() {
1045 await addExtensionsButtonAndMenu();1049 await addExtensionsButtonAndMenu();
1046 $('#extensionsMenuButton').css('display', 'flex');1050 $('#extensionsMenuButton').css('display', 'flex');
10471051
@@ -1060,4 +1064,4 @@ jQuery(async function () {
1060 * @listens #third_party_extension_button#click - The click event of the '#third_party_extension_button' element.1064 * @listens #third_party_extension_button#click - The click event of the '#third_party_extension_button' element.
1061 */1065 */
1062 $('#third_party_extension_button').on('click', () => openThirdPartyExtensionMenu());1066 $('#third_party_extension_button').on('click', () => openThirdPartyExtensionMenu());
1063});1067}