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>
Signed| @@ -69,6 +69,12 @@ const MODULE_NAME = 'sd'; | |||
| 69 | // This is a 1x1 transparent PNG | 69 | // This is a 1x1 transparent PNG |
| 70 | const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; | 70 | const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; |
| 71 | const CUSTOM_STOP_EVENT = 'sd_stop_generation'; | 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 | const sources = { | 78 | const sources = { |
| 73 | extras: 'extras', | 79 | extras: 'extras', |
| 74 | horde: 'horde', | 80 | horde: 'horde', |
| @@ -2743,6 +2749,62 @@ function ensureSelectionExists(setting, selector) { | |||
| 2743 | } | 2749 | } |
| 2744 | 2750 | ||
| 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 | */ | ||
| 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 | /** | ||
| 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 generation | 2809 | * @param {string} initiator The initiator of the image generation |
| 2748 | * @param {Record<string, object>} args Command arguments | 2810 | * @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 prompt | 2879 | prompt = eventData.prompt; // Allow extensions to modify the prompt |
| 2818 | 2880 | ||
| 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); |
| 2821 | 2886 | ||
| @@ -2826,6 +2891,13 @@ async function generatePicture(initiator, args, trigger, message, callback) { | |||
| 2826 | // generate the image | 2891 | // 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 failure | 2902 | // errors here are most likely due to text generation failure |
| 2831 | // sendGenerationRequest mostly deals with its own errors | 2903 | // 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 | } |
| 2842 | 2915 | ||
| 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 | */ |
| 3031 | async function generatePrompt(quietPrompt) { | 3104 | async 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); | ||
| 3034 | 3109 | ||
| 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 | } |
| 4705 | 4787 | ||
| 4706 | let buttonAbortController = null; | 4788 | /** @type {WeakMap<HTMLElement, AbortController>} */ |
| 4789 | const buttonAbortControllers = new WeakMap(); | ||
| 4707 | 4790 | ||
| 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 | } |
| 4725 | 4812 | ||
| 4726 | let $media = jQuery(); | 4813 | let $media = jQuery(); |
| 4727 | 4814 | ||
| 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 | })(); | ||
| 4730 | 4827 | ||
| 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 | } |
| 4736 | 4833 | ||
| @@ -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 | } |
| 4769 | 4866 | ||
| 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 | ); |
| 4778 | 4874 | ||
| 4779 | if (!newMediaAttachment) { | 4875 | if (!newMediaAttachment) { |