diff --git a/public/scripts/action-loader.js b/public/scripts/action-loader.js
index f8c427e47..cc5575edb 100644
--- a/public/scripts/action-loader.js
+++ b/public/scripts/action-loader.js
@@ -73,6 +73,15 @@ function hasBlockingLoaders() {
* Manages its own toast, stop handler, and lifecycle.
*/
export class ActionLoaderHandle {
+ /**
+ * A special empty handle that is already disposed. Useful as a default value to avoid null checks.
+ * Does not generate any id, toast, or overlay, and all its methods are no-ops.
+ * @type {ActionLoaderHandle}
+ */
+ static get EMPTY() {
+ return new ActionLoaderHandle({ predisposed: true });
+ }
+
/** @type {string} Unique identifier for this handle */
id;
@@ -99,6 +108,7 @@ export class ActionLoaderHandle {
* @param {string} [options.message='Generating...'] - Message to display in the toast
* @param {string} [options.title] - Title for the toast notification
* @param {string} [options.stopTooltip='Stop'] - Tooltip for the stop button
+ * @param {boolean} [options.predisposed=false] - Whether this handle is already disposed (for special use)
* @param {HTMLElement|string|null} [options.overlayContent] - Custom content for the overlay (replaces default spinner)
* @param {(() => void)|null} [options.onStop] - Custom stop handler
* @param {(() => void)|null} [options.onHide] - Custom hide handler
@@ -112,7 +122,13 @@ export class ActionLoaderHandle {
overlayContent = null,
onStop = null,
onHide = null,
+ predisposed = false,
} = {}) {
+ if (predisposed) {
+ this.#disposed = true;
+ return;
+ }
+
this.id = generateLoaderId();
this.#blocking = blocking;
this.#onStop = onStop;
diff --git a/public/scripts/extensions/stable-diffusion/button.html b/public/scripts/extensions/stable-diffusion/button.html
index 2962ff4f4..c16cb15b7 100644
--- a/public/scripts/extensions/stable-diffusion/button.html
+++ b/public/scripts/extensions/stable-diffusion/button.html
@@ -2,7 +2,3 @@
Generate Image
-
-
- Stop Image Generation
-
diff --git a/public/scripts/extensions/stable-diffusion/index.js b/public/scripts/extensions/stable-diffusion/index.js
index f8d00213c..6cae4a058 100644
--- a/public/scripts/extensions/stable-diffusion/index.js
+++ b/public/scripts/extensions/stable-diffusion/index.js
@@ -62,18 +62,13 @@ import { t, translate } from '../../i18n.js';
import { oai_settings } from '../../openai.js';
import { power_user } from '/scripts/power-user.js';
import { MacrosParser } from '/scripts/macros.js';
+import { ActionLoaderHandle, loader } from '/scripts/action-loader.js';
export { MODULE_NAME };
const MODULE_NAME = 'sd';
// This is a 1x1 transparent PNG
const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
-const CUSTOM_STOP_EVENT = 'sd_stop_generation';
-
-// Generation tracking for status indicator
-let activeGenerations = 0;
-/** @type {JQuery|null} */
-let generationToast = null;
const sources = {
extras: 'extras',
@@ -2980,62 +2975,6 @@ function ensureSelectionExists(setting, selector) {
}
}
-/**
- * Updates the generation status indicator based on active generation count.
- * Shows/hides various UI indicators to inform user of background image generation.
- */
-function updateGenerationIndicator() {
- if (activeGenerations > 0) {
- const countText = activeGenerations > 1 ? ` (${activeGenerations})` : '';
- const toastText = ` ${t`Generating an image`}${countText}...`;
-
- // Show persistent toast if not already showing
- if (!generationToast) {
- generationToast = toastr.info(
- toastText,
- 'Image Generation',
- {
- timeOut: 0,
- extendedTimeOut: 0,
- tapToDismiss: true,
- escapeHtml: false,
- onHidden: () => {
- generationToast = null;
- },
- },
- );
- } else if (activeGenerations > 1) {
- // Update count in existing toast
- const toastMessage = $(generationToast).find('.toast-message');
- if (toastMessage.length) {
- toastMessage.html(toastText);
- }
- }
- } else {
- // Hide toast when done
- if (generationToast) {
- toastr.clear(generationToast);
- generationToast = null;
- }
- }
-}
-
-/**
- * Increments the active generation counter and updates indicators.
- */
-function startGenerationTracking() {
- activeGenerations++;
- updateGenerationIndicator();
-}
-
-/**
- * Decrements the active generation counter and updates indicators.
- */
-function endGenerationTracking() {
- activeGenerations = Math.max(0, activeGenerations - 1);
- updateGenerationIndicator();
-}
-
/**
* Generates an image based on the given trigger word.
* @param {string} initiator The initiator of the image generation
@@ -3096,12 +3035,13 @@ async function generatePicture(initiator, args, trigger, message, callback) {
const dimensions = setTypeSpecificDimensions(generationType);
const abortController = new AbortController();
- const stopButton = document.getElementById('sd_stop_gen');
let negativePromptPrefix = args?.negative || '';
let imagePath = '';
const stopListener = () => abortController.abort('Aborted by user');
+ let loaderHandle = ActionLoaderHandle.EMPTY;
+
try {
const combineNegatives = (prefix) => { negativePromptPrefix = combinePrefixes(negativePromptPrefix, prefix); };
@@ -3114,16 +3054,18 @@ async function generatePicture(initiator, args, trigger, message, callback) {
await eventSource.emit(event_types.SD_PROMPT_PROCESSING, eventData);
prompt = eventData.prompt; // Allow extensions to modify the prompt
- // Track this generation for status indicator
- startGenerationTracking();
- // Show stop button after prompt is ready (prompt generation uses separate abort mechanism)
- $(stopButton).show();
- eventSource.once(CUSTOM_STOP_EVENT, stopListener);
-
if (typeof args?._abortController?.addEventListener === 'function') {
args._abortController.addEventListener('abort', stopListener);
}
+ // Show non-blocking stoppable toast for this generation
+ loaderHandle = loader.show({
+ blocking: false,
+ title: t`Image Generation`,
+ message: t`Generating an image...`,
+ onStop: stopListener,
+ });
+
// generate the image
imagePath = await sendGenerationRequest(generationType, prompt, negativePromptPrefix, characterName, callback, initiator, abortController.signal);
} catch (err) {
@@ -3142,10 +3084,8 @@ async function generatePicture(initiator, args, trigger, message, callback) {
toastr.error(errorText, 'Image Generation');
throw new Error(errorText);
} finally {
- $(stopButton).hide();
restoreOriginalDimensions(dimensions);
- eventSource.removeListener(CUSTOM_STOP_EVENT, stopListener);
- endGenerationTracking();
+ await loaderHandle.hide();
}
return imagePath;
@@ -5128,10 +5068,6 @@ async function addSDGenButtons() {
generatePicture(initiators.wand, {}, param);
}
});
-
- const stopGenButton = $('#sd_stop_gen');
- stopGenButton.hide();
- stopGenButton.on('click', () => eventSource.emit(CUSTOM_STOP_EVENT));
}
function isValidState() {
@@ -5216,10 +5152,6 @@ async function sdMessageButton($icon, { animate } = {}) {
$icon.toggleClass(classes.idle, !isBusy);
$icon.toggleClass(classes.busy, isBusy);
$media.toggleClass(classes.animation, isBusy);
-
- // Update generation counter toast
- const trackingFunction = isBusy ? startGenerationTracking : endGenerationTracking;
- trackingFunction();
}
let $media = jQuery();
@@ -5334,7 +5266,6 @@ async function writePromptFields(characterId) {
* @returns {Promise} - A promise that resolves to the newly generated media attachment, or null if generation failed or was aborted.
*/
async function generateMediaSwipe(mediaAttachment, message, onStart, onComplete, abortController = new AbortController()) {
- const stopButton = document.getElementById('sd_stop_gen');
const stopListener = () => abortController.abort('Aborted by user');
const generationType = mediaAttachment.generation_type ?? message?.extra?.generationType ?? generationMode.FREE;
let dimensions = { width: extension_settings.sd.width, height: extension_settings.sd.height };
@@ -5348,9 +5279,9 @@ async function generateMediaSwipe(mediaAttachment, message, onStart, onComplete,
source: MEDIA_SOURCE.GENERATED,
};
+ let loaderHandle = ActionLoaderHandle.EMPTY;
+
try {
- $(stopButton).show();
- eventSource.once(CUSTOM_STOP_EVENT, stopListener);
const callback = (_a, _b, _c, _d, _e, _f, format) => { result.type = isVideo(format) ? MEDIA_TYPE.VIDEO : MEDIA_TYPE.IMAGE; };
const savedPrompt = mediaAttachment.title ?? message.extra.title ?? '';
const savedNegative = mediaAttachment.negative ?? message.extra.negative ?? '';
@@ -5366,6 +5297,14 @@ async function generateMediaSwipe(mediaAttachment, message, onStart, onComplete,
? context.groups[Object.keys(context.groups).filter(x => context.groups[x].id === context.groupId)[0]]?.id?.toString()
: context.characters[context.characterId]?.name;
+ // Show non-blocking stoppable toast for this generation
+ loaderHandle = loader.show({
+ blocking: false,
+ title: t`Image Generation`,
+ message: t`Generating an image...`,
+ onStop: stopListener,
+ });
+
onStart();
result.url = await sendGenerationRequest(generationType, prompt, refineArgs.negative, characterName, callback, initiators.swipe, abortController.signal);
result.generation_type = generationType;
@@ -5377,11 +5316,10 @@ async function generateMediaSwipe(mediaAttachment, message, onStart, onComplete,
}
} finally {
onComplete();
- $(stopButton).hide();
- eventSource.removeListener(CUSTOM_STOP_EVENT, stopListener);
restoreOriginalDimensions(dimensions);
extension_settings.sd.seed = extension_settings.sd.original_seed;
delete extension_settings.sd.original_seed;
+ await loaderHandle.hide();
}
if (!result.url) {