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 | 69 | // This is a 1x1 transparent PNG |
| 70 | 70 | const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; |
| 71 | 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 | 79 | extras: 'extras', |
| 74 | 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 | 2808 | * Generates an image based on the given trigger word. |
| 2747 | 2809 | * @param {string} initiator The initiator of the image generation |
| 2748 | 2810 | * @param {Record<string, object>} args Command arguments |
| @@ -2816,6 +2878,9 @@ async function generatePicture(initiator, args, trigger, message, callback) { | ||
| 2816 | 2878 | await eventSource.emit(event_types.SD_PROMPT_PROCESSING, eventData); |
| 2817 | 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 | 2884 | $(stopButton).show(); |
| 2820 | 2885 | eventSource.once(CUSTOM_STOP_EVENT, stopListener); |
| 2821 | 2886 | |
| @@ -2826,6 +2891,13 @@ async function generatePicture(initiator, args, trigger, message, callback) { | ||
| 2826 | 2891 | // generate the image |
| 2827 | 2892 | imagePath = await sendGenerationRequest(generationType, prompt, negativePromptPrefix, characterName, callback, initiator, abortController.signal); |
| 2828 | 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 | 2901 | console.trace(err); |
| 2830 | 2902 | // errors here are most likely due to text generation failure |
| 2831 | 2903 | // sendGenerationRequest mostly deals with its own errors |
| @@ -2838,6 +2910,7 @@ async function generatePicture(initiator, args, trigger, message, callback) { | ||
| 2838 | 2910 | $(stopButton).hide(); |
| 2839 | 2911 | restoreOriginalDimensions(dimensions); |
| 2840 | 2912 | eventSource.removeListener(CUSTOM_STOP_EVENT, stopListener); |
| 2913 | + endGenerationTracking(); | |
| 2841 | 2914 | } |
| 2842 | 2915 | |
| 2843 | 2916 | return imagePath; |
| @@ -3029,8 +3102,10 @@ function getUserAvatarUrl() { | ||
| 3029 | 3102 | * @returns {Promise<string>} - A promise that resolves when the prompt generation completes. |
| 3030 | 3103 | */ |
| 3031 | 3104 | async function generatePrompt(quietPrompt) { |
| 3105 | + const toast = toastr.info('Generating image prompt with an LLM...', 'Image Generation'); | |
| 3032 | 3106 | const reply = await generateQuietPrompt({ quietPrompt }); |
| 3033 | 3107 | const processedReply = processReply(reply); |
| 3108 | + toastr.clear(toast); | |
| 3034 | 3109 | |
| 3035 | 3110 | if (!processedReply) { |
| 3036 | 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 | 3230 | throw new Error('Endpoint did not return image data.'); |
| 3156 | 3231 | } |
| 3157 | 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 | 3240 | console.error('Image generation request error: ', err); |
| 3159 | 3241 | toastr.error('Image generation failed. Please try again.' + '\n\n' + String(err), 'Image Generation'); |
| 3160 | 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 | 4792 | * "Paintbrush" button handler to generate a new image for a message. |
| @@ -4721,16 +4804,30 @@ async function sdMessageButton($icon, { animate } = {}) { | ||
| 4721 | 4804 | $icon.toggleClass(classes.idle, !isBusy); |
| 4722 | 4805 | $icon.toggleClass(classes.busy, isBusy); |
| 4723 | 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 | 4813 | let $media = jQuery(); |
| 4727 | 4814 | |
| 4728 | 4815 | const classes = { busy: 'fa-hourglass', idle: 'fa-paintbrush', animation: 'fa-fade' }; |
| 4729 | 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 | 4828 | if ($icon.hasClass(classes.busy)) { |
| 4732 | 4829 | buttonAbortController?abortController.abort('Aborted by user'); |
| 4733 | 4830 | console.log('PreviousSD: imageImage isgeneration stillaborted beingby generated...user'); |
| 4734 | 4831 | return; |
| 4735 | 4832 | } |
| 4736 | 4833 | |
| @@ -4767,13 +4864,12 @@ async function sdMessageButton($icon, { animate } = {}) { | ||
| 4767 | 4864 | $media = messageElement.find(`.mes_media_container[data-index="${index}"]`).find('.mes_img, .mes_video'); |
| 4768 | 4865 | } |
| 4769 | 4866 | |
| 4770 | - buttonAbortController = new AbortController(); | |
| 4771 | 4867 | const newMediaAttachment = await generateMediaSwipe( |
| 4772 | 4868 | selectedMedia, |
| 4773 | 4869 | message, |
| 4774 | 4870 | () => setBusyIcon(true), |
| 4775 | 4871 | () => setBusyIcon(false), |
| 4776 | 4872 | buttonAbortControllerabortController, |
| 4777 | 4873 | ); |
| 4778 | 4874 | |
| 4779 | 4875 | if (!newMediaAttachment) { |