Image Generation: Add swipes for generated images Supersedes #2648

e1a29b36f5355ef4dd081d3a589d429287314f32

Cohee <18619528+Cohee1207@users.noreply.github.com>

4 files changed, +146 -1Ignore whitespace
public/index.html+5 -0
@@ -5801,6 +5801,11 @@
58015801 <div title="Caption" class="right_menu_button fa-lg fa-solid fa-envelope-open-text mes_img_caption" data-i18n="[title]Caption"></div>
58025802 <div title="Delete" class="right_menu_button fa-lg fa-solid fa-trash-can mes_img_delete" data-i18n="[title]Delete"></div>
58035803 </div>
5804+ <div class="mes_img_swipes">
5805+ <div title="Swipe left" class="right_menu_button fa-lg fa-solid fa-chevron-left mes_img_swipe_left" data-i18n="[title]Swipe left"></div>
5806+ <div class="mes_img_swipe_counter">1/1</div>
5807+ <div title="Swipe right" class="right_menu_button fa-lg fa-solid fa-chevron-right mes_img_swipe_right" data-i18n="[title]Swipe right"></div>
5808+ </div>
58045809 <img class="mes_img" src="" />
58055810 </div>
58065811 <div class="mes_bias"></div>
public/script.js+21 -1
@@ -459,6 +459,7 @@ export const event_types = {
459459 LLM_FUNCTION_TOOL_REGISTER: 'llm_function_tool_register',
460460 LLM_FUNCTION_TOOL_CALL: 'llm_function_tool_call',
461461 ONLINE_STATUS_CHANGED: 'online_status_changed',
462+ IMAGE_SWIPED: 'image_swiped',
462463};
463464
464465export const eventSource = new EventEmitter();
@@ -2112,6 +2113,7 @@ export function updateMessageBlock(messageId, message) {
21122113export function appendMediaToMessage(mes, messageElement, adjustScroll = true) {
21132114 // Add image to message
21142115 if (mes.extra?.image) {
2116+ const container = messageElement.find('.mes_img_container');
21152117 const chatHeight = $('#chat').prop('scrollHeight');
21162118 const image = messageElement.find('.mes_img');
21172119 const text = messageElement.find('.mes_text');
@@ -2127,9 +2129,27 @@ export function appendMediaToMessage(mes, messageElement, adjustScroll = true) {
21272129 });
21282130 image.attr('src', mes.extra?.image);
21292131 image.attr('title', mes.extra?.title || mes.title || '');
21302132 messageElement.find('.mes_img_container')container.addClass('img_extra');
21312133 image.toggleClass('img_inline', isInline);
21322134 text.toggleClass('displayNone', !isInline);
2135+
2136+ const imageSwipes = mes.extra.image_swipes;
2137+ if (Array.isArray(imageSwipes) && imageSwipes.length > 0) {
2138+ container.addClass('img_swipes');
2139+ const counter = container.find('.mes_img_swipe_counter');
2140+ const currentImage = imageSwipes.indexOf(mes.extra.image) + 1;
2141+ counter.text(`${currentImage}/${imageSwipes.length}`);
2142+
2143+ const swipeLeft = container.find('.mes_img_swipe_left');
2144+ swipeLeft.off('click').on('click', function () {
2145+ eventSource.emit(event_types.IMAGE_SWIPED, { message: mes, element: messageElement, direction: 'left' });
2146+ });
2147+
2148+ const swipeRight = container.find('.mes_img_swipe_right');
2149+ swipeRight.off('click').on('click', function () {
2150+ eventSource.emit(event_types.IMAGE_SWIPED, { message: mes, element: messageElement, direction: 'right' });
2151+ });
2152+ }
21332153 }
21342154
21352155 // Add file to message
public/scripts/extensions/stable-diffusion/index.js+97 -0
@@ -59,6 +59,7 @@ const initiators = {
5959 action: 'action',
6060 interactive: 'interactive',
6161 wand: 'wand',
62+ swipe: 'swipe',
6263};
6364
6465const generationMode = {
@@ -3429,6 +3430,7 @@ async function sendMessage(prompt, image, generationType, additionalNegativePref
34293430 generationType: generationType,
34303431 negative: additionalNegativePrefix,
34313432 inline_image: false,
3433+ image_swipes: [image],
34323434 },
34333435 };
34343436 context.chat.push(message);
@@ -3657,6 +3659,99 @@ async function writePromptFields(characterId) {
36573659 await writeExtensionField(characterId, 'sd_character_prompt', promptObject);
36583660}
36593661
3662+/**
3663+ * Switches an image to the next or previous one in the swipe list.
3664+ * @param {object} args Event arguments
3665+ * @param {any} args.message Message object
3666+ * @param {JQuery<HTMLElement>} args.element Message element
3667+ * @param {string} args.direction Swipe direction
3668+ * @returns {Promise<void>}
3669+ */
3670+async function onImageSwiped({ message, element, direction }) {
3671+ const context = getContext();
3672+ const animationClass = 'fa-fade';
3673+ const messageImg = element.find('.mes_img');
3674+
3675+ // Current image is already animating
3676+ if (messageImg.hasClass(animationClass)) {
3677+ return;
3678+ }
3679+
3680+ const swipes = message?.extra?.image_swipes;
3681+
3682+ if (!Array.isArray(swipes)) {
3683+ console.warn('No image swipes found in the message');
3684+ return;
3685+ }
3686+
3687+ const currentIndex = swipes.indexOf(message.extra.image);
3688+
3689+ if (currentIndex === -1) {
3690+ console.warn('Current image not found in the swipes');
3691+ return;
3692+ }
3693+
3694+ // Switch to previous image or wrap around if at the beginning
3695+ if (direction === 'left') {
3696+ const newIndex = currentIndex === 0 ? swipes.length - 1 : currentIndex - 1;
3697+ message.extra.image = swipes[newIndex];
3698+
3699+ // Update the image in the message
3700+ appendMediaToMessage(message, element, false);
3701+ }
3702+
3703+ // Switch to next image or generate a new one if at the end
3704+ if (direction === 'right') {
3705+ const newIndex = currentIndex === swipes.length - 1 ? swipes.length : currentIndex + 1;
3706+
3707+ if (newIndex === swipes.length) {
3708+ const abortController = new AbortController();
3709+ const swipeControls = element.find('.mes_img_swipes');
3710+ const stopButton = document.getElementById('sd_stop_gen');
3711+ const stopListener = () => abortController.abort('Aborted by user');
3712+ const generationType = message?.extra?.generationType ?? generationMode.FREE;
3713+ const dimensions = setTypeSpecificDimensions(generationType);
3714+ const originalSeed = extension_settings.sd.seed;
3715+ extension_settings.sd.seed = Math.round(Math.random() * Number.MAX_SAFE_INTEGER);
3716+ let imagePath = '';
3717+
3718+ try {
3719+ $(stopButton).show();
3720+ eventSource.once(CUSTOM_STOP_EVENT, stopListener);
3721+ const callback = () => { };
3722+ const hasNegative = message.extra.negative;
3723+ const prompt = await refinePrompt(message.extra.title, false, false);
3724+ const negativePromptPrefix = hasNegative ? await refinePrompt(message.extra.negative, false, true) : '';
3725+ const characterName = context.characterId
3726+ ? context.characters[context.characterId].name
3727+ : context.groups[Object.keys(context.groups).filter(x => context.groups[x].id === context.groupId)[0]]?.id?.toString();
3728+
3729+ messageImg.addClass(animationClass);
3730+ swipeControls.hide();
3731+ imagePath = await sendGenerationRequest(generationType, prompt, negativePromptPrefix, characterName, callback, initiators.swipe, abortController.signal);
3732+ } finally {
3733+ $(stopButton).hide();
3734+ messageImg.removeClass(animationClass);
3735+ swipeControls.show();
3736+ eventSource.removeListener(CUSTOM_STOP_EVENT, stopListener);
3737+ restoreOriginalDimensions(dimensions);
3738+ extension_settings.sd.seed = originalSeed;
3739+ }
3740+
3741+ if (!imagePath) {
3742+ return;
3743+ }
3744+
3745+ swipes.push(imagePath);
3746+ }
3747+
3748+ message.extra.image = swipes[newIndex];
3749+ appendMediaToMessage(message, element, false);
3750+ }
3751+
3752+ await context.saveChat();
3753+}
3754+
36603755jQuery(async () => {
36613756 await addSDGenButtons();
36623757
@@ -3795,6 +3890,8 @@ jQuery(async () => {
37953890 }
37963891 });
37973892
3893+ eventSource.on(event_types.IMAGE_SWIPED, onImageSwiped);
3894+
37983895 eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
37993896
38003897 await loadSettings();
public/style.css+23 -0
@@ -4569,6 +4569,7 @@ a {
45694569 image-rendering: -webkit-optimize-contrast;
45704570}
45714571
4572+.mes_img_swipes,
45724573.mes_img_controls {
45734574 position: absolute;
45744575 top: 0.1em;
@@ -4578,9 +4579,16 @@ a {
45784579 opacity: 0;
45794580 flex-direction: row;
45804581 justify-content: space-between;
4582+ align-items: center;
45814583 padding: 1em;
45824584}
45834585
4586+.mes_img_swipes {
4587+ top: unset;
4588+ bottom: 0.1rem;
4589+}
4590+
4591+.mes_img_swipes .right_menu_button,
45844592.mes_img_controls .right_menu_button {
45854593 filter: brightness(90%);
45864594 text-shadow: 1px 1px var(--SmartThemeShadowColor) !important;
@@ -4589,16 +4597,20 @@ a {
45894597 width: 1.25em;
45904598}
45914599
4600+.mes_img_swipes .right_menu_button::before,
45924601.mes_img_controls .right_menu_button::before {
45934602 /* Fix weird alignment with this font-awesome icons on focus */
45944603 position: relative;
45954604 top: 0.6125em;
45964605}
45974606
4607+.mes_img_swipes .right_menu_button:hover,
45984608.mes_img_controls .right_menu_button:hover {
45994609 filter: brightness(150%);
46004610}
46014611
4612+.mes_img_container:hover .mes_img_swipes,
4613+.mes_img_container:focus-within .mes_img_swipes,
46024614.mes_img_container:hover .mes_img_controls,
46034615.mes_img_container:focus-within .mes_img_controls {
46044616 opacity: 1;
@@ -4612,6 +4624,17 @@ body:not(.caption) .mes_img_caption {
46124624 display: none;
46134625}
46144626
4627+.mes_img_container:not(.img_swipes) .mes_img_swipes,
4628+body:not(.sd) .mes_img_swipes {
4629+ display: none;
4630+}
4631+
4632+.mes_img_swipe_counter {
4633+ font-weight: 600;
4634+ filter: drop-shadow(2px 4px 6px black);
4635+ cursor: default;
4636+}
4637+
46154638.img_enlarged_holder {
46164639 /* Scaling via flex-grow and object-fit only works if we have some kind of base-height set */
46174640 min-height: 120px;