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 -5Ignore whitespace
public/scripts/extensions/stable-diffusion/index.js+101 -5
@@ -69,6 +69,12 @@ const MODULE_NAME = 'sd';
69// This is a 1x1 transparent PNG69// This is a 1x1 transparent PNG
70const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';70const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
71const CUSTOM_STOP_EVENT = 'sd_stop_generation';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;
77
72const sources = {78const sources = {
73 extras: 'extras',79 extras: 'extras',
74 horde: 'horde',80 horde: 'horde',
@@ -2743,6 +2749,62 @@ function ensureSelectionExists(setting, selector) {
2743}2749}
27442750
2745/**2751/**
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 */
2755function 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 */
2794function startGenerationTracking() {
2795 activeGenerations++;
2796 updateGenerationIndicator();
2797}
2798
2799/**
2800 * Decrements the active generation counter and updates indicators.
2801 */
2802function endGenerationTracking() {
2803 activeGenerations = Math.max(0, activeGenerations - 1);
2804 updateGenerationIndicator();
2805}
2806
2807/**
2746 * Generates an image based on the given trigger word.2808 * Generates an image based on the given trigger word.
2747 * @param {string} initiator The initiator of the image generation2809 * @param {string} initiator The initiator of the image generation
2748 * @param {Record<string, object>} args Command arguments2810 * @param {Record<string, object>} args Command arguments
@@ -2816,6 +2878,9 @@ async function generatePicture(initiator, args, trigger, message, callback) {
2816 await eventSource.emit(event_types.SD_PROMPT_PROCESSING, eventData);2878 await eventSource.emit(event_types.SD_PROMPT_PROCESSING, eventData);
2817 prompt = eventData.prompt; // Allow extensions to modify the prompt2879 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)
2819 $(stopButton).show();2884 $(stopButton).show();
2820 eventSource.once(CUSTOM_STOP_EVENT, stopListener);2885 eventSource.once(CUSTOM_STOP_EVENT, stopListener);
28212886
@@ -2826,6 +2891,13 @@ async function generatePicture(initiator, args, trigger, message, callback) {
2826 // generate the image2891 // generate the image
2827 imagePath = await sendGenerationRequest(generationType, prompt, negativePromptPrefix, characterName, callback, initiator, abortController.signal);2892 imagePath = await sendGenerationRequest(generationType, prompt, negativePromptPrefix, characterName, callback, initiator, abortController.signal);
2828 } catch (err) {2893 } 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
2829 console.trace(err);2901 console.trace(err);
2830 // errors here are most likely due to text generation failure2902 // errors here are most likely due to text generation failure
2831 // sendGenerationRequest mostly deals with its own errors2903 // sendGenerationRequest mostly deals with its own errors
@@ -2838,6 +2910,7 @@ async function generatePicture(initiator, args, trigger, message, callback) {
2838 $(stopButton).hide();2910 $(stopButton).hide();
2839 restoreOriginalDimensions(dimensions);2911 restoreOriginalDimensions(dimensions);
2840 eventSource.removeListener(CUSTOM_STOP_EVENT, stopListener);2912 eventSource.removeListener(CUSTOM_STOP_EVENT, stopListener);
2913 endGenerationTracking();
2841 }2914 }
28422915
2843 return imagePath;2916 return imagePath;
@@ -3029,8 +3102,10 @@ function getUserAvatarUrl() {
3029 * @returns {Promise<string>} - A promise that resolves when the prompt generation completes.3102 * @returns {Promise<string>} - A promise that resolves when the prompt generation completes.
3030 */3103 */
3031async function generatePrompt(quietPrompt) {3104async function generatePrompt(quietPrompt) {
3105 const toast = toastr.info('Generating image prompt with an LLM...', 'Image Generation');
3032 const reply = await generateQuietPrompt({ quietPrompt });3106 const reply = await generateQuietPrompt({ quietPrompt });
3033 const processedReply = processReply(reply);3107 const processedReply = processReply(reply);
3108 toastr.clear(toast);
30343109
3035 if (!processedReply) {3110 if (!processedReply) {
3036 toastr.error('Prompt generation produced no text. Make sure you\'re using a valid instruct template and try again', 'Image Generation');3111 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
3155 throw new Error('Endpoint did not return image data.');3230 throw new Error('Endpoint did not return image data.');
3156 }3231 }
3157 } catch (err) {3232 } 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
3158 console.error('Image generation request error: ', err);3240 console.error('Image generation request error: ', err);
3159 toastr.error('Image generation failed. Please try again.' + '\n\n' + String(err), 'Image Generation');3241 toastr.error('Image generation failed. Please try again.' + '\n\n' + String(err), 'Image Generation');
3160 return;3242 return;
@@ -4703,7 +4785,8 @@ function isValidState() {
4703 }4785 }
4704}4786}
47054787
4706let buttonAbortController = null;4788/** @type {WeakMap<HTMLElement, AbortController>} */
4789const buttonAbortControllers = new WeakMap();
47074790
4708/**4791/**
4709 * "Paintbrush" button handler to generate a new image for a message.4792 * "Paintbrush" button handler to generate a new image for a message.
@@ -4721,16 +4804,30 @@ async function sdMessageButton($icon, { animate } = {}) {
4721 $icon.toggleClass(classes.idle, !isBusy);4804 $icon.toggleClass(classes.idle, !isBusy);
4722 $icon.toggleClass(classes.busy, isBusy);4805 $icon.toggleClass(classes.busy, isBusy);
4723 $media.toggleClass(classes.animation, isBusy);4806 $media.toggleClass(classes.animation, isBusy);
4807
4808 // Update generation counter toast
4809 const trackingFunction = isBusy ? startGenerationTracking : endGenerationTracking;
4810 trackingFunction();
4724 }4811 }
47254812
4726 let $media = jQuery();4813 let $media = jQuery();
47274814
4728 const classes = { busy: 'fa-hourglass', idle: 'fa-paintbrush', animation: 'fa-fade' };4815 const classes = { busy: 'fa-hourglass', idle: 'fa-paintbrush', animation: 'fa-fade' };
4729 const context = getContext();4816 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
4731 if ($icon.hasClass(classes.busy)) {4828 if ($icon.hasClass(classes.busy)) {
4732 buttonAbortController?.abort('Aborted by user');4829 abortController.abort('Aborted by user');
4733 console.log('Previous image is still being generated...');4830 console.log('SD: Image generation aborted by user');
4734 return;4831 return;
4735 }4832 }
47364833
@@ -4767,13 +4864,12 @@ async function sdMessageButton($icon, { animate } = {}) {
4767 $media = messageElement.find(`.mes_media_container[data-index="${index}"]`).find('.mes_img, .mes_video');4864 $media = messageElement.find(`.mes_media_container[data-index="${index}"]`).find('.mes_img, .mes_video');
4768 }4865 }
47694866
4770 buttonAbortController = new AbortController();
4771 const newMediaAttachment = await generateMediaSwipe(4867 const newMediaAttachment = await generateMediaSwipe(
4772 selectedMedia,4868 selectedMedia,
4773 message,4869 message,
4774 () => setBusyIcon(true),4870 () => setBusyIcon(true),
4775 () => setBusyIcon(false),4871 () => setBusyIcon(false),
4776 buttonAbortController,4872 abortController,
4777 );4873 );
47784874
4779 if (!newMediaAttachment) {4875 if (!newMediaAttachment) {