Refactor: Replace SD image generation indicator with ActionLoader system (#5472) * refactor: replace custom generation indicator with action-loader Remove custom generation tracking (activeGenerations counter, generationToast) and replace with action-loader's non-blocking stoppable toast. Import loader from action-loader.js and use loader.show() with onStop callback in generatePicture() and generateMediaSwipe(). Remove updateGenerationIndicator(), startGenerationTracking(), and endGenerationTracking() functions. Remove manual stop button show/hide logic and generation counter updates * Remove legacy stop button, add sentinel handler value * fix: reassign loaderHandle in generateMediaSwipe * feat: add data attributes to action-loader toast for easier selection Add loaderId, title, and blocking data attributes to toast content div to enable programmatic identification and filtering of active loader toasts * Revert "feat: add data attributes to action-loader toast for easier selection" This reverts commit e8da27b4c94b389d5970a6bea6c7b1c94459b460. --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

d8b3d36a8476c73af40192e0ad565cfb77379833

Wolfsblvt <wolfsblvt@gmail.com>

Signed
3 files changed, +39 -89Showing whitespace changes
public/scripts/action-loader.js+16 -0
@@ -73,6 +73,15 @@ function hasBlockingLoaders() {
73 * Manages its own toast, stop handler, and lifecycle.73 * Manages its own toast, stop handler, and lifecycle.
74 */74 */
75export class ActionLoaderHandle {75export class ActionLoaderHandle {
76 /**
77 * A special empty handle that is already disposed. Useful as a default value to avoid null checks.
78 * Does not generate any id, toast, or overlay, and all its methods are no-ops.
79 * @type {ActionLoaderHandle}
80 */
81 static get EMPTY() {
82 return new ActionLoaderHandle({ predisposed: true });
83 }
84
76 /** @type {string} Unique identifier for this handle */85 /** @type {string} Unique identifier for this handle */
77 id;86 id;
7887
@@ -99,6 +108,7 @@ export class ActionLoaderHandle {
99 * @param {string} [options.message='Generating...'] - Message to display in the toast108 * @param {string} [options.message='Generating...'] - Message to display in the toast
100 * @param {string} [options.title] - Title for the toast notification109 * @param {string} [options.title] - Title for the toast notification
101 * @param {string} [options.stopTooltip='Stop'] - Tooltip for the stop button110 * @param {string} [options.stopTooltip='Stop'] - Tooltip for the stop button
111 * @param {boolean} [options.predisposed=false] - Whether this handle is already disposed (for special use)
102 * @param {HTMLElement|string|null} [options.overlayContent] - Custom content for the overlay (replaces default spinner)112 * @param {HTMLElement|string|null} [options.overlayContent] - Custom content for the overlay (replaces default spinner)
103 * @param {(() => void)|null} [options.onStop] - Custom stop handler113 * @param {(() => void)|null} [options.onStop] - Custom stop handler
104 * @param {(() => void)|null} [options.onHide] - Custom hide handler114 * @param {(() => void)|null} [options.onHide] - Custom hide handler
@@ -112,7 +122,13 @@ export class ActionLoaderHandle {
112 overlayContent = null,122 overlayContent = null,
113 onStop = null,123 onStop = null,
114 onHide = null,124 onHide = null,
125 predisposed = false,
115 } = {}) {126 } = {}) {
127 if (predisposed) {
128 this.#disposed = true;
129 return;
130 }
131
116 this.id = generateLoaderId();132 this.id = generateLoaderId();
117 this.#blocking = blocking;133 this.#blocking = blocking;
118 this.#onStop = onStop;134 this.#onStop = onStop;
public/scripts/extensions/stable-diffusion/button.html+0 -4
@@ -2,7 +2,3 @@
2 <div class="fa-solid fa-paintbrush extensionsMenuExtensionButton" title="Trigger Stable Diffusion" data-i18n="[title]Trigger Stable Diffusion"></div>2 <div class="fa-solid fa-paintbrush extensionsMenuExtensionButton" title="Trigger Stable Diffusion" data-i18n="[title]Trigger Stable Diffusion"></div>
3 <span data-i18n="Generate Image">Generate Image</span>3 <span data-i18n="Generate Image">Generate Image</span>
4</div>4</div>
5<div id="sd_stop_gen" class="list-group-item flex-container flexGap5">
6 <div class="fa-solid fa-circle-stop extensionsMenuExtensionButton" title="Abort current image generation task" data-i18n="[title]Abort current image generation task"></div>
7 <span data-i18n="Stop Image Generation">Stop Image Generation</span>
8</div>
public/scripts/extensions/stable-diffusion/index.js+23 -85
@@ -62,18 +62,13 @@ import { t, translate } from '../../i18n.js';
62import { oai_settings } from '../../openai.js';62import { oai_settings } from '../../openai.js';
63import { power_user } from '/scripts/power-user.js';63import { power_user } from '/scripts/power-user.js';
64import { MacrosParser } from '/scripts/macros.js';64import { MacrosParser } from '/scripts/macros.js';
65import { ActionLoaderHandle, loader } from '/scripts/action-loader.js';
6566
66export { MODULE_NAME };67export { MODULE_NAME };
6768
68const MODULE_NAME = 'sd';69const MODULE_NAME = 'sd';
69// This is a 1x1 transparent PNG70// This is a 1x1 transparent PNG
70const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';71const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
71const CUSTOM_STOP_EVENT = 'sd_stop_generation';
72
73// Generation tracking for status indicator
74let activeGenerations = 0;
75/** @type {JQuery<HTMLElement>|null} */
76let generationToast = null;
7772
78const sources = {73const sources = {
79 extras: 'extras',74 extras: 'extras',
@@ -2981,62 +2976,6 @@ function ensureSelectionExists(setting, selector) {
2981}2976}
29822977
2983/**2978/**
2984 * Updates the generation status indicator based on active generation count.
2985 * Shows/hides various UI indicators to inform user of background image generation.
2986 */
2987function updateGenerationIndicator() {
2988 if (activeGenerations > 0) {
2989 const countText = activeGenerations > 1 ? ` (${activeGenerations})` : '';
2990 const toastText = `<i class="fa-solid fa-spinner fa-spin"></i> ${t`Generating an image`}${countText}...`;
2991
2992 // Show persistent toast if not already showing
2993 if (!generationToast) {
2994 generationToast = toastr.info(
2995 toastText,
2996 'Image Generation',
2997 {
2998 timeOut: 0,
2999 extendedTimeOut: 0,
3000 tapToDismiss: true,
3001 escapeHtml: false,
3002 onHidden: () => {
3003 generationToast = null;
3004 },
3005 },
3006 );
3007 } else if (activeGenerations > 1) {
3008 // Update count in existing toast
3009 const toastMessage = $(generationToast).find('.toast-message');
3010 if (toastMessage.length) {
3011 toastMessage.html(toastText);
3012 }
3013 }
3014 } else {
3015 // Hide toast when done
3016 if (generationToast) {
3017 toastr.clear(generationToast);
3018 generationToast = null;
3019 }
3020 }
3021}
3022
3023/**
3024 * Increments the active generation counter and updates indicators.
3025 */
3026function startGenerationTracking() {
3027 activeGenerations++;
3028 updateGenerationIndicator();
3029}
3030
3031/**
3032 * Decrements the active generation counter and updates indicators.
3033 */
3034function endGenerationTracking() {
3035 activeGenerations = Math.max(0, activeGenerations - 1);
3036 updateGenerationIndicator();
3037}
3038
3039/**
3040 * Generates an image based on the given trigger word.2979 * Generates an image based on the given trigger word.
3041 * @param {string} initiator The initiator of the image generation2980 * @param {string} initiator The initiator of the image generation
3042 * @param {Record<string, object>} args Command arguments2981 * @param {Record<string, object>} args Command arguments
@@ -3096,12 +3035,13 @@ async function generatePicture(initiator, args, trigger, message, callback) {
30963035
3097 const dimensions = setTypeSpecificDimensions(generationType);3036 const dimensions = setTypeSpecificDimensions(generationType);
3098 const abortController = new AbortController();3037 const abortController = new AbortController();
3099 const stopButton = document.getElementById('sd_stop_gen');
3100 let negativePromptPrefix = args?.negative || '';3038 let negativePromptPrefix = args?.negative || '';
3101 let imagePath = '';3039 let imagePath = '';
31023040
3103 const stopListener = () => abortController.abort('Aborted by user');3041 const stopListener = () => abortController.abort('Aborted by user');
31043042
3043 let loaderHandle = ActionLoaderHandle.EMPTY;
3044
3105 try {3045 try {
3106 const combineNegatives = (prefix) => { negativePromptPrefix = combinePrefixes(negativePromptPrefix, prefix); };3046 const combineNegatives = (prefix) => { negativePromptPrefix = combinePrefixes(negativePromptPrefix, prefix); };
31073047
@@ -3114,16 +3054,18 @@ async function generatePicture(initiator, args, trigger, message, callback) {
3114 await eventSource.emit(event_types.SD_PROMPT_PROCESSING, eventData);3054 await eventSource.emit(event_types.SD_PROMPT_PROCESSING, eventData);
3115 prompt = eventData.prompt; // Allow extensions to modify the prompt3055 prompt = eventData.prompt; // Allow extensions to modify the prompt
31163056
3117 // Track this generation for status indicator
3118 startGenerationTracking();
3119 // Show stop button after prompt is ready (prompt generation uses separate abort mechanism)
3120 $(stopButton).show();
3121 eventSource.once(CUSTOM_STOP_EVENT, stopListener);
3122
3123 if (typeof args?._abortController?.addEventListener === 'function') {3057 if (typeof args?._abortController?.addEventListener === 'function') {
3124 args._abortController.addEventListener('abort', stopListener);3058 args._abortController.addEventListener('abort', stopListener);
3125 }3059 }
31263060
3061 // Show non-blocking stoppable toast for this generation
3062 loaderHandle = loader.show({
3063 blocking: false,
3064 title: t`Image Generation`,
3065 message: t`Generating an image...`,
3066 onStop: stopListener,
3067 });
3068
3127 // generate the image3069 // generate the image
3128 imagePath = await sendGenerationRequest(generationType, prompt, negativePromptPrefix, characterName, callback, initiator, abortController.signal);3070 imagePath = await sendGenerationRequest(generationType, prompt, negativePromptPrefix, characterName, callback, initiator, abortController.signal);
3129 } catch (err) {3071 } catch (err) {
@@ -3142,10 +3084,8 @@ async function generatePicture(initiator, args, trigger, message, callback) {
3142 toastr.error(errorText, 'Image Generation');3084 toastr.error(errorText, 'Image Generation');
3143 throw new Error(errorText);3085 throw new Error(errorText);
3144 } finally {3086 } finally {
3145 $(stopButton).hide();
3146 restoreOriginalDimensions(dimensions);3087 restoreOriginalDimensions(dimensions);
3147 eventSource.removeListener(CUSTOM_STOP_EVENT, stopListener);3088 await loaderHandle.hide();
3148 endGenerationTracking();
3149 }3089 }
31503090
3151 return imagePath;3091 return imagePath;
@@ -5128,10 +5068,6 @@ async function addSDGenButtons() {
5128 generatePicture(initiators.wand, {}, param);5068 generatePicture(initiators.wand, {}, param);
5129 }5069 }
5130 });5070 });
5131
5132 const stopGenButton = $('#sd_stop_gen');
5133 stopGenButton.hide();
5134 stopGenButton.on('click', () => eventSource.emit(CUSTOM_STOP_EVENT));
5135}5071}
51365072
5137function isValidState() {5073function isValidState() {
@@ -5216,10 +5152,6 @@ async function sdMessageButton($icon, { animate } = {}) {
5216 $icon.toggleClass(classes.idle, !isBusy);5152 $icon.toggleClass(classes.idle, !isBusy);
5217 $icon.toggleClass(classes.busy, isBusy);5153 $icon.toggleClass(classes.busy, isBusy);
5218 $media.toggleClass(classes.animation, isBusy);5154 $media.toggleClass(classes.animation, isBusy);
5219
5220 // Update generation counter toast
5221 const trackingFunction = isBusy ? startGenerationTracking : endGenerationTracking;
5222 trackingFunction();
5223 }5155 }
52245156
5225 let $media = jQuery();5157 let $media = jQuery();
@@ -5334,7 +5266,6 @@ async function writePromptFields(characterId) {
5334 * @returns {Promise<MediaAttachment|null>} - A promise that resolves to the newly generated media attachment, or null if generation failed or was aborted.5266 * @returns {Promise<MediaAttachment|null>} - A promise that resolves to the newly generated media attachment, or null if generation failed or was aborted.
5335 */5267 */
5336async function generateMediaSwipe(mediaAttachment, message, onStart, onComplete, abortController = new AbortController()) {5268async function generateMediaSwipe(mediaAttachment, message, onStart, onComplete, abortController = new AbortController()) {
5337 const stopButton = document.getElementById('sd_stop_gen');
5338 const stopListener = () => abortController.abort('Aborted by user');5269 const stopListener = () => abortController.abort('Aborted by user');
5339 const generationType = mediaAttachment.generation_type ?? message?.extra?.generationType ?? generationMode.FREE;5270 const generationType = mediaAttachment.generation_type ?? message?.extra?.generationType ?? generationMode.FREE;
5340 let dimensions = { width: extension_settings.sd.width, height: extension_settings.sd.height };5271 let dimensions = { width: extension_settings.sd.width, height: extension_settings.sd.height };
@@ -5348,9 +5279,9 @@ async function generateMediaSwipe(mediaAttachment, message, onStart, onComplete,
5348 source: MEDIA_SOURCE.GENERATED,5279 source: MEDIA_SOURCE.GENERATED,
5349 };5280 };
53505281
5282 let loaderHandle = ActionLoaderHandle.EMPTY;
5283
5351 try {5284 try {
5352 $(stopButton).show();
5353 eventSource.once(CUSTOM_STOP_EVENT, stopListener);
5354 const callback = (_a, _b, _c, _d, _e, _f, format) => { result.type = isVideo(format) ? MEDIA_TYPE.VIDEO : MEDIA_TYPE.IMAGE; };5285 const callback = (_a, _b, _c, _d, _e, _f, format) => { result.type = isVideo(format) ? MEDIA_TYPE.VIDEO : MEDIA_TYPE.IMAGE; };
5355 const savedPrompt = mediaAttachment.title ?? message.extra.title ?? '';5286 const savedPrompt = mediaAttachment.title ?? message.extra.title ?? '';
5356 const savedNegative = mediaAttachment.negative ?? message.extra.negative ?? '';5287 const savedNegative = mediaAttachment.negative ?? message.extra.negative ?? '';
@@ -5366,6 +5297,14 @@ async function generateMediaSwipe(mediaAttachment, message, onStart, onComplete,
5366 ? context.groups[Object.keys(context.groups).filter(x => context.groups[x].id === context.groupId)[0]]?.id?.toString()5297 ? context.groups[Object.keys(context.groups).filter(x => context.groups[x].id === context.groupId)[0]]?.id?.toString()
5367 : context.characters[context.characterId]?.name;5298 : context.characters[context.characterId]?.name;
53685299
5300 // Show non-blocking stoppable toast for this generation
5301 loaderHandle = loader.show({
5302 blocking: false,
5303 title: t`Image Generation`,
5304 message: t`Generating an image...`,
5305 onStop: stopListener,
5306 });
5307
5369 onStart();5308 onStart();
5370 result.url = await sendGenerationRequest(generationType, prompt, refineArgs.negative, characterName, callback, initiators.swipe, abortController.signal);5309 result.url = await sendGenerationRequest(generationType, prompt, refineArgs.negative, characterName, callback, initiators.swipe, abortController.signal);
5371 result.generation_type = generationType;5310 result.generation_type = generationType;
@@ -5377,11 +5316,10 @@ async function generateMediaSwipe(mediaAttachment, message, onStart, onComplete,
5377 }5316 }
5378 } finally {5317 } finally {
5379 onComplete();5318 onComplete();
5380 $(stopButton).hide();
5381 eventSource.removeListener(CUSTOM_STOP_EVENT, stopListener);
5382 restoreOriginalDimensions(dimensions);5319 restoreOriginalDimensions(dimensions);
5383 extension_settings.sd.seed = extension_settings.sd.original_seed;5320 extension_settings.sd.seed = extension_settings.sd.original_seed;
5384 delete extension_settings.sd.original_seed;5321 delete extension_settings.sd.original_seed;
5322 await loaderHandle.hide();
5385 }5323 }
53865324
5387 if (!result.url) {5325 if (!result.url) {