Merge pull request #2655 from SillyTavern/image-swipes Image Generation: Add swipes for generated images

e4d045f03ecfc96323b800e8594907b111dc75c9

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

Signed
6 files changed, +187 -10Showing whitespace changes
public/index.html+5 -0
@@ -5802,6 +5802,11 @@
5802 <div title="Caption" class="right_menu_button fa-lg fa-solid fa-envelope-open-text mes_img_caption" data-i18n="[title]Caption"></div>5802 <div title="Caption" class="right_menu_button fa-lg fa-solid fa-envelope-open-text mes_img_caption" data-i18n="[title]Caption"></div>
5803 <div title="Delete" class="right_menu_button fa-lg fa-solid fa-trash-can mes_img_delete" data-i18n="[title]Delete"></div>5803 <div title="Delete" class="right_menu_button fa-lg fa-solid fa-trash-can mes_img_delete" data-i18n="[title]Delete"></div>
5804 </div>5804 </div>
5805 <div class="mes_img_swipes">
5806 <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>
5807 <div class="mes_img_swipe_counter">1/1</div>
5808 <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>
5809 </div>
5805 <img class="mes_img" src="" />5810 <img class="mes_img" src="" />
5806 </div>5811 </div>
5807 <div class="mes_bias"></div>5812 <div class="mes_bias"></div>
public/script.js+21 -1
@@ -459,6 +459,7 @@ export const event_types = {
459 LLM_FUNCTION_TOOL_REGISTER: 'llm_function_tool_register',459 LLM_FUNCTION_TOOL_REGISTER: 'llm_function_tool_register',
460 LLM_FUNCTION_TOOL_CALL: 'llm_function_tool_call',460 LLM_FUNCTION_TOOL_CALL: 'llm_function_tool_call',
461 ONLINE_STATUS_CHANGED: 'online_status_changed',461 ONLINE_STATUS_CHANGED: 'online_status_changed',
462 IMAGE_SWIPED: 'image_swiped',
462};463};
463464
464export const eventSource = new EventEmitter();465export const eventSource = new EventEmitter();
@@ -2112,6 +2113,7 @@ export function updateMessageBlock(messageId, message) {
2112export function appendMediaToMessage(mes, messageElement, adjustScroll = true) {2113export function appendMediaToMessage(mes, messageElement, adjustScroll = true) {
2113 // Add image to message2114 // Add image to message
2114 if (mes.extra?.image) {2115 if (mes.extra?.image) {
2116 const container = messageElement.find('.mes_img_container');
2115 const chatHeight = $('#chat').prop('scrollHeight');2117 const chatHeight = $('#chat').prop('scrollHeight');
2116 const image = messageElement.find('.mes_img');2118 const image = messageElement.find('.mes_img');
2117 const text = messageElement.find('.mes_text');2119 const text = messageElement.find('.mes_text');
@@ -2127,9 +2129,27 @@ export function appendMediaToMessage(mes, messageElement, adjustScroll = true) {
2127 });2129 });
2128 image.attr('src', mes.extra?.image);2130 image.attr('src', mes.extra?.image);
2129 image.attr('title', mes.extra?.title || mes.title || '');2131 image.attr('title', mes.extra?.title || mes.title || '');
2130 messageElement.find('.mes_img_container').addClass('img_extra');2132 container.addClass('img_extra');
2131 image.toggleClass('img_inline', isInline);2133 image.toggleClass('img_inline', isInline);
2132 text.toggleClass('displayNone', !isInline);2134 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 }
2133 }2153 }
21342154
2135 // Add file to message2155 // Add file to message
public/scripts/extensions/stable-diffusion/index.js+117 -5
@@ -59,6 +59,7 @@ const initiators = {
59 action: 'action',59 action: 'action',
60 interactive: 'interactive',60 interactive: 'interactive',
61 wand: 'wand',61 wand: 'wand',
62 swipe: 'swipe',
62};63};
6364
64const generationMode = {65const generationMode = {
@@ -2275,9 +2276,9 @@ async function generatePicture(initiator, args, trigger, message, callback) {
2275 const quietPrompt = getQuietPrompt(generationType, trigger);2276 const quietPrompt = getQuietPrompt(generationType, trigger);
2276 const context = getContext();2277 const context = getContext();
22772278
2278 // if context.characterId is not null, then we get context.characters[context.characterId].avatar, else we get groupId and context.groups[groupId].id2279 const characterName = context.groupId
2279 // sadly, groups is not an array, but is a dict with keys being index numbers, so we have to filter it2280 ? context.groups[Object.keys(context.groups).filter(x => context.groups[x].id === context.groupId)[0]]?.id?.toString()
2280 const characterName = context.characterId ? context.characters[context.characterId].name : context.groups[Object.keys(context.groups).filter(x => context.groups[x].id === context.groupId)[0]]?.id?.toString();2281 : context.characters[context.characterId]?.name;
22812282
2282 if (generationType == generationMode.BACKGROUND) {2283 if (generationType == generationMode.BACKGROUND) {
2283 const callbackOriginal = callback;2284 const callbackOriginal = callback;
@@ -3393,6 +3394,7 @@ async function sendMessage(prompt, image, generationType, additionalNegativePref
3393 generationType: generationType,3394 generationType: generationType,
3394 negative: additionalNegativePrefix,3395 negative: additionalNegativePrefix,
3395 inline_image: false,3396 inline_image: false,
3397 image_swipes: [image],
3396 },3398 },
3397 };3399 };
3398 context.chat.push(message);3400 context.chat.push(message);
@@ -3535,7 +3537,9 @@ async function sdMessageButton(e) {
3535 const $mes = $icon.closest('.mes');3537 const $mes = $icon.closest('.mes');
3536 const message_id = $mes.attr('mesid');3538 const message_id = $mes.attr('mesid');
3537 const message = context.chat[message_id];3539 const message = context.chat[message_id];
3538 const characterFileName = context.characterId ? context.characters[context.characterId].name : context.groups[Object.keys(context.groups).filter(x => context.groups[x].id === context.groupId)[0]]?.id?.toString();3540 const characterFileName = context.groupId
3541 ? context.groups[Object.keys(context.groups).filter(x => context.groups[x].id === context.groupId)[0]]?.id?.toString()
3542 : context.characters[context.characterId]?.name;
3539 const messageText = message?.mes;3543 const messageText = message?.mes;
3540 const hasSavedImage = message?.extra?.image && message?.extra?.title;3544 const hasSavedImage = message?.extra?.image && message?.extra?.title;
3541 const hasSavedNegative = message?.extra?.negative;3545 const hasSavedNegative = message?.extra?.negative;
@@ -3579,10 +3583,23 @@ async function sdMessageButton(e) {
35793583
3580 function saveGeneratedImage(prompt, image, generationType, negative) {3584 function saveGeneratedImage(prompt, image, generationType, negative) {
3581 // Some message sources may not create the extra object3585 // Some message sources may not create the extra object
3582 if (typeof message.extra !== 'object') {3586 if (typeof message.extra !== 'object' || message.extra === null) {
3583 message.extra = {};3587 message.extra = {};
3584 }3588 }
35853589
3590 // Add image to the swipe list if it's not already there
3591 if (!Array.isArray(message.extra.image_swipes)) {
3592 message.extra.image_swipes = [];
3593 }
3594
3595 const swipes = message.extra.image_swipes;
3596
3597 if (message.extra.image && !swipes.includes(message.extra.image)) {
3598 swipes.push(message.extra.image);
3599 }
3600
3601 swipes.push(image);
3602
3586 // If already contains an image and it's not inline - leave it as is3603 // If already contains an image and it's not inline - leave it as is
3587 message.extra.inline_image = message.extra.image && !message.extra.inline_image ? false : true;3604 message.extra.inline_image = message.extra.image && !message.extra.inline_image ? false : true;
3588 message.extra.image = image;3605 message.extra.image = image;
@@ -3621,6 +3638,99 @@ async function writePromptFields(characterId) {
3621 await writeExtensionField(characterId, 'sd_character_prompt', promptObject);3638 await writeExtensionField(characterId, 'sd_character_prompt', promptObject);
3622}3639}
36233640
3641/**
3642 * Switches an image to the next or previous one in the swipe list.
3643 * @param {object} args Event arguments
3644 * @param {any} args.message Message object
3645 * @param {JQuery<HTMLElement>} args.element Message element
3646 * @param {string} args.direction Swipe direction
3647 * @returns {Promise<void>}
3648 */
3649async function onImageSwiped({ message, element, direction }) {
3650 const context = getContext();
3651 const animationClass = 'fa-fade';
3652 const messageImg = element.find('.mes_img');
3653
3654 // Current image is already animating
3655 if (messageImg.hasClass(animationClass)) {
3656 return;
3657 }
3658
3659 const swipes = message?.extra?.image_swipes;
3660
3661 if (!Array.isArray(swipes)) {
3662 console.warn('No image swipes found in the message');
3663 return;
3664 }
3665
3666 const currentIndex = swipes.indexOf(message.extra.image);
3667
3668 if (currentIndex === -1) {
3669 console.warn('Current image not found in the swipes');
3670 return;
3671 }
3672
3673 // Switch to previous image or wrap around if at the beginning
3674 if (direction === 'left') {
3675 const newIndex = currentIndex === 0 ? swipes.length - 1 : currentIndex - 1;
3676 message.extra.image = swipes[newIndex];
3677
3678 // Update the image in the message
3679 appendMediaToMessage(message, element, false);
3680 }
3681
3682 // Switch to next image or generate a new one if at the end
3683 if (direction === 'right') {
3684 const newIndex = currentIndex === swipes.length - 1 ? swipes.length : currentIndex + 1;
3685
3686 if (newIndex === swipes.length) {
3687 const abortController = new AbortController();
3688 const swipeControls = element.find('.mes_img_swipes');
3689 const stopButton = document.getElementById('sd_stop_gen');
3690 const stopListener = () => abortController.abort('Aborted by user');
3691 const generationType = message?.extra?.generationType ?? generationMode.FREE;
3692 const dimensions = setTypeSpecificDimensions(generationType);
3693 const originalSeed = extension_settings.sd.seed;
3694 extension_settings.sd.seed = Math.round(Math.random() * Number.MAX_SAFE_INTEGER);
3695 let imagePath = '';
3696
3697 try {
3698 $(stopButton).show();
3699 eventSource.once(CUSTOM_STOP_EVENT, stopListener);
3700 const callback = () => { };
3701 const hasNegative = message.extra.negative;
3702 const prompt = await refinePrompt(message.extra.title, false, false);
3703 const negativePromptPrefix = hasNegative ? await refinePrompt(message.extra.negative, false, true) : '';
3704 const characterName = context.groupId
3705 ? context.groups[Object.keys(context.groups).filter(x => context.groups[x].id === context.groupId)[0]]?.id?.toString()
3706 : context.characters[context.characterId]?.name;
3707
3708 messageImg.addClass(animationClass);
3709 swipeControls.hide();
3710 imagePath = await sendGenerationRequest(generationType, prompt, negativePromptPrefix, characterName, callback, initiators.swipe, abortController.signal);
3711 } finally {
3712 $(stopButton).hide();
3713 messageImg.removeClass(animationClass);
3714 swipeControls.show();
3715 eventSource.removeListener(CUSTOM_STOP_EVENT, stopListener);
3716 restoreOriginalDimensions(dimensions);
3717 extension_settings.sd.seed = originalSeed;
3718 }
3719
3720 if (!imagePath) {
3721 return;
3722 }
3723
3724 swipes.push(imagePath);
3725 }
3726
3727 message.extra.image = swipes[newIndex];
3728 appendMediaToMessage(message, element, false);
3729 }
3730
3731 await context.saveChat();
3732}
3733
3624jQuery(async () => {3734jQuery(async () => {
3625 await addSDGenButtons();3735 await addSDGenButtons();
36263736
@@ -3759,6 +3869,8 @@ jQuery(async () => {
3759 }3869 }
3760 });3870 });
37613871
3872 eventSource.on(event_types.IMAGE_SWIPED, onImageSwiped);
3873
3762 eventSource.on(event_types.CHAT_CHANGED, onChatChanged);3874 eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
37633875
3764 await loadSettings();3876 await loadSettings();
public/style.css+23 -0
@@ -4569,6 +4569,7 @@ a {
4569 image-rendering: -webkit-optimize-contrast;4569 image-rendering: -webkit-optimize-contrast;
4570}4570}
45714571
4572.mes_img_swipes,
4572.mes_img_controls {4573.mes_img_controls {
4573 position: absolute;4574 position: absolute;
4574 top: 0.1em;4575 top: 0.1em;
@@ -4578,9 +4579,16 @@ a {
4578 opacity: 0;4579 opacity: 0;
4579 flex-direction: row;4580 flex-direction: row;
4580 justify-content: space-between;4581 justify-content: space-between;
4582 align-items: center;
4581 padding: 1em;4583 padding: 1em;
4582}4584}
45834585
4586.mes_img_swipes {
4587 top: unset;
4588 bottom: 0.1rem;
4589}
4590
4591.mes_img_swipes .right_menu_button,
4584.mes_img_controls .right_menu_button {4592.mes_img_controls .right_menu_button {
4585 filter: brightness(90%);4593 filter: brightness(90%);
4586 text-shadow: 1px 1px var(--SmartThemeShadowColor) !important;4594 text-shadow: 1px 1px var(--SmartThemeShadowColor) !important;
@@ -4589,16 +4597,20 @@ a {
4589 width: 1.25em;4597 width: 1.25em;
4590}4598}
45914599
4600.mes_img_swipes .right_menu_button::before,
4592.mes_img_controls .right_menu_button::before {4601.mes_img_controls .right_menu_button::before {
4593 /* Fix weird alignment with this font-awesome icons on focus */4602 /* Fix weird alignment with this font-awesome icons on focus */
4594 position: relative;4603 position: relative;
4595 top: 0.6125em;4604 top: 0.6125em;
4596}4605}
45974606
4607.mes_img_swipes .right_menu_button:hover,
4598.mes_img_controls .right_menu_button:hover {4608.mes_img_controls .right_menu_button:hover {
4599 filter: brightness(150%);4609 filter: brightness(150%);
4600}4610}
46014611
4612.mes_img_container:hover .mes_img_swipes,
4613.mes_img_container:focus-within .mes_img_swipes,
4602.mes_img_container:hover .mes_img_controls,4614.mes_img_container:hover .mes_img_controls,
4603.mes_img_container:focus-within .mes_img_controls {4615.mes_img_container:focus-within .mes_img_controls {
4604 opacity: 1;4616 opacity: 1;
@@ -4612,6 +4624,17 @@ body:not(.caption) .mes_img_caption {
4612 display: none;4624 display: none;
4613}4625}
46144626
4627.mes_img_container:not(.img_swipes) .mes_img_swipes,
4628body: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
4615.img_enlarged_holder {4638.img_enlarged_holder {
4616 /* Scaling via flex-grow and object-fit only works if we have some kind of base-height set */4639 /* Scaling via flex-grow and object-fit only works if we have some kind of base-height set */
4617 min-height: 120px;4640 min-height: 120px;
src/endpoints/images.js+1 -1
@@ -82,7 +82,7 @@ router.post('/list/:folder', (request, response) => {
82 }82 }
8383
84 try {84 try {
85 const images = getImages(directoryPath);85 const images = getImages(directoryPath, 'date');
86 return response.send(images);86 return response.send(images);
87 } catch (error) {87 } catch (error) {
88 console.error(error);88 console.error(error);
src/util.js+20 -3
@@ -382,14 +382,31 @@ function removeOldBackups(directory, prefix) {
382 }382 }
383}383}
384384
385function getImages(path) {385/**
386 * Get a list of images in a directory.
387 * @param {string} directoryPath Path to the directory containing the images
388 * @param {'name' | 'date'} sortBy Sort images by name or date
389 * @returns {string[]} List of image file names
390 */
391function getImages(directoryPath, sortBy = 'name') {
392 function getSortFunction() {
393 switch (sortBy) {
394 case 'name':
395 return Intl.Collator().compare;
396 case 'date':
397 return (a, b) => fs.statSync(path.join(directoryPath, a)).mtimeMs - fs.statSync(path.join(directoryPath, b)).mtimeMs;
398 default:
399 return (_a, _b) => 0;
400 }
401 }
402
386 return fs403 return fs
387 .readdirSync(path)404 .readdirSync(directoryPath)
388 .filter(file => {405 .filter(file => {
389 const type = mime.lookup(file);406 const type = mime.lookup(file);
390 return type && type.startsWith('image/');407 return type && type.startsWith('image/');
391 })408 })
392 .sort(Intl.Collator().compare);409 .sort(getSortFunction());
393}410}
394411
395/**412/**