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>
Signed| @@ -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 | */ |
| 75 | export class ActionLoaderHandle { | 75 | export 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; |
| 78 | 87 | ||
| @@ -99,6 +108,7 @@ export class ActionLoaderHandle { | |||
| 99 | * @param {string} [options.message='Generating...'] - Message to display in the toast | 108 | * @param {string} [options.message='Generating...'] - Message to display in the toast |
| 100 | * @param {string} [options.title] - Title for the toast notification | 109 | * @param {string} [options.title] - Title for the toast notification |
| 101 | * @param {string} [options.stopTooltip='Stop'] - Tooltip for the stop button | 110 | * @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 handler | 113 | * @param {(() => void)|null} [options.onStop] - Custom stop handler |
| 104 | * @param {(() => void)|null} [options.onHide] - Custom hide handler | 114 | * @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; |
| @@ -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> | ||
| @@ -62,18 +62,13 @@ import { t, translate } from '../../i18n.js'; | |||
| 62 | import { oai_settings } from '../../openai.js'; | 62 | import { oai_settings } from '../../openai.js'; |
| 63 | import { power_user } from '/scripts/power-user.js'; | 63 | import { power_user } from '/scripts/power-user.js'; |
| 64 | import { MacrosParser } from '/scripts/macros.js'; | 64 | import { MacrosParser } from '/scripts/macros.js'; |
| 65 | import { ActionLoaderHandle, loader } from '/scripts/action-loader.js'; | ||
| 65 | 66 | ||
| 66 | export { MODULE_NAME }; | 67 | export { MODULE_NAME }; |
| 67 | 68 | ||
| 68 | const MODULE_NAME = 'sd'; | 69 | const MODULE_NAME = 'sd'; |
| 69 | // This is a 1x1 transparent PNG | 70 | // This is a 1x1 transparent PNG |
| 70 | const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; | 71 | const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; |
| 71 | const CUSTOM_STOP_EVENT = 'sd_stop_generation'; | ||
| 72 | |||
| 73 | // Generation tracking for status indicator | ||
| 74 | let activeGenerations = 0; | ||
| 75 | /** @type {JQuery<HTMLElement>|null} */ | ||
| 76 | let generationToast = null; | ||
| 77 | 72 | ||
| 78 | const sources = { | 73 | const sources = { |
| 79 | extras: 'extras', | 74 | extras: 'extras', |
| @@ -2981,62 +2976,6 @@ function ensureSelectionExists(setting, selector) { | |||
| 2981 | } | 2976 | } |
| 2982 | 2977 | ||
| 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 | */ | ||
| 2987 | function 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 | */ | ||
| 3026 | function startGenerationTracking() { | ||
| 3027 | activeGenerations++; | ||
| 3028 | updateGenerationIndicator(); | ||
| 3029 | } | ||
| 3030 | |||
| 3031 | /** | ||
| 3032 | * Decrements the active generation counter and updates indicators. | ||
| 3033 | */ | ||
| 3034 | function 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 generation | 2980 | * @param {string} initiator The initiator of the image generation |
| 3042 | * @param {Record<string, object>} args Command arguments | 2981 | * @param {Record<string, object>} args Command arguments |
| @@ -3096,12 +3035,13 @@ async function generatePicture(initiator, args, trigger, message, callback) { | |||
| 3096 | 3035 | ||
| 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 = ''; |
| 3102 | 3040 | ||
| 3103 | const stopListener = () => abortController.abort('Aborted by user'); | 3041 | const stopListener = () => abortController.abort('Aborted by user'); |
| 3104 | 3042 | ||
| 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); }; |
| 3107 | 3047 | ||
| @@ -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 prompt | 3055 | prompt = eventData.prompt; // Allow extensions to modify the prompt |
| 3116 | 3056 | ||
| 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 | } |
| 3126 | 3060 | ||
| 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 image | 3069 | // 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 | } |
| 3150 | 3090 | ||
| 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 | } |
| 5136 | 5072 | ||
| 5137 | function isValidState() { | 5073 | function 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 | } |
| 5224 | 5156 | ||
| 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 | */ |
| 5336 | async function generateMediaSwipe(mediaAttachment, message, onStart, onComplete, abortController = new AbortController()) { | 5268 | async 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 | }; |
| 5350 | 5281 | ||
| 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; |
| 5368 | 5299 | ||
| 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 | } |
| 5386 | 5324 | ||
| 5387 | if (!result.url) { | 5325 | if (!result.url) { |