Multiple attachments (#4719) * Multiple file uploads * mes_img_wrapper * mes_video_wrapper * Named export instead of function wrapper * Update public/scripts/chats.js Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Fix optional chaining for message extra * Preserve existing files with paste * Improve swipe message extras clean-up * Clean-up: Add chat_backgrounds to known images * Add ensureMessageMediaIsArray to getContext * Fix compatibility warning * Update to media array * Move de-dupe check logic * Fix comment * Fix clean-up logic * Improve typing * `feat/multi-file` Added a toggle between the old gallery and new image list. (#4722) * Added "Toggle Gallery" button. Added `getContainerInfo`. * Refactor * Change checkbox toggle to select * Ensure media_display is set correctly only if any image_swipes were migrated * Rename function * Support Date in parseTimestamp * Add type to main chat array * Add media display reload prompt --------- Co-authored-by: user <user@exmaple.com> Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com> * Use a single wrapper block for media * Fix type annotation * Fix video display in list mode * Refactor saveImageToMessage to include title in media object * Use named constants in migrateMediaToArray * Update img control styles * Fix error container state * Refactor onImageSwiped * Remove redundant event handler * Refactor expandMessageMedia * Use shared function for display handling, fix notice logic * Enhance ChatMessage and ChatMessageExtra types * Refactor media display reload logic * Improve styling for media containers * Adjust spacing in file form styles * Fix scroll handling in appendMediaToMessage * Reduce flicker in appendMediaToMessage * Extract scrollOnMediaLoad func * Improve scroll behavior in gallery display * Improve delegation for click events * Add file d&d handler to #form_sheld * Improve scroll adjust for slow connections * Adjust debounce timeout * Add messageMedia enum provider --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: DeclineThyself <FallenHaze@tutamail.com> Co-authored-by: user <user@exmaple.com>
Signed| @@ -25,6 +25,16 @@ | |||
| 25 | border-radius: 15px; | 25 | border-radius: 15px; |
| 26 | } | 26 | } |
| 27 | 27 | ||
| 28 | .mes .mes_file_wrapper:empty { | ||
| 29 | display: none; | ||
| 30 | } | ||
| 31 | |||
| 32 | .mes .mes_file_wrapper { | ||
| 33 | display: flex; | ||
| 34 | flex-direction: column; | ||
| 35 | gap: 0.5em; | ||
| 36 | } | ||
| 37 | |||
| 28 | .mes_file_container .right_menu_button { | 38 | .mes_file_container .right_menu_button { |
| 29 | padding-right: 0; | 39 | padding-right: 0; |
| 30 | } | 40 | } |
| @@ -4,6 +4,8 @@ import { power_user } from './scripts/power-user'; | |||
| 4 | import { QuickReplyApi } from './scripts/extensions/quick-reply/api/QuickReplyApi'; | 4 | import { QuickReplyApi } from './scripts/extensions/quick-reply/api/QuickReplyApi'; |
| 5 | import { oai_settings } from './scripts/openai'; | 5 | import { oai_settings } from './scripts/openai'; |
| 6 | import { textgenerationwebui_settings } from './scripts/textgen-settings'; | 6 | import { textgenerationwebui_settings } from './scripts/textgen-settings'; |
| 7 | import { FileAttachment } from './scripts/chats'; | ||
| 8 | import { ReasoningMessageExtra } from './scripts/reasoning'; | ||
| 7 | 9 | ||
| 8 | declare global { | 10 | declare global { |
| 9 | // Custom types | 11 | // Custom types |
| @@ -12,6 +14,72 @@ declare global { | |||
| 12 | type ReasoningSettings = typeof power_user.reasoning; | 14 | type ReasoningSettings = typeof power_user.reasoning; |
| 13 | type ChatCompletionSettings = typeof oai_settings; | 15 | type ChatCompletionSettings = typeof oai_settings; |
| 14 | type TextCompletionSettings = typeof textgenerationwebui_settings; | 16 | type TextCompletionSettings = typeof textgenerationwebui_settings; |
| 17 | type MessageTimestamp = string | number | Date; | ||
| 18 | |||
| 19 | interface ChatMessage { | ||
| 20 | name?: string; | ||
| 21 | mes?: string; | ||
| 22 | title?: string; | ||
| 23 | gen_started?: MessageTimestamp; | ||
| 24 | gen_finished?: MessageTimestamp; | ||
| 25 | send_date?: MessageTimestamp; | ||
| 26 | is_user?: boolean; | ||
| 27 | is_system?: boolean; | ||
| 28 | force_avatar?: string; | ||
| 29 | original_avatar?: string; | ||
| 30 | swipes?: string[]; | ||
| 31 | swipe_info?: Record<string, any>; | ||
| 32 | swipe_id?: number; | ||
| 33 | extra?: ChatMessageExtra & Partial<ReasoningMessageExtra> & Record<string, any>; | ||
| 34 | }; | ||
| 35 | |||
| 36 | interface ChatMessageExtra { | ||
| 37 | bias?: string; | ||
| 38 | uses_system_ui?: boolean; | ||
| 39 | memory?: string; | ||
| 40 | display_text?: string; | ||
| 41 | reasoning_display_text?: string; | ||
| 42 | tool_invocations?: any[]; | ||
| 43 | title?: string; | ||
| 44 | isSmallSys?: boolean; | ||
| 45 | token_count?: number; | ||
| 46 | files?: FileAttachment[]; | ||
| 47 | inline_image?: boolean; | ||
| 48 | media_display?: string; | ||
| 49 | media_index?: number; | ||
| 50 | media?: MediaAttachment[], | ||
| 51 | /** @deprecated Use `files` instead */ | ||
| 52 | file?: FileAttachment; | ||
| 53 | /** @deprecated Use `media` instead */ | ||
| 54 | image?: string; | ||
| 55 | /** @deprecated Use `media` instead */ | ||
| 56 | video?: string; | ||
| 57 | /** @deprecated Use `media` with `media_display = 'gallery'` instead */ | ||
| 58 | image_swipes?: string[]; | ||
| 59 | /** @deprecated Use `MediaAttachment.append_title` instead */ | ||
| 60 | append_title?: boolean; | ||
| 61 | /** @deprecated Use `MediaAttachment.generation_type` instead */ | ||
| 62 | generationType?: number; | ||
| 63 | /** @deprecated Use `MediaAttachment.negative` instead */ | ||
| 64 | negative?: string; | ||
| 65 | } | ||
| 66 | |||
| 67 | type MediaAttachment = MediaAttachmentProps & ImageGenerationAttachmentProps & ImageCaptionAttachmentProps; | ||
| 68 | |||
| 69 | interface MediaAttachmentProps { | ||
| 70 | url: string; | ||
| 71 | title?: string; | ||
| 72 | type: string; | ||
| 73 | } | ||
| 74 | |||
| 75 | interface ImageGenerationAttachmentProps { | ||
| 76 | generation_type?: number; | ||
| 77 | negative?: string; | ||
| 78 | } | ||
| 79 | |||
| 80 | interface ImageCaptionAttachmentProps { | ||
| 81 | append_title?: boolean; | ||
| 82 | } | ||
| 15 | 83 | ||
| 16 | // Global namespace modules | 84 | // Global namespace modules |
| 17 | interface Window { | 85 | interface Window { |
| @@ -4671,6 +4671,13 @@ | |||
| 4671 | <option value="2" data-i18n="Document">Document</option> | 4671 | <option value="2" data-i18n="Document">Document</option> |
| 4672 | </select> | 4672 | </select> |
| 4673 | </div> | 4673 | </div> |
| 4674 | <div class="flex-container alignitemscenter" title="Default display style for media attachments in chat messages. Extensions can override this setting." data-i18n="[title]Default display style for media attachments in chat messages. Extensions can override this setting."> | ||
| 4675 | <span data-i18n="Media Style:">Media Style:</span> | ||
| 4676 | <select id="media_display" class="widthNatural flex1 margin0 text_pole"> | ||
| 4677 | <option value="list" data-i18n="List">List</option> | ||
| 4678 | <option value="gallery" data-i18n="Gallery">Gallery</option> | ||
| 4679 | </select> | ||
| 4680 | </div> | ||
| 4674 | <div class="flex-container alignItemsBaseline"> | 4681 | <div class="flex-container alignItemsBaseline"> |
| 4675 | <span data-i18n="Notifications:">Notifications:</span> | 4682 | <span data-i18n="Notifications:">Notifications:</span> |
| 4676 | <select id="toastr_position" class="widthNatural flex1 margin0 text_pole"> | 4683 | <select id="toastr_position" class="widthNatural flex1 margin0 text_pole"> |
| @@ -7005,6 +7012,8 @@ | |||
| 7005 | <div title="Prompt" class="mes_button mes_prompt fa-solid fa-square-poll-horizontal " data-i18n="[title]Prompt" style="display: none;"></div> | 7012 | <div title="Prompt" class="mes_button mes_prompt fa-solid fa-square-poll-horizontal " data-i18n="[title]Prompt" style="display: none;"></div> |
| 7006 | <div title="Exclude message from prompts" class="mes_button mes_hide fa-solid fa-eye" data-i18n="[title]Exclude message from prompts"></div> | 7013 | <div title="Exclude message from prompts" class="mes_button mes_hide fa-solid fa-eye" data-i18n="[title]Exclude message from prompts"></div> |
| 7007 | <div title="Include message in prompts" class="mes_button mes_unhide fa-solid fa-eye-slash" data-i18n="[title]Include message in prompts"></div> | 7014 | <div title="Include message in prompts" class="mes_button mes_unhide fa-solid fa-eye-slash" data-i18n="[title]Include message in prompts"></div> |
| 7015 | <div title="Toggle media display style" class="mes_button mes_media_gallery fa-solid fa-photo-film" data-i18n="[title]Toggle media display style"></div> | ||
| 7016 | <div title="Toggle media display style" class="mes_button mes_media_list fa-solid fa-table-cells-large" data-i18n="[title]Toggle media display style"></div> | ||
| 7008 | <div title="Embed file or image" class="mes_button mes_embed fa-solid fa-paperclip" data-i18n="[title]Embed file or image"></div> | 7017 | <div title="Embed file or image" class="mes_button mes_embed fa-solid fa-paperclip" data-i18n="[title]Embed file or image"></div> |
| 7009 | <div title="Create checkpoint" class="mes_button mes_create_bookmark fa-regular fa-solid fa-flag-checkered" data-i18n="[title]Create checkpoint"></div> | 7018 | <div title="Create checkpoint" class="mes_button mes_create_bookmark fa-regular fa-solid fa-flag-checkered" data-i18n="[title]Create checkpoint"></div> |
| 7010 | <div title="Create branch" class="mes_button mes_create_branch fa-regular fa-code-branch" data-i18n="[title]Create Branch"></div> | 7019 | <div title="Create branch" class="mes_button mes_create_branch fa-regular fa-code-branch" data-i18n="[title]Create Branch"></div> |
| @@ -7043,19 +7052,8 @@ | |||
| 7043 | <div class="mes_reasoning"></div> | 7052 | <div class="mes_reasoning"></div> |
| 7044 | </details> | 7053 | </details> |
| 7045 | <div class="mes_text"></div> | 7054 | <div class="mes_text"></div> |
| 7046 | <div class="mes_img_container"> | 7055 | <div class="mes_media_wrapper"></div> |
| 7047 | <div class="mes_img_controls"> | 7056 | <div class="mes_file_wrapper"></div> |
| 7048 | <div title="Expand and zoom" class="right_menu_button fa-lg fa-solid fa-magnifying-glass mes_img_enlarge" data-i18n="[title]Expand and zoom"></div> | ||
| 7049 | <div title="Caption" class="right_menu_button fa-lg fa-solid fa-envelope-open-text mes_img_caption" data-i18n="[title]Caption"></div> | ||
| 7050 | <div title="Delete" class="right_menu_button fa-lg fa-solid fa-trash-can mes_img_delete" data-i18n="[title]Delete"></div> | ||
| 7051 | </div> | ||
| 7052 | <div class="mes_img_swipes"> | ||
| 7053 | <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> | ||
| 7054 | <div class="mes_img_swipe_counter">1/1</div> | ||
| 7055 | <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> | ||
| 7056 | </div> | ||
| 7057 | <img class="mes_img" src="" /> | ||
| 7058 | </div> | ||
| 7059 | <div class="mes_bias"></div> | 7057 | <div class="mes_bias"></div> |
| 7060 | </div> | 7058 | </div> |
| 7061 | <div class="flex-container swipeRightBlock flexFlowColumn flexNoGap"> | 7059 | <div class="flex-container swipeRightBlock flexFlowColumn flexNoGap"> |
| @@ -7265,9 +7263,9 @@ | |||
| 7265 | </div> | 7263 | </div> |
| 7266 | </div> | 7264 | </div> |
| 7267 | 7265 | ||
| 7268 | <!-- chat and input bar --> | 7266 | <!-- Media Templates --> |
| 7269 | <div id="message_file_template" class="template_element"> | 7267 | <div id="message_file_template" class="template_element"> |
| 7270 | <div class="mes_file_container"> | 7268 | <div class="mes_media_container mes_file_container"> |
| 7271 | <div class="fa-lg fa-solid fa-file-alt mes_file_icon"></div> | 7269 | <div class="fa-lg fa-solid fa-file-alt mes_file_icon"></div> |
| 7272 | <div class="mes_file_name"></div> | 7270 | <div class="mes_file_name"></div> |
| 7273 | <div class="mes_file_size"></div> | 7271 | <div class="mes_file_size"></div> |
| @@ -7276,15 +7274,34 @@ | |||
| 7276 | </div> | 7274 | </div> |
| 7277 | </div> | 7275 | </div> |
| 7278 | 7276 | ||
| 7277 | <div id="message_image_template" class="template_element"> | ||
| 7278 | <div class="mes_media_container mes_img_container"> | ||
| 7279 | <div class="mes_img_controls"> | ||
| 7280 | <div title="Expand and zoom" class="right_menu_button fa-lg fa-solid fa-magnifying-glass mes_media_enlarge" data-i18n="[title]Expand and zoom"></div> | ||
| 7281 | <div title="Caption" class="right_menu_button fa-lg fa-solid fa-envelope-open-text mes_img_caption" data-i18n="[title]Caption"></div> | ||
| 7282 | <div title="Delete" class="right_menu_button fa-lg fa-solid fa-trash-can mes_media_delete" data-i18n="[title]Delete"></div> | ||
| 7283 | </div> | ||
| 7284 | <img class="mes_img" src="" /> | ||
| 7285 | </div> | ||
| 7286 | </div> | ||
| 7287 | |||
| 7279 | <div id="message_video_template" class="template_element"> | 7288 | <div id="message_video_template" class="template_element"> |
| 7280 | <div class="mes_video_container"> | 7289 | <div class="mes_media_container mes_video_container"> |
| 7281 | <div class="mes_video_controls"> | 7290 | <div class="mes_video_controls"> |
| 7282 | <div><!-- Placeholder --></div> | 7291 | <div title="Expand and zoom" class="right_menu_button fa-lg fa-solid fa-magnifying-glass mes_media_enlarge" data-i18n="[title]Expand and zoom"></div> |
| 7283 | <div title="Delete" class="right_menu_button fa-lg fa-solid fa-trash-can mes_video_delete" data-i18n="[title]Delete"></div> | 7292 | <div title="Delete" class="right_menu_button fa-lg fa-solid fa-trash-can mes_media_delete" data-i18n="[title]Delete"></div> |
| 7284 | </div> | 7293 | </div> |
| 7285 | <video class="mes_video" controls preload="metadata"></video> | 7294 | <video class="mes_video" controls preload="metadata"></video> |
| 7286 | </div> | 7295 | </div> |
| 7287 | </div> | 7296 | </div> |
| 7297 | |||
| 7298 | <div id="message_gallery_controls" class="template_element"> | ||
| 7299 | <div class="mes_img_swipes"> | ||
| 7300 | <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> | ||
| 7301 | <div class="mes_img_swipe_counter">1/1</div> | ||
| 7302 | <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> | ||
| 7303 | </div> | ||
| 7304 | </div> | ||
| 7288 | </div> | 7305 | </div> |
| 7289 | <div id="movingDivs"> | 7306 | <div id="movingDivs"> |
| 7290 | <div id="floatingPrompt" class="drawer-content flexGap5"> | 7307 | <div id="floatingPrompt" class="drawer-content flexGap5"> |
| @@ -7641,8 +7658,8 @@ | |||
| 7641 | <div id="send_form" class="no-connection"> | 7658 | <div id="send_form" class="no-connection"> |
| 7642 | <form id="file_form" class="wide100p displayNone"> | 7659 | <form id="file_form" class="wide100p displayNone"> |
| 7643 | <div class="file_attached"> | 7660 | <div class="file_attached"> |
| 7644 | <input id="file_form_input" type="file" hidden> | 7661 | <input id="file_form_input" type="file" multiple hidden> |
| 7645 | <input id="embed_file_input" type="file" hidden> | 7662 | <input id="embed_file_input" type="file" multiple hidden> |
| 7646 | <i class="fa-solid fa-file-alt"></i> | 7663 | <i class="fa-solid fa-file-alt"></i> |
| 7647 | <span class="file_name">File Name</span> | 7664 | <span class="file_name">File Name</span> |
| 7648 | <span class="file_size">File Size</span> | 7665 | <span class="file_size">File Size</span> |
| @@ -183,7 +183,7 @@ import { | |||
| 183 | trimSpaces, | 183 | trimSpaces, |
| 184 | clamp, | 184 | clamp, |
| 185 | } from './scripts/utils.js'; | 185 | } from './scripts/utils.js'; |
| 186 | import { debounce_timeout, GENERATION_TYPE_TRIGGERS, IGNORE_SYMBOL, inject_ids, SWIPE_DIRECTION } from './scripts/constants.js'; | 186 | import { debounce_timeout, GENERATION_TYPE_TRIGGERS, IGNORE_SYMBOL, inject_ids, MEDIA_DISPLAY, MEDIA_TYPE, SWIPE_DIRECTION } from './scripts/constants.js'; |
| 187 | 187 | ||
| 188 | import { cancelDebouncedMetadataSave, doDailyExtensionUpdatesCheck, extension_settings, initExtensions, loadExtensionSettings, runGenerationInterceptors, saveMetadataDebounced } from './scripts/extensions.js'; | 188 | import { cancelDebouncedMetadataSave, doDailyExtensionUpdatesCheck, extension_settings, initExtensions, loadExtensionSettings, runGenerationInterceptors, saveMetadataDebounced } from './scripts/extensions.js'; |
| 189 | import { COMMENT_NAME_DEFAULT, CONNECT_API_MAP, executeSlashCommandsOnChatInput, initDefaultSlashCommands, isExecutingCommandsFromChatInput, pauseScriptExecution, stopScriptExecution, UNIQUE_APIS } from './scripts/slash-commands.js'; | 189 | import { COMMENT_NAME_DEFAULT, CONNECT_API_MAP, executeSlashCommandsOnChatInput, initDefaultSlashCommands, isExecutingCommandsFromChatInput, pauseScriptExecution, stopScriptExecution, UNIQUE_APIS } from './scripts/slash-commands.js'; |
| @@ -314,7 +314,10 @@ export { | |||
| 314 | getSystemMessageByType, | 314 | getSystemMessageByType, |
| 315 | event_types, | 315 | event_types, |
| 316 | eventSource, | 316 | eventSource, |
| 317 | /** @deprecated Use setCharacterSettingsOverrides instead. */ | ||
| 317 | setCharacterSettingsOverrides as setScenarioOverride, | 318 | setCharacterSettingsOverrides as setScenarioOverride, |
| 319 | /** @deprecated Use appendMediaToMessage instead. */ | ||
| 320 | appendMediaToMessage as appendImageToMessage, | ||
| 318 | }; | 321 | }; |
| 319 | 322 | ||
| 320 | /** | 323 | /** |
| @@ -367,6 +370,7 @@ export const neutralCharacterName = 'Assistant'; | |||
| 367 | let default_user_name = 'User'; | 370 | let default_user_name = 'User'; |
| 368 | export let name1 = default_user_name; | 371 | export let name1 = default_user_name; |
| 369 | export let name2 = systemUserName; | 372 | export let name2 = systemUserName; |
| 373 | /** @type {ChatMessage[]} */ | ||
| 370 | export let chat = []; | 374 | export let chat = []; |
| 371 | export let isSwipingAllowed = true; //false when a swipe is in progress, or swiping is blocked. | 375 | export let isSwipingAllowed = true; //false when a swipe is in progress, or swiping is blocked. |
| 372 | let chatSaveTimeout; | 376 | let chatSaveTimeout; |
| @@ -1407,30 +1411,45 @@ export async function printMessages() { | |||
| 1407 | addOneMessage(item, { scroll: false, forceId: i, showSwipes: false }); | 1411 | addOneMessage(item, { scroll: false, forceId: i, showSwipes: false }); |
| 1408 | } | 1412 | } |
| 1409 | 1413 | ||
| 1410 | // Scroll to bottom when all images are loaded | 1414 | chatElement.find('.mes').removeClass('last_mes'); |
| 1411 | const images = document.querySelectorAll('#chat .mes img'); | 1415 | chatElement.find('.mes').last().addClass('last_mes'); |
| 1412 | let imagesLoaded = 0; | 1416 | refreshSwipeButtons(); |
| 1417 | applyStylePins(); | ||
| 1418 | scrollChatToBottom(); | ||
| 1419 | delay(debounce_timeout.short).then(() => scrollOnMediaLoad()); | ||
| 1420 | } | ||
| 1421 | |||
| 1422 | function scrollOnMediaLoad() { | ||
| 1423 | const started = Date.now(); | ||
| 1424 | const media = chatElement.find('.mes_block img, .mes_block video').toArray(); | ||
| 1425 | let mediaLoaded = 0; | ||
| 1413 | 1426 | ||
| 1414 | for (let i = 0; i < images.length; i++) { | 1427 | for (const currentElement of media) { |
| 1415 | const image = images[i]; | 1428 | if (currentElement instanceof HTMLImageElement) { |
| 1416 | if (image instanceof HTMLImageElement) { | 1429 | if (currentElement.complete) { |
| 1417 | if (image.complete) { | 1430 | incrementAndCheck(); |
| 1431 | } else { | ||
| 1432 | currentElement.addEventListener('load', incrementAndCheck); | ||
| 1433 | currentElement.addEventListener('error', incrementAndCheck); | ||
| 1434 | } | ||
| 1435 | } | ||
| 1436 | if (currentElement instanceof HTMLVideoElement) { | ||
| 1437 | if (currentElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { | ||
| 1418 | incrementAndCheck(); | 1438 | incrementAndCheck(); |
| 1419 | } else { | 1439 | } else { |
| 1420 | image.addEventListener('load', incrementAndCheck); | 1440 | currentElement.addEventListener('loadeddata', incrementAndCheck); |
| 1441 | currentElement.addEventListener('error', incrementAndCheck); | ||
| 1421 | } | 1442 | } |
| 1422 | } | 1443 | } |
| 1423 | } | 1444 | } |
| 1424 | 1445 | ||
| 1425 | chatElement.find('.mes').removeClass('last_mes'); | ||
| 1426 | chatElement.find('.mes').last().addClass('last_mes'); | ||
| 1427 | refreshSwipeButtons(); | ||
| 1428 | scrollChatToBottom(); | ||
| 1429 | applyStylePins(); | ||
| 1430 | |||
| 1431 | function incrementAndCheck() { | 1446 | function incrementAndCheck() { |
| 1432 | imagesLoaded++; | 1447 | const MAX_DELAY = 1000; // 1 second |
| 1433 | if (imagesLoaded === images.length) { | 1448 | if ((Date.now() - started) > MAX_DELAY) { |
| 1449 | return; | ||
| 1450 | } | ||
| 1451 | mediaLoaded++; | ||
| 1452 | if (mediaLoaded === media.length) { | ||
| 1434 | scrollChatToBottom(); | 1453 | scrollChatToBottom(); |
| 1435 | } | 1454 | } |
| 1436 | } | 1455 | } |
| @@ -1885,110 +1904,335 @@ export function updateMessageBlock(messageId, message, { rerenderMessage = true | |||
| 1885 | } | 1904 | } |
| 1886 | 1905 | ||
| 1887 | /** | 1906 | /** |
| 1888 | * Appends image or file to the message element. | 1907 | * Ensures that the message media properties are arrays, adding getters/setters for single media items. |
| 1889 | * @param {object} mes Message object | 1908 | * @param {ChatMessage} mes Message object |
| 1890 | * @param {JQuery<HTMLElement>} messageElement Message element | ||
| 1891 | * @param {boolean} [adjustScroll=true] Whether to adjust the scroll position after appending the media | ||
| 1892 | */ | 1909 | */ |
| 1893 | export function appendMediaToMessage(mes, messageElement, adjustScroll = true) { | 1910 | export function ensureMessageMediaIsArray(mes) { |
| 1894 | // Add image to message | 1911 | /** |
| 1895 | if (mes.extra?.image) { | 1912 | * Determines if a property of an object is a plain property (not a getter/setter or non-enumerable). |
| 1896 | const container = messageElement.find('.mes_img_container'); | 1913 | * @param {object} obj Object to check |
| 1897 | const chatHeight = chatElement.prop('scrollHeight'); | 1914 | * @param {string} name Property name |
| 1898 | const image = messageElement.find('.mes_img'); | 1915 | * @returns {boolean} True if the property is a plain property, false otherwise |
| 1899 | const text = messageElement.find('.mes_text'); | 1916 | */ |
| 1900 | const isInline = !!mes.extra?.inline_image; | 1917 | function isPlainObjectProperty(obj, name) { |
| 1901 | const doAdjustScroll = () => { | 1918 | const hasProperty = Object.hasOwn(obj, name); |
| 1902 | if (!adjustScroll) { | 1919 | if (hasProperty) { |
| 1903 | return; | 1920 | const descriptor = Object.getOwnPropertyDescriptor(obj, name); |
| 1904 | } | 1921 | return descriptor && descriptor.enumerable && descriptor.configurable && descriptor.writable; |
| 1905 | const scrollPosition = chatElement.scrollTop(); | 1922 | } |
| 1906 | const newChatHeight = chatElement.prop('scrollHeight'); | 1923 | return false; |
| 1907 | const diff = newChatHeight - chatHeight; | 1924 | } |
| 1908 | chatElement.scrollTop(scrollPosition + diff); | 1925 | |
| 1909 | }; | 1926 | /** |
| 1910 | image.off('load').on('load', function () { | 1927 | * Determines if a property of an object is a getter (not a plain property). |
| 1911 | image.removeAttr('alt'); | 1928 | * @param {object} obj Object to check |
| 1912 | image.removeClass('error'); | 1929 | * @param {string} name Property name |
| 1913 | doAdjustScroll(); | 1930 | * @returns {boolean} True if the property is a getter, false otherwise |
| 1914 | }); | 1931 | */ |
| 1915 | image.off('error').on('error', function () { | 1932 | function isGetterObjectProperty(obj, name) { |
| 1916 | image.attr('alt', ''); | 1933 | const hasProperty = Object.hasOwn(obj, name); |
| 1917 | image.addClass('error'); | 1934 | if (hasProperty) { |
| 1918 | doAdjustScroll(); | 1935 | const descriptor = Object.getOwnPropertyDescriptor(obj, name); |
| 1936 | return descriptor && typeof descriptor.get === 'function'; | ||
| 1937 | } | ||
| 1938 | return false; | ||
| 1939 | } | ||
| 1940 | |||
| 1941 | /** | ||
| 1942 | * Adds a plain property to an object that wraps around an array property. | ||
| 1943 | * @param {object} obj Object to add property to | ||
| 1944 | * @param {string} plainProperty Plain property name | ||
| 1945 | * @param {string} arrayProperty Array property to back the plain property | ||
| 1946 | * @param {(value: any) => boolean} [filterFn] Optional filter function to apply when getting/setting the plain property | ||
| 1947 | * @param {(value: any) => any} [mapFn] Optional map function to apply when getting/setting the plain property | ||
| 1948 | */ | ||
| 1949 | function addArrayAutoWrapper(obj, plainProperty, arrayProperty, filterFn = () => true, mapFn = (t) => t) { | ||
| 1950 | // If the plain property is already a getter, do nothing. | ||
| 1951 | const hasGetterProperty = isGetterObjectProperty(obj, plainProperty); | ||
| 1952 | if (hasGetterProperty) { | ||
| 1953 | return; | ||
| 1954 | } | ||
| 1955 | |||
| 1956 | // Define the plain property as a getter/setter that wraps around the array property. | ||
| 1957 | Object.defineProperty(obj, plainProperty, { | ||
| 1958 | // Getting the plain property returns the first item in the array property, or undefined if the array is empty. | ||
| 1959 | get: function () { | ||
| 1960 | console.trace(`Attempting to GET an array-wrapped property '${plainProperty}'. Use the array property '${arrayProperty}' instead.`); | ||
| 1961 | const array = Array.isArray(this[arrayProperty]) ? this[arrayProperty].filter(filterFn).map(mapFn) : []; | ||
| 1962 | return array.length > 0 ? array[0] : void 0; | ||
| 1963 | }, | ||
| 1964 | // Setting the plain property is not supported, as it would be ambiguous. | ||
| 1965 | set: function () { | ||
| 1966 | console.trace(`Attempting to SET an array-wrapped property '${plainProperty}'. Use the array property '${arrayProperty}' instead.`); | ||
| 1967 | }, | ||
| 1968 | // Exclude the property from JSON serialization and from being listed in for...in loops. | ||
| 1969 | enumerable: false, | ||
| 1970 | // Make the property non-configurable to prevent deletion or redefinition. | ||
| 1971 | configurable: false, | ||
| 1919 | }); | 1972 | }); |
| 1920 | image.attr('src', mes.extra?.image); | 1973 | } |
| 1921 | image.attr('title', mes.extra?.title || mes.title || ''); | ||
| 1922 | container.addClass('img_extra'); | ||
| 1923 | image.toggleClass('img_inline', isInline); | ||
| 1924 | text.toggleClass('displayNone', !isInline); | ||
| 1925 | |||
| 1926 | const imageSwipes = mes.extra.image_swipes; | ||
| 1927 | if (Array.isArray(imageSwipes) && imageSwipes.length > 0) { | ||
| 1928 | container.addClass('img_swipes'); | ||
| 1929 | const counter = container.find('.mes_img_swipe_counter'); | ||
| 1930 | const currentImage = imageSwipes.indexOf(mes.extra.image) + 1; | ||
| 1931 | counter.text(`${currentImage}/${imageSwipes.length}`); | ||
| 1932 | |||
| 1933 | const swipeLeft = container.find('.mes_img_swipe_left'); | ||
| 1934 | swipeLeft.off('click').on('click', function () { | ||
| 1935 | eventSource.emit(event_types.IMAGE_SWIPED, { message: mes, element: messageElement, direction: 'left' }); | ||
| 1936 | }); | ||
| 1937 | 1974 | ||
| 1938 | const swipeRight = container.find('.mes_img_swipe_right'); | 1975 | /** |
| 1939 | swipeRight.off('click').on('click', function () { | 1976 | * Migrates image swipes from a single image property to an array. |
| 1940 | eventSource.emit(event_types.IMAGE_SWIPED, { message: mes, element: messageElement, direction: 'right' }); | 1977 | * @param {ChatMessageExtra} obj |
| 1941 | }); | 1978 | */ |
| 1979 | function migrateMediaToArray(obj) { | ||
| 1980 | if (isPlainObjectProperty(obj, 'file')) { | ||
| 1981 | if (!Array.isArray(obj.files)) { | ||
| 1982 | obj.files = []; | ||
| 1983 | } | ||
| 1984 | const fileValue = obj.file; | ||
| 1985 | delete obj.file; | ||
| 1986 | if (fileValue) { | ||
| 1987 | obj.files.push(fileValue); | ||
| 1988 | } | ||
| 1942 | } | 1989 | } |
| 1943 | } else { | 1990 | |
| 1944 | const container = messageElement.find('.mes_img_container'); | 1991 | if (Array.isArray(obj.image_swipes)) { |
| 1945 | container.removeClass('img_extra img_swipes'); | 1992 | if (!Array.isArray(obj.media)) { |
| 1946 | const text = messageElement.find('.mes_text'); | 1993 | obj.media = []; |
| 1947 | text.removeClass('displayNone'); | ||
| 1948 | } | ||
| 1949 | |||
| 1950 | // Add video to message | ||
| 1951 | if (mes.extra?.video) { | ||
| 1952 | const container = $('#message_video_template .mes_video_container').clone(); | ||
| 1953 | messageElement.find('.mes_video_container').remove(); | ||
| 1954 | messageElement.find('.mes_block').append(container); | ||
| 1955 | const chatHeight = chatElement.prop('scrollHeight'); | ||
| 1956 | const video = container.find('.mes_video'); | ||
| 1957 | video.off('loadedmetadata').on('loadedmetadata', function () { | ||
| 1958 | if (!adjustScroll) { | ||
| 1959 | return; | ||
| 1960 | } | 1994 | } |
| 1961 | const scrollPosition = chatElement.scrollTop(); | 1995 | for (const swipe of obj.image_swipes) { |
| 1962 | const newChatHeight = chatElement.prop('scrollHeight'); | 1996 | if (swipe && typeof swipe === 'string') { |
| 1963 | const diff = newChatHeight - chatHeight; | 1997 | obj.media_display = MEDIA_DISPLAY.GALLERY; |
| 1964 | chatElement.scrollTop(scrollPosition + diff); | 1998 | obj.media.push({ type: MEDIA_TYPE.IMAGE, url: swipe }); |
| 1965 | }); | 1999 | } |
| 2000 | } | ||
| 2001 | delete obj.image_swipes; | ||
| 2002 | } | ||
| 1966 | 2003 | ||
| 1967 | video.attr('src', mes.extra?.video); | 2004 | if (isPlainObjectProperty(obj, 'image')) { |
| 1968 | } else { | 2005 | if (!Array.isArray(obj.media)) { |
| 1969 | messageElement.find('.mes_video_container').remove(); | 2006 | obj.media = []; |
| 1970 | } | 2007 | } |
| 1971 | 2008 | const imageValue = obj.image; | |
| 1972 | // Add file to message | 2009 | delete obj.image; |
| 1973 | if (mes.extra?.file) { | 2010 | if (imageValue && typeof imageValue === 'string') { |
| 1974 | messageElement.find('.mes_file_container').remove(); | 2011 | obj.media.push({ type: MEDIA_TYPE.IMAGE, url: imageValue }); |
| 1975 | const messageId = messageElement.attr('mesid'); | 2012 | } |
| 1976 | const template = $('#message_file_template .mes_file_container').clone(); | 2013 | if (obj.media_display === MEDIA_DISPLAY.GALLERY) { |
| 1977 | template.find('.mes_file_name').text(mes.extra.file.name); | 2014 | const selectedIndex = obj.media.findIndex(t => t.url === imageValue); |
| 1978 | template.find('.mes_file_size').text(humanFileSize(mes.extra.file.size)); | 2015 | if (selectedIndex > -1) { |
| 1979 | template.find('.mes_file_download').attr('mesid', messageId); | 2016 | obj.media_index = selectedIndex; |
| 1980 | template.find('.mes_file_delete').attr('mesid', messageId); | 2017 | } |
| 1981 | messageElement.find('.mes_block').append(template); | 2018 | } |
| 1982 | } else { | 2019 | obj.media = obj.media.filter((v, i, a) => i === a.findIndex(t => t.url === v.url)); |
| 1983 | messageElement.find('.mes_file_container').remove(); | 2020 | } |
| 2021 | |||
| 2022 | if (isPlainObjectProperty(obj, 'video')) { | ||
| 2023 | if (!Array.isArray(obj.media)) { | ||
| 2024 | obj.media = []; | ||
| 2025 | } | ||
| 2026 | const videoValue = obj.video; | ||
| 2027 | delete obj.video; | ||
| 2028 | if (videoValue && typeof videoValue === 'string') { | ||
| 2029 | obj.media.push({ type: MEDIA_TYPE.VIDEO, url: videoValue }); | ||
| 2030 | } | ||
| 2031 | } | ||
| 1984 | } | 2032 | } |
| 2033 | |||
| 2034 | if (!mes || !mes.extra || typeof mes.extra !== 'object') { | ||
| 2035 | return; | ||
| 2036 | } | ||
| 2037 | |||
| 2038 | migrateMediaToArray(mes.extra); | ||
| 2039 | addArrayAutoWrapper(mes.extra, 'file', 'files'); | ||
| 2040 | addArrayAutoWrapper(mes.extra, 'image', 'media', (t) => t.type === MEDIA_TYPE.IMAGE, (t) => t.url); | ||
| 2041 | addArrayAutoWrapper(mes.extra, 'video', 'media', (t) => t.type === MEDIA_TYPE.VIDEO, (t) => t.url); | ||
| 2042 | } | ||
| 2043 | |||
| 2044 | /** | ||
| 2045 | * Gets the media display setting for a message. | ||
| 2046 | * @param {ChatMessage} mes Message object | ||
| 2047 | * @returns {MEDIA_DISPLAY} Media display setting | ||
| 2048 | */ | ||
| 2049 | export function getMediaDisplay(mes) { | ||
| 2050 | const value = mes?.extra?.media_display || power_user.media_display || MEDIA_DISPLAY.LIST; | ||
| 2051 | return Object.values(MEDIA_DISPLAY).includes(value) ? value : MEDIA_DISPLAY.LIST; | ||
| 1985 | } | 2052 | } |
| 1986 | 2053 | ||
| 1987 | /** | 2054 | /** |
| 1988 | * @deprecated Use appendMediaToMessage instead. | 2055 | * Gets the media index for a message. |
| 2056 | * @param {ChatMessage} mes Message object | ||
| 2057 | * @returns {number} Media index | ||
| 1989 | */ | 2058 | */ |
| 1990 | export function appendImageToMessage(mes, messageElement) { | 2059 | export function getMediaIndex(mes) { |
| 1991 | appendMediaToMessage(mes, messageElement); | 2060 | if (!Array.isArray(mes?.extra?.media)) { |
| 2061 | return 0; | ||
| 2062 | } | ||
| 2063 | const value = mes.extra?.media_index; | ||
| 2064 | if (isNaN(value) || value < 0 || value >= mes.extra.media.length) { | ||
| 2065 | return 0; | ||
| 2066 | } | ||
| 2067 | return value; | ||
| 2068 | } | ||
| 2069 | |||
| 2070 | /** | ||
| 2071 | * Appends image or file to the message element. | ||
| 2072 | * @param {ChatMessage} mes Message object | ||
| 2073 | * @param {JQuery<HTMLElement>} messageElement Message element | ||
| 2074 | * @param {boolean} [adjustScroll=true] Whether to adjust the scroll position after appending the media | ||
| 2075 | */ | ||
| 2076 | export function appendMediaToMessage(mes, messageElement, adjustScroll = true) { | ||
| 2077 | ensureMessageMediaIsArray(mes); | ||
| 2078 | |||
| 2079 | const hasMedia = Array.isArray(mes?.extra?.media) && mes.extra.media.length > 0; | ||
| 2080 | const hasFiles = Array.isArray(mes?.extra?.files) && mes.extra.files.length > 0; | ||
| 2081 | const mediaDisplay = getMediaDisplay(mes); | ||
| 2082 | const hideMessageText = hasMedia && mes?.extra?.inline_image === false; | ||
| 2083 | |||
| 2084 | const mediaBlocks = []; | ||
| 2085 | const mediaPromises = []; | ||
| 2086 | |||
| 2087 | const chatHeight = adjustScroll && (hasMedia || hasFiles) ? chatElement.prop('scrollHeight') : 0; | ||
| 2088 | const scrollPosition = chatElement.scrollTop(); | ||
| 2089 | const doAdjustScroll = () => { | ||
| 2090 | if (!adjustScroll) { | ||
| 2091 | chatElement.scrollTop(scrollPosition); | ||
| 2092 | return; | ||
| 2093 | } | ||
| 2094 | const newChatHeight = chatElement.prop('scrollHeight'); | ||
| 2095 | const diff = newChatHeight - chatHeight; | ||
| 2096 | chatElement.scrollTop(scrollPosition + diff); | ||
| 2097 | }; | ||
| 2098 | |||
| 2099 | // Set media display attribute | ||
| 2100 | messageElement.attr('data-media-display', mediaDisplay); | ||
| 2101 | // Toggle text visibility | ||
| 2102 | messageElement.find('.mes_text').toggleClass('displayNone', hideMessageText); | ||
| 2103 | |||
| 2104 | /** | ||
| 2105 | * Appends a single image attachment to the message element. | ||
| 2106 | * @param {MediaAttachment} attachment Image attachment object | ||
| 2107 | * @param {number} index Index of the image attachment | ||
| 2108 | * @returns {JQuery<HTMLElement>} The appended image container element | ||
| 2109 | */ | ||
| 2110 | function appendImageAttachment(attachment, index) { | ||
| 2111 | const template = $('#message_image_template .mes_img_container').clone(); | ||
| 2112 | template.attr('data-index', index); | ||
| 2113 | |||
| 2114 | const image = template.find('.mes_img'); | ||
| 2115 | image.attr('src', attachment.url); | ||
| 2116 | image.attr('title', attachment.title || mes.extra.title || ''); | ||
| 2117 | mediaPromises.push(new Promise((resolve) => { | ||
| 2118 | function onLoad() { | ||
| 2119 | image.removeAttr('alt'); | ||
| 2120 | image.removeClass('error'); | ||
| 2121 | resolve(); | ||
| 2122 | } | ||
| 2123 | function onError() { | ||
| 2124 | image.attr('alt', ''); | ||
| 2125 | image.addClass('error'); | ||
| 2126 | resolve(); | ||
| 2127 | } | ||
| 2128 | if (image.prop('complete')) { | ||
| 2129 | onLoad(); | ||
| 2130 | } else { | ||
| 2131 | image.off('load').on('load', onLoad); | ||
| 2132 | image.off('error').on('error', onError); | ||
| 2133 | } | ||
| 2134 | })); | ||
| 2135 | |||
| 2136 | mediaBlocks.push(template); | ||
| 2137 | return template; | ||
| 2138 | } | ||
| 2139 | |||
| 2140 | /** | ||
| 2141 | * Appends a single video attachment to the message element. | ||
| 2142 | * @param {MediaAttachment} attachment Video attachment object | ||
| 2143 | * @param {number} index Index of the video attachment | ||
| 2144 | * @returns {JQuery<HTMLElement>} The appended video container element | ||
| 2145 | */ | ||
| 2146 | function appendVideoAttachment(attachment, index) { | ||
| 2147 | const template = $('#message_video_template .mes_video_container').clone(); | ||
| 2148 | template.attr('data-index', index); | ||
| 2149 | |||
| 2150 | const video = template.find('.mes_video'); | ||
| 2151 | video.attr('src', attachment.url); | ||
| 2152 | video.attr('title', attachment.title || mes.extra.title || ''); | ||
| 2153 | mediaPromises.push(new Promise((resolve) => { | ||
| 2154 | function onLoad() { | ||
| 2155 | resolve(); | ||
| 2156 | } | ||
| 2157 | function onError() { | ||
| 2158 | video.addClass('error'); | ||
| 2159 | resolve(); | ||
| 2160 | } | ||
| 2161 | if (video.prop('readyState') >= HTMLMediaElement.HAVE_CURRENT_DATA) { | ||
| 2162 | onLoad(); | ||
| 2163 | } else { | ||
| 2164 | video.off('loadeddata').on('loadeddata', onLoad); | ||
| 2165 | video.off('error').on('error', onError); | ||
| 2166 | } | ||
| 2167 | })); | ||
| 2168 | |||
| 2169 | mediaBlocks.push(template); | ||
| 2170 | return template; | ||
| 2171 | } | ||
| 2172 | |||
| 2173 | /** | ||
| 2174 | * Appends a media attachment to the message element. | ||
| 2175 | * @param {MediaAttachment} attachment Media attachment object | ||
| 2176 | * @param {number} index Index of the media attachment | ||
| 2177 | * @returns {JQuery<HTMLElement>} The appended media container element | ||
| 2178 | */ | ||
| 2179 | function appendMediaAttachment(attachment, index) { | ||
| 2180 | if (!attachment.type) { | ||
| 2181 | attachment.type = MEDIA_TYPE.IMAGE; | ||
| 2182 | } | ||
| 2183 | switch (attachment.type) { | ||
| 2184 | case MEDIA_TYPE.IMAGE: | ||
| 2185 | return appendImageAttachment(attachment, index); | ||
| 2186 | case MEDIA_TYPE.VIDEO: | ||
| 2187 | return appendVideoAttachment(attachment, index); | ||
| 2188 | } | ||
| 2189 | |||
| 2190 | console.warn(`Unknown media type: ${attachment.type}, defaulting to image.`, attachment); | ||
| 2191 | return appendImageAttachment(attachment, index); | ||
| 2192 | } | ||
| 2193 | |||
| 2194 | // Add media gallery to message | ||
| 2195 | if (hasMedia && mediaDisplay === MEDIA_DISPLAY.GALLERY) { | ||
| 2196 | const mediaIndex = getMediaIndex(mes); | ||
| 2197 | const selectedMedia = mes.extra.media[mediaIndex]; | ||
| 2198 | |||
| 2199 | const galleryControls = $('#message_gallery_controls .mes_img_swipes').clone(); | ||
| 2200 | const counter = galleryControls.find('.mes_img_swipe_counter'); | ||
| 2201 | counter.text(`${mediaIndex + 1}/${mes.extra.media.length}`); | ||
| 2202 | |||
| 2203 | const template = appendMediaAttachment(selectedMedia, mediaIndex); | ||
| 2204 | template.addClass('img_swipes'); | ||
| 2205 | template.append(galleryControls); | ||
| 2206 | } | ||
| 2207 | |||
| 2208 | // Add media as a list to message | ||
| 2209 | if (hasMedia && mediaDisplay === MEDIA_DISPLAY.LIST) { | ||
| 2210 | for (let index = 0; index < mes.extra.media.length; index++) { | ||
| 2211 | const attachment = mes.extra.media[index]; | ||
| 2212 | appendMediaAttachment(attachment, index); | ||
| 2213 | } | ||
| 2214 | } | ||
| 2215 | |||
| 2216 | // Remove existing file containers | ||
| 2217 | messageElement.find('.mes_file_wrapper').empty(); | ||
| 2218 | |||
| 2219 | // Add files to message | ||
| 2220 | if (hasFiles) { | ||
| 2221 | for (let index = 0; index < mes.extra.files.length; index++) { | ||
| 2222 | const file = mes.extra.files[index]; | ||
| 2223 | const template = $('#message_file_template .mes_file_container').clone(); | ||
| 2224 | template.attr('data-index', index); | ||
| 2225 | template.find('.mes_file_name').text(file.name).attr('title', file.name); | ||
| 2226 | template.find('.mes_file_size').text(humanFileSize(file.size)).attr('title', file.size); | ||
| 2227 | messageElement.find('.mes_file_wrapper').append(template); | ||
| 2228 | } | ||
| 2229 | } | ||
| 2230 | |||
| 2231 | // TODO: Consider making this awaitable | ||
| 2232 | Promise.race([Promise.all(mediaPromises), delay(debounce_timeout.short)]).then(() => { | ||
| 2233 | messageElement.find('.mes_media_wrapper').empty().append(mediaBlocks); | ||
| 2234 | doAdjustScroll(); | ||
| 2235 | }); | ||
| 1992 | } | 2236 | } |
| 1993 | 2237 | ||
| 1994 | export function addCopyToCodeBlocks(messageElement) { | 2238 | export function addCopyToCodeBlocks(messageElement) { |
| @@ -2013,7 +2257,7 @@ export function addCopyToCodeBlocks(messageElement) { | |||
| 2013 | 2257 | ||
| 2014 | /** | 2258 | /** |
| 2015 | * Adds a single message to the chat. | 2259 | * Adds a single message to the chat. |
| 2016 | * @param {object} mes Message object | 2260 | * @param {ChatMessage} mes Message object |
| 2017 | * @param {object} [options] Options | 2261 | * @param {object} [options] Options |
| 2018 | * @param {string} [options.type='normal'] Message type | 2262 | * @param {string} [options.type='normal'] Message type |
| 2019 | * @param {number} [options.insertAfter=null] Message ID to insert the new message after | 2263 | * @param {number} [options.insertAfter=null] Message ID to insert the new message after |
| @@ -2065,8 +2309,8 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll | |||
| 2065 | avatarImg = mes['force_avatar']; | 2309 | avatarImg = mes['force_avatar']; |
| 2066 | } | 2310 | } |
| 2067 | 2311 | ||
| 2068 | // if mes.uses_system_ui is true, set an override on the sanitizer options | 2312 | // if mes.extra.uses_system_ui is true, set an override on the sanitizer options |
| 2069 | const sanitizerOverrides = mes.uses_system_ui ? { MESSAGE_ALLOW_SYSTEM_UI: true } : {}; | 2313 | const sanitizerOverrides = mes.extra?.uses_system_ui ? { MESSAGE_ALLOW_SYSTEM_UI: true } : {}; |
| 2070 | 2314 | ||
| 2071 | messageText = messageFormatting( | 2315 | messageText = messageFormatting( |
| 2072 | messageText, | 2316 | messageText, |
| @@ -2215,8 +2459,8 @@ export function formatCharacterAvatar(characterAvatar) { | |||
| 2215 | 2459 | ||
| 2216 | /** | 2460 | /** |
| 2217 | * Formats the title for the generation timer. | 2461 | * Formats the title for the generation timer. |
| 2218 | * @param {Date} gen_started Date when generation was started | 2462 | * @param {MessageTimestamp} gen_started Date when generation was started |
| 2219 | * @param {Date} gen_finished Date when generation was finished | 2463 | * @param {MessageTimestamp} gen_finished Date when generation was finished |
| 2220 | * @param {number} tokenCount Number of tokens generated (0 if not available) | 2464 | * @param {number} tokenCount Number of tokens generated (0 if not available) |
| 2221 | * @param {number?} [reasoningDuration=null] Reasoning duration (null if no reasoning was done) | 2465 | * @param {number?} [reasoningDuration=null] Reasoning duration (null if no reasoning was done) |
| 2222 | * @param {number?} [timeToFirstToken=null] Time to first token | 2466 | * @param {number?} [timeToFirstToken=null] Time to first token |
| @@ -3659,7 +3903,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro | |||
| 3659 | const prevStarted = chat[chat.length - 1]['gen_started']; | 3903 | const prevStarted = chat[chat.length - 1]['gen_started']; |
| 3660 | 3904 | ||
| 3661 | if (prevFinished && prevStarted) { | 3905 | if (prevFinished && prevStarted) { |
| 3662 | const timePassed = prevFinished - prevStarted; | 3906 | const timePassed = Number(prevFinished) - Number(prevStarted); |
| 3663 | generation_started = new Date(Date.now() - timePassed); | 3907 | generation_started = new Date(Date.now() - timePassed); |
| 3664 | chat[chat.length - 1]['gen_started'] = generation_started; | 3908 | chat[chat.length - 1]['gen_started'] = generation_started; |
| 3665 | } | 3909 | } |
| @@ -3739,7 +3983,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro | |||
| 3739 | coreChat.pop(); | 3983 | coreChat.pop(); |
| 3740 | } | 3984 | } |
| 3741 | 3985 | ||
| 3742 | coreChat = await Promise.all(coreChat.map(async (chatItem, index) => { | 3986 | coreChat = await Promise.all(coreChat.map(async (/** @type {ChatMessage} */ chatItem, index) => { |
| 3743 | let message = chatItem.mes; | 3987 | let message = chatItem.mes; |
| 3744 | let regexType = chatItem.is_user ? regex_placement.USER_INPUT : regex_placement.AI_OUTPUT; | 3988 | let regexType = chatItem.is_user ? regex_placement.USER_INPUT : regex_placement.AI_OUTPUT; |
| 3745 | let options = { isPrompt: true, depth: (coreChat.length - index - (isContinue ? 2 : 1)) }; | 3989 | let options = { isPrompt: true, depth: (coreChat.length - index - (isContinue ? 2 : 1)) }; |
| @@ -3747,8 +3991,19 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro | |||
| 3747 | let regexedMessage = getRegexedString(message, regexType, options); | 3991 | let regexedMessage = getRegexedString(message, regexType, options); |
| 3748 | regexedMessage = await appendFileContent(chatItem, regexedMessage); | 3992 | regexedMessage = await appendFileContent(chatItem, regexedMessage); |
| 3749 | 3993 | ||
| 3994 | const titles = []; | ||
| 3750 | if (chatItem?.extra?.append_title && chatItem?.extra?.title) { | 3995 | if (chatItem?.extra?.append_title && chatItem?.extra?.title) { |
| 3751 | regexedMessage = `${regexedMessage}\n\n${chatItem.extra.title}`; | 3996 | titles.push(chatItem.extra.title); |
| 3997 | } | ||
| 3998 | if (Array.isArray(chatItem?.extra?.media)) { | ||
| 3999 | for (const mediaItem of chatItem.extra.media) { | ||
| 4000 | if (mediaItem?.title && mediaItem?.append_title) { | ||
| 4001 | titles.push(mediaItem.title); | ||
| 4002 | } | ||
| 4003 | } | ||
| 4004 | } | ||
| 4005 | if (titles.length > 0) { | ||
| 4006 | regexedMessage = `${regexedMessage}\n\n${titles.join('\n\n')}`; | ||
| 3752 | } | 4007 | } |
| 3753 | 4008 | ||
| 3754 | return { | 4009 | return { |
| @@ -6097,7 +6352,7 @@ export function syncSwipeToMes(messageId = null, swipeId = null) { | |||
| 6097 | /** | 6352 | /** |
| 6098 | * Saves the image to the message object. | 6353 | * Saves the image to the message object. |
| 6099 | * @param {ParsedImage} img Image object | 6354 | * @param {ParsedImage} img Image object |
| 6100 | * @param {object} mes Chat message object | 6355 | * @param {ChatMessage} mes Chat message object |
| 6101 | * @typedef {{ image?: string, title?: string, inline?: boolean }} ParsedImage | 6356 | * @typedef {{ image?: string, title?: string, inline?: boolean }} ParsedImage |
| 6102 | */ | 6357 | */ |
| 6103 | function saveImageToMessage(img, mes) { | 6358 | function saveImageToMessage(img, mes) { |
| @@ -6105,8 +6360,10 @@ function saveImageToMessage(img, mes) { | |||
| 6105 | if (!mes.extra || typeof mes.extra !== 'object') { | 6360 | if (!mes.extra || typeof mes.extra !== 'object') { |
| 6106 | mes.extra = {}; | 6361 | mes.extra = {}; |
| 6107 | } | 6362 | } |
| 6108 | mes.extra.image = img.image; | 6363 | if (!Array.isArray(mes.extra.media)) { |
| 6109 | mes.extra.title = img.title; | 6364 | mes.extra.media = []; |
| 6365 | } | ||
| 6366 | mes.extra.media.push({ url: img.image, type: MEDIA_TYPE.IMAGE, title: img.title }); | ||
| 6110 | mes.extra.inline_image = img.inline; | 6367 | mes.extra.inline_image = img.inline; |
| 6111 | } | 6368 | } |
| 6112 | } | 6369 | } |
| @@ -6732,6 +6989,7 @@ export async function getChat() { | |||
| 6732 | chat_metadata = chat[0]['chat_metadata'] ?? {}; | 6989 | chat_metadata = chat[0]['chat_metadata'] ?? {}; |
| 6733 | 6990 | ||
| 6734 | chat.shift(); | 6991 | chat.shift(); |
| 6992 | chat.forEach(ensureMessageMediaIsArray); | ||
| 6735 | } else { | 6993 | } else { |
| 6736 | chat_create_date = humanizedDateTime(); | 6994 | chat_create_date = humanizedDateTime(); |
| 6737 | } | 6995 | } |
| @@ -8961,17 +9219,17 @@ export async function swipe(_event, direction, { source, repeated, message = cha | |||
| 8961 | //Update the swipe_id. | 9219 | //Update the swipe_id. |
| 8962 | chat[mesId]['swipe_id'] = newSwipeId; | 9220 | chat[mesId]['swipe_id'] = newSwipeId; |
| 8963 | 9221 | ||
| 8964 | if (chat[mesId].extra) { | 9222 | if (chat[mesId].extra && typeof chat[mesId].extra === 'object') { |
| 8965 | // if message has memory attached - remove it to allow regen | ||
| 8966 | delete chat[mesId].extra.memory; | 9223 | delete chat[mesId].extra.memory; |
| 8967 | |||
| 8968 | // ditto for display text | ||
| 8969 | delete chat[mesId].extra.display_text; | 9224 | delete chat[mesId].extra.display_text; |
| 8970 | 9225 | delete chat[mesId].extra.media; | |
| 8971 | delete chat[mesId].extra.image; | ||
| 8972 | delete chat[mesId].extra.image_swipes; | ||
| 8973 | delete chat[mesId].extra.video; | ||
| 8974 | delete chat[mesId].extra.inline_image; | 9226 | delete chat[mesId].extra.inline_image; |
| 9227 | delete chat[mesId].extra.files; | ||
| 9228 | delete chat[mesId].extra.fileLength; | ||
| 9229 | delete chat[mesId].extra.generationType; | ||
| 9230 | delete chat[mesId].extra.negative; | ||
| 9231 | delete chat[mesId].extra.title; | ||
| 9232 | delete chat[mesId].extra.append_title; | ||
| 8975 | } | 9233 | } |
| 8976 | delete chat[mesId].gen_started; | 9234 | delete chat[mesId].gen_started; |
| 8977 | delete chat[mesId].gen_finished; | 9235 | delete chat[mesId].gen_finished; |
| @@ -8979,7 +9237,6 @@ export async function swipe(_event, direction, { source, repeated, message = cha | |||
| 8979 | syncSwipeToMes(mesId, chat[mesId]['swipe_id']); | 9237 | syncSwipeToMes(mesId, chat[mesId]['swipe_id']); |
| 8980 | } | 9238 | } |
| 8981 | 9239 | ||
| 8982 | //Deepseek-V3.1 | ||
| 8983 | // Helper function to convert transition to promise | 9240 | // Helper function to convert transition to promise |
| 8984 | const transitionPromise = (element, properties) => { | 9241 | const transitionPromise = (element, properties) => { |
| 8985 | return new Promise((resolve) => { | 9242 | return new Promise((resolve) => { |
| @@ -9101,7 +9358,7 @@ export async function swipe(_event, direction, { source, repeated, message = cha | |||
| 9101 | if (run_generate && !is_send_press) { | 9358 | if (run_generate && !is_send_press) { |
| 9102 | is_send_press = true; | 9359 | is_send_press = true; |
| 9103 | generation = Generate('swipe'); | 9360 | generation = Generate('swipe'); |
| 9104 | } else if (parseInt(chat[mesId]['swipe_id']) !== chat[mesId]['swipes'].length) { | 9361 | } else if (Number(chat[mesId]['swipe_id']) !== chat[mesId]['swipes'].length) { |
| 9105 | saveChatDebounced(); | 9362 | saveChatDebounced(); |
| 9106 | } | 9363 | } |
| 9107 | 9364 | ||
| @@ -10745,7 +11002,7 @@ jQuery(async function () { | |||
| 10745 | const oldScroll = chatElement[0].scrollTop; | 11002 | const oldScroll = chatElement[0].scrollTop; |
| 10746 | const clone = structuredClone(chat[this_edit_mes_id]); | 11003 | const clone = structuredClone(chat[this_edit_mes_id]); |
| 10747 | clone.send_date = Date.now(); | 11004 | clone.send_date = Date.now(); |
| 10748 | clone.mes = $(this).closest('.mes').find('.edit_textarea').val(); | 11005 | clone.mes = $(this).closest('.mes').find('.edit_textarea').val().toString(); |
| 10749 | 11006 | ||
| 10750 | if (power_user.trim_spaces) { | 11007 | if (power_user.trim_spaces) { |
| 10751 | clone.mes = clone.mes.trim(); | 11008 | clone.mes = clone.mes.trim(); |
| @@ -344,6 +344,7 @@ export async function convertSoloToGroupChat() { | |||
| 344 | 344 | ||
| 345 | // Save group-chat marker | 345 | // Save group-chat marker |
| 346 | if (index == 0) { | 346 | if (index == 0) { |
| 347 | // @ts-ignore | ||
| 347 | message.is_group = true; | 348 | message.is_group = true; |
| 348 | } | 349 | } |
| 349 | 350 | ||
| @@ -26,6 +26,9 @@ import { | |||
| 26 | printMessages, | 26 | printMessages, |
| 27 | clearChat, | 27 | clearChat, |
| 28 | refreshSwipeButtons, | 28 | refreshSwipeButtons, |
| 29 | getMediaIndex, | ||
| 30 | getMediaDisplay, | ||
| 31 | chatElement, | ||
| 29 | } from '../script.js'; | 32 | } from '../script.js'; |
| 30 | import { selected_group } from './group-chats.js'; | 33 | import { selected_group } from './group-chats.js'; |
| 31 | import { power_user } from './power-user.js'; | 34 | import { power_user } from './power-user.js'; |
| @@ -43,6 +46,8 @@ import { | |||
| 43 | getFileText, | 46 | getFileText, |
| 44 | getFileExtension, | 47 | getFileExtension, |
| 45 | convertTextToBase64, | 48 | convertTextToBase64, |
| 49 | isSameFile, | ||
| 50 | clamp, | ||
| 46 | } from './utils.js'; | 51 | } from './utils.js'; |
| 47 | import { extension_settings, renderExtensionTemplateAsync, saveMetadataDebounced } from './extensions.js'; | 52 | import { extension_settings, renderExtensionTemplateAsync, saveMetadataDebounced } from './extensions.js'; |
| 48 | import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; | 53 | import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; |
| @@ -52,6 +57,7 @@ import { renderTemplateAsync } from './templates.js'; | |||
| 52 | import { t } from './i18n.js'; | 57 | import { t } from './i18n.js'; |
| 53 | import { humanizedDateTime } from './RossAscends-mods.js'; | 58 | import { humanizedDateTime } from './RossAscends-mods.js'; |
| 54 | import { accountStorage } from './util/AccountStorage.js'; | 59 | import { accountStorage } from './util/AccountStorage.js'; |
| 60 | import { MEDIA_DISPLAY, MEDIA_TYPE, SWIPE_DIRECTION } from './constants.js'; | ||
| 55 | 61 | ||
| 56 | /** | 62 | /** |
| 57 | * @typedef {Object} FileAttachment | 63 | * @typedef {Object} FileAttachment |
| @@ -187,62 +193,64 @@ export async function unhideChatMessage(messageId, _messageBlock) { | |||
| 187 | 193 | ||
| 188 | /** | 194 | /** |
| 189 | * Adds a file attachment to the message. | 195 | * Adds a file attachment to the message. |
| 190 | * @param {object} message Message object | 196 | * @param {ChatMessage} message Message object |
| 191 | * @returns {Promise<void>} A promise that resolves when file is uploaded. | 197 | * @returns {Promise<void>} A promise that resolves when file is uploaded. |
| 192 | */ | 198 | */ |
| 193 | export async function populateFileAttachment(message, inputId = 'file_form_input') { | 199 | export async function populateFileAttachment(message, inputId = 'file_form_input') { |
| 194 | try { | 200 | try { |
| 195 | if (!message) return; | 201 | if (!message) return; |
| 196 | if (!message.extra) message.extra = {}; | 202 | if (!message.extra || typeof message.extra !== 'object') message.extra = {}; |
| 197 | const fileInput = document.getElementById(inputId); | 203 | const fileInput = document.getElementById(inputId); |
| 198 | if (!(fileInput instanceof HTMLInputElement)) return; | 204 | if (!(fileInput instanceof HTMLInputElement)) return; |
| 199 | const file = fileInput.files[0]; | 205 | |
| 200 | if (!file) return; | 206 | for (const file of fileInput.files) { |
| 201 | 207 | const slug = getStringHash(file.name); | |
| 202 | const slug = getStringHash(file.name); | 208 | const fileNamePrefix = `${Date.now()}_${slug}`; |
| 203 | const fileNamePrefix = `${Date.now()}_${slug}`; | 209 | const fileBase64 = await getBase64Async(file); |
| 204 | const fileBase64 = await getBase64Async(file); | 210 | let base64Data = fileBase64.split(',')[1]; |
| 205 | let base64Data = fileBase64.split(',')[1]; | 211 | const extension = getFileExtension(file); |
| 206 | const extension = getFileExtension(file); | 212 | |
| 207 | 213 | const mediaType = MEDIA_TYPE.getFromMime(file.type); | |
| 208 | // If file is image | 214 | if (mediaType) { |
| 209 | if (file.type.startsWith('image/')) { | 215 | const imageUrl = await saveBase64AsFile(base64Data, name2, fileNamePrefix, extension); |
| 210 | const imageUrl = await saveBase64AsFile(base64Data, name2, fileNamePrefix, extension); | 216 | if (!Array.isArray(message.extra.media)) { |
| 211 | message.extra.image = imageUrl; | 217 | message.extra.media = []; |
| 212 | message.extra.inline_image = true; | 218 | } |
| 213 | } | 219 | message.extra.media.push({ url: imageUrl, type: mediaType }); |
| 214 | // If file is video | 220 | message.extra.media_index = message.extra.media.length - 1; |
| 215 | else if (file.type.startsWith('video/')) { | 221 | message.extra.inline_image = true; |
| 216 | const videoUrl = await saveBase64AsFile(base64Data, name2, fileNamePrefix, extension); | 222 | } else { |
| 217 | message.extra.video = videoUrl; | 223 | const uniqueFileName = `${fileNamePrefix}.txt`; |
| 218 | } else { | 224 | |
| 219 | const uniqueFileName = `${fileNamePrefix}.txt`; | 225 | if (isConvertible(file.type)) { |
| 220 | 226 | try { | |
| 221 | if (isConvertible(file.type)) { | 227 | const converter = getConverter(file.type); |
| 222 | try { | 228 | const fileText = await converter(file); |
| 223 | const converter = getConverter(file.type); | 229 | base64Data = convertTextToBase64(fileText); |
| 224 | const fileText = await converter(file); | 230 | } catch (error) { |
| 225 | base64Data = convertTextToBase64(fileText); | 231 | toastr.error(String(error), t`Could not convert file`); |
| 226 | } catch (error) { | 232 | console.error('Could not convert file', error); |
| 227 | toastr.error(String(error), t`Could not convert file`); | 233 | } |
| 228 | console.error('Could not convert file', error); | ||
| 229 | } | 234 | } |
| 230 | } | ||
| 231 | 235 | ||
| 232 | const fileUrl = await uploadFileAttachment(uniqueFileName, base64Data); | 236 | const fileUrl = await uploadFileAttachment(uniqueFileName, base64Data); |
| 233 | 237 | ||
| 234 | if (!fileUrl) { | 238 | if (!fileUrl) { |
| 235 | return; | 239 | continue; |
| 236 | } | 240 | } |
| 237 | 241 | ||
| 238 | message.extra.file = { | 242 | if (!Array.isArray(message.extra.files)) { |
| 239 | url: fileUrl, | 243 | message.extra.files = []; |
| 240 | size: file.size, | 244 | } |
| 241 | name: file.name, | ||
| 242 | created: Date.now(), | ||
| 243 | }; | ||
| 244 | } | ||
| 245 | 245 | ||
| 246 | message.extra.files.push({ | ||
| 247 | url: fileUrl, | ||
| 248 | size: file.size, | ||
| 249 | name: file.name, | ||
| 250 | created: Date.now(), | ||
| 251 | }); | ||
| 252 | } | ||
| 253 | } | ||
| 246 | } catch (error) { | 254 | } catch (error) { |
| 247 | console.error('Could not upload file', error); | 255 | console.error('Could not upload file', error); |
| 248 | toastr.error(t`Either the file is corrupted or its format is not supported.`, t`Could not upload the file`); | 256 | toastr.error(t`Either the file is corrupted or its format is not supported.`, t`Could not upload the file`); |
| @@ -314,16 +322,16 @@ export async function getFileAttachment(url) { | |||
| 314 | */ | 322 | */ |
| 315 | async function validateFile(file) { | 323 | async function validateFile(file) { |
| 316 | const fileText = await file.text(); | 324 | const fileText = await file.text(); |
| 317 | const isImage = file.type.startsWith('image/'); | 325 | const isMedia = file.type.startsWith('image/') || file.type.startsWith('video/'); |
| 318 | const isBinary = /^[\x00-\x08\x0E-\x1F\x7F-\xFF]*$/.test(fileText); | 326 | const isBinary = /^[\x00-\x08\x0E-\x1F\x7F-\xFF]*$/.test(fileText); |
| 319 | 327 | ||
| 320 | if (!isImage && file.size > fileSizeLimit) { | 328 | if (!isMedia && file.size > fileSizeLimit) { |
| 321 | toastr.error(t`File is too big. Maximum size is ${humanFileSize(fileSizeLimit)}.`); | 329 | toastr.error(t`File is too big. Maximum size is ${humanFileSize(fileSizeLimit)}.`); |
| 322 | return false; | 330 | return false; |
| 323 | } | 331 | } |
| 324 | 332 | ||
| 325 | // If file is binary | 333 | // If file is binary |
| 326 | if (isBinary && !isImage && !isConvertible(file.type)) { | 334 | if (isBinary && !isMedia && !isConvertible(file.type)) { |
| 327 | toastr.error(t`Binary files are not supported. Select a text file or image.`); | 335 | toastr.error(t`Binary files are not supported. Select a text file or image.`); |
| 328 | return false; | 336 | return false; |
| 329 | } | 337 | } |
| @@ -340,22 +348,28 @@ export function hasPendingFileAttachment() { | |||
| 340 | 348 | ||
| 341 | /** | 349 | /** |
| 342 | * Displays file information in the message sending form. | 350 | * Displays file information in the message sending form. |
| 343 | * @param {File} file File object | 351 | * @param {FileList} fileList File object |
| 344 | * @returns {Promise<void>} | 352 | * @returns {Promise<void>} |
| 345 | */ | 353 | */ |
| 346 | async function onFileAttach(file) { | 354 | async function onFileAttach(fileList) { |
| 347 | if (!file) return; | 355 | if (!fileList || fileList.length === 0) return; |
| 348 | 356 | ||
| 349 | const isValid = await validateFile(file); | 357 | for (const file of fileList) { |
| 358 | const isValid = await validateFile(file); | ||
| 350 | 359 | ||
| 351 | // If file is binary | 360 | // If file is binary |
| 352 | if (!isValid) { | 361 | if (!isValid) { |
| 353 | $('#file_form').trigger('reset'); | 362 | toastr.warning(t`File ${file.name} is not supported.`); |
| 354 | return; | 363 | $('#file_form').trigger('reset'); |
| 364 | return; | ||
| 365 | } | ||
| 355 | } | 366 | } |
| 356 | 367 | ||
| 357 | $('#file_form .file_name').text(file.name); | 368 | const name = fileList.length === 1 ? fileList[0].name : t`${fileList.length} files selected`; |
| 358 | $('#file_form .file_size').text(humanFileSize(file.size)); | 369 | const size = [...fileList].reduce((acc, file) => acc + file.size, 0); |
| 370 | const title = [...fileList].map(x => x.name).join('\n'); | ||
| 371 | $('#file_form .file_name').text(name).attr('title', title); | ||
| 372 | $('#file_form .file_size').text(humanFileSize(size)).attr('title', size); | ||
| 359 | $('#file_form').removeClass('displayNone'); | 373 | $('#file_form').removeClass('displayNone'); |
| 360 | 374 | ||
| 361 | // Reset form on chat change (if not on a welcome screen) | 375 | // Reset form on chat change (if not on a welcome screen) |
| @@ -368,10 +382,17 @@ async function onFileAttach(file) { | |||
| 368 | } | 382 | } |
| 369 | 383 | ||
| 370 | /** | 384 | /** |
| 371 | * Deletes file from message. | 385 | * Deletes file from a message. |
| 386 | * @param {JQuery<HTMLElement>} messageBlock Message block element | ||
| 372 | * @param {number} messageId Message ID | 387 | * @param {number} messageId Message ID |
| 388 | * @param {number} fileIndex File index | ||
| 373 | */ | 389 | */ |
| 374 | async function deleteMessageFile(messageId) { | 390 | async function deleteMessageFile(messageBlock, messageId, fileIndex) { |
| 391 | if (isNaN(messageId) || isNaN(fileIndex)) { | ||
| 392 | console.warn('Invalid message ID or file index'); | ||
| 393 | return; | ||
| 394 | } | ||
| 395 | |||
| 375 | const confirm = await callGenericPopup('Are you sure you want to delete this file?', POPUP_TYPE.CONFIRM); | 396 | const confirm = await callGenericPopup('Are you sure you want to delete this file?', POPUP_TYPE.CONFIRM); |
| 376 | 397 | ||
| 377 | if (confirm !== POPUP_RESULT.AFFIRMATIVE) { | 398 | if (confirm !== POPUP_RESULT.AFFIRMATIVE) { |
| @@ -381,26 +402,49 @@ async function deleteMessageFile(messageId) { | |||
| 381 | 402 | ||
| 382 | const message = chat[messageId]; | 403 | const message = chat[messageId]; |
| 383 | 404 | ||
| 384 | if (!message?.extra?.file) { | 405 | if (!Array.isArray(message?.extra?.files)) { |
| 385 | console.debug('Message has no file'); | 406 | console.debug('Message has no files'); |
| 386 | return; | 407 | return; |
| 387 | } | 408 | } |
| 388 | 409 | ||
| 389 | const url = message.extra.file.url; | 410 | if (fileIndex < 0 || fileIndex >= message.extra.files.length) { |
| 411 | console.warn('Invalid file index for message'); | ||
| 412 | return; | ||
| 413 | } | ||
| 414 | |||
| 415 | const url = message.extra.files[fileIndex]?.url; | ||
| 416 | message.extra.files.splice(fileIndex, 1); | ||
| 390 | 417 | ||
| 391 | delete message.extra.file; | ||
| 392 | $(`.mes[mesid="${messageId}"] .mes_file_container`).remove(); | ||
| 393 | await saveChatConditional(); | 418 | await saveChatConditional(); |
| 394 | await deleteFileFromServer(url); | 419 | await deleteFileFromServer(url); |
| 395 | } | ||
| 396 | 420 | ||
| 421 | appendMediaToMessage(message, messageBlock, false); | ||
| 422 | } | ||
| 397 | 423 | ||
| 398 | /** | 424 | /** |
| 399 | * Opens file from message in a modal. | 425 | * Opens file from message in a modal. |
| 400 | * @param {number} messageId Message ID | 426 | * @param {number} messageId Message ID |
| 427 | * @param {number} fileIndex File index | ||
| 401 | */ | 428 | */ |
| 402 | async function viewMessageFile(messageId) { | 429 | async function viewMessageFile(messageId, fileIndex) { |
| 403 | const messageFile = chat[messageId]?.extra?.file; | 430 | if (isNaN(messageId) || isNaN(fileIndex)) { |
| 431 | console.warn('Invalid message ID or file index'); | ||
| 432 | return; | ||
| 433 | } | ||
| 434 | |||
| 435 | const message = chat[messageId]; | ||
| 436 | |||
| 437 | if (!Array.isArray(message?.extra?.files)) { | ||
| 438 | console.debug('Message has no files'); | ||
| 439 | return; | ||
| 440 | } | ||
| 441 | |||
| 442 | if (fileIndex < 0 || fileIndex >= message.extra.files.length) { | ||
| 443 | console.warn('Invalid file index for message'); | ||
| 444 | return; | ||
| 445 | } | ||
| 446 | |||
| 447 | const messageFile = message.extra.files[fileIndex]; | ||
| 404 | 448 | ||
| 405 | if (!messageFile) { | 449 | if (!messageFile) { |
| 406 | console.debug('Message has no file or it is empty'); | 450 | console.debug('Message has no file or it is empty'); |
| @@ -429,39 +473,51 @@ function embedMessageFile(messageId, messageBlock) { | |||
| 429 | .on('change', parseAndUploadEmbed) | 473 | .on('change', parseAndUploadEmbed) |
| 430 | .trigger('click'); | 474 | .trigger('click'); |
| 431 | 475 | ||
| 432 | async function parseAndUploadEmbed(e) { | 476 | async function parseAndUploadEmbed(/** @type {JQuery.ChangeEvent} */ e) { |
| 433 | const file = e.target.files[0]; | 477 | if (!(e.target instanceof HTMLInputElement)) return; |
| 434 | if (!file) return; | 478 | if (!e.target.files.length) return; |
| 435 | 479 | ||
| 436 | const isValid = await validateFile(file); | 480 | for (const file of e.target.files) { |
| 481 | const isValid = await validateFile(file); | ||
| 437 | 482 | ||
| 438 | if (!isValid) { | 483 | if (!isValid) { |
| 439 | $('#file_form').trigger('reset'); | 484 | toastr.warning(t`File ${file.name} is not supported.`); |
| 440 | return; | 485 | $('#file_form').trigger('reset'); |
| 486 | return; | ||
| 487 | } | ||
| 441 | } | 488 | } |
| 442 | 489 | ||
| 443 | await populateFileAttachment(message, 'embed_file_input'); | 490 | await populateFileAttachment(message, 'embed_file_input'); |
| 444 | await eventSource.emit(event_types.MESSAGE_FILE_EMBEDDED, messageId); | 491 | await eventSource.emit(event_types.MESSAGE_FILE_EMBEDDED, messageId); |
| 445 | appendMediaToMessage(message, messageBlock); | 492 | appendMediaToMessage(message, messageBlock, false); |
| 446 | await saveChatConditional(); | 493 | await saveChatConditional(); |
| 447 | } | 494 | } |
| 448 | } | 495 | } |
| 449 | 496 | ||
| 450 | /** | 497 | /** |
| 451 | * Appends file content to the message text. | 498 | * Appends file content to the message text. |
| 452 | * @param {object} message Message object | 499 | * @param {ChatMessage} message Message object |
| 453 | * @param {string} messageText Message text | 500 | * @param {string} messageText Message text |
| 454 | * @returns {Promise<string>} Message text with file content appended. | 501 | * @returns {Promise<string>} Message text with file content appended. |
| 455 | */ | 502 | */ |
| 456 | export async function appendFileContent(message, messageText) { | 503 | export async function appendFileContent(message, messageText) { |
| 457 | if (message.extra?.file) { | 504 | if (!message || !message.extra || typeof message.extra !== 'object') { |
| 458 | const fileText = message.extra.file.text || (await getFileAttachment(message.extra.file.url)); | 505 | return messageText; |
| 459 | 506 | } | |
| 460 | if (fileText) { | 507 | if (message.extra.fileLength >= 0) { |
| 461 | const fileWrapped = `${fileText}\n\n`; | 508 | delete message.extra.fileLength; |
| 462 | message.extra.fileLength = fileWrapped.length; | 509 | } |
| 463 | messageText = fileWrapped + messageText; | 510 | if (Array.isArray(message.extra?.files) && message.extra.files.length > 0) { |
| 511 | const fileTexts = []; | ||
| 512 | for (const file of message.extra.files) { | ||
| 513 | const fileText = file.text || (await getFileAttachment(file.url)); | ||
| 514 | if (fileText) { | ||
| 515 | fileTexts.push(fileText); | ||
| 516 | } | ||
| 464 | } | 517 | } |
| 518 | const mergedFileTexts = fileTexts.join('\n\n') + '\n\n'; | ||
| 519 | message.extra.fileLength = mergedFileTexts.length; | ||
| 520 | return mergedFileTexts + messageText; | ||
| 465 | } | 521 | } |
| 466 | return messageText; | 522 | return messageText; |
| 467 | } | 523 | } |
| @@ -807,62 +863,119 @@ export function isExternalMediaAllowed() { | |||
| 807 | return !power_user.forbid_external_media; | 863 | return !power_user.forbid_external_media; |
| 808 | } | 864 | } |
| 809 | 865 | ||
| 810 | function expandMessageImage(event) { | 866 | /** |
| 811 | const mesBlock = $(event.currentTarget).closest('.mes'); | 867 | * Expands the message media attachment. |
| 812 | const mesId = mesBlock.attr('mesid'); | 868 | * @param {number} messageId Message ID |
| 813 | const message = chat[mesId]; | 869 | * @param {number} mediaIndex Media index |
| 814 | const imgSrc = message?.extra?.image; | 870 | * @returns {HTMLElement} Enlarged media element |
| 815 | const title = message?.extra?.title; | 871 | */ |
| 872 | function expandMessageMedia(messageId, mediaIndex) { | ||
| 873 | if (isNaN(messageId) || isNaN(mediaIndex)) { | ||
| 874 | console.warn('Invalid message ID or media index'); | ||
| 875 | return; | ||
| 876 | } | ||
| 877 | |||
| 878 | /** @type {ChatMessage} */ | ||
| 879 | const message = chat[messageId]; | ||
| 816 | 880 | ||
| 817 | if (!imgSrc) { | 881 | if (!Array.isArray(message?.extra?.media) || message.extra.media.length === 0) { |
| 882 | console.warn('Message has no media to expand'); | ||
| 818 | return; | 883 | return; |
| 819 | } | 884 | } |
| 820 | 885 | ||
| 821 | const img = document.createElement('img'); | 886 | const mediaAttachment = message.extra.media[mediaIndex]; |
| 822 | img.classList.add('img_enlarged'); | 887 | const title = mediaAttachment.title || message.extra.title || ''; |
| 823 | img.src = imgSrc; | ||
| 824 | const imgHolder = document.createElement('div'); | ||
| 825 | imgHolder.classList.add('img_enlarged_holder'); | ||
| 826 | imgHolder.append(img); | ||
| 827 | const imgContainer = $('<div><pre><code class="img_enlarged_title"></code></pre></div>'); | ||
| 828 | imgContainer.prepend(imgHolder); | ||
| 829 | imgContainer.addClass('img_enlarged_container'); | ||
| 830 | 888 | ||
| 831 | const codeTitle = imgContainer.find('.img_enlarged_title'); | 889 | if (!mediaAttachment) { |
| 832 | codeTitle.addClass('txt').text(title); | 890 | return; |
| 833 | const titleEmpty = !title || title.trim().length === 0; | 891 | } |
| 834 | imgContainer.find('pre').toggle(!titleEmpty); | ||
| 835 | addCopyToCodeBlocks(imgContainer); | ||
| 836 | 892 | ||
| 837 | const popup = new Popup(imgContainer, POPUP_TYPE.DISPLAY, '', { large: true, transparent: true }); | 893 | /** |
| 894 | * Gets the media element based on its type. | ||
| 895 | * @returns {HTMLElement} Media element | ||
| 896 | */ | ||
| 897 | function getMediaElement() { | ||
| 898 | function getImageElement() { | ||
| 899 | const img = document.createElement('img'); | ||
| 900 | img.src = mediaAttachment.url; | ||
| 901 | img.classList.add('img_enlarged'); | ||
| 902 | return img; | ||
| 903 | } | ||
| 838 | 904 | ||
| 839 | popup.dlg.style.width = 'unset'; | 905 | function getVideoElement() { |
| 840 | popup.dlg.style.height = 'unset'; | 906 | const video = document.createElement('video'); |
| 907 | video.src = mediaAttachment.url; | ||
| 908 | video.classList.add('img_enlarged'); | ||
| 909 | video.controls = true; | ||
| 910 | video.autoplay = true; | ||
| 911 | return video; | ||
| 912 | } | ||
| 841 | 913 | ||
| 842 | img.addEventListener('click', event => { | 914 | switch (mediaAttachment.type) { |
| 843 | const shouldZoom = !img.classList.contains('zoomed'); | 915 | case MEDIA_TYPE.IMAGE: |
| 844 | img.classList.toggle('zoomed', shouldZoom); | 916 | return getImageElement(); |
| 845 | event.stopPropagation(); | 917 | case MEDIA_TYPE.VIDEO: |
| 846 | }); | 918 | return getVideoElement(); |
| 847 | codeTitle[0]?.addEventListener('click', event => { | 919 | } |
| 920 | |||
| 921 | console.warn('Unsupported media type for enlargement:', mediaAttachment.type); | ||
| 922 | return getImageElement(); | ||
| 923 | } | ||
| 924 | |||
| 925 | const mediaElement = getMediaElement(); | ||
| 926 | const mediaHolder = document.createElement('div'); | ||
| 927 | mediaHolder.classList.add('img_enlarged_holder'); | ||
| 928 | mediaHolder.append(mediaElement); | ||
| 929 | const mediaContainer = document.createElement('div'); | ||
| 930 | mediaContainer.classList.add('img_enlarged_container'); | ||
| 931 | mediaContainer.append(mediaHolder); | ||
| 932 | |||
| 933 | mediaElement.addEventListener('click', event => { | ||
| 934 | const shouldZoom = !mediaElement.classList.contains('zoomed') && mediaElement.nodeName === 'IMG'; | ||
| 935 | mediaElement.classList.toggle('zoomed', shouldZoom); | ||
| 848 | event.stopPropagation(); | 936 | event.stopPropagation(); |
| 849 | }); | 937 | }); |
| 850 | 938 | ||
| 851 | popup.dlg.addEventListener('click', event => { | 939 | if (title.trim().length > 0) { |
| 940 | const mediaTitlePre = document.createElement('pre'); | ||
| 941 | const mediaTitleCode = document.createElement('code'); | ||
| 942 | mediaTitleCode.classList.add('img_enlarged_title', 'txt'); | ||
| 943 | mediaTitleCode.textContent = title; | ||
| 944 | mediaTitlePre.append(mediaTitleCode); | ||
| 945 | mediaTitleCode.addEventListener('click', event => { | ||
| 946 | event.stopPropagation(); | ||
| 947 | }); | ||
| 948 | mediaContainer.append(mediaTitlePre); | ||
| 949 | addCopyToCodeBlocks(mediaContainer); | ||
| 950 | } | ||
| 951 | |||
| 952 | const popup = new Popup(mediaContainer, POPUP_TYPE.DISPLAY, '', { large: true, transparent: true }); | ||
| 953 | |||
| 954 | popup.dlg.style.width = 'unset'; | ||
| 955 | popup.dlg.style.height = 'unset'; | ||
| 956 | popup.dlg.addEventListener('click', () => { | ||
| 852 | popup.completeCancelled(); | 957 | popup.completeCancelled(); |
| 853 | }); | 958 | }); |
| 854 | 959 | ||
| 855 | popup.show(); | 960 | popup.show(); |
| 856 | return img; | 961 | return mediaElement; |
| 857 | } | 962 | } |
| 858 | 963 | ||
| 859 | function expandAndZoomMessageImage(event) { | 964 | /** |
| 860 | expandMessageImage(event).click(); | 965 | * Deletes an image from a message. |
| 861 | } | 966 | * @param {number} messageId Message ID |
| 967 | * @param {number} mediaIndex Image index | ||
| 968 | * @param {JQuery<HTMLElement>} messageBlock Message block element | ||
| 969 | */ | ||
| 970 | async function deleteMessageMedia(messageId, mediaIndex, messageBlock) { | ||
| 971 | if (isNaN(messageId) || isNaN(mediaIndex)) { | ||
| 972 | console.warn('Invalid message ID or media index'); | ||
| 973 | return; | ||
| 974 | } | ||
| 862 | 975 | ||
| 863 | async function deleteMessageImage() { | 976 | const value = await Popup.show.confirm(t`Delete media from message?`, t`This action can't be undone.`, { |
| 864 | const value = await callGenericPopup('<h3>Delete image from message?<br>This action can\'t be undone.</h3>', POPUP_TYPE.TEXT, '', { | ||
| 865 | okButton: t`Delete one`, | 977 | okButton: t`Delete one`, |
| 978 | cancelButton: false, | ||
| 866 | customButtons: [ | 979 | customButtons: [ |
| 867 | { | 980 | { |
| 868 | text: t`Delete all`, | 981 | text: t`Delete all`, |
| @@ -881,58 +994,64 @@ async function deleteMessageImage() { | |||
| 881 | return; | 994 | return; |
| 882 | } | 995 | } |
| 883 | 996 | ||
| 884 | const mesBlock = $(this).closest('.mes'); | 997 | /** @type {ChatMessage} */ |
| 885 | const mesId = mesBlock.attr('mesid'); | 998 | const message = chat[messageId]; |
| 886 | const message = chat[mesId]; | ||
| 887 | 999 | ||
| 888 | let isLastImage = true; | 1000 | if (!Array.isArray(message?.extra?.media)) { |
| 1001 | console.debug('Message has no media'); | ||
| 1002 | return; | ||
| 1003 | } | ||
| 889 | 1004 | ||
| 890 | if (Array.isArray(message.extra.image_swipes)) { | 1005 | if (mediaIndex < 0 || mediaIndex >= message.extra.media.length) { |
| 891 | const indexOf = message.extra.image_swipes.indexOf(message.extra.image); | 1006 | console.warn('Invalid media index for message'); |
| 892 | if (indexOf > -1) { | 1007 | return; |
| 893 | message.extra.image_swipes.splice(indexOf, 1); | 1008 | } |
| 894 | isLastImage = message.extra.image_swipes.length === 0; | 1009 | |
| 895 | if (!isLastImage) { | 1010 | message.extra.media.splice(mediaIndex, 1); |
| 896 | const newIndex = Math.min(indexOf, message.extra.image_swipes.length - 1); | 1011 | |
| 897 | message.extra.image = message.extra.image_swipes[newIndex]; | 1012 | if (message.extra.media_index === mediaIndex) { |
| 898 | } | 1013 | const newIndex = mediaIndex > 0 ? mediaIndex - 1 : 0; |
| 899 | } | 1014 | message.extra.media_index = clamp(newIndex, 0, message.extra.media.length - 1); |
| 900 | } | 1015 | } |
| 901 | 1016 | ||
| 902 | if (isLastImage || value === POPUP_RESULT.CUSTOM1) { | 1017 | if (value === POPUP_RESULT.CUSTOM1) { |
| 903 | delete message.extra.image; | 1018 | delete message.extra.media; |
| 904 | delete message.extra.inline_image; | 1019 | delete message.extra.inline_image; |
| 905 | delete message.extra.title; | 1020 | delete message.extra.title; |
| 906 | delete message.extra.append_title; | 1021 | delete message.extra.append_title; |
| 907 | delete message.extra.image_swipes; | ||
| 908 | mesBlock.find('.mes_img_container').removeClass('img_extra'); | ||
| 909 | mesBlock.find('.mes_img').attr('src', ''); | ||
| 910 | } else { | ||
| 911 | appendMediaToMessage(message, mesBlock); | ||
| 912 | } | 1022 | } |
| 913 | 1023 | ||
| 914 | await saveChatConditional(); | 1024 | await saveChatConditional(); |
| 1025 | appendMediaToMessage(message, messageBlock, false); | ||
| 915 | } | 1026 | } |
| 916 | 1027 | ||
| 917 | async function deleteMessageVideo() { | 1028 | /** |
| 918 | const confirm = await Popup.show.confirm(t`Delete video from message?`, t`This action can't be undone.`); | 1029 | * Switches the media display mode for a message. |
| 919 | if (!confirm) { | 1030 | * @param {number} messageId Message ID |
| 1031 | * @param {JQuery<HTMLElement>} messageBlock Message block element | ||
| 1032 | * @param {MEDIA_DISPLAY} targetDisplay Target display mode | ||
| 1033 | */ | ||
| 1034 | async function switchMessageMediaDisplay(messageId, messageBlock, targetDisplay) { | ||
| 1035 | if (isNaN(messageId)) { | ||
| 1036 | console.warn('Invalid message ID'); | ||
| 920 | return; | 1037 | return; |
| 921 | } | 1038 | } |
| 922 | 1039 | ||
| 923 | const mesBlock = $(this).closest('.mes'); | 1040 | /** @type {ChatMessage} */ |
| 924 | const mesId = mesBlock.attr('mesid'); | 1041 | const message = chat[messageId]; |
| 925 | const message = chat[mesId]; | ||
| 926 | 1042 | ||
| 927 | if (!message?.extra?.video) { | 1043 | if (!message) { |
| 928 | console.warn('Message has no video or it is empty'); | 1044 | console.warn('Message not found for ID', messageId); |
| 929 | return; | 1045 | return; |
| 930 | } | 1046 | } |
| 931 | 1047 | ||
| 932 | delete message.extra.video; | 1048 | if (!message.extra || typeof message.extra !== 'object') { |
| 933 | mesBlock.find('.mes_video_container').remove(); | 1049 | message.extra = {}; |
| 1050 | } | ||
| 934 | 1051 | ||
| 1052 | message.extra.media_display = targetDisplay; | ||
| 935 | await saveChatConditional(); | 1053 | await saveChatConditional(); |
| 1054 | appendMediaToMessage(message, messageBlock, false); | ||
| 936 | } | 1055 | } |
| 937 | 1056 | ||
| 938 | /** | 1057 | /** |
| @@ -1865,6 +1984,68 @@ export function addDOMPurifyHooks() { | |||
| 1865 | }); | 1984 | }); |
| 1866 | } | 1985 | } |
| 1867 | 1986 | ||
| 1987 | /** | ||
| 1988 | * Switches an image to the next or previous one in the swipe list. | ||
| 1989 | * @param {number} messageId Message ID | ||
| 1990 | * @param {JQuery<HTMLElement>} element Message element | ||
| 1991 | * @param {string} direction Swipe direction | ||
| 1992 | * @returns {Promise<void>} | ||
| 1993 | */ | ||
| 1994 | async function onImageSwiped(messageId, element, direction) { | ||
| 1995 | const animationClass = 'fa-fade'; | ||
| 1996 | const messageMedia = element.find('.mes_img, .mes_video'); | ||
| 1997 | |||
| 1998 | // Current image is already animating | ||
| 1999 | if (messageMedia.hasClass(animationClass)) { | ||
| 2000 | return; | ||
| 2001 | } | ||
| 2002 | |||
| 2003 | const message = chat[messageId]; | ||
| 2004 | const media = message?.extra?.media; | ||
| 2005 | |||
| 2006 | if (!message || !Array.isArray(media) || media.length === 0) { | ||
| 2007 | console.warn('No media found in the message'); | ||
| 2008 | return; | ||
| 2009 | } | ||
| 2010 | |||
| 2011 | const currentIndex = getMediaIndex(message); | ||
| 2012 | const mediaDisplay = getMediaDisplay(message); | ||
| 2013 | |||
| 2014 | if (mediaDisplay !== MEDIA_DISPLAY.GALLERY) { | ||
| 2015 | console.warn('Image swiping is only supported for gallery media display'); | ||
| 2016 | return; | ||
| 2017 | } | ||
| 2018 | |||
| 2019 | // Switch to previous image or wrap around if at the beginning | ||
| 2020 | if (direction === SWIPE_DIRECTION.LEFT) { | ||
| 2021 | const newIndex = currentIndex === 0 ? media.length - 1 : currentIndex - 1; | ||
| 2022 | message.extra.media_index = newIndex; | ||
| 2023 | } | ||
| 2024 | |||
| 2025 | // Switch to next image or generate a new one if at the end | ||
| 2026 | if (direction === SWIPE_DIRECTION.RIGHT) { | ||
| 2027 | const newIndex = currentIndex === media.length - 1 ? 0 : currentIndex + 1; | ||
| 2028 | message.extra.media_index = newIndex >= media.length ? 0 : newIndex; | ||
| 2029 | } | ||
| 2030 | |||
| 2031 | // Show a message that swipe right no longer automatically generates an image | ||
| 2032 | if (media.length > 0 && direction === SWIPE_DIRECTION.RIGHT && message.extra.media_index === 0) { | ||
| 2033 | const key = 'imageSwipeNoticeShown'; | ||
| 2034 | const hasSeenNotice = accountStorage.getItem(key); | ||
| 2035 | if (!hasSeenNotice) { | ||
| 2036 | await Popup.show.text( | ||
| 2037 | t`Image swiping no longer automatically generates new images.`, | ||
| 2038 | t`Use the 'Generate Image' (paintbrush) button in the message actions menu to generate more images. This message will not be shown again.`, | ||
| 2039 | ); | ||
| 2040 | accountStorage.setItem(key, 'true'); | ||
| 2041 | } | ||
| 2042 | } | ||
| 2043 | |||
| 2044 | await saveChatConditional(); | ||
| 2045 | await eventSource.emit(event_types.IMAGE_SWIPED, { message, element, direction }); | ||
| 2046 | appendMediaToMessage(message, element); | ||
| 2047 | } | ||
| 2048 | |||
| 1868 | export function initChatUtilities() { | 2049 | export function initChatUtilities() { |
| 1869 | $(document).on('click', '.mes_hide', async function () { | 2050 | $(document).on('click', '.mes_hide', async function () { |
| 1870 | const messageBlock = $(this).closest('.mes'); | 2051 | const messageBlock = $(this).closest('.mes'); |
| @@ -1881,13 +2062,17 @@ export function initChatUtilities() { | |||
| 1881 | $(document).on('click', '.mes_file_delete', async function () { | 2062 | $(document).on('click', '.mes_file_delete', async function () { |
| 1882 | const messageBlock = $(this).closest('.mes'); | 2063 | const messageBlock = $(this).closest('.mes'); |
| 1883 | const messageId = Number(messageBlock.attr('mesid')); | 2064 | const messageId = Number(messageBlock.attr('mesid')); |
| 1884 | await deleteMessageFile(messageId); | 2065 | const fileBlock = $(this).closest('.mes_file_container'); |
| 2066 | const fileIndex = Number(fileBlock.attr('data-index')); | ||
| 2067 | await deleteMessageFile(messageBlock, messageId, fileIndex); | ||
| 1885 | }); | 2068 | }); |
| 1886 | 2069 | ||
| 1887 | $(document).on('click', '.mes_file_open', async function () { | 2070 | $(document).on('click', '.mes_file_open', async function () { |
| 1888 | const messageBlock = $(this).closest('.mes'); | 2071 | const messageBlock = $(this).closest('.mes'); |
| 1889 | const messageId = Number(messageBlock.attr('mesid')); | 2072 | const messageId = Number(messageBlock.attr('mesid')); |
| 1890 | await viewMessageFile(messageId); | 2073 | const fileBlock = $(this).closest('.mes_file_container'); |
| 2074 | const fileIndex = Number(fileBlock.attr('data-index')); | ||
| 2075 | await viewMessageFile(messageId, fileIndex); | ||
| 1891 | }); | 2076 | }); |
| 1892 | 2077 | ||
| 1893 | $(document).on('click', '.assistant_note_export', async function () { | 2078 | $(document).on('click', '.assistant_note_export', async function () { |
| @@ -2052,16 +2237,55 @@ export function initChatUtilities() { | |||
| 2052 | openGlobalStylesPreferenceDialog(); | 2237 | openGlobalStylesPreferenceDialog(); |
| 2053 | }); | 2238 | }); |
| 2054 | 2239 | ||
| 2055 | $(document).on('click', '.mes_img', expandMessageImage); | 2240 | /** |
| 2056 | $(document).on('click', '.mes_img_enlarge', expandAndZoomMessageImage); | 2241 | * Returns information about the closest media container. |
| 2057 | $(document).on('click', '.mes_img_delete', deleteMessageImage); | 2242 | * @returns {MediaContainerInfo} Information about the media container |
| 2058 | $(document).on('click', '.mes_video_delete', deleteMessageVideo); | 2243 | * @typedef {object} MediaContainerInfo |
| 2244 | * @property {JQuery<HTMLElement>} messageBlock The closest message block | ||
| 2245 | * @property {number} messageId The message ID | ||
| 2246 | * @property {JQuery<HTMLElement>} mediaBlock The closest media container block | ||
| 2247 | * @property {number} mediaIndex The media index within the message | ||
| 2248 | */ | ||
| 2249 | function getMediaContainerInfo(containerClass = '.mes_media_container') { | ||
| 2250 | const messageBlock = $(this).closest('.mes'); | ||
| 2251 | const messageId = Number(messageBlock.attr('mesid')); | ||
| 2252 | const mediaBlock = $(this).closest(containerClass); | ||
| 2253 | const mediaIndex = Number(mediaBlock.attr('data-index')); | ||
| 2254 | return { messageBlock, messageId, mediaBlock, mediaIndex }; | ||
| 2255 | } | ||
| 2256 | chatElement.on('click', '.mes_img', async function () { | ||
| 2257 | const { messageId, mediaIndex } = getMediaContainerInfo.call(this); | ||
| 2258 | expandMessageMedia(messageId, mediaIndex); | ||
| 2259 | }); | ||
| 2260 | chatElement.on('click', '.mes_media_enlarge', async function () { | ||
| 2261 | const { messageId, mediaIndex } = getMediaContainerInfo.call(this); | ||
| 2262 | expandMessageMedia(messageId, mediaIndex).click(); | ||
| 2263 | }); | ||
| 2264 | chatElement.on('click', '.mes_media_delete', async function () { | ||
| 2265 | const { messageId, mediaIndex, messageBlock } = getMediaContainerInfo.call(this); | ||
| 2266 | await deleteMessageMedia(messageId, mediaIndex, messageBlock); | ||
| 2267 | }); | ||
| 2268 | chatElement.on('click', '.mes_media_list', async function () { | ||
| 2269 | const { messageId, messageBlock } = getMediaContainerInfo.call(this); | ||
| 2270 | await switchMessageMediaDisplay(messageId, messageBlock, MEDIA_DISPLAY.GALLERY); | ||
| 2271 | }); | ||
| 2272 | chatElement.on('click', '.mes_media_gallery', async function () { | ||
| 2273 | const { messageId, messageBlock } = getMediaContainerInfo.call(this); | ||
| 2274 | await switchMessageMediaDisplay(messageId, messageBlock, MEDIA_DISPLAY.LIST); | ||
| 2275 | }); | ||
| 2276 | chatElement.on('click', '.mes_img_swipe_left', async function () { | ||
| 2277 | const { messageId, messageBlock } = getMediaContainerInfo.call(this); | ||
| 2278 | await onImageSwiped(messageId, messageBlock, SWIPE_DIRECTION.LEFT); | ||
| 2279 | }); | ||
| 2280 | chatElement.on('click', '.mes_img_swipe_right', async function () { | ||
| 2281 | const { messageId, messageBlock } = getMediaContainerInfo.call(this); | ||
| 2282 | await onImageSwiped(messageId, messageBlock, SWIPE_DIRECTION.RIGHT); | ||
| 2283 | }); | ||
| 2059 | 2284 | ||
| 2060 | $('#file_form_input').on('change', async () => { | 2285 | $('#file_form_input').on('change', async () => { |
| 2061 | const fileInput = document.getElementById('file_form_input'); | 2286 | const fileInput = document.getElementById('file_form_input'); |
| 2062 | if (!(fileInput instanceof HTMLInputElement)) return; | 2287 | if (!(fileInput instanceof HTMLInputElement)) return; |
| 2063 | const file = fileInput.files[0]; | 2288 | await onFileAttach(fileInput.files); |
| 2064 | await onFileAttach(file); | ||
| 2065 | }); | 2289 | }); |
| 2066 | $('#file_form').on('reset', function () { | 2290 | $('#file_form').on('reset', function () { |
| 2067 | $('#file_form').addClass('displayNone'); | 2291 | $('#file_form').addClass('displayNone'); |
| @@ -2075,18 +2299,38 @@ export function initChatUtilities() { | |||
| 2075 | event.preventDefault(); | 2299 | event.preventDefault(); |
| 2076 | event.stopPropagation(); | 2300 | event.stopPropagation(); |
| 2077 | 2301 | ||
| 2302 | await handleFileAttach(Array.from(event.clipboardData.files)); | ||
| 2303 | }); | ||
| 2304 | |||
| 2305 | new DragAndDropHandler('#form_sheld', async (files) => { | ||
| 2306 | await handleFileAttach(files); | ||
| 2307 | }); | ||
| 2308 | |||
| 2309 | /** | ||
| 2310 | * Common handler for file attachments. | ||
| 2311 | * @param {File[]} files Files to attach | ||
| 2312 | * @returns {Promise<void>} | ||
| 2313 | */ | ||
| 2314 | async function handleFileAttach(files) { | ||
| 2078 | const fileInput = document.getElementById('file_form_input'); | 2315 | const fileInput = document.getElementById('file_form_input'); |
| 2079 | if (!(fileInput instanceof HTMLInputElement)) return; | 2316 | if (!(fileInput instanceof HTMLInputElement)) return; |
| 2080 | 2317 | ||
| 2081 | // Workaround for Firefox: Use a DataTransfer object to indirectly set fileInput.files | 2318 | // Workaround for Firefox: Use a DataTransfer object to indirectly set fileInput.files |
| 2082 | const dataTransfer = new DataTransfer(); | 2319 | const dataTransfer = new DataTransfer(); |
| 2083 | for (let i = 0; i < event.clipboardData.files.length; i++) { | 2320 | for (let i = 0; i < files.length; i++) { |
| 2084 | dataTransfer.items.add(event.clipboardData.files[i]); | 2321 | dataTransfer.items.add(files[i]); |
| 2322 | } | ||
| 2323 | |||
| 2324 | // Preserve existing non-duplicate files in the input | ||
| 2325 | for (const file of fileInput.files) { | ||
| 2326 | if (!Array.from(dataTransfer.files).some(f => isSameFile(f, file))) { | ||
| 2327 | dataTransfer.items.add(file); | ||
| 2328 | } | ||
| 2085 | } | 2329 | } |
| 2086 | 2330 | ||
| 2087 | fileInput.files = dataTransfer.files; | 2331 | fileInput.files = dataTransfer.files; |
| 2088 | await onFileAttach(fileInput.files[0]); | 2332 | await onFileAttach(fileInput.files); |
| 2089 | }); | 2333 | } |
| 2090 | 2334 | ||
| 2091 | eventSource.on(event_types.CHAT_CHANGED, checkForCreatorNotesStyles); | 2335 | eventSource.on(event_types.CHAT_CHANGED, checkForCreatorNotesStyles); |
| 2092 | } | 2336 | } |
| @@ -68,6 +68,32 @@ export const COMETAPI_IGNORE_PATTERNS = [ | |||
| 68 | ]; | 68 | ]; |
| 69 | 69 | ||
| 70 | /** | 70 | /** |
| 71 | * @enum {string} | ||
| 72 | * @readonly | ||
| 73 | */ | ||
| 74 | export const MEDIA_DISPLAY = { | ||
| 75 | LIST: 'list', | ||
| 76 | GALLERY: 'gallery', | ||
| 77 | }; | ||
| 78 | |||
| 79 | /** | ||
| 80 | * @readonly | ||
| 81 | */ | ||
| 82 | export const MEDIA_TYPE = { | ||
| 83 | getFromMime: (/** @type {string} */ mimeType) => { | ||
| 84 | if (mimeType.startsWith('image/')) { | ||
| 85 | return MEDIA_TYPE.IMAGE; | ||
| 86 | } | ||
| 87 | if (mimeType.startsWith('video/')) { | ||
| 88 | return MEDIA_TYPE.VIDEO; | ||
| 89 | } | ||
| 90 | return null; | ||
| 91 | }, | ||
| 92 | IMAGE: 'image', | ||
| 93 | VIDEO: 'video', | ||
| 94 | }; | ||
| 95 | |||
| 96 | /** | ||
| 71 | * @type {{readonly LEFT: 'left', readonly RIGHT: 'right'}} | 97 | * @type {{readonly LEFT: 'left', readonly RIGHT: 'right'}} |
| 72 | */ | 98 | */ |
| 73 | export const SWIPE_DIRECTION = { | 99 | export const SWIPE_DIRECTION = { |
| @@ -10,6 +10,7 @@ import { SlashCommand } from '../../slash-commands/SlashCommand.js'; | |||
| 10 | import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js'; | 10 | import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js'; |
| 11 | import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; | 11 | import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 12 | import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js'; | 12 | import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js'; |
| 13 | import { MEDIA_DISPLAY, MEDIA_TYPE } from '../../constants.js'; | ||
| 13 | export { MODULE_NAME }; | 14 | export { MODULE_NAME }; |
| 14 | 15 | ||
| 15 | const MODULE_NAME = 'caption'; | 16 | const MODULE_NAME = 'caption'; |
| @@ -119,15 +120,26 @@ async function wrapCaptionTemplate(caption) { | |||
| 119 | 120 | ||
| 120 | /** | 121 | /** |
| 121 | * Appends caption to an existing message. | 122 | * Appends caption to an existing message. |
| 122 | * @param {Object} data Message data | 123 | * @param {ChatMessage} message Message data |
| 124 | * @param {number} mediaIndex Index of the image to caption | ||
| 123 | * @returns {Promise<void>} | 125 | * @returns {Promise<void>} |
| 124 | */ | 126 | */ |
| 125 | async function captionExistingMessage(data) { | 127 | async function captionExistingMessage(message, mediaIndex) { |
| 126 | if (!(data?.extra?.image)) { | 128 | if (!Array.isArray(message?.extra?.media) || message.extra.media.length === 0) { |
| 127 | return; | 129 | return; |
| 128 | } | 130 | } |
| 129 | 131 | ||
| 130 | const imageData = await fetch(data.extra.image); | 132 | if (mediaIndex === undefined || isNaN(mediaIndex) || mediaIndex < 0 || mediaIndex >= message.extra.media.length) { |
| 133 | mediaIndex = 0; | ||
| 134 | } | ||
| 135 | |||
| 136 | const mediaAttachment = message.extra.media[mediaIndex]; | ||
| 137 | |||
| 138 | if (!mediaAttachment || !mediaAttachment.url || mediaAttachment.type === MEDIA_TYPE.VIDEO) { | ||
| 139 | return; | ||
| 140 | } | ||
| 141 | |||
| 142 | const imageData = await fetch(mediaAttachment.url); | ||
| 131 | const blob = await imageData.blob(); | 143 | const blob = await imageData.blob(); |
| 132 | const type = imageData.headers.get('Content-Type'); | 144 | const type = imageData.headers.get('Content-Type'); |
| 133 | const file = new File([blob], 'image.png', { type }); | 145 | const file = new File([blob], 'image.png', { type }); |
| @@ -140,17 +152,17 @@ async function captionExistingMessage(data) { | |||
| 140 | 152 | ||
| 141 | const wrappedCaption = await wrapCaptionTemplate(caption); | 153 | const wrappedCaption = await wrapCaptionTemplate(caption); |
| 142 | 154 | ||
| 143 | const messageText = String(data.mes).trim(); | 155 | const messageText = String(message.mes).trim(); |
| 144 | 156 | ||
| 145 | if (!messageText) { | 157 | if (!messageText) { |
| 146 | data.extra.inline_image = false; | 158 | message.extra.inline_image = false; |
| 147 | data.mes = wrappedCaption; | 159 | message.mes = wrappedCaption; |
| 148 | data.extra.title = wrappedCaption; | 160 | mediaAttachment.title = wrappedCaption; |
| 149 | } | 161 | } |
| 150 | else { | 162 | else { |
| 151 | data.extra.inline_image = true; | 163 | message.extra.inline_image = true; |
| 152 | data.extra.append_title = true; | 164 | mediaAttachment.append_title = true; |
| 153 | data.extra.title = wrappedCaption; | 165 | mediaAttachment.title = wrappedCaption; |
| 154 | } | 166 | } |
| 155 | } | 167 | } |
| 156 | 168 | ||
| @@ -163,14 +175,23 @@ async function sendCaptionedMessage(caption, image) { | |||
| 163 | const messageText = await wrapCaptionTemplate(caption); | 175 | const messageText = await wrapCaptionTemplate(caption); |
| 164 | 176 | ||
| 165 | const context = getContext(); | 177 | const context = getContext(); |
| 178 | |||
| 179 | /** @type {MediaAttachment} */ | ||
| 180 | const mediaAttachment = { | ||
| 181 | url: image, | ||
| 182 | type: MEDIA_TYPE.IMAGE, | ||
| 183 | title: messageText, | ||
| 184 | }; | ||
| 185 | /** @type {ChatMessage} */ | ||
| 166 | const message = { | 186 | const message = { |
| 167 | name: context.name1, | 187 | name: context.name1, |
| 168 | is_user: true, | 188 | is_user: true, |
| 169 | send_date: getMessageTimeStamp(), | 189 | send_date: getMessageTimeStamp(), |
| 170 | mes: messageText, | 190 | mes: messageText, |
| 171 | extra: { | 191 | extra: { |
| 172 | image: image, | 192 | media: [mediaAttachment], |
| 173 | title: messageText, | 193 | media_display: MEDIA_DISPLAY.GALLERY, |
| 194 | media_index: 0, | ||
| 174 | inline_image: !!extension_settings.caption.show_in_chat, | 195 | inline_image: !!extension_settings.caption.show_in_chat, |
| 175 | }, | 196 | }, |
| 176 | }; | 197 | }; |
| @@ -365,13 +386,24 @@ function onRefineModeInput() { | |||
| 365 | */ | 386 | */ |
| 366 | async function captionCommandCallback(args, prompt) { | 387 | async function captionCommandCallback(args, prompt) { |
| 367 | const quiet = isTrueBoolean(args?.quiet); | 388 | const quiet = isTrueBoolean(args?.quiet); |
| 368 | const mesId = args?.mesId ?? args?.id; | 389 | const messageId = args?.mesId ?? args?.id; |
| 390 | const index = Number(args?.index ?? 0); | ||
| 369 | 391 | ||
| 370 | if (!isNaN(Number(mesId))) { | 392 | if (!isNaN(Number(messageId))) { |
| 371 | const message = getContext().chat[mesId]; | 393 | /** @type {ChatMessage} */ |
| 372 | if (message?.extra?.image) { | 394 | const message = getContext().chat[messageId]; |
| 395 | if (Array.isArray(message?.extra?.media) && message.extra.media.length > 0) { | ||
| 373 | try { | 396 | try { |
| 374 | const fetchResult = await fetch(message.extra.image); | 397 | const mediaAttachment = message.extra.media[index] || message.extra.media[0]; |
| 398 | if (!mediaAttachment || !mediaAttachment.url) { | ||
| 399 | toastr.error('The specified message does not contain an image.'); | ||
| 400 | return ''; | ||
| 401 | } | ||
| 402 | if (mediaAttachment.type === MEDIA_TYPE.VIDEO) { | ||
| 403 | toastr.error('The specified media is a video. Captioning videos is not supported.'); | ||
| 404 | return ''; | ||
| 405 | } | ||
| 406 | const fetchResult = await fetch(mediaAttachment.url); | ||
| 375 | const blob = await fetchResult.blob(); | 407 | const blob = await fetchResult.blob(); |
| 376 | const file = new File([blob], 'image.jpg', { type: blob.type }); | 408 | const file = new File([blob], 'image.jpg', { type: blob.type }); |
| 377 | return await getCaptionForFile(file, prompt, quiet); | 409 | return await getCaptionForFile(file, prompt, quiet); |
| @@ -636,13 +668,13 @@ jQuery(async function () { | |||
| 636 | saveSettingsDebounced(); | 668 | saveSettingsDebounced(); |
| 637 | }); | 669 | }); |
| 638 | 670 | ||
| 639 | const onMessageEvent = async (index) => { | 671 | const onMessageEvent = async (messageId) => { |
| 640 | if (!extension_settings.caption.auto_mode) { | 672 | if (!extension_settings.caption.auto_mode) { |
| 641 | return; | 673 | return; |
| 642 | } | 674 | } |
| 643 | 675 | ||
| 644 | const data = getContext().chat[index]; | 676 | const message = getContext().chat[messageId]; |
| 645 | await captionExistingMessage(data); | 677 | await captionExistingMessage(message, 0); |
| 646 | }; | 678 | }; |
| 647 | 679 | ||
| 648 | eventSource.on(event_types.MESSAGE_SENT, onMessageEvent); | 680 | eventSource.on(event_types.MESSAGE_SENT, onMessageEvent); |
| @@ -651,13 +683,15 @@ jQuery(async function () { | |||
| 651 | $(document).on('click', '.mes_img_caption', async function () { | 683 | $(document).on('click', '.mes_img_caption', async function () { |
| 652 | const animationClass = 'fa-fade'; | 684 | const animationClass = 'fa-fade'; |
| 653 | const messageBlock = $(this).closest('.mes'); | 685 | const messageBlock = $(this).closest('.mes'); |
| 654 | const messageImg = messageBlock.find('.mes_img'); | 686 | const imageBlock = $(this).closest('.mes_img_container'); |
| 687 | const messageImg = imageBlock.find('.mes_img'); | ||
| 655 | if (messageImg.hasClass(animationClass)) return; | 688 | if (messageImg.hasClass(animationClass)) return; |
| 656 | messageImg.addClass(animationClass); | 689 | messageImg.addClass(animationClass); |
| 657 | try { | 690 | try { |
| 658 | const index = Number(messageBlock.attr('mesid')); | 691 | const messageId = Number(messageBlock.attr('mesid')); |
| 659 | const data = getContext().chat[index]; | 692 | const imageIndex = Number(imageBlock.attr('data-index')); |
| 660 | await captionExistingMessage(data); | 693 | const data = getContext().chat[messageId]; |
| 694 | await captionExistingMessage(data, imageIndex); | ||
| 661 | appendMediaToMessage(data, messageBlock, false); | 695 | appendMediaToMessage(data, messageBlock, false); |
| 662 | await saveChatConditional(); | 696 | await saveChatConditional(); |
| 663 | } catch (e) { | 697 | } catch (e) { |
| @@ -681,6 +715,12 @@ jQuery(async function () { | |||
| 681 | typeList: [ARGUMENT_TYPE.NUMBER], | 715 | typeList: [ARGUMENT_TYPE.NUMBER], |
| 682 | enumProvider: commonEnumProviders.messages(), | 716 | enumProvider: commonEnumProviders.messages(), |
| 683 | }), | 717 | }), |
| 718 | SlashCommandNamedArgument.fromProps({ | ||
| 719 | name: 'index', | ||
| 720 | description: 'index of the image in the message to caption (starting from 0)', | ||
| 721 | typeList: [ARGUMENT_TYPE.NUMBER], | ||
| 722 | enumProvider: commonEnumProviders.messageMedia(), | ||
| 723 | }), | ||
| 684 | ], | 724 | ], |
| 685 | unnamedArgumentList: [ | 725 | unnamedArgumentList: [ |
| 686 | new SlashCommandArgument( | 726 | new SlashCommandArgument( |
| @@ -52,7 +52,7 @@ import { | |||
| 52 | SlashCommandArgument, | 52 | SlashCommandArgument, |
| 53 | SlashCommandNamedArgument, | 53 | SlashCommandNamedArgument, |
| 54 | } from '../../slash-commands/SlashCommandArgument.js'; | 54 | } from '../../slash-commands/SlashCommandArgument.js'; |
| 55 | import { debounce_timeout, VIDEO_EXTENSIONS } from '../../constants.js'; | 55 | import { debounce_timeout, MEDIA_DISPLAY, MEDIA_TYPE, VIDEO_EXTENSIONS } from '../../constants.js'; |
| 56 | import { SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js'; | 56 | import { SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js'; |
| 57 | import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js'; | 57 | import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js'; |
| 58 | import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; | 58 | import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; |
| @@ -468,6 +468,12 @@ async function loadSettings() { | |||
| 468 | extension_settings.sd.styles = defaultStyles; | 468 | extension_settings.sd.styles = defaultStyles; |
| 469 | } | 469 | } |
| 470 | 470 | ||
| 471 | // Preserve an original seed if exists | ||
| 472 | if (extension_settings.sd.original_seed >= 0) { | ||
| 473 | extension_settings.sd.seed = extension_settings.sd.original_seed; | ||
| 474 | delete extension_settings.sd.original_seed; | ||
| 475 | } | ||
| 476 | |||
| 471 | $('#sd_source').val(extension_settings.sd.source); | 477 | $('#sd_source').val(extension_settings.sd.source); |
| 472 | $('#sd_scale').val(extension_settings.sd.scale).trigger('input'); | 478 | $('#sd_scale').val(extension_settings.sd.scale).trigger('input'); |
| 473 | $('#sd_steps').val(extension_settings.sd.steps).trigger('input'); | 479 | $('#sd_steps').val(extension_settings.sd.steps).trigger('input'); |
| @@ -4065,6 +4071,16 @@ async function sendMessage(prompt, image, generationType, additionalNegativePref | |||
| 4065 | const name = context.groupId ? systemUserName : context.name2; | 4071 | const name = context.groupId ? systemUserName : context.name2; |
| 4066 | const template = extension_settings.sd.prompts[generationMode.MESSAGE] || '{{prompt}}'; | 4072 | const template = extension_settings.sd.prompts[generationMode.MESSAGE] || '{{prompt}}'; |
| 4067 | const messageText = substituteParamsExtended(template, { char: name, prompt: prompt, prefixedPrompt: prefixedPrompt }); | 4073 | const messageText = substituteParamsExtended(template, { char: name, prompt: prompt, prefixedPrompt: prefixedPrompt }); |
| 4074 | const mediaType = isVideo(format) ? MEDIA_TYPE.VIDEO : MEDIA_TYPE.IMAGE; | ||
| 4075 | /** @type {MediaAttachment} */ | ||
| 4076 | const mediaAttachment = { | ||
| 4077 | url: image, | ||
| 4078 | type: mediaType, | ||
| 4079 | title: prompt, | ||
| 4080 | generation_type: generationType, | ||
| 4081 | negative: additionalNegativePrefix, | ||
| 4082 | }; | ||
| 4083 | /** @type {ChatMessage} */ | ||
| 4068 | const message = { | 4084 | const message = { |
| 4069 | name: name, | 4085 | name: name, |
| 4070 | is_user: false, | 4086 | is_user: false, |
| @@ -4072,20 +4088,12 @@ async function sendMessage(prompt, image, generationType, additionalNegativePref | |||
| 4072 | send_date: getMessageTimeStamp(), | 4088 | send_date: getMessageTimeStamp(), |
| 4073 | mes: messageText, | 4089 | mes: messageText, |
| 4074 | extra: { | 4090 | extra: { |
| 4075 | image: image, | 4091 | media: [mediaAttachment], |
| 4076 | title: prompt, | 4092 | media_display: MEDIA_DISPLAY.GALLERY, |
| 4077 | generationType: generationType, | 4093 | media_index: 0, |
| 4078 | negative: additionalNegativePrefix, | ||
| 4079 | inline_image: false, | 4094 | inline_image: false, |
| 4080 | image_swipes: [image], | ||
| 4081 | }, | 4095 | }, |
| 4082 | }; | 4096 | }; |
| 4083 | if (isVideo(format)) { | ||
| 4084 | message.extra.video = image; | ||
| 4085 | delete message.extra.image; | ||
| 4086 | delete message.extra.image_swipes; | ||
| 4087 | delete message.extra.inline_image; | ||
| 4088 | } | ||
| 4089 | context.chat.push(message); | 4097 | context.chat.push(message); |
| 4090 | const messageId = context.chat.length - 1; | 4098 | const messageId = context.chat.length - 1; |
| 4091 | await eventSource.emit(event_types.MESSAGE_RECEIVED, messageId, 'extension'); | 4099 | await eventSource.emit(event_types.MESSAGE_RECEIVED, messageId, 'extension'); |
| @@ -4215,98 +4223,76 @@ function isValidState() { | |||
| 4215 | 4223 | ||
| 4216 | let buttonAbortController = null; | 4224 | let buttonAbortController = null; |
| 4217 | 4225 | ||
| 4226 | /** | ||
| 4227 | * "Paintbrush" button handler to generate a new image for a message. | ||
| 4228 | * @param {JQuery.ClickEvent} e The click event object. | ||
| 4229 | * @returns {Promise<void>} A promise that resolves when the image generation process is complete. | ||
| 4230 | */ | ||
| 4218 | async function sdMessageButton(e) { | 4231 | async function sdMessageButton(e) { |
| 4232 | /** | ||
| 4233 | * Sets the icon to indicate busy or idle state. | ||
| 4234 | * @param {boolean} isBusy Whether the icon should indicate a busy state. | ||
| 4235 | */ | ||
| 4219 | function setBusyIcon(isBusy) { | 4236 | function setBusyIcon(isBusy) { |
| 4220 | $icon.toggleClass('fa-paintbrush', !isBusy); | 4237 | $icon.toggleClass(classes.idle, !isBusy); |
| 4221 | $icon.toggleClass(busyClass, isBusy); | 4238 | $icon.toggleClass(classes.busy, isBusy); |
| 4222 | } | 4239 | } |
| 4223 | 4240 | ||
| 4224 | const busyClass = 'fa-hourglass'; | 4241 | const classes = { busy: 'fa-hourglass', idle: 'fa-paintbrush' }; |
| 4225 | const context = getContext(); | 4242 | const context = getContext(); |
| 4226 | const $icon = $(e.currentTarget); | 4243 | const $icon = $(e.currentTarget); |
| 4227 | const $mes = $icon.closest('.mes'); | ||
| 4228 | const message_id = $mes.attr('mesid'); | ||
| 4229 | const message = context.chat[message_id]; | ||
| 4230 | const characterFileName = context.groupId | ||
| 4231 | ? context.groups[Object.keys(context.groups).filter(x => context.groups[x].id === context.groupId)[0]]?.id?.toString() | ||
| 4232 | : context.characters[context.characterId]?.name; | ||
| 4233 | const messageText = message?.mes; | ||
| 4234 | const hasSavedImage = message?.extra?.image && message?.extra?.title; | ||
| 4235 | const hasSavedNegative = message?.extra?.negative; | ||
| 4236 | 4244 | ||
| 4237 | if ($icon.hasClass(busyClass)) { | 4245 | if ($icon.hasClass(classes.busy)) { |
| 4238 | buttonAbortController?.abort('Aborted by user'); | 4246 | buttonAbortController?.abort('Aborted by user'); |
| 4239 | console.log('Previous image is still being generated...'); | 4247 | console.log('Previous image is still being generated...'); |
| 4240 | return; | 4248 | return; |
| 4241 | } | 4249 | } |
| 4242 | 4250 | ||
| 4243 | let dimensions = null; | 4251 | const messageElement = $icon.closest('.mes'); |
| 4244 | buttonAbortController = new AbortController(); | 4252 | const messageId = Number(messageElement.attr('mesid')); |
| 4245 | 4253 | ||
| 4246 | try { | 4254 | /** @type {ChatMessage} */ |
| 4247 | setBusyIcon(true); | 4255 | const message = context.chat[messageId]; |
| 4248 | if (hasSavedImage) { | ||
| 4249 | const prompt = await refinePrompt(message.extra.title, false); | ||
| 4250 | const negative = hasSavedNegative ? await refinePrompt(message.extra.negative, true) : ''; | ||
| 4251 | message.extra.title = prompt; | ||
| 4252 | |||
| 4253 | const generationType = message?.extra?.generationType ?? generationMode.FREE; | ||
| 4254 | console.log('Regenerating an image, using existing prompt:', prompt); | ||
| 4255 | dimensions = setTypeSpecificDimensions(generationType); | ||
| 4256 | await sendGenerationRequest(generationType, prompt, negative, characterFileName, saveGeneratedImage, initiators.action, buttonAbortController?.signal); | ||
| 4257 | } | ||
| 4258 | else { | ||
| 4259 | console.log('doing /sd raw last'); | ||
| 4260 | await generatePicture(initiators.action, {}, 'raw_last', messageText, saveGeneratedImage); | ||
| 4261 | } | ||
| 4262 | } | ||
| 4263 | catch (error) { | ||
| 4264 | console.error('Could not generate inline image: ', error); | ||
| 4265 | } | ||
| 4266 | finally { | ||
| 4267 | setBusyIcon(false); | ||
| 4268 | 4256 | ||
| 4269 | if (dimensions) { | 4257 | if (!message) { |
| 4270 | restoreOriginalDimensions(dimensions); | 4258 | console.error('Could not find message for SD generation button'); |
| 4271 | } | 4259 | return; |
| 4272 | } | 4260 | } |
| 4273 | 4261 | ||
| 4274 | function saveGeneratedImage(prompt, image, generationType, negative, _initiator, _prefixedPrompt, format) { | 4262 | if (!message.extra || typeof message.extra !== 'object') { |
| 4275 | // Some message sources may not create the extra object | 4263 | message.extra = {}; |
| 4276 | if (typeof message.extra !== 'object' || message.extra === null) { | 4264 | } |
| 4277 | message.extra = {}; | ||
| 4278 | } | ||
| 4279 | |||
| 4280 | // Add image to the swipe list if it's not already there | ||
| 4281 | if (!Array.isArray(message.extra.image_swipes)) { | ||
| 4282 | message.extra.image_swipes = []; | ||
| 4283 | } | ||
| 4284 | |||
| 4285 | const swipes = message.extra.image_swipes; | ||
| 4286 | 4265 | ||
| 4287 | if (message.extra.image && !swipes.includes(message.extra.image)) { | 4266 | if (!Array.isArray(message.extra.media)) { |
| 4288 | swipes.push(message.extra.image); | 4267 | message.extra.media = []; |
| 4289 | } | 4268 | } |
| 4290 | 4269 | ||
| 4291 | const isVideoFormat = isVideo(format); | 4270 | /** @type {MediaAttachment} */ |
| 4271 | const selectedMedia = message.extra.media.length > 0 | ||
| 4272 | ? (message.extra.media[message.extra.media_index] ?? message.extra.media[message.extra.media.length - 1]) | ||
| 4273 | : { url: '', title: message.mes, type: MEDIA_TYPE.IMAGE, generation_type: generationMode.FREE }; | ||
| 4292 | 4274 | ||
| 4293 | if (isVideoFormat) { | 4275 | buttonAbortController = new AbortController(); |
| 4294 | message.extra.video = image; | 4276 | const newMediaAttachment = await generateMediaSwipe( |
| 4295 | } else { | 4277 | selectedMedia, |
| 4296 | swipes.push(image); | 4278 | message, |
| 4279 | () => setBusyIcon(true), | ||
| 4280 | () => setBusyIcon(false), | ||
| 4281 | buttonAbortController, | ||
| 4282 | ); | ||
| 4283 | |||
| 4284 | if (!newMediaAttachment) { | ||
| 4285 | return; | ||
| 4286 | } | ||
| 4297 | 4287 | ||
| 4298 | // If already contains an image and it's not inline - leave it as is | 4288 | // If already contains an image and it's not inline - leave it as is |
| 4299 | message.extra.inline_image = !(message.extra.image && !message.extra.inline_image); | 4289 | message.extra.inline_image = !(message.extra.media.length && !message.extra.inline_image); |
| 4300 | message.extra.image = image; | 4290 | message.extra.media.push(newMediaAttachment); |
| 4301 | } | 4291 | message.extra.media_index = message.extra.media.length - 1; |
| 4302 | 4292 | ||
| 4303 | message.extra.title = prompt; | 4293 | appendMediaToMessage(message, messageElement, false); |
| 4304 | message.extra.generationType = generationType; | ||
| 4305 | message.extra.negative = negative; | ||
| 4306 | appendMediaToMessage(message, $mes); | ||
| 4307 | 4294 | ||
| 4308 | return context.saveChat(); | 4295 | await context.saveChat(); |
| 4309 | } | ||
| 4310 | } | 4296 | } |
| 4311 | 4297 | ||
| 4312 | async function onCharacterPromptShareInput() { | 4298 | async function onCharacterPromptShareInput() { |
| @@ -4336,97 +4322,61 @@ async function writePromptFields(characterId) { | |||
| 4336 | } | 4322 | } |
| 4337 | 4323 | ||
| 4338 | /** | 4324 | /** |
| 4339 | * Switches an image to the next or previous one in the swipe list. | 4325 | * Generates a new media attachment based on the provided media attachment metadata. |
| 4340 | * @param {object} args Event arguments | 4326 | * @param {MediaAttachment} mediaAttachment - The media attachment metadata. |
| 4341 | * @param {any} args.message Message object | 4327 | * @param {ChatMessage} message - The chat message containing the media attachment. |
| 4342 | * @param {JQuery<HTMLElement>} args.element Message element | 4328 | * @param {Function} onStart - Callback function to be called when generation starts. |
| 4343 | * @param {string} args.direction Swipe direction | 4329 | * @param {Function} onComplete - Callback function to be called when generation completes. |
| 4344 | * @returns {Promise<void>} | 4330 | * @param {AbortController} abortController - An AbortController to handle cancellation of the generation process. |
| 4331 | * @returns {Promise<MediaAttachment|null>} - A promise that resolves to the newly generated media attachment, or null if generation failed or was aborted. | ||
| 4345 | */ | 4332 | */ |
| 4346 | async function onImageSwiped({ message, element, direction }) { | 4333 | async function generateMediaSwipe(mediaAttachment, message, onStart, onComplete, abortController = new AbortController()) { |
| 4347 | const context = getContext(); | 4334 | const stopButton = document.getElementById('sd_stop_gen'); |
| 4348 | const animationClass = 'fa-fade'; | 4335 | const stopListener = () => abortController.abort('Aborted by user'); |
| 4349 | const messageImg = element.find('.mes_img'); | 4336 | const generationType = mediaAttachment.generation_type ?? message?.extra?.generationType ?? generationMode.FREE; |
| 4350 | 4337 | const dimensions = setTypeSpecificDimensions(generationType); | |
| 4351 | // Current image is already animating | 4338 | extension_settings.sd.original_seed = extension_settings.sd.seed; |
| 4352 | if (messageImg.hasClass(animationClass)) { | 4339 | extension_settings.sd.seed = extension_settings.sd.seed >= 0 ? Math.round(Math.random() * (Math.pow(2, 32) - 1)) : -1; |
| 4353 | return; | ||
| 4354 | } | ||
| 4355 | |||
| 4356 | const swipes = message?.extra?.image_swipes; | ||
| 4357 | |||
| 4358 | if (!Array.isArray(swipes)) { | ||
| 4359 | console.warn('No image swipes found in the message'); | ||
| 4360 | return; | ||
| 4361 | } | ||
| 4362 | |||
| 4363 | const currentIndex = swipes.indexOf(message.extra.image); | ||
| 4364 | 4340 | ||
| 4365 | if (currentIndex === -1) { | 4341 | /** @type {MediaAttachment} */ |
| 4366 | console.warn('Current image not found in the swipes'); | 4342 | const result = { |
| 4367 | return; | 4343 | url: '', |
| 4368 | } | 4344 | type: MEDIA_TYPE.IMAGE, |
| 4345 | }; | ||
| 4369 | 4346 | ||
| 4370 | // Switch to previous image or wrap around if at the beginning | 4347 | try { |
| 4371 | if (direction === 'left') { | 4348 | $(stopButton).show(); |
| 4372 | const newIndex = currentIndex === 0 ? swipes.length - 1 : currentIndex - 1; | 4349 | eventSource.once(CUSTOM_STOP_EVENT, stopListener); |
| 4373 | message.extra.image = swipes[newIndex]; | 4350 | const callback = (_a, _b, _c, _d, _e, _f, format) => { result.type = isVideo(format) ? MEDIA_TYPE.VIDEO : MEDIA_TYPE.IMAGE; }; |
| 4351 | const savedPrompt = mediaAttachment.title ?? message.extra.title ?? ''; | ||
| 4352 | const prompt = await refinePrompt(savedPrompt, false); | ||
| 4353 | const savedNegative = mediaAttachment.negative ?? message.extra.negative ?? ''; | ||
| 4354 | const negative = savedNegative ? await refinePrompt(savedNegative, true) : ''; | ||
| 4374 | 4355 | ||
| 4375 | // Update the image in the message | 4356 | const context = getContext(); |
| 4376 | appendMediaToMessage(message, element, false); | 4357 | const characterName = context.groupId |
| 4358 | ? context.groups[Object.keys(context.groups).filter(x => context.groups[x].id === context.groupId)[0]]?.id?.toString() | ||
| 4359 | : context.characters[context.characterId]?.name; | ||
| 4360 | |||
| 4361 | onStart(); | ||
| 4362 | result.url = await sendGenerationRequest(generationType, prompt, negative, characterName, callback, initiators.swipe, abortController.signal); | ||
| 4363 | result.generation_type = generationType; | ||
| 4364 | result.title = prompt; | ||
| 4365 | result.negative = negative; | ||
| 4366 | } finally { | ||
| 4367 | onComplete(); | ||
| 4368 | $(stopButton).hide(); | ||
| 4369 | eventSource.removeListener(CUSTOM_STOP_EVENT, stopListener); | ||
| 4370 | restoreOriginalDimensions(dimensions); | ||
| 4371 | extension_settings.sd.seed = extension_settings.sd.original_seed; | ||
| 4372 | delete extension_settings.sd.original_seed; | ||
| 4377 | } | 4373 | } |
| 4378 | 4374 | ||
| 4379 | // Switch to next image or generate a new one if at the end | 4375 | if (!result.url) { |
| 4380 | if (direction === 'right') { | 4376 | return null; |
| 4381 | const newIndex = currentIndex === swipes.length - 1 ? swipes.length : currentIndex + 1; | ||
| 4382 | |||
| 4383 | if (newIndex === swipes.length) { | ||
| 4384 | const abortController = new AbortController(); | ||
| 4385 | const swipeControls = element.find('.mes_img_swipes'); | ||
| 4386 | const stopButton = document.getElementById('sd_stop_gen'); | ||
| 4387 | const stopListener = () => abortController.abort('Aborted by user'); | ||
| 4388 | const generationType = message?.extra?.generationType ?? generationMode.FREE; | ||
| 4389 | const dimensions = setTypeSpecificDimensions(generationType); | ||
| 4390 | const originalSeed = extension_settings.sd.seed; | ||
| 4391 | extension_settings.sd.seed = extension_settings.sd.seed >= 0 ? Math.round(Math.random() * (Math.pow(2, 32) - 1)) : -1; | ||
| 4392 | let imagePath = ''; | ||
| 4393 | |||
| 4394 | try { | ||
| 4395 | $(stopButton).show(); | ||
| 4396 | eventSource.once(CUSTOM_STOP_EVENT, stopListener); | ||
| 4397 | const callback = () => { }; | ||
| 4398 | const hasNegative = message.extra.negative; | ||
| 4399 | const prompt = await refinePrompt(message.extra.title, false); | ||
| 4400 | const negativePromptPrefix = hasNegative ? await refinePrompt(message.extra.negative, true) : ''; | ||
| 4401 | message.extra.title = prompt; | ||
| 4402 | const characterName = context.groupId | ||
| 4403 | ? context.groups[Object.keys(context.groups).filter(x => context.groups[x].id === context.groupId)[0]]?.id?.toString() | ||
| 4404 | : context.characters[context.characterId]?.name; | ||
| 4405 | |||
| 4406 | messageImg.addClass(animationClass); | ||
| 4407 | swipeControls.hide(); | ||
| 4408 | imagePath = await sendGenerationRequest(generationType, prompt, negativePromptPrefix, characterName, callback, initiators.swipe, abortController.signal); | ||
| 4409 | } finally { | ||
| 4410 | $(stopButton).hide(); | ||
| 4411 | messageImg.removeClass(animationClass); | ||
| 4412 | swipeControls.show(); | ||
| 4413 | eventSource.removeListener(CUSTOM_STOP_EVENT, stopListener); | ||
| 4414 | restoreOriginalDimensions(dimensions); | ||
| 4415 | extension_settings.sd.seed = originalSeed; | ||
| 4416 | } | ||
| 4417 | |||
| 4418 | if (!imagePath) { | ||
| 4419 | return; | ||
| 4420 | } | ||
| 4421 | |||
| 4422 | swipes.push(imagePath); | ||
| 4423 | } | ||
| 4424 | |||
| 4425 | message.extra.image = swipes[newIndex]; | ||
| 4426 | appendMediaToMessage(message, element, false); | ||
| 4427 | } | 4377 | } |
| 4428 | 4378 | ||
| 4429 | await context.saveChat(); | 4379 | return result; |
| 4430 | } | 4380 | } |
| 4431 | 4381 | ||
| 4432 | /** | 4382 | /** |
| @@ -4914,8 +4864,6 @@ jQuery(async () => { | |||
| 4914 | } | 4864 | } |
| 4915 | }); | 4865 | }); |
| 4916 | 4866 | ||
| 4917 | eventSource.on(event_types.IMAGE_SWIPED, onImageSwiped); | ||
| 4918 | |||
| 4919 | eventSource.on(event_types.CHAT_CHANGED, onChatChanged); | 4867 | eventSource.on(event_types.CHAT_CHANGED, onChatChanged); |
| 4920 | 4868 | ||
| 4921 | [event_types.SECRET_WRITTEN, event_types.SECRET_DELETED, event_types.SECRET_ROTATED].forEach(event => { | 4869 | [event_types.SECRET_WRITTEN, event_types.SECRET_DELETED, event_types.SECRET_ROTATED].forEach(event => { |
| @@ -426,7 +426,7 @@ function getStringHash(str) { | |||
| 426 | 426 | ||
| 427 | /** | 427 | /** |
| 428 | * Retrieves files from the chat and inserts them into the vector index. | 428 | * Retrieves files from the chat and inserts them into the vector index. |
| 429 | * @param {object[]} chat Array of chat messages | 429 | * @param {ChatMessage[]} chat Array of chat messages |
| 430 | * @returns {Promise<void>} | 430 | * @returns {Promise<void>} |
| 431 | */ | 431 | */ |
| 432 | async function processFiles(chat) { | 432 | async function processFiles(chat) { |
| @@ -443,39 +443,49 @@ async function processFiles(chat) { | |||
| 443 | } | 443 | } |
| 444 | 444 | ||
| 445 | for (const message of chat) { | 445 | for (const message of chat) { |
| 446 | // Message has no file | 446 | // Message has no files |
| 447 | if (!message?.extra?.file) { | 447 | if (!Array.isArray(message?.extra?.files) || !message.extra.files.length) { |
| 448 | continue; | 448 | continue; |
| 449 | } | 449 | } |
| 450 | 450 | ||
| 451 | // Trim file inserted by the script | 451 | // Trim file inserted by the script |
| 452 | const fileText = String(message.mes) | 452 | const allFileText = String(message.mes || '').substring(0, message.extra.fileLength).trim(); |
| 453 | .substring(0, message.extra.fileLength).trim(); | ||
| 454 | 453 | ||
| 455 | // Convert kilobytes to string length | 454 | // Convert kilobytes to string length |
| 456 | const thresholdLength = settings.size_threshold * 1024; | 455 | const thresholdLength = settings.size_threshold * 1024; |
| 457 | 456 | ||
| 458 | // File is too small | 457 | // File is too small |
| 459 | if (fileText.length < thresholdLength) { | 458 | if (allFileText.length < thresholdLength) { |
| 460 | continue; | 459 | continue; |
| 461 | } | 460 | } |
| 462 | 461 | ||
| 463 | message.mes = message.mes.substring(message.extra.fileLength); | 462 | message.mes = message.mes.substring(message.extra.fileLength); |
| 464 | 463 | ||
| 465 | const fileName = message.extra.file.name; | 464 | const allFileChunks = []; |
| 466 | const fileUrl = message.extra.file.url; | 465 | const queryText = await getQueryText(chat, 'file'); |
| 467 | const collectionId = getFileCollectionId(fileUrl); | ||
| 468 | const hashesInCollection = await getSavedHashes(collectionId); | ||
| 469 | 466 | ||
| 470 | // File is already in the collection | 467 | for (const file of message.extra.files) { |
| 471 | if (!hashesInCollection.length) { | 468 | const fileName = file.name; |
| 472 | await vectorizeFile(fileText, fileName, collectionId, settings.chunk_size, settings.overlap_percent); | 469 | const fileUrl = file.url; |
| 473 | } | 470 | const collectionId = getFileCollectionId(fileUrl); |
| 471 | const hashesInCollection = await getSavedHashes(collectionId); | ||
| 472 | |||
| 473 | // File is not vectorized yet | ||
| 474 | if (!hashesInCollection.length) { | ||
| 475 | const fileText = file.text || (await getFileAttachment(fileUrl)); | ||
| 476 | if (!fileText) { | ||
| 477 | continue; | ||
| 478 | } | ||
| 479 | await vectorizeFile(fileText, fileName, collectionId, settings.chunk_size, settings.overlap_percent); | ||
| 480 | } | ||
| 474 | 481 | ||
| 475 | const queryText = await getQueryText(chat, 'file'); | 482 | const fileChunks = await retrieveFileChunks(queryText, collectionId); |
| 476 | const fileChunks = await retrieveFileChunks(queryText, collectionId); | 483 | if (fileChunks) { |
| 484 | allFileChunks.push(fileChunks); | ||
| 485 | } | ||
| 486 | } | ||
| 477 | 487 | ||
| 478 | message.mes = `${fileChunks}\n\n${message.mes}`; | 488 | message.mes = `${allFileChunks.join('\n\n')}\n\n${message.mes}`; |
| 479 | } | 489 | } |
| 480 | } catch (error) { | 490 | } catch (error) { |
| 481 | console.error('Vectors: Failed to retrieve files', error); | 491 | console.error('Vectors: Failed to retrieve files', error); |
| @@ -614,7 +624,7 @@ async function vectorizeFile(fileText, fileName, collectionId, chunkSize, overla | |||
| 614 | 624 | ||
| 615 | /** | 625 | /** |
| 616 | * Removes the most relevant messages from the chat and displays them in the extension prompt | 626 | * Removes the most relevant messages from the chat and displays them in the extension prompt |
| 617 | * @param {object[]} chat Array of chat messages | 627 | * @param {ChatMessage[]} chat Array of chat messages |
| 618 | * @param {number} _contextSize Context size (unused) | 628 | * @param {number} _contextSize Context size (unused) |
| 619 | * @param {function} _abort Abort function (unused) | 629 | * @param {function} _abort Abort function (unused) |
| 620 | * @param {string} type Generation type | 630 | * @param {string} type Generation type |
| @@ -740,13 +750,18 @@ const onChatEvent = debounce(async () => await moduleWorker.update(), debounce_t | |||
| 740 | 750 | ||
| 741 | /** | 751 | /** |
| 742 | * Gets the text to query from the chat | 752 | * Gets the text to query from the chat |
| 743 | * @param {object[]} chat Chat messages | 753 | * @param {ChatMessage[]} chat Chat messages |
| 744 | * @param {'file'|'chat'|'world-info'} initiator Initiator of the query | 754 | * @param {'file'|'chat'|'world-info'} initiator Initiator of the query |
| 745 | * @returns {Promise<string>} Text to query | 755 | * @returns {Promise<string>} Text to query |
| 746 | */ | 756 | */ |
| 747 | async function getQueryText(chat, initiator) { | 757 | async function getQueryText(chat, initiator) { |
| 758 | const getTextWithoutAttachments = (x) => { | ||
| 759 | const fileLength = x?.extra?.fileLength || 0; | ||
| 760 | return String(x?.mes || '').substring(fileLength).trim(); | ||
| 761 | }; | ||
| 762 | |||
| 748 | let hashedMessages = chat | 763 | let hashedMessages = chat |
| 749 | .map(x => ({ text: String(substituteParams(x.mes)), hash: getStringHash(substituteParams(x.mes)), index: chat.indexOf(x) })) | 764 | .map(x => ({ text: substituteParams(getTextWithoutAttachments(x)), hash: getStringHash(substituteParams(getTextWithoutAttachments(x))), index: chat.indexOf(x) })) |
| 750 | .filter(x => x.text) | 765 | .filter(x => x.text) |
| 751 | .reverse() | 766 | .reverse() |
| 752 | .slice(0, settings.query); | 767 | .slice(0, settings.query); |
| @@ -1313,7 +1328,7 @@ async function onViewStatsClick() { | |||
| 1313 | async function onVectorizeAllFilesClick() { | 1328 | async function onVectorizeAllFilesClick() { |
| 1314 | try { | 1329 | try { |
| 1315 | const dataBank = getDataBankAttachments(); | 1330 | const dataBank = getDataBankAttachments(); |
| 1316 | const chatAttachments = getContext().chat.filter(x => x.extra?.file).map(x => x.extra.file); | 1331 | const chatAttachments = getContext().chat.filter(x => Array.isArray(x.extra?.files)).map(x => x.extra.files).flat(); |
| 1317 | const allFiles = [...dataBank, ...chatAttachments]; | 1332 | const allFiles = [...dataBank, ...chatAttachments]; |
| 1318 | 1333 | ||
| 1319 | /** | 1334 | /** |
| @@ -1390,7 +1405,7 @@ async function onVectorizeAllFilesClick() { | |||
| 1390 | async function onPurgeFilesClick() { | 1405 | async function onPurgeFilesClick() { |
| 1391 | try { | 1406 | try { |
| 1392 | const dataBank = getDataBankAttachments(); | 1407 | const dataBank = getDataBankAttachments(); |
| 1393 | const chatAttachments = getContext().chat.filter(x => x.extra?.file).map(x => x.extra.file); | 1408 | const chatAttachments = getContext().chat.filter(x => Array.isArray(x.extra?.files)).map(x => x.extra.files).flat(); |
| 1394 | const allFiles = [...dataBank, ...chatAttachments]; | 1409 | const allFiles = [...dataBank, ...chatAttachments]; |
| 1395 | 1410 | ||
| 1396 | for (const file of allFiles) { | 1411 | for (const file of allFiles) { |
| @@ -78,6 +78,7 @@ import { | |||
| 78 | shouldAutoContinue, | 78 | shouldAutoContinue, |
| 79 | unshallowCharacter, | 79 | unshallowCharacter, |
| 80 | chatElement, | 80 | chatElement, |
| 81 | ensureMessageMediaIsArray, | ||
| 81 | } from '../script.js'; | 82 | } from '../script.js'; |
| 82 | import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect } from './tags.js'; | 83 | import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect } from './tags.js'; |
| 83 | import { FILTER_TYPES, FilterHelper } from './filters.js'; | 84 | import { FILTER_TYPES, FilterHelper } from './filters.js'; |
| @@ -267,6 +268,7 @@ export async function getGroupChat(groupId, reload = false) { | |||
| 267 | } else if (Array.isArray(data) && data.length) { | 268 | } else if (Array.isArray(data) && data.length) { |
| 268 | data[0].is_group = true; | 269 | data[0].is_group = true; |
| 269 | chat.splice(0, chat.length, ...data); | 270 | chat.splice(0, chat.length, ...data); |
| 271 | chat.forEach(ensureMessageMediaIsArray); | ||
| 270 | chatElement.find('.mes').remove(); | 272 | chatElement.find('.mes').remove(); |
| 271 | await printMessages(); | 273 | await printMessages(); |
| 272 | } | 274 | } |
| @@ -15,6 +15,8 @@ import { | |||
| 15 | Generate, | 15 | Generate, |
| 16 | getExtensionPrompt, | 16 | getExtensionPrompt, |
| 17 | getExtensionPromptMaxDepth, | 17 | getExtensionPromptMaxDepth, |
| 18 | getMediaDisplay, | ||
| 19 | getMediaIndex, | ||
| 18 | getRequestHeaders, | 20 | getRequestHeaders, |
| 19 | getStoppingStrings, | 21 | getStoppingStrings, |
| 20 | is_send_press, | 22 | is_send_press, |
| @@ -74,7 +76,7 @@ import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js'; | |||
| 74 | import { t } from './i18n.js'; | 76 | import { t } from './i18n.js'; |
| 75 | import { ToolManager } from './tool-calling.js'; | 77 | import { ToolManager } from './tool-calling.js'; |
| 76 | import { accountStorage } from './util/AccountStorage.js'; | 78 | import { accountStorage } from './util/AccountStorage.js'; |
| 77 | import { COMETAPI_IGNORE_PATTERNS, IGNORE_SYMBOL } from './constants.js'; | 79 | import { COMETAPI_IGNORE_PATTERNS, IGNORE_SYMBOL, MEDIA_DISPLAY, MEDIA_TYPE } from './constants.js'; |
| 78 | 80 | ||
| 79 | export { | 81 | export { |
| 80 | openai_messages_count, | 82 | openai_messages_count, |
| @@ -539,10 +541,11 @@ function setOpenAIMessages(chat) { | |||
| 539 | // Apply the "wrap in quotes" option | 541 | // Apply the "wrap in quotes" option |
| 540 | if (role == 'user' && oai_settings.wrap_in_quotes) content = `"${content}"`; | 542 | if (role == 'user' && oai_settings.wrap_in_quotes) content = `"${content}"`; |
| 541 | const name = chat[j]['name']; | 543 | const name = chat[j]['name']; |
| 542 | const image = chat[j]?.extra?.image; | 544 | const media = chat[j]?.extra?.media; |
| 543 | const video = chat[j]?.extra?.video; | 545 | const mediaDisplay = getMediaDisplay(chat[j]); |
| 546 | const mediaIndex = getMediaIndex(chat[j]); | ||
| 544 | const invocations = chat[j]?.extra?.tool_invocations; | 547 | const invocations = chat[j]?.extra?.tool_invocations; |
| 545 | messages[i] = { 'role': role, 'content': content, name: name, 'image': image, 'video': video, 'invocations': invocations }; | 548 | messages[i] = { 'role': role, 'content': content, name: name, 'media': media, 'mediaDisplay': mediaDisplay, 'mediaIndex': mediaIndex, 'invocations': invocations }; |
| 546 | j++; | 549 | j++; |
| 547 | } | 550 | } |
| 548 | 551 | ||
| @@ -852,12 +855,35 @@ async function populateChatHistory(messages, prompts, chatCompletion, type = nul | |||
| 852 | await chatMessage.setName(messageName); | 855 | await chatMessage.setName(messageName); |
| 853 | } | 856 | } |
| 854 | 857 | ||
| 855 | if (imageInlining && chatPrompt.image) { | 858 | /** |
| 856 | await chatMessage.addImage(chatPrompt.image); | 859 | * Inline a media attachment into the chat message. |
| 860 | * @param {MediaAttachment} media - The media attachment to inline. | ||
| 861 | */ | ||
| 862 | async function inlineMediaAttachment(media) { | ||
| 863 | if (!media || !media.url) { | ||
| 864 | return; | ||
| 865 | } | ||
| 866 | if (!media.type) { | ||
| 867 | media.type = MEDIA_TYPE.IMAGE; | ||
| 868 | } | ||
| 869 | if (imageInlining && media.type === MEDIA_TYPE.IMAGE) { | ||
| 870 | await chatMessage.addImage(media.url); | ||
| 871 | } | ||
| 872 | if (videoInlining && media.type === MEDIA_TYPE.VIDEO) { | ||
| 873 | await chatMessage.addVideo(media.url); | ||
| 874 | } | ||
| 857 | } | 875 | } |
| 858 | 876 | ||
| 859 | if (videoInlining && chatPrompt.video) { | 877 | if (Array.isArray(chatPrompt.media) && chatPrompt.media.length) { |
| 860 | await chatMessage.addVideo(chatPrompt.video); | 878 | if (chatPrompt.mediaDisplay === MEDIA_DISPLAY.LIST) { |
| 879 | for (const media of chatPrompt.media) { | ||
| 880 | await inlineMediaAttachment(media); | ||
| 881 | } | ||
| 882 | } | ||
| 883 | if (chatPrompt.mediaDisplay === MEDIA_DISPLAY.GALLERY) { | ||
| 884 | const media = chatPrompt.media[chatPrompt.mediaIndex]; | ||
| 885 | await inlineMediaAttachment(media); | ||
| 886 | } | ||
| 861 | } | 887 | } |
| 862 | 888 | ||
| 863 | if (canUseTools && Array.isArray(chatPrompt.invocations)) { | 889 | if (canUseTools && Array.isArray(chatPrompt.invocations)) { |
| @@ -2905,6 +2931,13 @@ class Message { | |||
| 2905 | */ | 2931 | */ |
| 2906 | async addImage(image) { | 2932 | async addImage(image) { |
| 2907 | const textContent = this.content; | 2933 | const textContent = this.content; |
| 2934 | if (!Array.isArray(this.content)) { | ||
| 2935 | this.content = []; | ||
| 2936 | if (typeof textContent === 'string') { | ||
| 2937 | this.content.push({ type: 'text', text: textContent }); | ||
| 2938 | } | ||
| 2939 | } | ||
| 2940 | |||
| 2908 | const isDataUrl = isDataURL(image); | 2941 | const isDataUrl = isDataURL(image); |
| 2909 | if (!isDataUrl) { | 2942 | if (!isDataUrl) { |
| 2910 | try { | 2943 | try { |
| @@ -2921,10 +2954,7 @@ class Message { | |||
| 2921 | image = await this.compressImage(image); | 2954 | image = await this.compressImage(image); |
| 2922 | 2955 | ||
| 2923 | const quality = oai_settings.inline_image_quality || default_settings.inline_image_quality; | 2956 | const quality = oai_settings.inline_image_quality || default_settings.inline_image_quality; |
| 2924 | this.content = [ | 2957 | this.content.push({ type: 'image_url', image_url: { 'url': image, 'detail': quality } }); |
| 2925 | { type: 'text', text: textContent }, | ||
| 2926 | { type: 'image_url', image_url: { 'url': image, 'detail': quality } }, | ||
| 2927 | ]; | ||
| 2928 | 2958 | ||
| 2929 | try { | 2959 | try { |
| 2930 | const tokens = await this.getImageTokenCost(image, quality); | 2960 | const tokens = await this.getImageTokenCost(image, quality); |
| @@ -2935,8 +2965,20 @@ class Message { | |||
| 2935 | } | 2965 | } |
| 2936 | } | 2966 | } |
| 2937 | 2967 | ||
| 2968 | /** | ||
| 2969 | * Adds a video to the message. | ||
| 2970 | * @param {string} video Video URL or Data URL. | ||
| 2971 | * @returns {Promise<void>} | ||
| 2972 | */ | ||
| 2938 | async addVideo(video) { | 2973 | async addVideo(video) { |
| 2939 | const textContent = this.content; | 2974 | const textContent = this.content; |
| 2975 | if (!Array.isArray(this.content)) { | ||
| 2976 | this.content = []; | ||
| 2977 | if (typeof textContent === 'string') { | ||
| 2978 | this.content.push({ type: 'text', text: textContent }); | ||
| 2979 | } | ||
| 2980 | } | ||
| 2981 | |||
| 2940 | const isDataUrl = isDataURL(video); | 2982 | const isDataUrl = isDataURL(video); |
| 2941 | if (!isDataUrl) { | 2983 | if (!isDataUrl) { |
| 2942 | try { | 2984 | try { |
| @@ -2951,10 +2993,7 @@ class Message { | |||
| 2951 | } | 2993 | } |
| 2952 | 2994 | ||
| 2953 | // Note: No compression for videos (unlike images) | 2995 | // Note: No compression for videos (unlike images) |
| 2954 | this.content = [ | 2996 | this.content.push({ type: 'video_url', video_url: { 'url': video } }); |
| 2955 | { type: 'text', text: textContent }, | ||
| 2956 | { type: 'video_url', video_url: { 'url': video } }, | ||
| 2957 | ]; | ||
| 2958 | 2997 | ||
| 2959 | try { | 2998 | try { |
| 2960 | // Convservative estimate for video token cost without knowing duration | 2999 | // Convservative estimate for video token cost without knowing duration |
| @@ -63,6 +63,8 @@ import { fuzzySearchCategories } from './filters.js'; | |||
| 63 | import { accountStorage } from './util/AccountStorage.js'; | 63 | import { accountStorage } from './util/AccountStorage.js'; |
| 64 | import { DEFAULT_REASONING_TEMPLATE, loadReasoningTemplates } from './reasoning.js'; | 64 | import { DEFAULT_REASONING_TEMPLATE, loadReasoningTemplates } from './reasoning.js'; |
| 65 | import { bindModelTemplates } from './chat-templates.js'; | 65 | import { bindModelTemplates } from './chat-templates.js'; |
| 66 | import { MEDIA_DISPLAY } from './constants.js'; | ||
| 67 | import { t } from './i18n.js'; | ||
| 66 | 68 | ||
| 67 | export const toastPositionClasses = [ | 69 | export const toastPositionClasses = [ |
| 68 | 'toast-top-left', | 70 | 'toast-top-left', |
| @@ -337,6 +339,7 @@ export const power_user = { | |||
| 337 | external_media_forbidden_overrides: [], | 339 | external_media_forbidden_overrides: [], |
| 338 | pin_styles: true, | 340 | pin_styles: true, |
| 339 | click_to_edit: false, | 341 | click_to_edit: false, |
| 342 | media_display: MEDIA_DISPLAY.LIST, | ||
| 340 | }; | 343 | }; |
| 341 | 344 | ||
| 342 | let themes = []; | 345 | let themes = []; |
| @@ -1178,6 +1181,46 @@ function applyFontScale(type) { | |||
| 1178 | $('#font_scale').val(power_user.font_scale); | 1181 | $('#font_scale').val(power_user.font_scale); |
| 1179 | } | 1182 | } |
| 1180 | 1183 | ||
| 1184 | /** | ||
| 1185 | * Checks if the chat needs to be reloaded to apply media display settings. | ||
| 1186 | * @returns {boolean} True if the chat needs reload to apply media display settings | ||
| 1187 | */ | ||
| 1188 | function isMediaDisplayReloadNeeded() { | ||
| 1189 | // A user is not currently in a chat. | ||
| 1190 | const chatId = getCurrentChatId(); | ||
| 1191 | if (!chatId) { | ||
| 1192 | return false; | ||
| 1193 | } | ||
| 1194 | |||
| 1195 | const firstDisplayedIndex = getFirstDisplayedMessageId(); | ||
| 1196 | const hasUnprocessedMediaMessages = chat.some((message, index) => { | ||
| 1197 | // Skip messages that are not currently displayed | ||
| 1198 | if (index < firstDisplayedIndex) { | ||
| 1199 | return false; | ||
| 1200 | } | ||
| 1201 | const hasMediaAttachments = Array.isArray(message?.extra?.media) && message.extra.media.length > 0; | ||
| 1202 | const lacksMediaDisplay = !message?.extra?.media_display; | ||
| 1203 | return hasMediaAttachments && lacksMediaDisplay; | ||
| 1204 | }); | ||
| 1205 | |||
| 1206 | return hasUnprocessedMediaMessages; | ||
| 1207 | } | ||
| 1208 | |||
| 1209 | /** | ||
| 1210 | * Shows a toast notification prompting the user to reload the chat if media display settings have changed | ||
| 1211 | * and there are messages with media attachments that haven't been processed with the new display format. | ||
| 1212 | */ | ||
| 1213 | function showMediaDisplayReloadPrompt() { | ||
| 1214 | if (!isMediaDisplayReloadNeeded()) { | ||
| 1215 | return; | ||
| 1216 | } | ||
| 1217 | toastr.info( | ||
| 1218 | t`Reload the chat to apply the changes. Click here to reload.`, | ||
| 1219 | t`Media Style changed`, | ||
| 1220 | { onclick: () => void reloadCurrentChat() }, | ||
| 1221 | ); | ||
| 1222 | } | ||
| 1223 | |||
| 1181 | function applyTheme(name) { | 1224 | function applyTheme(name) { |
| 1182 | const theme = themes.find(x => x.name == name); | 1225 | const theme = themes.find(x => x.name == name); |
| 1183 | 1226 | ||
| @@ -1367,14 +1410,25 @@ function applyTheme(name) { | |||
| 1367 | $('#click_to_edit').prop('checked', power_user.click_to_edit); | 1410 | $('#click_to_edit').prop('checked', power_user.click_to_edit); |
| 1368 | }, | 1411 | }, |
| 1369 | }, | 1412 | }, |
| 1413 | { | ||
| 1414 | key: 'media_display', | ||
| 1415 | action: (oldValue, newValue) => { | ||
| 1416 | $('#media_display').val(power_user.media_display); | ||
| 1417 | if (oldValue !== newValue) { | ||
| 1418 | showMediaDisplayReloadPrompt(); | ||
| 1419 | } | ||
| 1420 | }, | ||
| 1421 | }, | ||
| 1370 | ]; | 1422 | ]; |
| 1371 | 1423 | ||
| 1372 | for (const { key, selector, type, action } of themeProperties) { | 1424 | for (const { key, selector, type, action } of themeProperties) { |
| 1373 | if (theme[key] !== undefined) { | 1425 | if (theme[key] !== undefined) { |
| 1374 | power_user[key] = theme[key]; | 1426 | const oldValue = power_user[key]; |
| 1375 | if (selector) $(selector).attr('color', power_user[key]); | 1427 | const newValue = theme[key]; |
| 1428 | power_user[key] = newValue; | ||
| 1429 | if (selector) $(selector).attr('color', newValue); | ||
| 1376 | if (type) applyThemeColor(type); | 1430 | if (type) applyThemeColor(type); |
| 1377 | if (action) action(); | 1431 | if (action) action(oldValue, newValue); |
| 1378 | } else { | 1432 | } else { |
| 1379 | console.debug(`Empty theme key: ${key}`); | 1433 | console.debug(`Empty theme key: ${key}`); |
| 1380 | } | 1434 | } |
| @@ -1714,6 +1768,7 @@ export async function loadPowerUserSettings(settings, data) { | |||
| 1714 | $('#forbid_external_media').prop('checked', power_user.forbid_external_media); | 1768 | $('#forbid_external_media').prop('checked', power_user.forbid_external_media); |
| 1715 | $('#pin_styles').prop('checked', power_user.pin_styles); | 1769 | $('#pin_styles').prop('checked', power_user.pin_styles); |
| 1716 | $('#click_to_edit').prop('checked', power_user.click_to_edit); | 1770 | $('#click_to_edit').prop('checked', power_user.click_to_edit); |
| 1771 | $('#media_display').val(power_user.media_display); | ||
| 1717 | 1772 | ||
| 1718 | for (const theme of themes) { | 1773 | for (const theme of themes) { |
| 1719 | const option = document.createElement('option'); | 1774 | const option = document.createElement('option'); |
| @@ -2506,6 +2561,7 @@ function getThemeObject(name) { | |||
| 2506 | compact_input_area: power_user.compact_input_area, | 2561 | compact_input_area: power_user.compact_input_area, |
| 2507 | show_swipe_num_all_messages: power_user.show_swipe_num_all_messages, | 2562 | show_swipe_num_all_messages: power_user.show_swipe_num_all_messages, |
| 2508 | click_to_edit: power_user.click_to_edit, | 2563 | click_to_edit: power_user.click_to_edit, |
| 2564 | media_display: power_user.media_display, | ||
| 2509 | }; | 2565 | }; |
| 2510 | } | 2566 | } |
| 2511 | 2567 | ||
| @@ -4121,6 +4177,14 @@ jQuery(() => { | |||
| 4121 | await exportTheme(); | 4177 | await exportTheme(); |
| 4122 | }); | 4178 | }); |
| 4123 | 4179 | ||
| 4180 | $('#media_display').on('input', async function () { | ||
| 4181 | power_user.media_display = $(this).val().toString(); | ||
| 4182 | saveSettingsDebounced(); | ||
| 4183 | if (isMediaDisplayReloadNeeded()) { | ||
| 4184 | await reloadCurrentChat(); | ||
| 4185 | } | ||
| 4186 | }); | ||
| 4187 | |||
| 4124 | $(document).on('click', '#debug_table [data-debug-function]', function () { | 4188 | $(document).on('click', '#debug_table [data-debug-function]', function () { |
| 4125 | const functionId = $(this).data('debug-function'); | 4189 | const functionId = $(this).data('debug-function'); |
| 4126 | const functionRecord = debug_functions.find(f => f.functionId === functionId); | 4190 | const functionRecord = debug_functions.find(f => f.functionId === functionId); |
| @@ -409,7 +409,7 @@ export class ReasoningHandler { | |||
| 409 | if (!power_user.reasoning.prefix || !power_user.reasoning.suffix) | 409 | if (!power_user.reasoning.prefix || !power_user.reasoning.suffix) |
| 410 | return mesChanged; | 410 | return mesChanged; |
| 411 | 411 | ||
| 412 | /** @type {{ mes: string, [key: string]: any}} */ | 412 | /** @type {ChatMessage} */ |
| 413 | const message = chat[messageId]; | 413 | const message = chat[messageId]; |
| 414 | if (!message) return mesChanged; | 414 | if (!message) return mesChanged; |
| 415 | 415 | ||
| @@ -1259,7 +1259,7 @@ export function parseReasoningFromString(str, { strict = true } = {}) { | |||
| 1259 | /** | 1259 | /** |
| 1260 | * Parse reasoning in an array of swipe strings if auto-parsing is enabled. | 1260 | * Parse reasoning in an array of swipe strings if auto-parsing is enabled. |
| 1261 | * @param {string[]} swipes Array of swipe strings | 1261 | * @param {string[]} swipes Array of swipe strings |
| 1262 | * @param {{extra: ReasoningMessageExtra}[]} swipeInfoArray Array of swipe info objects | 1262 | * @param {{extra: Partial<ReasoningMessageExtra>}[]} swipeInfoArray Array of swipe info objects |
| 1263 | * @param {number?} duration Duration of the reasoning | 1263 | * @param {number?} duration Duration of the reasoning |
| 1264 | * @typedef {object} ReasoningMessageExtra Extra reasoning data | 1264 | * @typedef {object} ReasoningMessageExtra Extra reasoning data |
| 1265 | * @property {string} reasoning Reasoning block | 1265 | * @property {string} reasoning Reasoning block |
| @@ -3866,11 +3866,6 @@ async function addSwipeCallback(args, value) { | |||
| 3866 | return ''; | 3866 | return ''; |
| 3867 | } | 3867 | } |
| 3868 | 3868 | ||
| 3869 | if (lastMessage.extra?.image) { | ||
| 3870 | toastr.warning(t`Can't add swipes to message containing an image.`); | ||
| 3871 | return ''; | ||
| 3872 | } | ||
| 3873 | |||
| 3874 | if (!Array.isArray(lastMessage.swipes)) { | 3869 | if (!Array.isArray(lastMessage.swipes)) { |
| 3875 | lastMessage.swipes = [lastMessage.mes]; | 3870 | lastMessage.swipes = [lastMessage.mes]; |
| 3876 | lastMessage.swipe_info = [{}]; | 3871 | lastMessage.swipe_info = [{}]; |
| @@ -40,6 +40,7 @@ export const enumIcons = { | |||
| 40 | server: '🖥️', | 40 | server: '🖥️', |
| 41 | popup: '🗔', | 41 | popup: '🗔', |
| 42 | image: '🖼️', | 42 | image: '🖼️', |
| 43 | video: '🎥', | ||
| 43 | key: '🔑', | 44 | key: '🔑', |
| 44 | 45 | ||
| 45 | true: '✔️', | 46 | true: '✔️', |
| @@ -263,6 +264,22 @@ export const commonEnumProviders = { | |||
| 263 | }, | 264 | }, |
| 264 | 265 | ||
| 265 | /** | 266 | /** |
| 267 | * Media items attached to a specific message | ||
| 268 | * @returns {(executor:SlashCommandExecutor, scope:SlashCommandScope) => SlashCommandEnumValue[]} | ||
| 269 | */ | ||
| 270 | messageMedia: () => (executor, _scope) => { | ||
| 271 | const messageId = Number(executor.namedArgumentList.find(it => ['mesId', 'id'].includes(it.name))?.value || ''); | ||
| 272 | if (isNaN(messageId) || messageId === null || messageId < 0 || messageId >= chat.length) { | ||
| 273 | return []; | ||
| 274 | } | ||
| 275 | const message = chat[messageId]; | ||
| 276 | if (!Array.isArray(message?.extra?.media)) { | ||
| 277 | return []; | ||
| 278 | } | ||
| 279 | return message.extra.media.map((media, index) => new SlashCommandEnumValue(index.toString(), media.title || message.extra.title || '[Untitled]', enumTypes.enum, enumIcons[media.type] || enumIcons.file)); | ||
| 280 | }, | ||
| 281 | |||
| 282 | /** | ||
| 266 | * All names used in the current chat. | 283 | * All names used in the current chat. |
| 267 | * | 284 | * |
| 268 | * @returns {SlashCommandEnumValue[]} | 285 | * @returns {SlashCommandEnumValue[]} |
| @@ -58,6 +58,9 @@ import { | |||
| 58 | deleteMessage, | 58 | deleteMessage, |
| 59 | refreshSwipeButtons, | 59 | refreshSwipeButtons, |
| 60 | isSwipingAllowed, | 60 | isSwipingAllowed, |
| 61 | ensureMessageMediaIsArray, | ||
| 62 | getMediaDisplay, | ||
| 63 | getMediaIndex, | ||
| 61 | } from '../script.js'; | 64 | } from '../script.js'; |
| 62 | import { | 65 | import { |
| 63 | extension_settings, | 66 | extension_settings, |
| @@ -209,6 +212,9 @@ export function getContext() { | |||
| 209 | humanizedDateTime, | 212 | humanizedDateTime, |
| 210 | updateMessageBlock, | 213 | updateMessageBlock, |
| 211 | appendMediaToMessage, | 214 | appendMediaToMessage, |
| 215 | ensureMessageMediaIsArray, | ||
| 216 | getMediaDisplay, | ||
| 217 | getMediaIndex, | ||
| 212 | swipe: { | 218 | swipe: { |
| 213 | left: swipe_left, | 219 | left: swipe_left, |
| 214 | right: swipe_right, | 220 | right: swipe_right, |
| @@ -5,8 +5,9 @@ import { getSlashCommandsHelp } from './slash-commands.js'; | |||
| 5 | import { SlashCommandBrowser } from './slash-commands/SlashCommandBrowser.js'; | 5 | import { SlashCommandBrowser } from './slash-commands/SlashCommandBrowser.js'; |
| 6 | import { renderTemplateAsync } from './templates.js'; | 6 | import { renderTemplateAsync } from './templates.js'; |
| 7 | 7 | ||
| 8 | // Initialized in getSystemMessages() | 8 | /** @type {Record<string, ChatMessage>} */ |
| 9 | export const system_messages = {}; | 9 | export const system_messages = {}; |
| 10 | /** @type {ChatMessage[]} */ | ||
| 10 | export const SAFETY_CHAT = []; | 11 | export const SAFETY_CHAT = []; |
| 11 | 12 | ||
| 12 | /** | 13 | /** |
| @@ -29,6 +30,7 @@ export const system_message_types = { | |||
| 29 | }; | 30 | }; |
| 30 | 31 | ||
| 31 | export async function initSystemMessages() { | 32 | export async function initSystemMessages() { |
| 33 | /** @type {Record<string, ChatMessage>} */ | ||
| 32 | const result = { | 34 | const result = { |
| 33 | help: { | 35 | help: { |
| 34 | name: systemUserName, | 36 | name: systemUserName, |
| @@ -65,14 +67,15 @@ export async function initSystemMessages() { | |||
| 65 | is_system: true, | 67 | is_system: true, |
| 66 | mes: await renderTemplateAsync('macros'), | 68 | mes: await renderTemplateAsync('macros'), |
| 67 | }, | 69 | }, |
| 68 | welcome: | 70 | welcome: { |
| 69 | { | ||
| 70 | name: systemUserName, | 71 | name: systemUserName, |
| 71 | force_avatar: system_avatar, | 72 | force_avatar: system_avatar, |
| 72 | is_user: false, | 73 | is_user: false, |
| 73 | is_system: true, | 74 | is_system: true, |
| 74 | uses_system_ui: true, | ||
| 75 | mes: await renderTemplateAsync('welcome', { displayVersion }), | 75 | mes: await renderTemplateAsync('welcome', { displayVersion }), |
| 76 | extra: { | ||
| 77 | uses_system_ui: true, | ||
| 78 | }, | ||
| 76 | }, | 79 | }, |
| 77 | empty: { | 80 | empty: { |
| 78 | name: systemUserName, | 81 | name: systemUserName, |
| @@ -93,9 +96,9 @@ export async function initSystemMessages() { | |||
| 93 | force_avatar: system_avatar, | 96 | force_avatar: system_avatar, |
| 94 | is_user: false, | 97 | is_user: false, |
| 95 | is_system: true, | 98 | is_system: true, |
| 96 | uses_system_ui: true, | ||
| 97 | mes: await renderTemplateAsync('welcomePrompt'), | 99 | mes: await renderTemplateAsync('welcomePrompt'), |
| 98 | extra: { | 100 | extra: { |
| 101 | uses_system_ui: true, | ||
| 99 | isSmallSys: true, | 102 | isSmallSys: true, |
| 100 | }, | 103 | }, |
| 101 | }, | 104 | }, |
| @@ -105,8 +108,8 @@ export async function initSystemMessages() { | |||
| 105 | is_user: false, | 108 | is_user: false, |
| 106 | is_system: true, | 109 | is_system: true, |
| 107 | mes: await renderTemplateAsync('assistantNote'), | 110 | mes: await renderTemplateAsync('assistantNote'), |
| 108 | uses_system_ui: true, | ||
| 109 | extra: { | 111 | extra: { |
| 112 | uses_system_ui: true, | ||
| 110 | isSmallSys: true, | 113 | isSmallSys: true, |
| 111 | }, | 114 | }, |
| 112 | }, | 115 | }, |
| @@ -114,12 +117,13 @@ export async function initSystemMessages() { | |||
| 114 | 117 | ||
| 115 | Object.assign(system_messages, result); | 118 | Object.assign(system_messages, result); |
| 116 | 119 | ||
| 120 | /** @type {ChatMessage} */ | ||
| 117 | const safetyMessage = { | 121 | const safetyMessage = { |
| 118 | name: systemUserName, | 122 | name: systemUserName, |
| 119 | force_avatar: system_avatar, | 123 | force_avatar: system_avatar, |
| 120 | is_system: true, | 124 | is_system: true, |
| 121 | is_user: false, | 125 | is_user: false, |
| 122 | create_date: 0, | 126 | send_date: getMessageTimeStamp(), |
| 123 | mes: t`You deleted a character/chat and arrived back here for safety reasons! Pick another character!`, | 127 | mes: t`You deleted a character/chat and arrived back here for safety reasons! Pick another character!`, |
| 124 | }; | 128 | }; |
| 125 | SAFETY_CHAT.splice(0, SAFETY_CHAT.length, safetyMessage); | 129 | SAFETY_CHAT.splice(0, SAFETY_CHAT.length, safetyMessage); |
| @@ -130,8 +134,8 @@ export async function initSystemMessages() { | |||
| 130 | * Gets a system message by type. | 134 | * Gets a system message by type. |
| 131 | * @param {string} type Type of system message | 135 | * @param {string} type Type of system message |
| 132 | * @param {string} [text] Text to be sent | 136 | * @param {string} [text] Text to be sent |
| 133 | * @param {object} [extra] Additional data to be added to the message | 137 | * @param {ChatMessageExtra} [extra] Additional data to be added to the message |
| 134 | * @returns {object} System message object | 138 | * @returns {ChatMessage} System message object |
| 135 | */ | 139 | */ |
| 136 | export function getSystemMessageByType(type, text, extra = {}) { | 140 | export function getSystemMessageByType(type, text, extra = {}) { |
| 137 | const systemMessage = system_messages[type]; | 141 | const systemMessage = system_messages[type]; |
| @@ -150,7 +154,7 @@ export function getSystemMessageByType(type, text, extra = {}) { | |||
| 150 | newMessage.mes = getSlashCommandsHelp(); | 154 | newMessage.mes = getSlashCommandsHelp(); |
| 151 | } | 155 | } |
| 152 | 156 | ||
| 153 | if (!newMessage.extra) { | 157 | if (!newMessage.extra || typeof newMessage.extra !== 'object') { |
| 154 | newMessage.extra = {}; | 158 | newMessage.extra = {}; |
| 155 | } | 159 | } |
| 156 | 160 | ||
| @@ -163,7 +167,7 @@ export function getSystemMessageByType(type, text, extra = {}) { | |||
| 163 | * Sends a system message to the chat. | 167 | * Sends a system message to the chat. |
| 164 | * @param {string} type Type of system message | 168 | * @param {string} type Type of system message |
| 165 | * @param {string} [text] Text to be sent | 169 | * @param {string} [text] Text to be sent |
| 166 | * @param {object} [extra] Additional data to be added to the message | 170 | * @param {ChatMessageExtra} [extra] Additional data to be added to the message |
| 167 | */ | 171 | */ |
| 168 | export function sendSystemMessage(type, text, extra = {}) { | 172 | export function sendSystemMessage(type, text, extra = {}) { |
| 169 | const newMessage = getSystemMessageByType(type, text, extra); | 173 | const newMessage = getSystemMessageByType(type, text, extra); |
| @@ -408,6 +408,16 @@ export async function urlContentToDataUri(url, params) { | |||
| 408 | } | 408 | } |
| 409 | 409 | ||
| 410 | /** | 410 | /** |
| 411 | * Fuzzily compares two files for equality. Only checks attributes, not contents. | ||
| 412 | * @param {File} a First file | ||
| 413 | * @param {File} b Second file | ||
| 414 | * @returns {boolean} True if the files are probably the same, false otherwise. | ||
| 415 | */ | ||
| 416 | export function isSameFile(a, b) { | ||
| 417 | return a.lastModified === b.lastModified && a.name === b.name && a.size === b.size && a.type === b.type; | ||
| 418 | } | ||
| 419 | |||
| 420 | /** | ||
| 411 | * Returns a promise that resolves to the file's text. | 421 | * Returns a promise that resolves to the file's text. |
| 412 | * @param {Blob} file The file to read. | 422 | * @param {Blob} file The file to read. |
| 413 | * @returns {Promise<string>} A promise that resolves to the file's text. | 423 | * @returns {Promise<string>} A promise that resolves to the file's text. |
| @@ -1009,7 +1019,7 @@ const dateCache = new Map(); | |||
| 1009 | /** | 1019 | /** |
| 1010 | * Cached version of moment() to avoid re-parsing the same date strings. | 1020 | * Cached version of moment() to avoid re-parsing the same date strings. |
| 1011 | * Important: Moment objects are mutable, so use clone() before modifying them! | 1021 | * Important: Moment objects are mutable, so use clone() before modifying them! |
| 1012 | * @param {string|number} timestamp String or number representing a date. | 1022 | * @param {MessageTimestamp} timestamp String or number representing a date. |
| 1013 | * @returns {import('moment').Moment} Moment object | 1023 | * @returns {import('moment').Moment} Moment object |
| 1014 | */ | 1024 | */ |
| 1015 | export function timestampToMoment(timestamp) { | 1025 | export function timestampToMoment(timestamp) { |
| @@ -1026,12 +1036,17 @@ export function timestampToMoment(timestamp) { | |||
| 1026 | 1036 | ||
| 1027 | /** | 1037 | /** |
| 1028 | * Parses a timestamp and returns a moment object representing the parsed date and time. | 1038 | * Parses a timestamp and returns a moment object representing the parsed date and time. |
| 1029 | * @param {string|number} timestamp - The timestamp to parse. It can be a string or a number. | 1039 | * @param {MessageTimestamp} timestamp - The timestamp to parse. It can be a string or a number. |
| 1030 | * @returns {string} - If the timestamp is valid, returns an ISO 8601 string. | 1040 | * @returns {string} - If the timestamp is valid, returns an ISO 8601 string. |
| 1031 | */ | 1041 | */ |
| 1032 | function parseTimestamp(timestamp) { | 1042 | function parseTimestamp(timestamp) { |
| 1033 | if (!timestamp) return; | 1043 | if (!timestamp) return; |
| 1034 | 1044 | ||
| 1045 | // Date object | ||
| 1046 | if (timestamp instanceof Date) { | ||
| 1047 | return timestamp.toISOString(); | ||
| 1048 | } | ||
| 1049 | |||
| 1035 | // Unix time (legacy TAI / tags) | 1050 | // Unix time (legacy TAI / tags) |
| 1036 | if (typeof timestamp === 'number' || /^\d+$/.test(timestamp)) { | 1051 | if (typeof timestamp === 'number' || /^\d+$/.test(timestamp)) { |
| 1037 | const unixTime = Number(timestamp); | 1052 | const unixTime = Number(timestamp); |
| @@ -388,6 +388,11 @@ input[type='checkbox']:focus-visible { | |||
| 388 | margin-bottom: 10px; | 388 | margin-bottom: 10px; |
| 389 | } | 389 | } |
| 390 | 390 | ||
| 391 | .mes_text p:last-child, | ||
| 392 | .mes_reasoning p:last-child { | ||
| 393 | margin-bottom: 0; | ||
| 394 | } | ||
| 395 | |||
| 391 | .mes_text li tt, | 396 | .mes_text li tt, |
| 392 | .mes_reasoning li tt { | 397 | .mes_reasoning li tt { |
| 393 | display: inline-block; | 398 | display: inline-block; |
| @@ -637,6 +642,16 @@ input[type='checkbox']:focus-visible { | |||
| 637 | display: flex; | 642 | display: flex; |
| 638 | } | 643 | } |
| 639 | 644 | ||
| 645 | .mes .mes_media_gallery, | ||
| 646 | .mes .mes_media_list { | ||
| 647 | display: none; | ||
| 648 | } | ||
| 649 | |||
| 650 | .mes[data-media-display="gallery"] .mes_media_gallery, | ||
| 651 | .mes[data-media-display="list"] .mes_media_list { | ||
| 652 | display: flex; | ||
| 653 | } | ||
| 654 | |||
| 640 | small { | 655 | small { |
| 641 | color: var(--SmartThemeBodyColor); | 656 | color: var(--SmartThemeBodyColor); |
| 642 | opacity: 0.7; | 657 | opacity: 0.7; |
| @@ -4952,6 +4967,22 @@ a:hover { | |||
| 4952 | } | 4967 | } |
| 4953 | 4968 | ||
| 4954 | /* Message images/video */ | 4969 | /* Message images/video */ |
| 4970 | .mes .mes_media_wrapper:empty { | ||
| 4971 | display: none; | ||
| 4972 | } | ||
| 4973 | |||
| 4974 | .mes .mes_media_wrapper { | ||
| 4975 | display: flex; | ||
| 4976 | flex-direction: row; | ||
| 4977 | align-items: center; | ||
| 4978 | flex-wrap: wrap; | ||
| 4979 | gap: 0.5em; | ||
| 4980 | } | ||
| 4981 | |||
| 4982 | .mes_media_wrapper:not(:empty)~.mes_file_wrapper:not(:empty) { | ||
| 4983 | margin-top: 0.5em; | ||
| 4984 | } | ||
| 4985 | |||
| 4955 | .mes .mes_img_container, | 4986 | .mes .mes_img_container, |
| 4956 | .mes .mes_video_container { | 4987 | .mes .mes_video_container { |
| 4957 | max-width: 100%; | 4988 | max-width: 100%; |
| @@ -4960,7 +4991,8 @@ a:hover { | |||
| 4960 | position: relative; | 4991 | position: relative; |
| 4961 | width: fit-content; | 4992 | width: fit-content; |
| 4962 | transition: all var(--animation-duration); | 4993 | transition: all var(--animation-duration); |
| 4963 | padding: 0.5rem; | 4994 | border-radius: 5px; |
| 4995 | overflow: hidden; | ||
| 4964 | } | 4996 | } |
| 4965 | 4997 | ||
| 4966 | .mes .mes_video_container:has(.mes_video[src]) { | 4998 | .mes .mes_video_container:has(.mes_video[src]) { |
| @@ -4968,7 +5000,6 @@ a:hover { | |||
| 4968 | } | 5000 | } |
| 4969 | 5001 | ||
| 4970 | .mes_img { | 5002 | .mes_img { |
| 4971 | border-radius: 5px; | ||
| 4972 | max-width: 100%; | 5003 | max-width: 100%; |
| 4973 | max-height: 40vh; | 5004 | max-height: 40vh; |
| 4974 | image-rendering: -webkit-optimize-contrast; | 5005 | image-rendering: -webkit-optimize-contrast; |
| @@ -4985,7 +5016,7 @@ a:hover { | |||
| 4985 | .mes_img_controls, | 5016 | .mes_img_controls, |
| 4986 | .mes_video_controls { | 5017 | .mes_video_controls { |
| 4987 | position: absolute; | 5018 | position: absolute; |
| 4988 | top: 0.1em; | 5019 | top: 0; |
| 4989 | left: 0; | 5020 | left: 0; |
| 4990 | width: 100%; | 5021 | width: 100%; |
| 4991 | display: flex; | 5022 | display: flex; |
| @@ -4993,14 +5024,16 @@ a:hover { | |||
| 4993 | flex-direction: row; | 5024 | flex-direction: row; |
| 4994 | justify-content: space-between; | 5025 | justify-content: space-between; |
| 4995 | align-items: center; | 5026 | align-items: center; |
| 4996 | padding: 1em; | 5027 | padding: 10px; |
| 4997 | z-index: 1; | 5028 | z-index: 1; |
| 4998 | transition: opacity var(--animation-duration) ease-in-out; | 5029 | transition: opacity var(--animation-duration) ease-in-out; |
| 5030 | background: linear-gradient(rgba(0, 0, 0, 0.8), transparent); | ||
| 4999 | } | 5031 | } |
| 5000 | 5032 | ||
| 5001 | .mes_img_swipes { | 5033 | .mes_img_swipes { |
| 5002 | top: unset; | 5034 | top: unset; |
| 5003 | bottom: 0.1rem; | 5035 | bottom: 0; |
| 5036 | background: linear-gradient(transparent, rgba(0, 0, 0, 0.8)); | ||
| 5004 | } | 5037 | } |
| 5005 | 5038 | ||
| 5006 | .mes_img_swipes .right_menu_button, | 5039 | .mes_img_swipes .right_menu_button, |
| @@ -5036,11 +5069,18 @@ a:hover { | |||
| 5036 | .mes_img_container:focus-within .mes_img_swipes, | 5069 | .mes_img_container:focus-within .mes_img_swipes, |
| 5037 | .mes_img_container:hover .mes_img_controls, | 5070 | .mes_img_container:hover .mes_img_controls, |
| 5038 | .mes_img_container:focus-within .mes_img_controls, | 5071 | .mes_img_container:focus-within .mes_img_controls, |
| 5039 | .mes_video_container:hover .mes_video_controls { | 5072 | .mes_video_container:hover .mes_video_controls, |
| 5073 | .mes_video_container:has(.mes_video.error) .mes_img_swipes, | ||
| 5074 | .mes_video_container:hover .mes_img_swipes { | ||
| 5040 | opacity: 1; | 5075 | opacity: 1; |
| 5041 | } | 5076 | } |
| 5042 | 5077 | ||
| 5043 | .mes .mes_img_container.img_extra { | 5078 | .mes_media_container:has(.error) .mes_img_controls, |
| 5079 | .mes_media_container:has(.error) .mes_img_swipes { | ||
| 5080 | background: none; | ||
| 5081 | } | ||
| 5082 | |||
| 5083 | .mes .mes_img_container { | ||
| 5044 | display: flex; | 5084 | display: flex; |
| 5045 | } | 5085 | } |
| 5046 | 5086 | ||
| @@ -5048,8 +5088,7 @@ body:not(.caption) .mes_img_caption { | |||
| 5048 | display: none; | 5088 | display: none; |
| 5049 | } | 5089 | } |
| 5050 | 5090 | ||
| 5051 | .mes_img_container:not(.img_swipes) .mes_img_swipes, | 5091 | .mes_img_container:not(.img_swipes) .mes_img_swipes { |
| 5052 | body:not(.sd) .mes_img_swipes { | ||
| 5053 | display: none; | 5092 | display: none; |
| 5054 | } | 5093 | } |
| 5055 | 5094 | ||
| @@ -5136,7 +5175,6 @@ body:not(.sd) .mes_img_swipes { | |||
| 5136 | .mes_video { | 5175 | .mes_video { |
| 5137 | max-width: 100%; | 5176 | max-width: 100%; |
| 5138 | max-height: 400px; | 5177 | max-height: 400px; |
| 5139 | border-radius: 5px; | ||
| 5140 | background: #000; | 5178 | background: #000; |
| 5141 | } | 5179 | } |
| 5142 | 5180 | ||
| @@ -57,16 +57,24 @@ const sha256 = str => crypto.createHash('sha256').update(str).digest('hex'); | |||
| 57 | */ | 57 | */ |
| 58 | 58 | ||
| 59 | /** | 59 | /** |
| 60 | * @typedef {object} DataMaidMedia - The media object. | ||
| 61 | * @property {string} url - The media URL | ||
| 62 | */ | ||
| 63 | |||
| 64 | /** | ||
| 60 | * @typedef {object} DataMaidChatMetadata - The chat metadata object. | 65 | * @typedef {object} DataMaidChatMetadata - The chat metadata object. |
| 61 | * @property {DataMaidFile[]} [attachments] - The array of attachments, if any. | 66 | * @property {DataMaidFile[]} [attachments] - The array of attachments, if any. |
| 67 | * @property {string[]} [chat_backgrounds] - The array of chat background image links, if any. | ||
| 62 | */ | 68 | */ |
| 63 | 69 | ||
| 64 | /** | 70 | /** |
| 65 | * @typedef {object} DataMaidMessageExtra - The extra data object. | 71 | * @typedef {object} DataMaidMessageExtra - The extra data object. |
| 66 | * @property {string} [image] - The link to the image, if any. | 72 | * @property {string} [image] - The link to the image, if any - DEPRECATED, use `media` instead. |
| 67 | * @property {string} [video] - The link to the video, if any. | 73 | * @property {string} [video] - The link to the video, if any - DEPRECATED, use `media` instead. |
| 68 | * @property {string[]} [image_swipes] - The links to the image swipes, if any. | 74 | * @property {string[]} [image_swipes] - The links to the image swipes, if any - DEPRECATED, use `media` instead. |
| 69 | * @property {DataMaidFile} [file] - The file object, if any. | 75 | * @property {DataMaidMedia[]} [media] - The links to the media, if any. |
| 76 | * @property {DataMaidFile} [file] - The file object, if any - DEPRECATED, use `files` instead. | ||
| 77 | * @property {DataMaidFile[]} [files] - The array of file objects, if any. | ||
| 70 | */ | 78 | */ |
| 71 | 79 | ||
| 72 | /** | 80 | /** |
| @@ -166,7 +174,7 @@ export class DataMaidService { | |||
| 166 | const result = []; | 174 | const result = []; |
| 167 | 175 | ||
| 168 | try { | 176 | try { |
| 169 | const messages = await this.#parseAllChats(x => !!x?.extra?.image || !!x?.extra?.video || Array.isArray(x?.extra?.image_swipes)); | 177 | const messages = await this.#parseAllChats(x => !!x?.extra?.image || !!x?.extra?.video || Array.isArray(x?.extra?.image_swipes) || Array.isArray(x?.extra?.media)); |
| 170 | const knownImages = new Set(); | 178 | const knownImages = new Set(); |
| 171 | for (const message of messages) { | 179 | for (const message of messages) { |
| 172 | if (message?.extra?.image) { | 180 | if (message?.extra?.image) { |
| @@ -180,6 +188,23 @@ export class DataMaidService { | |||
| 180 | knownImages.add(swipe); | 188 | knownImages.add(swipe); |
| 181 | } | 189 | } |
| 182 | } | 190 | } |
| 191 | if (Array.isArray(message?.extra?.media)) { | ||
| 192 | for (const media of message.extra.media) { | ||
| 193 | if (media?.url) { | ||
| 194 | knownImages.add(media.url); | ||
| 195 | } | ||
| 196 | } | ||
| 197 | } | ||
| 198 | } | ||
| 199 | const metadata = await this.#parseAllMetadata(x => Array.isArray(x?.chat_backgrounds) && x.chat_backgrounds.length > 0); | ||
| 200 | for (const meta of metadata) { | ||
| 201 | if (Array.isArray(meta?.chat_backgrounds)) { | ||
| 202 | for (const background of meta.chat_backgrounds) { | ||
| 203 | if (background) { | ||
| 204 | knownImages.add(background); | ||
| 205 | } | ||
| 206 | } | ||
| 207 | } | ||
| 183 | } | 208 | } |
| 184 | const knownImageFullPaths = new Set(); | 209 | const knownImageFullPaths = new Set(); |
| 185 | knownImages.forEach(image => { | 210 | knownImages.forEach(image => { |
| @@ -221,12 +246,19 @@ export class DataMaidService { | |||
| 221 | const result = []; | 246 | const result = []; |
| 222 | 247 | ||
| 223 | try { | 248 | try { |
| 224 | const messages = await this.#parseAllChats(x => !!x?.extra?.file?.url); | 249 | const messages = await this.#parseAllChats(x => !!x?.extra?.file?.url || (Array.isArray(x?.extra?.files) && x.extra.files.length > 0)); |
| 225 | const knownFiles = new Set(); | 250 | const knownFiles = new Set(); |
| 226 | for (const message of messages) { | 251 | for (const message of messages) { |
| 227 | if (message?.extra?.file?.url) { | 252 | if (message?.extra?.file?.url) { |
| 228 | knownFiles.add(message.extra.file.url); | 253 | knownFiles.add(message.extra.file.url); |
| 229 | } | 254 | } |
| 255 | if (Array.isArray(message?.extra?.files)) { | ||
| 256 | for (const file of message.extra.files) { | ||
| 257 | if (file?.url) { | ||
| 258 | knownFiles.add(file.url); | ||
| 259 | } | ||
| 260 | } | ||
| 261 | } | ||
| 230 | } | 262 | } |
| 231 | const metadata = await this.#parseAllMetadata(x => Array.isArray(x?.attachments) && x.attachments.length > 0); | 263 | const metadata = await this.#parseAllMetadata(x => Array.isArray(x?.attachments) && x.attachments.length > 0); |
| 232 | for (const meta of metadata) { | 264 | for (const meta of metadata) { |