Add Action Loader Module with Stacking Support and STscript Commands (#5311) * Add action loader utility with stoppable toast notifications * Add slash commands for action loader control (/loader-wrap, /loader-show, /loader-hide, /loader-stop) * Refactor action loader to support stacking multiple loaders with individual toast management - Convert action loader from singleton to class-based handle system (ActionLoaderHandle) - Support multiple concurrent loaders with single overlay and stacked toasts - Add unique ID generation and tracking for each loader instance - Implement onHide callback alongside existing onStop callback - Add getActiveLoaderHandles() and getLoaderHandleById() utility functions - Refactor hideActionLoader() to accept... * Improve action loader overlay and toast handling with better state checks and cleanup timing - Check isLoaderDisplayed() before showing overlay to prevent conflicts with existing loaders - Use toastr.options.hideDuration for toast removal timing instead of hardcoded 250ms - Simplify hideActionLoader() by using getActiveLoaderHandles() helper - Remove redundant empty check in hideActionLoader() - Add clarifying comment for intentional error throw in createClosureHandler() - Remove redundant 'onStop' default * fix stop button toast removal issue by using toastr.clear force option instead of manual removal with timeout * Fix isLoaderDisplayed() by using double negation operator instead of null comparison * Add non-blocking loader support with `blocking` parameter for toast-only action loaders - Add `blocking` option to ActionLoaderHandle (default: true) - Implement hasBlockingLoaders() helper to check for active blocking loaders - Show/hide overlay only when blocking loaders are active - Add `blocking` named argument to /loader-wrap and /loader-show commands - Update help strings with non-blocking usage examples - Import commonEnumProviders for boolean enum and isFalseBoolean utility * Add optional title parameter to action loader toast notifications and reorder constructor parameters for consistency - Add `title` parameter to ActionLoaderOptions and ActionLoaderHandle constructor - Pass title to toastr.info() for toast notifications - Reorder parameters: blocking, toastMode, message, title, stopTooltip (grouped by importance) - Add warning when creating non-blocking loader without toast (invisible to user) - Update /loader-wrap and /loader-show commands with title argument * Add loader utility API to action-loader and expose it in ST context for convenient programmatic access - Create `loader` object with show/hide/active/get methods and ToastMode/Handle exports - Add JSDoc examples for basic usage, non-blocking tasks, and hiding all loaders - Import and expose `loader` in ST context alongside existing loader functions * Split slash command and functional modules * Create abort controller on app init * Remove HTML tags from "returns" declaration --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

a6486d7f081223a4ded0853deb2fbd46f5be18f0

Wolfsblvt <wolfsblvt@gmail.com>

Signed
9 files changed, +833 -8Showing whitespace changes
public/css/loader.css+24 -0
@@ -29,3 +29,27 @@
29 align-items: center;29 align-items: center;
30 justify-content: center;30 justify-content: center;
31}31}
32
33/* Action loader toast styles */
34.action-loader-toast {
35 display: flex;
36 align-items: center;
37 gap: 10px;
38 width: 100%;
39}
40
41.action-loader-message {
42 flex: 1;
43}
44
45.action-loader-stop {
46 cursor: pointer;
47 font-size: 1.2em;
48 opacity: 0.8;
49 transition: opacity 0.15s ease, color 0.15s ease;
50}
51
52.action-loader-stop:hover {
53 opacity: 1;
54 color: color-mix(in srgb, currentColor 40%, #e74c3c 60%);
55}
public/script.js+33 -7
@@ -361,12 +361,36 @@ toastr.options = {
361 // so the toasts still show up inside there.361 // so the toasts still show up inside there.
362 fixToastrForDialogs();362 fixToastrForDialogs();
363 },363 },
364 onShown: function () {
365 // Set tooltip to the notification message
366 $(this).attr('title', t`Tap to close`);
367 },
368};364};
369365
366// Run once during startup
367toastr.subscribe(function (args) {
368 if (args.state !== 'visible') {
369 return;
370 }
371
372 const $container = toastr.getContainer(args.options, false);
373 if (!$container || !$container.length) {
374 return;
375 }
376
377 // toastr has already inserted the element at this point
378 const $toast = args.options.newestOnTop
379 ? $container.children().first()
380 : $container.children().last();
381
382 // Meaning of "clickable":
383 // Interactable unless tapToDismiss was explicitly false
384 const isInteractable = args.options.tapToDismiss !== false;
385 $toast.toggleClass('interactable', isInteractable);
386 if (isInteractable) {
387 $toast.attr('title', t`Tap to close`);
388 } else {
389 $toast.removeAttr('title');
390 $toast.addClass('toast-non-interactable');
391 }
392});
393
370export const characterGroupOverlay = new BulkEditOverlay();394export const characterGroupOverlay = new BulkEditOverlay();
371395
372// Markdown converter396// Markdown converter
@@ -600,8 +624,7 @@ export let recentSwipes = 0;
600export let extension_prompts = {};624export let extension_prompts = {};
601625
602export let main_api;// = "kobold";626export let main_api;// = "kobold";
603/** @type {AbortController} */627let abortController = new AbortController();
604let abortController;
605628
606//css629//css
607var css_send_form_display = $('<div id=send_form></div>').css('display');630var css_send_form_display = $('<div id=send_form></div>').css('display');
@@ -3862,7 +3885,10 @@ export async function generateRawData({ prompt = '', api = null, instructOverrid
38623885
3863 // Allow extensions to stop generation before it happens3886 // Allow extensions to stop generation before it happens
3864 const eventAbortController = new AbortController();3887 const eventAbortController = new AbortController();
3865 const abortHook = () => eventAbortController.abort(new Error('Cancelled by extension'));3888 const abortHook = () => {
3889 abortController.abort(new Error('Cancelled by stop event'));
3890 eventAbortController.abort(new Error('Cancelled by extension'));
3891 };
3866 eventSource.on(event_types.GENERATION_STOPPED, abortHook);3892 eventSource.on(event_types.GENERATION_STOPPED, abortHook);
38673893
3868 try {3894 try {
public/scripts/action-loader-slashcommands.js+355 -0
@@ -0,0 +1,355 @@
1import { ActionLoaderToastMode, getActiveLoaderHandles, getLoaderHandleById, hideActionLoader, showActionLoader } 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';
10
11/**
12 * Registers slash commands for the action loader module.
13 */
14export function registerActionLoaderSlashCommands() {
15 /**
16 * Helper to create a closure-based handler from a SlashCommandClosure argument.
17 * Allows all possible slash command arg types to be passed in, but only closure is accepted.
18 * @param {string | SlashCommandClosure | (string | SlashCommandClosure)[]} closure - The closure argument
19 * @param {Object} options - Configuration options
20 * @param {string} [options.argName='onStop'] - Name of the argument for error messages
21 * @param {boolean} [options.throwInvalid=true] - Whether to throw an error for invalid input
22 * @returns {(() => Promise<void>)|null} The handler function, or null if no closure
23 */
24 function createClosureHandler(closure, { argName = 'onStop', throwInvalid = true } = {}) {
25 if (!(closure instanceof SlashCommandClosure)) {
26 if (closure && throwInvalid) {
27 // Throw error on purpose. This is defined as a syntax error.
28 throw new Error(t`Invalid argument for ${argName} provided. This is not a closure.`);
29 }
30 return null;
31 }
32 return async () => {
33 try {
34 const localClosure = closure.getCopy();
35 localClosure.onProgress = () => { };
36 await localClosure.execute();
37 } catch (e) {
38 console.error('Error executing closure handler', e);
39 }
40 };
41 }
42
43 // Shared loader enum providers
44 const loaderEnumProviders = {
45 toastModeEnumProvider: () => [
46 new SlashCommandEnumValue(ActionLoaderToastMode.NONE, 'No toast displayed', enumTypes.enum, enumIcons.disabled),
47 new SlashCommandEnumValue(ActionLoaderToastMode.STATIC, 'Static toast without stop button', enumTypes.enum, enumIcons.spinner),
48 new SlashCommandEnumValue(ActionLoaderToastMode.STOPPABLE, 'Toast with stop button (default)', enumTypes.enum, enumIcons.stop),
49 ],
50 loaderHandleProvider: () => getActiveLoaderHandles().map(
51 handle => new SlashCommandEnumValue(handle.id, `Active loader: ${handle.id}`, enumTypes.enum, enumIcons.spinner),
52 ).concat(
53 new SlashCommandEnumValue('Temporary loader handle', 'Any loader handle saved in variables or similar', 'enum', '๐Ÿ“„', () => true, () => ''),
54 ),
55 };
56
57 // /loader-wrap command - wraps a closure with loader display
58 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
59 name: 'loader-wrap',
60 returns: 'result of the closure execution',
61 helpString: `
62 <div>
63 Wraps a closure execution with an action loader overlay and optional toast notification.
64 By default, the loader blocks UI interaction until the closure completes.
65 Multiple loaders can be stacked - each gets its own toast, but the overlay stays single.
66 </div>
67 <div>
68 <strong>Toast modes:</strong>
69 <ul>
70 <li><code>stoppable</code> - Shows toast with a stop button (default)</li>
71 <li><code>static</code> - Shows toast without stop button</li>
72 <li><code>none</code> - No toast, only loader overlay</li>
73 </ul>
74 </div>
75 <div>
76 Set <code>blocking=false</code> to show only a toast without blocking the UI.
77 Useful for background operations like image captioning or generation.
78 </div>
79 <div>
80 The default stop behavior is calling <code>stopGeneration()</code>.
81 If the wrapped action is doing something different than generating, a custom stop closure can be provided.
82 </div>
83 <div>
84 <strong>Examples:</strong>
85 <ul>
86 <li><pre><code class="language-stscript">/loader-wrap message="Generating summary..." {: /gen Summary of the last message | /echo Done :}</code></pre></li>
87 <li><pre><code class="language-stscript">/loader-wrap blocking=false message="Captioning..." {: /caption :}</code></pre></li>
88 <li><pre><code class="language-stscript">/loader-wrap toast=stoppable onStop={: /echo "Stopped by user" :} {: /delay 10000 :}</code></pre></li>
89 </ul>
90 </div>
91 `,
92 namedArgumentList: [
93 SlashCommandNamedArgument.fromProps({
94 name: 'blocking',
95 description: 'Whether to show blocking overlay. Set to false for non-blocking toast-only loaders.',
96 typeList: [ARGUMENT_TYPE.BOOLEAN],
97 defaultValue: 'true',
98 enumList: commonEnumProviders.boolean()(),
99 }),
100 SlashCommandNamedArgument.fromProps({
101 name: 'toast',
102 description: 'Toast display mode: stoppable (with stop button), static (no stop button), or none',
103 typeList: [ARGUMENT_TYPE.STRING],
104 defaultValue: ActionLoaderToastMode.STOPPABLE,
105 enumList: loaderEnumProviders.toastModeEnumProvider(),
106 }),
107 SlashCommandNamedArgument.fromProps({
108 name: 'message',
109 description: 'Message to display in the toast notification',
110 typeList: [ARGUMENT_TYPE.STRING],
111 defaultValue: 'Generating...',
112 }),
113 SlashCommandNamedArgument.fromProps({
114 name: 'title',
115 description: 'Optional title for the toast notification',
116 typeList: [ARGUMENT_TYPE.STRING],
117 }),
118 SlashCommandNamedArgument.fromProps({
119 name: 'stopTooltip',
120 description: 'Tooltip text for the stop button (only used when toast=stoppable)',
121 typeList: [ARGUMENT_TYPE.STRING],
122 defaultValue: 'Stop',
123 }),
124 SlashCommandNamedArgument.fromProps({
125 name: 'onStop',
126 description: 'Closure to execute when the stop button is clicked. If not provided, uses default stop behavior.',
127 typeList: [ARGUMENT_TYPE.CLOSURE],
128 }),
129 ],
130 unnamedArgumentList: [
131 SlashCommandArgument.fromProps({
132 description: 'Closure to execute while the loader is displayed',
133 typeList: [ARGUMENT_TYPE.CLOSURE],
134 isRequired: true,
135 }),
136 ],
137 callback: async (args, value) => {
138 if (!(value instanceof SlashCommandClosure)) {
139 // Throw error on purpose. This is defined as a syntax error.
140 throw new Error(t`Invalid argument for unnamed argument provided. This is not a closure.`);
141 }
142
143 const blocking = !isFalseBoolean(String(args.blocking));
144 const toastMode = Object.values(ActionLoaderToastMode).includes(String(args.toast))
145 ? String(args.toast)
146 : ActionLoaderToastMode.STOPPABLE;
147 const message = String(args.message ?? t`Generating...`);
148 const title = args.title ? String(args.title) : '';
149 const stopTooltip = String(args.stopTooltip ?? t`Stop`);
150
151 const loader = showActionLoader({
152 blocking,
153 toastMode,
154 message,
155 title,
156 stopTooltip,
157 onStop: createClosureHandler(args.onStop),
158 });
159
160 try {
161 const closureCopy = value.getCopy();
162 const result = await closureCopy.execute();
163 return result.pipe;
164 } finally {
165 await loader.hide();
166 }
167 },
168 }));
169
170 // /loader-show command - manually show a loader, returns handle ID
171 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
172 name: 'loader-show',
173 returns: 'loader handle ID (use with /loader-hide)',
174 helpString: `
175 <div>
176 Manually shows an action loader. Returns a handle ID that can be used with <code>/loader-hide</code> to hide it.
177 Use this for fine-grained control when you need to show/hide the loader at specific points.
178 Multiple loaders can be stacked - each gets its own toast, but the overlay stays single.
179 </div>
180 <div>
181 <strong>Toast modes:</strong>
182 <ul>
183 <li><code>stoppable</code> - Shows toast with a stop button (default)</li>
184 <li><code>static</code> - Shows toast without stop button</li>
185 <li><code>none</code> - No toast, only loader overlay</li>
186 </ul>
187 </div>
188 <div>
189 Set <code>blocking=false</code> to show only a toast without blocking the UI.
190 Useful for background operations like image captioning or generation.
191 </div>
192 <div>
193 The default stop behavior is calling <code>stopGeneration()</code>.
194 If the wrapped action is doing something different than generating, a custom stop closure can be provided.
195 </div>
196 <div>
197 <strong>Example:</strong>
198 <pre>
199 <code class="language-stscript">
200/loader-show message="Loading..." |
201/setvar key=myLoader |
202/some-operation |
203/loader-hide handle={{getvar::myLoader}}
204 </code>
205 </pre>
206 </div>
207 `,
208 namedArgumentList: [
209 SlashCommandNamedArgument.fromProps({
210 name: 'blocking',
211 description: 'Whether to show blocking overlay. Set to false for non-blocking toast-only loaders.',
212 typeList: [ARGUMENT_TYPE.BOOLEAN],
213 defaultValue: 'true',
214 enumList: commonEnumProviders.boolean()(),
215 }),
216 SlashCommandNamedArgument.fromProps({
217 name: 'toast',
218 description: 'Toast display mode: stoppable (with stop button), static (no stop button), or none',
219 typeList: [ARGUMENT_TYPE.STRING],
220 defaultValue: ActionLoaderToastMode.STOPPABLE,
221 enumList: loaderEnumProviders.toastModeEnumProvider(),
222 }),
223 SlashCommandNamedArgument.fromProps({
224 name: 'message',
225 description: 'Message to display in the toast notification',
226 typeList: [ARGUMENT_TYPE.STRING],
227 defaultValue: 'Generating...',
228 }),
229 SlashCommandNamedArgument.fromProps({
230 name: 'title',
231 description: 'Optional title for the toast notification',
232 typeList: [ARGUMENT_TYPE.STRING],
233 }),
234 SlashCommandNamedArgument.fromProps({
235 name: 'stopTooltip',
236 description: 'Tooltip text for the stop button (only used when toast=stoppable)',
237 typeList: [ARGUMENT_TYPE.STRING],
238 defaultValue: 'Stop',
239 }),
240 SlashCommandNamedArgument.fromProps({
241 name: 'onStop',
242 description: 'Closure to execute when the stop button is clicked',
243 typeList: [ARGUMENT_TYPE.CLOSURE],
244 }),
245 SlashCommandNamedArgument.fromProps({
246 name: 'onHide',
247 description: 'Closure to execute when the loader is hidden (not stopped)',
248 typeList: [ARGUMENT_TYPE.CLOSURE],
249 }),
250 ],
251 unnamedArgumentList: [],
252 callback: async (args) => {
253 const blocking = !isFalseBoolean(String(args.blocking));
254 const toastMode = Object.values(ActionLoaderToastMode).includes(String(args.toast))
255 ? String(args.toast)
256 : ActionLoaderToastMode.STOPPABLE;
257 const message = String(args.message ?? t`Generating...`);
258 const title = args.title ? String(args.title) : '';
259 const stopTooltip = String(args.stopTooltip ?? t`Stop`);
260
261 const handle = showActionLoader({
262 blocking,
263 toastMode,
264 message,
265 title,
266 stopTooltip,
267 onStop: createClosureHandler(args.onStop),
268 onHide: createClosureHandler(args.onHide, { argName: 'onHide' }),
269 });
270
271 return handle.id;
272 },
273 }));
274
275 // /loader-hide command - manually hide a loader by handle ID
276 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
277 name: 'loader-hide',
278 returns: 'true if an active loader was hidden, otherwise false',
279 helpString: `
280 <div>
281 Hides an action loader that was shown with <code>/loader-show</code>.
282 If no handle is provided, hides <strong>all</strong> active loaders.
283 </div>
284 <div>
285 <strong>Example:</strong>
286 <pre><code class="language-stscript">/loader-hide handle={{getvar::myLoader}}</code></pre>
287 </div>
288 `,
289 namedArgumentList: [
290 SlashCommandNamedArgument.fromProps({
291 name: 'handle',
292 description: 'Loader handle ID returned by /loader-show. If not provided, hides all active loaders.',
293 typeList: [ARGUMENT_TYPE.STRING],
294 enumProvider: loaderEnumProviders.loaderHandleProvider,
295 }),
296 ],
297 callback: async (args) => {
298 const handleId = args.handle ? String(args.handle) : null;
299
300 if (handleId) {
301 const handle = getLoaderHandleById(handleId);
302 if (handle && handle.isActive) {
303 await handle.hide();
304 return 'true';
305 }
306 return 'false';
307 }
308
309 // No handle provided - hide all active loaders
310 const result = await hideActionLoader();
311 return result ? 'true' : 'false';
312 },
313 }));
314
315 // /loader-stop command - trigger the stop action on a loader
316 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
317 name: 'loader-stop',
318 returns: 'true if an active loader was stopped, otherwise false',
319 helpString: `
320 <div>
321 Triggers the stop action on a specific action loader, as if the user clicked the stop button.
322 Unlike <code>/loader-hide</code>, this command requires a handle - you must specify which loader to stop.
323 </div>
324 <div>
325 <strong>Example:</strong>
326 <pre><code class="language-stscript">/loader-stop handle={{getvar::myLoader}}</code></pre>
327 </div>
328 `,
329 namedArgumentList: [
330 SlashCommandNamedArgument.fromProps({
331 name: 'handle',
332 description: 'Loader handle ID returned by /loader-show.',
333 typeList: [ARGUMENT_TYPE.STRING],
334 isRequired: true,
335 enumProvider: loaderEnumProviders.loaderHandleProvider,
336 }),
337 ],
338 callback: async (args) => {
339 const handleId = args.handle ? String(args.handle) : null;
340
341 if (!handleId) {
342 toastr.warning(t`No handle provided. You must specify which loader to stop.`);
343 return 'false';
344 }
345
346 const handle = getLoaderHandleById(handleId);
347 if (handle && handle.isActive) {
348 await handle.stop();
349 return 'true';
350 }
351
352 return 'false';
353 },
354 }));
355}
public/scripts/action-loader.js+406 -0
@@ -0,0 +1,406 @@
1/**
2 * Action loader utility - shows loader overlay with stoppable toast notification.
3 * Designed to be flexible and reusable for various long-running operations.
4 * Supports stacking multiple loaders - overlay stays single, but toasts can stack.
5 *
6 * With default arguments, will function as a generation loader / wrapper.
7 *
8 * @module action-loader
9 */
10
11import { t } from './i18n.js';
12import { stopGeneration } from '../script.js';
13import { showLoader, hideLoader, isLoaderDisplayed } from './loader.js';
14
15/**
16 * Enum representing the toast display mode for the action loader.
17 * @readonly
18 * @enum {string}
19 */
20export const ActionLoaderToastMode = {
21 /** No toast is displayed */
22 NONE: 'none',
23 /** Toast is displayed without stop button (non-interactable) */
24 STATIC: 'static',
25 /** Toast is displayed with stop button (default) */
26 STOPPABLE: 'stoppable',
27};
28
29/**
30 * @typedef {object} ActionLoaderOptions
31 * @property {boolean} [blocking=true] - Whether to show the blocking overlay. Set to false for non-blocking toast-only loaders.
32 * @property {ActionLoaderToastMode} [toastMode='stoppable'] - Toast display mode
33 * @property {string} [message='Generating...'] - The message to display in the toast
34 * @property {string} [title] - Optional title for the toast notification
35 * @property {string} [stopTooltip='Stop'] - Tooltip text for the stop button
36 * @property {(() => void)|null} [onStop=null] - Custom stop handler. If null, calls `stopGeneration()`
37 * @property {(() => void)|null} [onHide=null] - Custom hide handler. Called when the loader is hidden (not stopped).
38 */
39
40/** Counter for generating unique loader IDs */
41let loaderIdCounter = 0;
42
43/** @type {Set<ActionLoaderHandle>} Set of all active loader handles */
44const activeHandles = new Set();
45
46/**
47 * Generates a unique loader ID.
48 * @returns {string} Unique loader ID
49 */
50function generateLoaderId() {
51 return `loader_${++loaderIdCounter}`;
52}
53
54/**
55 * Checks if there are any active blocking loaders.
56 * @returns {boolean} True if at least one blocking loader is active
57 */
58function hasBlockingLoaders() {
59 for (const handle of activeHandles) {
60 if (handle.isBlocking && handle.isActive) {
61 return true;
62 }
63 }
64 return false;
65}
66
67/**
68 * Class representing an action loader handle.
69 * Manages its own toast, stop handler, and lifecycle.
70 */
71export class ActionLoaderHandle {
72 /** @type {string} Unique identifier for this handle */
73 id;
74
75 /** @type {JQuery<HTMLElement>|null} The toast element for this loader */
76 #toast = null;
77
78 /** @type {(() => void)|null} Custom stop handler */
79 #onStop = null;
80
81 /** @type {(() => void)|null} Custom hide handler */
82 #onHide = null;
83
84 /** @type {boolean} Whether this loader blocks the UI with an overlay */
85 #blocking = true;
86
87 /** @type {boolean} Whether this handle has been disposed */
88 #disposed = false;
89
90 /**
91 * Creates a new ActionLoaderHandle.
92 * @param {object} options - Configuration options
93 * @param {boolean} [options.blocking=true] - Whether to show blocking overlay
94 * @param {ActionLoaderToastMode} [options.toastMode] - Toast display mode
95 * @param {string} [options.message='Generating...'] - Message to display in the toast
96 * @param {string} [options.title] - Title for the toast notification
97 * @param {string} [options.stopTooltip='Stop'] - Tooltip for the stop button
98 * @param {(() => void)|null} [options.onStop] - Custom stop handler
99 * @param {(() => void)|null} [options.onHide] - Custom hide handler
100 */
101 constructor({
102 blocking = true,
103 toastMode = ActionLoaderToastMode.STOPPABLE,
104 message = t`Generating...`,
105 title = '',
106 stopTooltip = t`Stop`,
107 onStop = null,
108 onHide = null,
109 } = {}) {
110 this.id = generateLoaderId();
111 this.#blocking = blocking;
112 this.#onStop = onStop;
113 this.#onHide = onHide;
114
115 // Warn if non-blocking loader has no toast - it won't be visible to the user
116 if (!blocking && toastMode === ActionLoaderToastMode.NONE) {
117 console.warn('[ActionLoader] Non-blocking loader created without a toast. This loader will not be visible to the user.');
118 }
119
120 // Show the blocking loader overlay if this is the first blocking handle
121 if (blocking && !hasBlockingLoaders() && !isLoaderDisplayed()) {
122 showLoader();
123 }
124
125 // Register this handle
126 activeHandles.add(this);
127
128 // Create toast if needed
129 if (toastMode !== ActionLoaderToastMode.NONE) {
130 this.#createToast(message, title, toastMode, stopTooltip);
131 }
132 }
133
134 /**
135 * Creates the toast element for this loader.
136 * @param {string} message - Message to display
137 * @param {string} title - Title for the toast
138 * @param {ActionLoaderToastMode} toastMode - Toast mode
139 * @param {string} stopTooltip - Tooltip for stop button
140 */
141 #createToast(message, title, toastMode, stopTooltip) {
142 const toastContent = document.createElement('div');
143 toastContent.className = 'action-loader-toast';
144
145 const messageSpan = document.createElement('span');
146 messageSpan.className = 'action-loader-message';
147 messageSpan.textContent = message;
148 toastContent.appendChild(messageSpan);
149
150 // Add stop button if mode is STOPPABLE
151 if (toastMode === ActionLoaderToastMode.STOPPABLE) {
152 const stopButton = document.createElement('i');
153 stopButton.className = 'fa-solid fa-stop-circle action-loader-stop interactable';
154 stopButton.title = stopTooltip;
155 stopButton.addEventListener('click', (e) => {
156 e.preventDefault();
157 e.stopPropagation();
158 this.stop();
159 });
160 toastContent.appendChild(stopButton);
161 }
162
163 // Show toast with no timeout (sticky)
164 this.#toast = toastr.info($(toastContent), title, {
165 timeOut: 0,
166 extendedTimeOut: 0,
167 tapToDismiss: false,
168 escapeHtml: false,
169 });
170 }
171
172 /**
173 * Clears the toast element for this loader.
174 */
175 #clearToast() {
176 if (this.#toast) {
177 toastr.clear(this.#toast, { force: true }); // Need to force as the toast might have focus/hover
178 this.#toast = null;
179 }
180 }
181
182 /**
183 * Disposes this handle, removing it from active handles and hiding overlay if last.
184 */
185 async #dispose() {
186 if (this.#disposed) return;
187 this.#disposed = true;
188
189 this.#clearToast();
190 activeHandles.delete(this);
191
192 // Hide the overlay if this was the last blocking handle
193 if (this.#blocking && !hasBlockingLoaders()) {
194 await hideLoader();
195 }
196 }
197
198 /**
199 * Whether this handle is still active (not disposed).
200 * @returns {boolean}
201 */
202 get isActive() {
203 return !this.#disposed;
204 }
205
206 /**
207 * Whether this loader blocks the UI with an overlay.
208 * @returns {boolean}
209 */
210 get isBlocking() {
211 return this.#blocking;
212 }
213
214 /**
215 * Triggers the stop action on this loader.
216 * Calls the custom onStop handler if provided, otherwise calls stopGeneration().
217 * Then hides this loader.
218 */
219 async stop() {
220 if (this.#disposed) return;
221
222 // Call custom stop handler or default
223 if (this.#onStop) {
224 try {
225 await this.#onStop();
226 } catch (e) {
227 console.error('Error executing onStop handler', e);
228 }
229 } else {
230 stopGeneration();
231 }
232
233 // Dispose without calling onHide (stop is different from hide)
234 await this.#dispose();
235 }
236
237 /**
238 * Hides this loader and clears its toast.
239 * Calls the custom onHide handler if provided.
240 */
241 async hide() {
242 if (this.#disposed) return;
243
244 // Call custom hide handler if provided
245 if (this.#onHide) {
246 try {
247 await this.#onHide();
248 } catch (e) {
249 console.error('Error executing onHide handler', e);
250 }
251 }
252
253 await this.#dispose();
254 }
255}
256
257/**
258 * Action loader utility API.
259 * Provides a convenient interface for showing and managing loading indicators.
260 *
261 * Read the functions documentation for more details.
262 *
263 * @example
264 * // Basic usage
265 * const handle = loader.show({ message: 'Loading...' });
266 * await someOperation();
267 * handle.hide();
268 *
269 * @example
270 * // Non-blocking background task
271 * const handle = loader.show({ blocking: false, message: 'Processing...' });
272 *
273 * @example
274 * // Hide all active loaders
275 * loader.hide();
276 */
277export const loader = {
278 /**
279 * Shows an action loader with optional toast notification.
280 * Returns a handle to control the loader.
281 * @type {typeof showActionLoader}
282 */
283 show: showActionLoader,
284
285 /**
286 * Hides a specific loader by handle, or all loaders if no handle provided.
287 * @type {typeof hideActionLoader}
288 */
289 hide: hideActionLoader,
290
291 /**
292 * Gets all currently active loader handles.
293 * @type {typeof getActiveLoaderHandles}
294 */
295 active: getActiveLoaderHandles,
296
297 /**
298 * Gets a loader handle by its ID.
299 * @type {typeof getLoaderHandleById}
300 */
301 get: getLoaderHandleById,
302
303 /**
304 * Toast display mode constants.
305 * @type {typeof ActionLoaderToastMode}
306 */
307 ToastMode: ActionLoaderToastMode,
308
309 /**
310 * The ActionLoaderHandle class.
311 * @type {typeof ActionLoaderHandle}
312 */
313 Handle: ActionLoaderHandle,
314};
315
316/**
317 * Shows an action loader with an optional stoppable toast notification.
318 * Multiple loaders can be stacked - the overlay stays single, but each gets its own toast.
319 * When the last loader is hidden, the overlay is removed.
320 *
321 * With default arguments, will function as a generation loader / wrapper.
322 *
323 * @param {ActionLoaderOptions} [options={}] - Configuration options
324 * @returns {ActionLoaderHandle} Handle to control the loader
325 *
326 * @example
327 * // Basic usage
328 * const loader = showActionLoader({ message: 'Generating title...' });
329 * try {
330 * const result = await generateRaw({ prompt });
331 * // process result
332 * } finally {
333 * await loader.hide();
334 * }
335 *
336 * @example
337 * // With custom stop and hide handlers
338 * const loader = showActionLoader({
339 * message: 'Downloading...',
340 * stopTooltip: 'Cancel download',
341 * onStop: () => myCustomCancelFunction(),
342 * onHide: () => console.log('Loader hidden'),
343 * });
344 *
345 * @example
346 * // Stacking multiple loaders
347 * const loader1 = showActionLoader({ message: 'Task 1...' });
348 * const loader2 = showActionLoader({ message: 'Task 2...' });
349 * await loader1.hide(); // Overlay stays, loader2 still active
350 * await loader2.hide(); // Now overlay hides
351 *
352 * @example
353 * // Non-blocking loader (toast only, no overlay)
354 * const loader = showActionLoader({
355 * message: 'Captioning image...',
356 * blocking: false,
357 * onStop: () => abortCaptioning(),
358 * });
359 */
360export function showActionLoader(options = {}) {
361 return new ActionLoaderHandle(options);
362}
363
364/**
365 * Hides a specific action loader by handle, or all active loaders if no handle provided.
366 * @param {ActionLoaderHandle|null} [handle=null] - Specific handle to hide, or undefined to hide all
367 * @returns {Promise<boolean>} Whether any loader was hidden
368 */
369export async function hideActionLoader(handle = null) {
370 if (handle instanceof ActionLoaderHandle) {
371 if (handle.isActive) {
372 await handle.hide();
373 return true;
374 }
375 return false;
376 }
377
378 // No handle provided - hide all active loaders
379 const handles = getActiveLoaderHandles();
380 for (const h of handles) {
381 await h.hide();
382 }
383 return handles.length > 0;
384}
385
386/**
387 * Gets all currently active loader handles.
388 * @returns {ActionLoaderHandle[]} Array of active handles
389 */
390export function getActiveLoaderHandles() {
391 return Array.from(activeHandles);
392}
393
394/**
395 * Gets a loader handle by its ID.
396 * @param {string} id - The handle ID
397 * @returns {ActionLoaderHandle|undefined} The handle, or undefined if not found
398 */
399export function getLoaderHandleById(id) {
400 for (const handle of activeHandles) {
401 if (handle.id === id) {
402 return handle;
403 }
404 }
405 return undefined;
406}
public/scripts/loader.js+4 -0
@@ -5,6 +5,10 @@ let loaderPopup;
55
6let preloaderYoinked = false;6let preloaderYoinked = false;
77
8export function isLoaderDisplayed() {
9 return !!loaderPopup;
10}
11
8export function showLoader() {12export function showLoader() {
9 // Two loaders don't make sense. Don't await, we can overlay the old loader while it closes13 // Two loaders don't make sense. Don't await, we can overlay the old loader while it closes
10 if (loaderPopup) loaderPopup.complete(POPUP_RESULT.CANCELLED);14 if (loaderPopup) loaderPopup.complete(POPUP_RESULT.CANCELLED);
public/scripts/slash-commands.js+2 -0
@@ -77,6 +77,7 @@ import { SERVER_INPUTS, textgen_types, textgenerationwebui_settings } from './te
77import { decodeTextTokens, getAvailableTokenizers, getFriendlyTokenizerName, getTextTokens, getTokenCountAsync, selectTokenizer } from './tokenizers.js';77import { decodeTextTokens, getAvailableTokenizers, getFriendlyTokenizerName, getTextTokens, getTokenCountAsync, selectTokenizer } from './tokenizers.js';
78import { debounce, delay, equalsIgnoreCaseAndAccents, findChar, getCharIndex, isFalseBoolean, isTrueBoolean, onlyUnique, regexFromString, showFontAwesomePicker, stringToRange, trimToEndSentence, trimToStartSentence, waitUntilCondition } from './utils.js';78import { debounce, delay, equalsIgnoreCaseAndAccents, findChar, getCharIndex, isFalseBoolean, isTrueBoolean, onlyUnique, regexFromString, showFontAwesomePicker, stringToRange, trimToEndSentence, trimToStartSentence, waitUntilCondition } from './utils.js';
79import { registerVariableCommands, resolveVariable } from './variables.js';79import { registerVariableCommands, resolveVariable } from './variables.js';
80import { registerActionLoaderSlashCommands } from './action-loader-slashcommands.js';
80import { background_settings } from './backgrounds.js';81import { background_settings } from './backgrounds.js';
81import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';82import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
82import { SlashCommandClosureResult } from './slash-commands/SlashCommandClosureResult.js';83import { SlashCommandClosureResult } from './slash-commands/SlashCommandClosureResult.js';
@@ -3714,6 +3715,7 @@ export function initDefaultSlashCommands() {
3714 }));3715 }));
37153716
3716 registerVariableCommands();3717 registerVariableCommands();
3718 registerActionLoaderSlashCommands();
3717}3719}
37183720
3719const NARRATOR_NAME_KEY = 'narrator_name';3721const NARRATOR_NAME_KEY = 'narrator_name';
public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js+2 -0
@@ -42,6 +42,8 @@ export const enumIcons = {
42 image: '๐Ÿ–ผ๏ธ',42 image: '๐Ÿ–ผ๏ธ',
43 video: '๐ŸŽฅ',43 video: '๐ŸŽฅ',
44 key: '๐Ÿ”‘',44 key: '๐Ÿ”‘',
45 spinner: 'โ™ป๏ธ',
46 stop: '๐Ÿ›‘',
4547
46 true: 'โœ”๏ธ',48 true: 'โœ”๏ธ',
47 false: 'โŒ',49 false: 'โŒ',
public/scripts/st-context.js+2 -0
@@ -81,6 +81,7 @@ import {
81import { groups, openGroupChat, selected_group, unshallowGroupMembers } from './group-chats.js';81import { groups, openGroupChat, selected_group, unshallowGroupMembers } from './group-chats.js';
82import { addLocaleData, getCurrentLocale, t, translate } from './i18n.js';82import { addLocaleData, getCurrentLocale, t, translate } from './i18n.js';
83import { hideLoader, showLoader } from './loader.js';83import { hideLoader, showLoader } from './loader.js';
84import { loader } from './action-loader.js';
84import { MacrosParser } from './macros.js';85import { MacrosParser } from './macros.js';
85import { getChatCompletionModel, oai_settings } from './openai.js';86import { getChatCompletionModel, oai_settings } from './openai.js';
86import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js';87import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js';
@@ -235,6 +236,7 @@ export function getContext() {
235 scrollChatToBottom,236 scrollChatToBottom,
236 scrollOnMediaLoad,237 scrollOnMediaLoad,
237 macros,238 macros,
239 loader,
238 swipe: {240 swipe: {
239 left: swipe_left,241 left: swipe_left,
240 right: swipe_right,242 right: swipe_right,
public/style.css+5 -1
@@ -3973,7 +3973,7 @@ grammarly-extension {
39733973
39743974
3975/* Override toastr default styles */3975/* Override toastr default styles */
3976body>#toast-container {3976body #toast-container {
3977 margin-top: var(--topBarBlockSize);3977 margin-top: var(--topBarBlockSize);
3978}3978}
39793979
@@ -3986,6 +3986,10 @@ body #toast-container>div {
3986 width: 300px;3986 width: 300px;
3987}3987}
39883988
3989body #toast-container .toast.toast-non-interactable {
3990 cursor: default;
3991}
3992
3989body #toast-container .toast-success {3993body #toast-container .toast-success {
3990 background-color: #5d9e5d;3994 background-color: #5d9e5d;
3991}3995}