feat(sd): Add generation status indicator and improve abort handling (#5015) * feat(sd): Add generation status indicator and improve abort handling - Add persistent toast notification showing "Generating image..." during generation - Toast displays spinner, supports multiple concurrent generations with count - Click toast to dismiss (generation continues in background) - Improve abort handling: show friendly "Image generation stopped" info message instead of error when user clicks stop button - Handle abort in both generatePicture() and sendGenerationRequest() catch blocks Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Scope abort controllers for each individual button * Use native element reference for caching button abort controller * Update toast in paintbrush button handler * refactor: Update abort message for image generation in sdMessageButton * feat: Enhance generation indicator with active generation count in toast * Move tracking after prompt generation, add toast for LLM prompt gen progress * Add type annotation for generation toastr * Simplify generation abort checks * Remove unnecessary blank line in addSDGenButtons function --------- Co-authored-by: mschienbein <mschienbein@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

a50fecee258da15b934c04e47b03a3891d4bfc71

Mooki <m@schienbein.dev>

Signed
1 files changed, +101 -5Showing whitespace changes
public/scripts/extensions/stable-diffusion/index.js+101 -5
@@ -69,6 +69,12 @@ const MODULE_NAME = 'sd';
6969// This is a 1x1 transparent PNG
7070const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
7171const 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+
7278const sources = {
7379 extras: 'extras',
7480 horde: 'horde',
@@ -2743,6 +2749,62 @@ function ensureSelectionExists(setting, selector) {
27432749}
27442750
27452751/**
2752+ * Updates the generation status indicator based on active generation count.
2753+ * Shows/hides various UI indicators to inform user of background image generation.
2754+ */
2755+function updateGenerationIndicator() {
2756+ if (activeGenerations > 0) {
2757+ const countText = activeGenerations > 1 ? ` (${activeGenerations})` : '';
2758+ const toastText = `<i class="fa-solid fa-spinner fa-spin"></i> ${t`Generating image`}${countText}...`;
2759+
2760+ // Show persistent toast if not already showing
2761+ if (!generationToast) {
2762+ generationToast = toastr.info(
2763+ toastText,
2764+ 'Image Generation',
2765+ {
2766+ timeOut: 0,
2767+ extendedTimeOut: 0,
2768+ tapToDismiss: true,
2769+ escapeHtml: false,
2770+ onHidden: () => {
2771+ generationToast = null;
2772+ },
2773+ },
2774+ );
2775+ } else if (activeGenerations > 1) {
2776+ // Update count in existing toast
2777+ const toastMessage = $(generationToast).find('.toast-message');
2778+ if (toastMessage.length) {
2779+ toastMessage.html(toastText);
2780+ }
2781+ }
2782+ } else {
2783+ // Hide toast when done
2784+ if (generationToast) {
2785+ toastr.clear(generationToast);
2786+ generationToast = null;
2787+ }
2788+ }
2789+}
2790+
2791+/**
2792+ * Increments the active generation counter and updates indicators.
2793+ */
2794+function startGenerationTracking() {
2795+ activeGenerations++;
2796+ updateGenerationIndicator();
2797+}
2798+
2799+/**
2800+ * Decrements the active generation counter and updates indicators.
2801+ */
2802+function endGenerationTracking() {
2803+ activeGenerations = Math.max(0, activeGenerations - 1);
2804+ updateGenerationIndicator();
2805+}
2806+
2807+/**
27462808 * Generates an image based on the given trigger word.
27472809 * @param {string} initiator The initiator of the image generation
27482810 * @param {Record<string, object>} args Command arguments
@@ -2816,6 +2878,9 @@ async function generatePicture(initiator, args, trigger, message, callback) {
28162878 await eventSource.emit(event_types.SD_PROMPT_PROCESSING, eventData);
28172879 prompt = eventData.prompt; // Allow extensions to modify the prompt
28182880
2881+ // Track this generation for status indicator
2882+ startGenerationTracking();
2883+ // Show stop button after prompt is ready (prompt generation uses separate abort mechanism)
28192884 $(stopButton).show();
28202885 eventSource.once(CUSTOM_STOP_EVENT, stopListener);
28212886
@@ -2826,6 +2891,13 @@ async function generatePicture(initiator, args, trigger, message, callback) {
28262891 // generate the image
28272892 imagePath = await sendGenerationRequest(generationType, prompt, negativePromptPrefix, characterName, callback, initiator, abortController.signal);
28282893 } catch (err) {
2894+ // Check if this was an intentional abort by user
2895+ if (abortController.signal.aborted) {
2896+ console.log('SD: Image generation aborted by user');
2897+ toastr.info('Image generation stopped.', 'Image Generation');
2898+ return;
2899+ }
2900+
28292901 console.trace(err);
28302902 // errors here are most likely due to text generation failure
28312903 // sendGenerationRequest mostly deals with its own errors
@@ -2838,6 +2910,7 @@ async function generatePicture(initiator, args, trigger, message, callback) {
28382910 $(stopButton).hide();
28392911 restoreOriginalDimensions(dimensions);
28402912 eventSource.removeListener(CUSTOM_STOP_EVENT, stopListener);
2913+ endGenerationTracking();
28412914 }
28422915
28432916 return imagePath;
@@ -3029,8 +3102,10 @@ function getUserAvatarUrl() {
30293102 * @returns {Promise<string>} - A promise that resolves when the prompt generation completes.
30303103 */
30313104async function generatePrompt(quietPrompt) {
3105+ const toast = toastr.info('Generating image prompt with an LLM...', 'Image Generation');
30323106 const reply = await generateQuietPrompt({ quietPrompt });
30333107 const processedReply = processReply(reply);
3108+ toastr.clear(toast);
30343109
30353110 if (!processedReply) {
30363111 toastr.error('Prompt generation produced no text. Make sure you\'re using a valid instruct template and try again', 'Image Generation');
@@ -3155,6 +3230,13 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP
31553230 throw new Error('Endpoint did not return image data.');
31563231 }
31573232 } catch (err) {
3233+ // Check if this was an intentional abort by user
3234+ if (signal?.aborted) {
3235+ console.log('SD: Image generation aborted by user');
3236+ toastr.info('Image generation stopped.', 'Image Generation');
3237+ return;
3238+ }
3239+
31583240 console.error('Image generation request error: ', err);
31593241 toastr.error('Image generation failed. Please try again.' + '\n\n' + String(err), 'Image Generation');
31603242 return;
@@ -4703,7 +4785,8 @@ function isValidState() {
47034785 }
47044786}
47054787
4706-let buttonAbortController = null;
4788+/** @type {WeakMap<HTMLElement, AbortController>} */
4789+const buttonAbortControllers = new WeakMap();
47074790
47084791/**
47094792 * "Paintbrush" button handler to generate a new image for a message.
@@ -4721,16 +4804,30 @@ async function sdMessageButton($icon, { animate } = {}) {
47214804 $icon.toggleClass(classes.idle, !isBusy);
47224805 $icon.toggleClass(classes.busy, isBusy);
47234806 $media.toggleClass(classes.animation, isBusy);
4807+
4808+ // Update generation counter toast
4809+ const trackingFunction = isBusy ? startGenerationTracking : endGenerationTracking;
4810+ trackingFunction();
47244811 }
47254812
47264813 let $media = jQuery();
47274814
47284815 const classes = { busy: 'fa-hourglass', idle: 'fa-paintbrush', animation: 'fa-fade' };
47294816 const context = getContext();
4817+ const abortController = (() => {
4818+ const nativeElement = $icon.get(0);
4819+ if (buttonAbortControllers.has(nativeElement)) {
4820+ return buttonAbortControllers.get(nativeElement);
4821+ } else {
4822+ const controller = new AbortController();
4823+ buttonAbortControllers.set(nativeElement, controller);
4824+ return controller;
4825+ }
4826+ })();
47304827
47314828 if ($icon.hasClass(classes.busy)) {
47324829 buttonAbortController?abortController.abort('Aborted by user');
47334830 console.log('PreviousSD: imageImage isgeneration stillaborted beingby generated...user');
47344831 return;
47354832 }
47364833
@@ -4767,13 +4864,12 @@ async function sdMessageButton($icon, { animate } = {}) {
47674864 $media = messageElement.find(`.mes_media_container[data-index="${index}"]`).find('.mes_img, .mes_video');
47684865 }
47694866
4770- buttonAbortController = new AbortController();
47714867 const newMediaAttachment = await generateMediaSwipe(
47724868 selectedMedia,
47734869 message,
47744870 () => setBusyIcon(true),
47754871 () => setBusyIcon(false),
47764872 buttonAbortControllerabortController,
47774873 );
47784874
47794875 if (!newMediaAttachment) {