Merge pull request #2655 from SillyTavern/image-swipes Image Generation: Add swipes for generated images
Signed| @@ -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> |
| @@ -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 | }; |
| 463 | 464 | ||
| 464 | export const eventSource = new EventEmitter(); | 465 | export const eventSource = new EventEmitter(); |
| @@ -2112,6 +2113,7 @@ export function updateMessageBlock(messageId, message) { | |||
| 2112 | export function appendMediaToMessage(mes, messageElement, adjustScroll = true) { | 2113 | export function appendMediaToMessage(mes, messageElement, adjustScroll = true) { |
| 2113 | // Add image to message | 2114 | // 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 | } |
| 2134 | 2154 | ||
| 2135 | // Add file to message | 2155 | // Add file to message |
| @@ -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 | }; |
| 63 | 64 | ||
| 64 | const generationMode = { | 65 | const 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(); |
| 2277 | 2278 | ||
| 2278 | // if context.characterId is not null, then we get context.characters[context.characterId].avatar, else we get groupId and context.groups[groupId].id | 2279 | 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 it | 2280 | ? 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; |
| 2281 | 2282 | ||
| 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) { | |||
| 3579 | 3583 | ||
| 3580 | function saveGeneratedImage(prompt, image, generationType, negative) { | 3584 | function saveGeneratedImage(prompt, image, generationType, negative) { |
| 3581 | // Some message sources may not create the extra object | 3585 | // 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 | } |
| 3585 | 3589 | ||
| 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 is | 3603 | // 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 | } |
| 3623 | 3640 | ||
| 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 | */ | ||
| 3649 | async 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 | |||
| 3624 | jQuery(async () => { | 3734 | jQuery(async () => { |
| 3625 | await addSDGenButtons(); | 3735 | await addSDGenButtons(); |
| 3626 | 3736 | ||
| @@ -3759,6 +3869,8 @@ jQuery(async () => { | |||
| 3759 | } | 3869 | } |
| 3760 | }); | 3870 | }); |
| 3761 | 3871 | ||
| 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); |
| 3763 | 3875 | ||
| 3764 | await loadSettings(); | 3876 | await loadSettings(); |
| @@ -4569,6 +4569,7 @@ a { | |||
| 4569 | image-rendering: -webkit-optimize-contrast; | 4569 | image-rendering: -webkit-optimize-contrast; |
| 4570 | } | 4570 | } |
| 4571 | 4571 | ||
| 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 | } |
| 4583 | 4585 | ||
| 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 | } |
| 4591 | 4599 | ||
| 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 | } |
| 4597 | 4606 | ||
| 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 | } |
| 4601 | 4611 | ||
| 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 | } |
| 4614 | 4626 | ||
| 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 | |||
| 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; |
| @@ -82,7 +82,7 @@ router.post('/list/:folder', (request, response) => { | |||
| 82 | } | 82 | } |
| 83 | 83 | ||
| 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); |
| @@ -382,14 +382,31 @@ function removeOldBackups(directory, prefix) { | |||
| 382 | } | 382 | } |
| 383 | } | 383 | } |
| 384 | 384 | ||
| 385 | function 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 | */ | ||
| 391 | function 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 fs | 403 | 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 | } |
| 394 | 411 | ||
| 395 | /** | 412 | /** |