Blame Raw
Cohee · 51ad27fb · · 370 lines (17.0 KB)
2 contributors
1import { ActionLoaderToastMode, getActiveLoaderHandles, getLoaderHandleById, loader } from './action-loader.js';
2import { t } from './i18n.js';
3import { SlashCommand } from './slash-commands/SlashCommand.js';
4import { SlashCommandNamedArgument, ARGUMENT_TYPE, SlashCommandArgument } from './slash-commands/SlashCommandArgument.js';
5import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
6import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
7import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';
8import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
9import { isFalseBoolean } from './utils.js';
10import { DOMPurify } from '../lib.js';
11
12/**
13 * Registers slash commands for the action loader module.
14 */
15export function registerActionLoaderSlashCommands() {
16 /**
17 * Helper to create a closure-based handler from a SlashCommandClosure argument.
18 * Allows all possible slash command arg types to be passed in, but only closure is accepted.
19 * @param {string | SlashCommandClosure | (string | SlashCommandClosure)[]} closure - The closure argument
20 * @param {Object} options - Configuration options
21 * @param {string} [options.argName='onStop'] - Name of the argument for error messages
22 * @param {boolean} [options.throwInvalid=true] - Whether to throw an error for invalid input
23 * @returns {(() => Promise<void>)|null} The handler function, or null if no closure
24 */
25 function createClosureHandler(closure, { argName = 'onStop', throwInvalid = true } = {}) {
26 if (!(closure instanceof SlashCommandClosure)) {
27 if (closure && throwInvalid) {
28 // Throw error on purpose. This is defined as a syntax error.
29 throw new Error(t`Invalid argument for ${argName} provided. This is not a closure.`);
30 }
31 return null;
32 }
33 return async () => {
34 try {
35 const localClosure = closure.getCopy();
36 localClosure.onProgress = () => { };
37 await localClosure.execute();
38 } catch (e) {
39 console.error('Error executing closure handler', e);
40 }
41 };
42 }
43
44 // Shared loader enum providers
45 const loaderEnumProviders = {
46 toastModeEnumProvider: () => [
47 new SlashCommandEnumValue(ActionLoaderToastMode.NONE, 'No toast displayed', enumTypes.enum, enumIcons.disabled),
48 new SlashCommandEnumValue(ActionLoaderToastMode.STATIC, 'Static toast without stop button', enumTypes.enum, enumIcons.spinner),
49 new SlashCommandEnumValue(ActionLoaderToastMode.STOPPABLE, 'Toast with stop button (default)', enumTypes.enum, enumIcons.stop),
50 ],
51 loaderHandleProvider: () => getActiveLoaderHandles().map(
52 handle => new SlashCommandEnumValue(handle.id, `Active loader: ${handle.id}`, enumTypes.enum, enumIcons.spinner),
53 ).concat(
54 new SlashCommandEnumValue('Temporary loader handle', 'Any loader handle saved in variables or similar', 'enum', '📄', () => true, () => ''),
55 ),
56 };
57
58 // /loader-wrap command - wraps a closure with loader display
59 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
60 name: 'loader-wrap',
61 returns: 'result of the closure execution',
62 helpString: `
63 <div>
64 Wraps a closure execution with an action loader overlay and optional toast notification.
65 By default, the loader blocks UI interaction until the closure completes.
66 Multiple loaders can be stacked - each gets its own toast, but the overlay stays single.
67 </div>
68 <div>
69 <strong>Toast modes:</strong>
70 <ul>
71 <li><code>stoppable</code> - Shows toast with a stop button (default)</li>
72 <li><code>static</code> - Shows toast without stop button</li>
73 <li><code>none</code> - No toast, only loader overlay</li>
74 </ul>
75 </div>
76 <div>
77 Set <code>blocking=false</code> to show only a toast without blocking the UI.
78 Useful for background operations like image captioning or generation.
79 </div>
80 <div>
81 The default stop behavior is calling <code>stopGeneration()</code>.
82 If the wrapped action is doing something different than generating, a custom stop closure can be provided.
83 </div>
84 <div>
85 <strong>Examples:</strong>
86 <ul>
87 <li><pre><code class="language-stscript">/loader-wrap message="Generating summary..." {: /gen Summary of the last message | /echo Done :}</code></pre></li>
88 <li><pre><code class="language-stscript">/loader-wrap blocking=false message="Captioning..." {: /caption :}</code></pre></li>
89 <li><pre><code class="language-stscript">/loader-wrap toast=stoppable onStop={: /echo "Stopped by user" :} {: /delay 10000 :}</code></pre></li>
90 </ul>
91 </div>
92 `,
93 namedArgumentList: [
94 SlashCommandNamedArgument.fromProps({
95 name: 'blocking',
96 description: 'Whether to show blocking overlay. Set to false for non-blocking toast-only loaders.',
97 typeList: [ARGUMENT_TYPE.BOOLEAN],
98 defaultValue: 'true',
99 enumList: commonEnumProviders.boolean()(),
100 }),
101 SlashCommandNamedArgument.fromProps({
102 name: 'toast',
103 description: 'Toast display mode: stoppable (with stop button), static (no stop button), or none',
104 typeList: [ARGUMENT_TYPE.STRING],
105 defaultValue: ActionLoaderToastMode.STOPPABLE,
106 enumList: loaderEnumProviders.toastModeEnumProvider(),
107 }),
108 SlashCommandNamedArgument.fromProps({
109 name: 'message',
110 description: 'Message to display in the toast notification',
111 typeList: [ARGUMENT_TYPE.STRING],
112 defaultValue: 'Generating...',
113 }),
114 SlashCommandNamedArgument.fromProps({
115 name: 'title',
116 description: 'Optional title for the toast notification',
117 typeList: [ARGUMENT_TYPE.STRING],
118 }),
119 SlashCommandNamedArgument.fromProps({
120 name: 'slug',
121 description: 'Unique slug for the loader (to identify it easily via code or CSS)',
122 typeList: [ARGUMENT_TYPE.STRING],
123 defaultValue: 'slash-wrap',
124 }),
125 SlashCommandNamedArgument.fromProps({
126 name: 'stopTooltip',
127 description: 'Tooltip text for the stop button (only used when toast=stoppable)',
128 typeList: [ARGUMENT_TYPE.STRING],
129 defaultValue: 'Stop',
130 }),
131 SlashCommandNamedArgument.fromProps({
132 name: 'onStop',
133 description: 'Closure to execute when the stop button is clicked. If not provided, uses default stop behavior.',
134 typeList: [ARGUMENT_TYPE.CLOSURE],
135 }),
136 ],
137 unnamedArgumentList: [
138 SlashCommandArgument.fromProps({
139 description: 'Closure to execute while the loader is displayed',
140 typeList: [ARGUMENT_TYPE.CLOSURE],
141 isRequired: true,
142 }),
143 ],
144 callback: async (args, value) => {
145 if (!(value instanceof SlashCommandClosure)) {
146 // Throw error on purpose. This is defined as a syntax error.
147 throw new Error(t`Invalid argument for unnamed argument provided. This is not a closure.`);
148 }
149
150 const blocking = !isFalseBoolean(String(args.blocking));
151 const toastMode = Object.values(ActionLoaderToastMode).includes(String(args.toast))
152 ? String(args.toast)
153 : ActionLoaderToastMode.STOPPABLE;
154 const message = String(args.message ?? t`Generating...`);
155 const title = args.title ? String(args.title) : '';
156 const stopTooltip = String(args.stopTooltip ?? t`Stop`);
157
158 const actionLoader = loader.show({
159 slug: typeof args.slug === 'string' ? String(args.slug) : 'slash-wrap',
160 blocking,
161 toastMode,
162 message,
163 title,
164 stopTooltip,
165 onStop: createClosureHandler(args.onStop),
166 });
167
168 try {
169 const closureCopy = value.getCopy();
170 const result = await closureCopy.execute();
171 return result.pipe;
172 } finally {
173 await actionLoader.hide();
174 }
175 },
176 }));
177
178 // /loader-show command - manually show a loader, returns handle ID
179 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
180 name: 'loader-show',
181 returns: 'loader handle ID (use with /loader-hide)',
182 helpString: `
183 <div>
184 Manually shows an action loader. Returns a handle ID that can be used with <code>/loader-hide</code> to hide it.
185 Use this for fine-grained control when you need to show/hide the loader at specific points.
186 Multiple loaders can be stacked - each gets its own toast, but the overlay stays single.
187 </div>
188 <div>
189 <strong>Toast modes:</strong>
190 <ul>
191 <li><code>stoppable</code> - Shows toast with a stop button (default)</li>
192 <li><code>static</code> - Shows toast without stop button</li>
193 <li><code>none</code> - No toast, only loader overlay</li>
194 </ul>
195 </div>
196 <div>
197 Set <code>blocking=false</code> to show only a toast without blocking the UI.
198 Useful for background operations like image captioning or generation.
199 </div>
200 <div>
201 The default stop behavior is calling <code>stopGeneration()</code>.
202 If the wrapped action is doing something different than generating, a custom stop closure can be provided.
203 </div>
204 <div>
205 <strong>Example:</strong>
206 <pre>
207 <code class="language-stscript">
208/loader-show message="Loading..." |
209/setvar key=myLoader |
210/some-operation |
211/loader-hide handle={{getvar::myLoader}}
212 </code>
213 </pre>
214 </div>
215 `,
216 namedArgumentList: [
217 SlashCommandNamedArgument.fromProps({
218 name: 'blocking',
219 description: 'Whether to show blocking overlay. Set to false for non-blocking toast-only loaders.',
220 typeList: [ARGUMENT_TYPE.BOOLEAN],
221 defaultValue: 'true',
222 enumList: commonEnumProviders.boolean()(),
223 }),
224 SlashCommandNamedArgument.fromProps({
225 name: 'toast',
226 description: 'Toast display mode: stoppable (with stop button), static (no stop button), or none',
227 typeList: [ARGUMENT_TYPE.STRING],
228 defaultValue: ActionLoaderToastMode.STOPPABLE,
229 enumList: loaderEnumProviders.toastModeEnumProvider(),
230 }),
231 SlashCommandNamedArgument.fromProps({
232 name: 'message',
233 description: 'Message to display in the toast notification',
234 typeList: [ARGUMENT_TYPE.STRING],
235 defaultValue: 'Generating...',
236 }),
237 SlashCommandNamedArgument.fromProps({
238 name: 'title',
239 description: 'Optional title for the toast notification',
240 typeList: [ARGUMENT_TYPE.STRING],
241 }),
242 SlashCommandNamedArgument.fromProps({
243 name: 'slug',
244 description: 'Unique slug for the loader (to identify it easily via code or CSS)',
245 typeList: [ARGUMENT_TYPE.STRING],
246 defaultValue: 'slash-show',
247 }),
248 SlashCommandNamedArgument.fromProps({
249 name: 'stopTooltip',
250 description: 'Tooltip text for the stop button (only used when toast=stoppable)',
251 typeList: [ARGUMENT_TYPE.STRING],
252 defaultValue: 'Stop',
253 }),
254 SlashCommandNamedArgument.fromProps({
255 name: 'onStop',
256 description: 'Closure to execute when the stop button is clicked',
257 typeList: [ARGUMENT_TYPE.CLOSURE],
258 }),
259 SlashCommandNamedArgument.fromProps({
260 name: 'onHide',
261 description: 'Closure to execute when the loader is hidden (not stopped)',
262 typeList: [ARGUMENT_TYPE.CLOSURE],
263 }),
264 ],
265 unnamedArgumentList: [],
266 callback: async (args) => {
267 const blocking = !isFalseBoolean(String(args.blocking));
268 const toastMode = Object.values(ActionLoaderToastMode).includes(String(args.toast))
269 ? String(args.toast)
270 : ActionLoaderToastMode.STOPPABLE;
271 const message = String(args.message ?? t`Generating...`);
272 const title = args.title ? String(args.title) : '';
273 const stopTooltip = String(args.stopTooltip ?? t`Stop`);
274
275 const handle = loader.show({
276 slug: typeof args.slug === 'string' ? String(args.slug) : 'slash-show',
277 blocking,
278 toastMode,
279 message: DOMPurify.sanitize(message),
280 title: DOMPurify.sanitize(title),
281 stopTooltip,
282 onStop: createClosureHandler(args.onStop),
283 onHide: createClosureHandler(args.onHide, { argName: 'onHide' }),
284 });
285
286 return handle.id;
287 },
288 }));
289
290 // /loader-hide command - manually hide a loader by handle ID
291 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
292 name: 'loader-hide',
293 returns: 'true if an active loader was hidden, otherwise false',
294 helpString: `
295 <div>
296 Hides an action loader that was shown with <code>/loader-show</code>.
297 If no handle is provided, hides <strong>all</strong> active loaders.
298 </div>
299 <div>
300 <strong>Example:</strong>
301 <pre><code class="language-stscript">/loader-hide handle={{getvar::myLoader}}</code></pre>
302 </div>
303 `,
304 namedArgumentList: [
305 SlashCommandNamedArgument.fromProps({
306 name: 'handle',
307 description: 'Loader handle ID returned by /loader-show. If not provided, hides all active loaders.',
308 typeList: [ARGUMENT_TYPE.STRING],
309 enumProvider: loaderEnumProviders.loaderHandleProvider,
310 }),
311 ],
312 callback: async (args) => {
313 const handleId = args.handle ? String(args.handle) : null;
314
315 if (handleId) {
316 const handle = getLoaderHandleById(handleId);
317 if (handle && handle.isActive) {
318 await handle.hide();
319 return 'true';
320 }
321 return 'false';
322 }
323
324 // No handle provided - hide all active loaders
325 const result = await loader.hide();
326 return result ? 'true' : 'false';
327 },
328 }));
329
330 // /loader-stop command - trigger the stop action on a loader
331 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
332 name: 'loader-stop',
333 returns: 'true if an active loader was stopped, otherwise false',
334 helpString: `
335 <div>
336 Triggers the stop action on a specific action loader, as if the user clicked the stop button.
337 Unlike <code>/loader-hide</code>, this command requires a handle - you must specify which loader to stop.
338 </div>
339 <div>
340 <strong>Example:</strong>
341 <pre><code class="language-stscript">/loader-stop handle={{getvar::myLoader}}</code></pre>
342 </div>
343 `,
344 namedArgumentList: [
345 SlashCommandNamedArgument.fromProps({
346 name: 'handle',
347 description: 'Loader handle ID returned by /loader-show.',
348 typeList: [ARGUMENT_TYPE.STRING],
349 isRequired: true,
350 enumProvider: loaderEnumProviders.loaderHandleProvider,
351 }),
352 ],
353 callback: async (args) => {
354 const handleId = args.handle ? String(args.handle) : null;
355
356 if (!handleId) {
357 toastr.warning(t`No handle provided. You must specify which loader to stop.`);
358 return 'false';
359 }
360
361 const handle = getLoaderHandleById(handleId);
362 if (handle && handle.isActive) {
363 await handle.stop();
364 return 'true';
365 }
366
367 return 'false';
368 },
369 }));
370}