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 | 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 | 38 | .mes_file_container .right_menu_button { |
| 29 | 39 | padding-right: 0; |
| 30 | 40 | } |
| @@ -4,6 +4,8 @@ import { power_user } from './scripts/power-user'; | ||
| 4 | 4 | import { QuickReplyApi } from './scripts/extensions/quick-reply/api/QuickReplyApi'; |
| 5 | 5 | import { oai_settings } from './scripts/openai'; |
| 6 | 6 | import { textgenerationwebui_settings } from './scripts/textgen-settings'; |
| 7 | +import { FileAttachment } from './scripts/chats'; | |
| 8 | +import { ReasoningMessageExtra } from './scripts/reasoning'; | |
| 7 | 9 | |
| 8 | 10 | declare global { |
| 9 | 11 | // Custom types |
| @@ -12,6 +14,72 @@ declare global { | ||
| 12 | 14 | type ReasoningSettings = typeof power_user.reasoning; |
| 13 | 15 | type ChatCompletionSettings = typeof oai_settings; |
| 14 | 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 | 84 | // Global namespace modules |
| 17 | 85 | interface Window { |
| @@ -4671,6 +4671,13 @@ | ||
| 4671 | 4671 | <option value="2" data-i18n="Document">Document</option> |
| 4672 | 4672 | </select> |
| 4673 | 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 | 4681 | <div class="flex-container alignItemsBaseline"> |
| 4675 | 4682 | <span data-i18n="Notifications:">Notifications:</span> |
| 4676 | 4683 | <select id="toastr_position" class="widthNatural flex1 margin0 text_pole"> |
| @@ -7005,6 +7012,8 @@ | ||
| 7005 | 7012 | <div title="Prompt" class="mes_button mes_prompt fa-solid fa-square-poll-horizontal " data-i18n="[title]Prompt" style="display: none;"></div> |
| 7006 | 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 | 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 | 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 | 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 | 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 | 7052 | <div class="mes_reasoning"></div> |
| 7044 | 7053 | </details> |
| 7045 | 7054 | <div class="mes_text"></div> |
| 7046 | 7055 | <div class="mes_img_containermes_media_wrapper"></div> |
| 7047 | 7056 | <div class="mes_img_controlsmes_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 | 7057 | <div class="mes_bias"></div> |
| 7060 | 7058 | </div> |
| 7061 | 7059 | <div class="flex-container swipeRightBlock flexFlowColumn flexNoGap"> |
| @@ -7265,9 +7263,9 @@ | ||
| 7265 | 7263 | </div> |
| 7266 | 7264 | </div> |
| 7267 | 7265 | |
| 7268 | 7266 | <!-- chat and inputMedia barTemplates --> |
| 7269 | 7267 | <div id="message_file_template" class="template_element"> |
| 7270 | 7268 | <div class="mes_media_container mes_file_container"> |
| 7271 | 7269 | <div class="fa-lg fa-solid fa-file-alt mes_file_icon"></div> |
| 7272 | 7270 | <div class="mes_file_name"></div> |
| 7273 | 7271 | <div class="mes_file_size"></div> |
| @@ -7276,15 +7274,34 @@ | ||
| 7276 | 7274 | </div> |
| 7277 | 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 | 7288 | <div id="message_video_template" class="template_element"> |
| 7280 | 7289 | <div class="mes_media_container mes_video_container"> |
| 7281 | 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 | 7292 | <div title="Delete" class="right_menu_button fa-lg fa-solid fa-trash-can mes_video_deletemes_media_delete" data-i18n="[title]Delete"></div> |
| 7284 | 7293 | </div> |
| 7285 | 7294 | <video class="mes_video" controls preload="metadata"></video> |
| 7286 | 7295 | </div> |
| 7287 | 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 | 7305 | </div> |
| 7289 | 7306 | <div id="movingDivs"> |
| 7290 | 7307 | <div id="floatingPrompt" class="drawer-content flexGap5"> |
| @@ -7641,8 +7658,8 @@ | ||
| 7641 | 7658 | <div id="send_form" class="no-connection"> |
| 7642 | 7659 | <form id="file_form" class="wide100p displayNone"> |
| 7643 | 7660 | <div class="file_attached"> |
| 7644 | 7661 | <input id="file_form_input" type="file" multiple hidden> |
| 7645 | 7662 | <input id="embed_file_input" type="file" multiple hidden> |
| 7646 | 7663 | <i class="fa-solid fa-file-alt"></i> |
| 7647 | 7664 | <span class="file_name">File Name</span> |
| 7648 | 7665 | <span class="file_size">File Size</span> |
| @@ -183,7 +183,7 @@ import { | ||
| 183 | 183 | trimSpaces, |
| 184 | 184 | clamp, |
| 185 | 185 | } from './scripts/utils.js'; |
| 186 | 186 | import { debounce_timeout, GENERATION_TYPE_TRIGGERS, IGNORE_SYMBOL, inject_ids, MEDIA_DISPLAY, MEDIA_TYPE, SWIPE_DIRECTION } from './scripts/constants.js'; |
| 187 | 187 | |
| 188 | 188 | import { cancelDebouncedMetadataSave, doDailyExtensionUpdatesCheck, extension_settings, initExtensions, loadExtensionSettings, runGenerationInterceptors, saveMetadataDebounced } from './scripts/extensions.js'; |
| 189 | 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 | 314 | getSystemMessageByType, |
| 315 | 315 | event_types, |
| 316 | 316 | eventSource, |
| 317 | + /** @deprecated Use setCharacterSettingsOverrides instead. */ | |
| 317 | 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 | 370 | let default_user_name = 'User'; |
| 368 | 371 | export let name1 = default_user_name; |
| 369 | 372 | export let name2 = systemUserName; |
| 373 | +/** @type {ChatMessage[]} */ | |
| 370 | 374 | export let chat = []; |
| 371 | 375 | export let isSwipingAllowed = true; //false when a swipe is in progress, or swiping is blocked. |
| 372 | 376 | let chatSaveTimeout; |
| @@ -1407,30 +1411,45 @@ export async function printMessages() { | ||
| 1407 | 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 | 1429 | if (image instanceof HTMLImageElementcurrentElement.complete) { |
| 1417 | - if (image.complete) { | |
| 1418 | 1430 | incrementAndCheck(); |
| 1419 | 1431 | } else { |
| 1420 | 1432 | imagecurrentElement.addEventListener('load', incrementAndCheck); |
| 1433 | + currentElement.addEventListener('error', incrementAndCheck); | |
| 1434 | + } | |
| 1435 | + } | |
| 1436 | + if (currentElement instanceof HTMLVideoElement) { | |
| 1437 | + if (currentElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { | |
| 1438 | + incrementAndCheck(); | |
| 1439 | + } else { | |
| 1440 | + currentElement.addEventListener('loadeddata', incrementAndCheck); | |
| 1441 | + currentElement.addEventListener('error', incrementAndCheck); | |
| 1421 | 1442 | } |
| 1422 | 1443 | } |
| 1423 | 1444 | } |
| 1424 | - | |
| 1425 | - chatElement.find('.mes').removeClass('last_mes'); | |
| 1426 | - chatElement.find('.mes').last().addClass('last_mes'); | |
| 1427 | - refreshSwipeButtons(); | |
| 1428 | - scrollChatToBottom(); | |
| 1429 | - applyStylePins(); | |
| 1430 | 1445 | |
| 1431 | 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 | 1453 | scrollChatToBottom(); |
| 1435 | 1454 | } |
| 1436 | 1455 | } |
| @@ -1885,110 +1904,335 @@ export function updateMessageBlock(messageId, message, { rerenderMessage = true | ||
| 1885 | 1904 | } |
| 1886 | 1905 | |
| 1887 | 1906 | /** |
| 1907 | + * Ensures that the message media properties are arrays, adding getters/setters for single media items. | |
| 1908 | + * @param {ChatMessage} mes Message object | |
| 1909 | + */ | |
| 1910 | +export function ensureMessageMediaIsArray(mes) { | |
| 1911 | + /** | |
| 1912 | + * Determines if a property of an object is a plain property (not a getter/setter or non-enumerable). | |
| 1913 | + * @param {object} obj Object to check | |
| 1914 | + * @param {string} name Property name | |
| 1915 | + * @returns {boolean} True if the property is a plain property, false otherwise | |
| 1916 | + */ | |
| 1917 | + function isPlainObjectProperty(obj, name) { | |
| 1918 | + const hasProperty = Object.hasOwn(obj, name); | |
| 1919 | + if (hasProperty) { | |
| 1920 | + const descriptor = Object.getOwnPropertyDescriptor(obj, name); | |
| 1921 | + return descriptor && descriptor.enumerable && descriptor.configurable && descriptor.writable; | |
| 1922 | + } | |
| 1923 | + return false; | |
| 1924 | + } | |
| 1925 | + | |
| 1926 | + /** | |
| 1927 | + * Determines if a property of an object is a getter (not a plain property). | |
| 1928 | + * @param {object} obj Object to check | |
| 1929 | + * @param {string} name Property name | |
| 1930 | + * @returns {boolean} True if the property is a getter, false otherwise | |
| 1931 | + */ | |
| 1932 | + function isGetterObjectProperty(obj, name) { | |
| 1933 | + const hasProperty = Object.hasOwn(obj, name); | |
| 1934 | + if (hasProperty) { | |
| 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, | |
| 1972 | + }); | |
| 1973 | + } | |
| 1974 | + | |
| 1975 | + /** | |
| 1976 | + * Migrates image swipes from a single image property to an array. | |
| 1977 | + * @param {ChatMessageExtra} obj | |
| 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 | + } | |
| 1989 | + } | |
| 1990 | + | |
| 1991 | + if (Array.isArray(obj.image_swipes)) { | |
| 1992 | + if (!Array.isArray(obj.media)) { | |
| 1993 | + obj.media = []; | |
| 1994 | + } | |
| 1995 | + for (const swipe of obj.image_swipes) { | |
| 1996 | + if (swipe && typeof swipe === 'string') { | |
| 1997 | + obj.media_display = MEDIA_DISPLAY.GALLERY; | |
| 1998 | + obj.media.push({ type: MEDIA_TYPE.IMAGE, url: swipe }); | |
| 1999 | + } | |
| 2000 | + } | |
| 2001 | + delete obj.image_swipes; | |
| 2002 | + } | |
| 2003 | + | |
| 2004 | + if (isPlainObjectProperty(obj, 'image')) { | |
| 2005 | + if (!Array.isArray(obj.media)) { | |
| 2006 | + obj.media = []; | |
| 2007 | + } | |
| 2008 | + const imageValue = obj.image; | |
| 2009 | + delete obj.image; | |
| 2010 | + if (imageValue && typeof imageValue === 'string') { | |
| 2011 | + obj.media.push({ type: MEDIA_TYPE.IMAGE, url: imageValue }); | |
| 2012 | + } | |
| 2013 | + if (obj.media_display === MEDIA_DISPLAY.GALLERY) { | |
| 2014 | + const selectedIndex = obj.media.findIndex(t => t.url === imageValue); | |
| 2015 | + if (selectedIndex > -1) { | |
| 2016 | + obj.media_index = selectedIndex; | |
| 2017 | + } | |
| 2018 | + } | |
| 2019 | + obj.media = obj.media.filter((v, i, a) => i === a.findIndex(t => t.url === v.url)); | |
| 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 | + } | |
| 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; | |
| 2052 | +} | |
| 2053 | + | |
| 2054 | +/** | |
| 2055 | + * Gets the media index for a message. | |
| 2056 | + * @param {ChatMessage} mes Message object | |
| 2057 | + * @returns {number} Media index | |
| 2058 | + */ | |
| 2059 | +export function getMediaIndex(mes) { | |
| 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 | +/** | |
| 1888 | 2071 | * Appends image or file to the message element. |
| 1889 | 2072 | * @param {objectChatMessage} mes Message object |
| 1890 | 2073 | * @param {JQuery<HTMLElement>} messageElement Message element |
| 1891 | 2074 | * @param {boolean} [adjustScroll=true] Whether to adjust the scroll position after appending the media |
| 1892 | 2075 | */ |
| 1893 | 2076 | export function appendMediaToMessage(mes, messageElement, adjustScroll = true) { |
| 1894 | - // Add image to message | |
| 2077 | + ensureMessageMediaIsArray(mes); | |
| 1895 | - if (mes.extra?.image) { | |
| 2078 | + | |
| 1896 | - const container = messageElement.find('.mes_img_container'); | |
| 2079 | + const hasMedia = Array.isArray(mes?.extra?.media) && mes.extra.media.length > 0; | |
| 1897 | - const chatHeight = chatElement.prop('scrollHeight'); | |
| 2080 | + const hasFiles = Array.isArray(mes?.extra?.files) && mes.extra.files.length > 0; | |
| 1898 | 2081 | const imagemediaDisplay = messageElement.findgetMediaDisplay('.mes_img'mes); |
| 1899 | - const text = messageElement.find('.mes_text'); | |
| 2082 | + const hideMessageText = hasMedia && mes?.extra?.inline_image === false; | |
| 1900 | - const isInline = !!mes.extra?.inline_image; | |
| 2083 | + | |
| 2084 | + const mediaBlocks = []; | |
| 2085 | + const mediaPromises = []; | |
| 2086 | + | |
| 2087 | + const chatHeight = adjustScroll && (hasMedia || hasFiles) ? chatElement.prop('scrollHeight') : 0; | |
| 2088 | + const scrollPosition = chatElement.scrollTop(); | |
| 1901 | 2089 | const doAdjustScroll = () => { |
| 1902 | 2090 | if (!adjustScroll) { |
| 2091 | + chatElement.scrollTop(scrollPosition); | |
| 1903 | 2092 | return; |
| 1904 | 2093 | } |
| 1905 | - const scrollPosition = chatElement.scrollTop(); | |
| 1906 | 2094 | const newChatHeight = chatElement.prop('scrollHeight'); |
| 1907 | 2095 | const diff = newChatHeight - chatHeight; |
| 1908 | 2096 | chatElement.scrollTop(scrollPosition + diff); |
| 1909 | 2097 | }; |
| 1910 | - image.off('load').on('load', function () { | |
| 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() { | |
| 1911 | 2119 | image.removeAttr('alt'); |
| 1912 | 2120 | image.removeClass('error'); |
| 1913 | 2121 | doAdjustScroll resolve(); |
| 1914 | - }); | |
| 2122 | + } | |
| 1915 | - image.off('error').on('error', function () { | |
| 2123 | + function onError() { | |
| 1916 | 2124 | image.attr('alt', ''); |
| 1917 | 2125 | image.addClass('error'); |
| 1918 | 2126 | doAdjustScroll resolve(); |
| 1919 | - }); | |
| 2127 | + } | |
| 1920 | - image.attr('src', mes.extra?.image); | |
| 2128 | + if (image.prop('complete')) { | |
| 1921 | - image.attr('title', mes.extra?.title || mes.title || ''); | |
| 2129 | + onLoad(); | |
| 1922 | - container.addClass('img_extra'); | |
| 2130 | + } else { | |
| 1923 | 2131 | image.toggleClassoff('img_inlineload').on('load', isInlineonLoad); |
| 1924 | - text.toggleClass('displayNone', !isInline); | |
| 2132 | + image.off('error').on('error', onError); | |
| 2133 | + } | |
| 2134 | + })); | |
| 1925 | 2135 | |
| 1926 | - const imageSwipes = mes.extra.image_swipes; | |
| 2136 | + mediaBlocks.push(template); | |
| 1927 | - if (Array.isArray(imageSwipes) && imageSwipes.length > 0) { | |
| 2137 | + return template; | |
| 1928 | - container.addClass('img_swipes'); | |
| 2138 | + } | |
| 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 | 2139 | |
| 1933 | - const swipeLeft = container.find('.mes_img_swipe_left'); | |
| 2140 | + /** | |
| 1934 | - swipeLeft.off('click').on('click', function () { | |
| 2141 | + * Appends a single video attachment to the message element. | |
| 1935 | - eventSource.emit(event_types.IMAGE_SWIPED, { message: mes, element: messageElement, direction: 'left' }); | |
| 2142 | + * @param {MediaAttachment} attachment Video attachment object | |
| 1936 | - }); | |
| 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); | |
| 1937 | 2149 | |
| 1938 | 2150 | const swipeRightvideo = containertemplate.find('.mes_img_swipe_rightmes_video'); |
| 1939 | - swipeRight.off('click').on('click', function () { | |
| 2151 | + video.attr('src', attachment.url); | |
| 1940 | - eventSource.emit(event_types.IMAGE_SWIPED, { message: mes, element: messageElement, direction: 'right' }); | |
| 2152 | + video.attr('title', attachment.title || mes.extra.title || ''); | |
| 1941 | - }); | |
| 2153 | + mediaPromises.push(new Promise((resolve) => { | |
| 2154 | + function onLoad() { | |
| 2155 | + resolve(); | |
| 2156 | + } | |
| 2157 | + function onError() { | |
| 2158 | + video.addClass('error'); | |
| 2159 | + resolve(); | |
| 1942 | 2160 | } |
| 2161 | + if (video.prop('readyState') >= HTMLMediaElement.HAVE_CURRENT_DATA) { | |
| 2162 | + onLoad(); | |
| 1943 | 2163 | } else { |
| 1944 | - const container = messageElement.find('.mes_img_container'); | |
| 2164 | + video.off('loadeddata').on('loadeddata', onLoad); | |
| 1945 | - container.removeClass('img_extra img_swipes'); | |
| 2165 | + video.off('error').on('error', onError); | |
| 1946 | - const text = messageElement.find('.mes_text'); | |
| 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 | 2166 | } |
| 1961 | - const scrollPosition = chatElement.scrollTop(); | |
| 2167 | + })); | |
| 1962 | - const newChatHeight = chatElement.prop('scrollHeight'); | |
| 1963 | - const diff = newChatHeight - chatHeight; | |
| 1964 | - chatElement.scrollTop(scrollPosition + diff); | |
| 1965 | - }); | |
| 1966 | 2168 | |
| 1967 | - video.attr('src', mes.extra?.video); | |
| 2169 | + mediaBlocks.push(template); | |
| 1968 | - } else { | |
| 2170 | + return template; | |
| 1969 | - messageElement.find('.mes_video_container').remove(); | |
| 1970 | 2171 | } |
| 1971 | 2172 | |
| 1972 | - // Add file to message | |
| 2173 | + /** | |
| 1973 | - if (mes.extra?.file) { | |
| 2174 | + * Appends a media attachment to the message element. | |
| 1974 | - messageElement.find('.mes_file_container').remove(); | |
| 2175 | + * @param {MediaAttachment} attachment Media attachment object | |
| 1975 | - const messageId = messageElement.attr('mesid'); | |
| 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]; | |
| 1976 | 2223 | const template = $('#message_file_template .mes_file_container').clone(); |
| 1977 | - template.find('.mes_file_name').text(mes.extra.file.name); | |
| 2224 | + template.attr('data-index', index); | |
| 1978 | 2225 | template.find('.mes_file_sizemes_file_name').text(humanFileSize(mesfile.extraname).attr('title', file.size)name); |
| 1979 | 2226 | template.find('.mes_file_downloadmes_file_size').text(humanFileSize(file.size)).attr('mesidtitle', messageIdfile.size); |
| 1980 | 2227 | template messageElement.find('.mes_file_deletemes_file_wrapper').attrappend('mesid', messageIdtemplate); |
| 1981 | - messageElement.find('.mes_block').append(template); | |
| 1982 | - } else { | |
| 1983 | - messageElement.find('.mes_file_container').remove(); | |
| 1984 | 2228 | } |
| 1985 | 2229 | } |
| 1986 | 2230 | |
| 1987 | -/** | |
| 2231 | + // TODO: Consider making this awaitable | |
| 1988 | - * @deprecated Use appendMediaToMessage instead. | |
| 2232 | + Promise.race([Promise.all(mediaPromises), delay(debounce_timeout.short)]).then(() => { | |
| 1989 | - */ | |
| 2233 | + messageElement.find('.mes_media_wrapper').empty().append(mediaBlocks); | |
| 1990 | -export function appendImageToMessage(mes, messageElement) { | |
| 2234 | + doAdjustScroll(); | |
| 1991 | - appendMediaToMessage(mes, messageElement); | |
| 2235 | + }); | |
| 1992 | 2236 | } |
| 1993 | 2237 | |
| 1994 | 2238 | export function addCopyToCodeBlocks(messageElement) { |
| @@ -2013,7 +2257,7 @@ export function addCopyToCodeBlocks(messageElement) { | ||
| 2013 | 2257 | |
| 2014 | 2258 | /** |
| 2015 | 2259 | * Adds a single message to the chat. |
| 2016 | 2260 | * @param {objectChatMessage} mes Message object |
| 2017 | 2261 | * @param {object} [options] Options |
| 2018 | 2262 | * @param {string} [options.type='normal'] Message type |
| 2019 | 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 | 2309 | avatarImg = mes['force_avatar']; |
| 2066 | 2310 | } |
| 2067 | 2311 | |
| 2068 | 2312 | // if mes.extra.uses_system_ui is true, set an override on the sanitizer options |
| 2069 | 2313 | const sanitizerOverrides = mes.extra?.uses_system_ui ? { MESSAGE_ALLOW_SYSTEM_UI: true } : {}; |
| 2070 | 2314 | |
| 2071 | 2315 | messageText = messageFormatting( |
| 2072 | 2316 | messageText, |
| @@ -2215,8 +2459,8 @@ export function formatCharacterAvatar(characterAvatar) { | ||
| 2215 | 2459 | |
| 2216 | 2460 | /** |
| 2217 | 2461 | * Formats the title for the generation timer. |
| 2218 | 2462 | * @param {DateMessageTimestamp} gen_started Date when generation was started |
| 2219 | 2463 | * @param {DateMessageTimestamp} gen_finished Date when generation was finished |
| 2220 | 2464 | * @param {number} tokenCount Number of tokens generated (0 if not available) |
| 2221 | 2465 | * @param {number?} [reasoningDuration=null] Reasoning duration (null if no reasoning was done) |
| 2222 | 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 | 3903 | const prevStarted = chat[chat.length - 1]['gen_started']; |
| 3660 | 3904 | |
| 3661 | 3905 | if (prevFinished && prevStarted) { |
| 3662 | 3906 | const timePassed = Number(prevFinished) - Number(prevStarted); |
| 3663 | 3907 | generation_started = new Date(Date.now() - timePassed); |
| 3664 | 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 | 3983 | coreChat.pop(); |
| 3740 | 3984 | } |
| 3741 | 3985 | |
| 3742 | 3986 | coreChat = await Promise.all(coreChat.map(async (/** @type {ChatMessage} */ chatItem, index) => { |
| 3743 | 3987 | let message = chatItem.mes; |
| 3744 | 3988 | let regexType = chatItem.is_user ? regex_placement.USER_INPUT : regex_placement.AI_OUTPUT; |
| 3745 | 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 | 3991 | let regexedMessage = getRegexedString(message, regexType, options); |
| 3748 | 3992 | regexedMessage = await appendFileContent(chatItem, regexedMessage); |
| 3749 | 3993 | |
| 3994 | + const titles = []; | |
| 3750 | 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 | 4009 | return { |
| @@ -6097,7 +6352,7 @@ export function syncSwipeToMes(messageId = null, swipeId = null) { | ||
| 6097 | 6352 | /** |
| 6098 | 6353 | * Saves the image to the message object. |
| 6099 | 6354 | * @param {ParsedImage} img Image object |
| 6100 | 6355 | * @param {objectChatMessage} mes Chat message object |
| 6101 | 6356 | * @typedef {{ image?: string, title?: string, inline?: boolean }} ParsedImage |
| 6102 | 6357 | */ |
| 6103 | 6358 | function saveImageToMessage(img, mes) { |
| @@ -6105,8 +6360,10 @@ function saveImageToMessage(img, mes) { | ||
| 6105 | 6360 | if (!mes.extra || typeof mes.extra !== 'object') { |
| 6106 | 6361 | mes.extra = {}; |
| 6107 | 6362 | } |
| 6108 | - mes.extra.image = img.image; | |
| 6363 | + if (!Array.isArray(mes.extra.media)) { | |
| 6109 | 6364 | mes.extra.titlemedia = img.title[]; |
| 6365 | + } | |
| 6366 | + mes.extra.media.push({ url: img.image, type: MEDIA_TYPE.IMAGE, title: img.title }); | |
| 6110 | 6367 | mes.extra.inline_image = img.inline; |
| 6111 | 6368 | } |
| 6112 | 6369 | } |
| @@ -6732,6 +6989,7 @@ export async function getChat() { | ||
| 6732 | 6989 | chat_metadata = chat[0]['chat_metadata'] ?? {}; |
| 6733 | 6990 | |
| 6734 | 6991 | chat.shift(); |
| 6992 | + chat.forEach(ensureMessageMediaIsArray); | |
| 6735 | 6993 | } else { |
| 6736 | 6994 | chat_create_date = humanizedDateTime(); |
| 6737 | 6995 | } |
| @@ -8961,17 +9219,17 @@ export async function swipe(_event, direction, { source, repeated, message = cha | ||
| 8961 | 9219 | //Update the swipe_id. |
| 8962 | 9220 | chat[mesId]['swipe_id'] = newSwipeId; |
| 8963 | 9221 | |
| 8964 | 9222 | if (chat[mesId].extra && typeof chat[mesId].extra === 'object') { |
| 8965 | - // if message has memory attached - remove it to allow regen | |
| 8966 | 9223 | delete chat[mesId].extra.memory; |
| 8967 | - | |
| 8968 | - // ditto for display text | |
| 8969 | 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 | 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 | 9234 | delete chat[mesId].gen_started; |
| 8977 | 9235 | delete chat[mesId].gen_finished; |
| @@ -8979,7 +9237,6 @@ export async function swipe(_event, direction, { source, repeated, message = cha | ||
| 8979 | 9237 | syncSwipeToMes(mesId, chat[mesId]['swipe_id']); |
| 8980 | 9238 | } |
| 8981 | 9239 | |
| 8982 | - //Deepseek-V3.1 | |
| 8983 | 9240 | // Helper function to convert transition to promise |
| 8984 | 9241 | const transitionPromise = (element, properties) => { |
| 8985 | 9242 | return new Promise((resolve) => { |
| @@ -9101,7 +9358,7 @@ export async function swipe(_event, direction, { source, repeated, message = cha | ||
| 9101 | 9358 | if (run_generate && !is_send_press) { |
| 9102 | 9359 | is_send_press = true; |
| 9103 | 9360 | generation = Generate('swipe'); |
| 9104 | 9361 | } else if (parseIntNumber(chat[mesId]['swipe_id']) !== chat[mesId]['swipes'].length) { |
| 9105 | 9362 | saveChatDebounced(); |
| 9106 | 9363 | } |
| 9107 | 9364 | |
| @@ -10745,7 +11002,7 @@ jQuery(async function () { | ||
| 10745 | 11002 | const oldScroll = chatElement[0].scrollTop; |
| 10746 | 11003 | const clone = structuredClone(chat[this_edit_mes_id]); |
| 10747 | 11004 | clone.send_date = Date.now(); |
| 10748 | 11005 | clone.mes = $(this).closest('.mes').find('.edit_textarea').val().toString(); |
| 10749 | 11006 | |
| 10750 | 11007 | if (power_user.trim_spaces) { |
| 10751 | 11008 | clone.mes = clone.mes.trim(); |
| @@ -344,6 +344,7 @@ export async function convertSoloToGroupChat() { | ||
| 344 | 344 | |
| 345 | 345 | // Save group-chat marker |
| 346 | 346 | if (index == 0) { |
| 347 | + // @ts-ignore | |
| 347 | 348 | message.is_group = true; |
| 348 | 349 | } |
| 349 | 350 | |
| @@ -26,6 +26,9 @@ import { | ||
| 26 | 26 | printMessages, |
| 27 | 27 | clearChat, |
| 28 | 28 | refreshSwipeButtons, |
| 29 | + getMediaIndex, | |
| 30 | + getMediaDisplay, | |
| 31 | + chatElement, | |
| 29 | 32 | } from '../script.js'; |
| 30 | 33 | import { selected_group } from './group-chats.js'; |
| 31 | 34 | import { power_user } from './power-user.js'; |
| @@ -43,6 +46,8 @@ import { | ||
| 43 | 46 | getFileText, |
| 44 | 47 | getFileExtension, |
| 45 | 48 | convertTextToBase64, |
| 49 | + isSameFile, | |
| 50 | + clamp, | |
| 46 | 51 | } from './utils.js'; |
| 47 | 52 | import { extension_settings, renderExtensionTemplateAsync, saveMetadataDebounced } from './extensions.js'; |
| 48 | 53 | import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; |
| @@ -52,6 +57,7 @@ import { renderTemplateAsync } from './templates.js'; | ||
| 52 | 57 | import { t } from './i18n.js'; |
| 53 | 58 | import { humanizedDateTime } from './RossAscends-mods.js'; |
| 54 | 59 | import { accountStorage } from './util/AccountStorage.js'; |
| 60 | +import { MEDIA_DISPLAY, MEDIA_TYPE, SWIPE_DIRECTION } from './constants.js'; | |
| 55 | 61 | |
| 56 | 62 | /** |
| 57 | 63 | * @typedef {Object} FileAttachment |
| @@ -187,34 +193,32 @@ export async function unhideChatMessage(messageId, _messageBlock) { | ||
| 187 | 193 | |
| 188 | 194 | /** |
| 189 | 195 | * Adds a file attachment to the message. |
| 190 | 196 | * @param {objectChatMessage} message Message object |
| 191 | 197 | * @returns {Promise<void>} A promise that resolves when file is uploaded. |
| 192 | 198 | */ |
| 193 | 199 | export async function populateFileAttachment(message, inputId = 'file_form_input') { |
| 194 | 200 | try { |
| 195 | 201 | if (!message) return; |
| 196 | 202 | if (!message.extra || typeof message.extra !== 'object') message.extra = {}; |
| 197 | 203 | const fileInput = document.getElementById(inputId); |
| 198 | 204 | if (!(fileInput instanceof HTMLInputElement)) return; |
| 199 | - const file = fileInput.files[0]; | |
| 200 | - if (!file) return; | |
| 201 | 205 | |
| 206 | + for (const file of fileInput.files) { | |
| 202 | 207 | const slug = getStringHash(file.name); |
| 203 | 208 | const fileNamePrefix = `${Date.now()}_${slug}`; |
| 204 | 209 | const fileBase64 = await getBase64Async(file); |
| 205 | 210 | let base64Data = fileBase64.split(',')[1]; |
| 206 | 211 | const extension = getFileExtension(file); |
| 207 | 212 | |
| 208 | - // If file is image | |
| 213 | + const mediaType = MEDIA_TYPE.getFromMime(file.type); | |
| 209 | - if (file.type.startsWith('image/')) { | |
| 214 | + if (mediaType) { | |
| 210 | 215 | const imageUrl = await saveBase64AsFile(base64Data, name2, fileNamePrefix, extension); |
| 211 | - message.extra.image = imageUrl; | |
| 216 | + if (!Array.isArray(message.extra.media)) { | |
| 212 | 217 | message.extra.inline_imagemedia = true[]; |
| 213 | 218 | } |
| 214 | - // If file is video | |
| 219 | + message.extra.media.push({ url: imageUrl, type: mediaType }); | |
| 215 | - else if (file.type.startsWith('video/')) { | |
| 220 | + message.extra.media_index = message.extra.media.length - 1; | |
| 216 | - const videoUrl = await saveBase64AsFile(base64Data, name2, fileNamePrefix, extension); | |
| 221 | + message.extra.inline_image = true; | |
| 217 | - message.extra.video = videoUrl; | |
| 218 | 222 | } else { |
| 219 | 223 | const uniqueFileName = `${fileNamePrefix}.txt`; |
| 220 | 224 | |
| @@ -232,17 +236,21 @@ export async function populateFileAttachment(message, inputId = 'file_form_input | ||
| 232 | 236 | const fileUrl = await uploadFileAttachment(uniqueFileName, base64Data); |
| 233 | 237 | |
| 234 | 238 | if (!fileUrl) { |
| 235 | - return; | |
| 239 | + continue; | |
| 236 | 240 | } |
| 237 | 241 | |
| 238 | - message.extra.file = { | |
| 242 | + if (!Array.isArray(message.extra.files)) { | |
| 243 | + message.extra.files = []; | |
| 244 | + } | |
| 245 | + | |
| 246 | + message.extra.files.push({ | |
| 239 | 247 | url: fileUrl, |
| 240 | 248 | size: file.size, |
| 241 | 249 | name: file.name, |
| 242 | 250 | created: Date.now(), |
| 243 | 251 | }); |
| 252 | + } | |
| 244 | 253 | } |
| 245 | - | |
| 246 | 254 | } catch (error) { |
| 247 | 255 | console.error('Could not upload file', error); |
| 248 | 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 | 323 | async function validateFile(file) { |
| 316 | 324 | const fileText = await file.text(); |
| 317 | 325 | const isImageisMedia = file.type.startsWith('image/') || file.type.startsWith('video/'); |
| 318 | 326 | const isBinary = /^[\x00-\x08\x0E-\x1F\x7F-\xFF]*$/.test(fileText); |
| 319 | 327 | |
| 320 | 328 | if (!isImageisMedia && file.size > fileSizeLimit) { |
| 321 | 329 | toastr.error(t`File is too big. Maximum size is ${humanFileSize(fileSizeLimit)}.`); |
| 322 | 330 | return false; |
| 323 | 331 | } |
| 324 | 332 | |
| 325 | 333 | // If file is binary |
| 326 | 334 | if (isBinary && !isImageisMedia && !isConvertible(file.type)) { |
| 327 | 335 | toastr.error(t`Binary files are not supported. Select a text file or image.`); |
| 328 | 336 | return false; |
| 329 | 337 | } |
| @@ -340,22 +348,28 @@ export function hasPendingFileAttachment() { | ||
| 340 | 348 | |
| 341 | 349 | /** |
| 342 | 350 | * Displays file information in the message sending form. |
| 343 | 351 | * @param {FileFileList} filefileList File object |
| 344 | 352 | * @returns {Promise<void>} |
| 345 | 353 | */ |
| 346 | 354 | async function onFileAttach(filefileList) { |
| 347 | 355 | if (!filefileList || fileList.length === 0) return; |
| 348 | 356 | |
| 357 | + for (const file of fileList) { | |
| 349 | 358 | const isValid = await validateFile(file); |
| 350 | 359 | |
| 351 | 360 | // If file is binary |
| 352 | 361 | if (!isValid) { |
| 362 | + toastr.warning(t`File ${file.name} is not supported.`); | |
| 353 | 363 | $('#file_form').trigger('reset'); |
| 354 | 364 | return; |
| 355 | 365 | } |
| 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 | 373 | $('#file_form').removeClass('displayNone'); |
| 360 | 374 | |
| 361 | 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 | 385 | * Deletes file from a message. |
| 386 | + * @param {JQuery<HTMLElement>} messageBlock Message block element | |
| 372 | 387 | * @param {number} messageId Message ID |
| 388 | + * @param {number} fileIndex File index | |
| 373 | 389 | */ |
| 374 | 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 | 396 | const confirm = await callGenericPopup('Are you sure you want to delete this file?', POPUP_TYPE.CONFIRM); |
| 376 | 397 | |
| 377 | 398 | if (confirm !== POPUP_RESULT.AFFIRMATIVE) { |
| @@ -381,26 +402,49 @@ async function deleteMessageFile(messageId) { | ||
| 381 | 402 | |
| 382 | 403 | const message = chat[messageId]; |
| 383 | 404 | |
| 384 | 405 | if (!Array.isArray(message?.extra?.filefiles)) { |
| 385 | 406 | console.debug('Message has no filefiles'); |
| 386 | 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 | 418 | await saveChatConditional(); |
| 394 | 419 | await deleteFileFromServer(url); |
| 395 | -} | |
| 396 | 420 | |
| 421 | + appendMediaToMessage(message, messageBlock, false); | |
| 422 | +} | |
| 397 | 423 | |
| 398 | 424 | /** |
| 399 | 425 | * Opens file from message in a modal. |
| 400 | 426 | * @param {number} messageId Message ID |
| 427 | + * @param {number} fileIndex File index | |
| 401 | 428 | */ |
| 402 | 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 | 449 | if (!messageFile) { |
| 406 | 450 | console.debug('Message has no file or it is empty'); |
| @@ -429,39 +473,51 @@ function embedMessageFile(messageId, messageBlock) { | ||
| 429 | 473 | .on('change', parseAndUploadEmbed) |
| 430 | 474 | .trigger('click'); |
| 431 | 475 | |
| 432 | 476 | async function parseAndUploadEmbed(/** @type {JQuery.ChangeEvent} */ e) { |
| 433 | - const file = e.target.files[0]; | |
| 477 | + if (!(e.target instanceof HTMLInputElement)) return; | |
| 434 | 478 | if (!filee.target.files.length) return; |
| 435 | 479 | |
| 480 | + for (const file of e.target.files) { | |
| 436 | 481 | const isValid = await validateFile(file); |
| 437 | 482 | |
| 438 | 483 | if (!isValid) { |
| 484 | + toastr.warning(t`File ${file.name} is not supported.`); | |
| 439 | 485 | $('#file_form').trigger('reset'); |
| 440 | 486 | return; |
| 441 | 487 | } |
| 488 | + } | |
| 442 | 489 | |
| 443 | 490 | await populateFileAttachment(message, 'embed_file_input'); |
| 444 | 491 | await eventSource.emit(event_types.MESSAGE_FILE_EMBEDDED, messageId); |
| 445 | 492 | appendMediaToMessage(message, messageBlock, false); |
| 446 | 493 | await saveChatConditional(); |
| 447 | 494 | } |
| 448 | 495 | } |
| 449 | 496 | |
| 450 | 497 | /** |
| 451 | 498 | * Appends file content to the message text. |
| 452 | 499 | * @param {objectChatMessage} message Message object |
| 453 | 500 | * @param {string} messageText Message text |
| 454 | 501 | * @returns {Promise<string>} Message text with file content appended. |
| 455 | 502 | */ |
| 456 | 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 | + } | |
| 507 | + if (message.extra.fileLength >= 0) { | |
| 508 | + delete message.extra.fileLength; | |
| 509 | + } | |
| 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)); | |
| 460 | 514 | if (fileText) { |
| 461 | - const fileWrapped = `${fileText}\n\n`; | |
| 515 | + fileTexts.push(fileText); | |
| 462 | - message.extra.fileLength = fileWrapped.length; | |
| 516 | + } | |
| 463 | - messageText = fileWrapped + messageText; | |
| 464 | 517 | } |
| 518 | + const mergedFileTexts = fileTexts.join('\n\n') + '\n\n'; | |
| 519 | + message.extra.fileLength = mergedFileTexts.length; | |
| 520 | + return mergedFileTexts + messageText; | |
| 465 | 521 | } |
| 466 | 522 | return messageText; |
| 467 | 523 | } |
| @@ -807,62 +863,119 @@ export function isExternalMediaAllowed() { | ||
| 807 | 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 | + } | |
| 816 | 877 | |
| 817 | - if (!imgSrc) { | |
| 878 | + /** @type {ChatMessage} */ | |
| 879 | + const message = chat[messageId]; | |
| 880 | + | |
| 881 | + if (!Array.isArray(message?.extra?.media) || message.extra.media.length === 0) { | |
| 882 | + console.warn('Message has no media to expand'); | |
| 883 | + return; | |
| 884 | + } | |
| 885 | + | |
| 886 | + const mediaAttachment = message.extra.media[mediaIndex]; | |
| 887 | + const title = mediaAttachment.title || message.extra.title || ''; | |
| 888 | + | |
| 889 | + if (!mediaAttachment) { | |
| 818 | 890 | return; |
| 819 | 891 | } |
| 820 | 892 | |
| 893 | + /** | |
| 894 | + * Gets the media element based on its type. | |
| 895 | + * @returns {HTMLElement} Media element | |
| 896 | + */ | |
| 897 | + function getMediaElement() { | |
| 898 | + function getImageElement() { | |
| 821 | 899 | const img = document.createElement('img'); |
| 900 | + img.src = mediaAttachment.url; | |
| 822 | 901 | img.classList.add('img_enlarged'); |
| 823 | - img.src = imgSrc; | |
| 902 | + return img; | |
| 824 | - const imgHolder = document.createElement('div'); | |
| 903 | + } | |
| 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 | - | |
| 831 | - const codeTitle = imgContainer.find('.img_enlarged_title'); | |
| 832 | - codeTitle.addClass('txt').text(title); | |
| 833 | - const titleEmpty = !title || title.trim().length === 0; | |
| 834 | - imgContainer.find('pre').toggle(!titleEmpty); | |
| 835 | - addCopyToCodeBlocks(imgContainer); | |
| 836 | - | |
| 837 | - const popup = new Popup(imgContainer, POPUP_TYPE.DISPLAY, '', { large: true, transparent: true }); | |
| 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 | + } | |
| 913 | + | |
| 914 | + switch (mediaAttachment.type) { | |
| 915 | + case MEDIA_TYPE.IMAGE: | |
| 916 | + return getImageElement(); | |
| 917 | + case MEDIA_TYPE.VIDEO: | |
| 918 | + return getVideoElement(); | |
| 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); | |
| 841 | 932 | |
| 842 | 933 | imgmediaElement.addEventListener('click', event => { |
| 843 | 934 | const shouldZoom = !imgmediaElement.classList.contains('zoomed') && mediaElement.nodeName === 'IMG'; |
| 844 | 935 | imgmediaElement.classList.toggle('zoomed', shouldZoom); |
| 845 | 936 | event.stopPropagation(); |
| 846 | 937 | }); |
| 847 | - codeTitle[0]?.addEventListener('click', event => { | |
| 938 | + | |
| 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 => { | |
| 848 | 946 | event.stopPropagation(); |
| 849 | 947 | }); |
| 948 | + mediaContainer.append(mediaTitlePre); | |
| 949 | + addCopyToCodeBlocks(mediaContainer); | |
| 950 | + } | |
| 850 | 951 | |
| 851 | - popup.dlg.addEventListener('click', event => { | |
| 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 | 957 | popup.completeCancelled(); |
| 853 | 958 | }); |
| 854 | 959 | |
| 855 | 960 | popup.show(); |
| 856 | 961 | return imgmediaElement; |
| 857 | 962 | } |
| 858 | 963 | |
| 859 | -function expandAndZoomMessageImage(event) { | |
| 964 | +/** | |
| 860 | - expandMessageImage(event).click(); | |
| 965 | + * Deletes an image from a message. | |
| 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; | |
| 861 | 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 | 977 | okButton: t`Delete one`, |
| 978 | + cancelButton: false, | |
| 866 | 979 | customButtons: [ |
| 867 | 980 | { |
| 868 | 981 | text: t`Delete all`, |
| @@ -881,58 +994,64 @@ async function deleteMessageImage() { | ||
| 881 | 994 | return; |
| 882 | 995 | } |
| 883 | 996 | |
| 884 | - const mesBlock = $(this).closest('.mes'); | |
| 997 | + /** @type {ChatMessage} */ | |
| 885 | 998 | const mesIdmessage = mesBlock.attr('mesid')chat[messageId]; |
| 886 | - const message = chat[mesId]; | |
| 887 | - | |
| 888 | - let isLastImage = true; | |
| 889 | 999 | |
| 890 | 1000 | if (!Array.isArray(message?.extra?.image_swipesmedia)) { |
| 891 | - const indexOf = message.extra.image_swipes.indexOf(message.extra.image); | |
| 1001 | + console.debug('Message has no media'); | |
| 892 | - if (indexOf > -1) { | |
| 1002 | + return; | |
| 893 | - message.extra.image_swipes.splice(indexOf, 1); | |
| 894 | - isLastImage = message.extra.image_swipes.length === 0; | |
| 895 | - if (!isLastImage) { | |
| 896 | - const newIndex = Math.min(indexOf, message.extra.image_swipes.length - 1); | |
| 897 | - message.extra.image = message.extra.image_swipes[newIndex]; | |
| 898 | 1003 | } |
| 1004 | + | |
| 1005 | + if (mediaIndex < 0 || mediaIndex >= message.extra.media.length) { | |
| 1006 | + console.warn('Invalid media index for message'); | |
| 1007 | + return; | |
| 899 | 1008 | } |
| 1009 | + | |
| 1010 | + message.extra.media.splice(mediaIndex, 1); | |
| 1011 | + | |
| 1012 | + if (message.extra.media_index === mediaIndex) { | |
| 1013 | + const newIndex = mediaIndex > 0 ? mediaIndex - 1 : 0; | |
| 1014 | + message.extra.media_index = clamp(newIndex, 0, message.extra.media.length - 1); | |
| 900 | 1015 | } |
| 901 | 1016 | |
| 902 | 1017 | if (isLastImage || value === POPUP_RESULT.CUSTOM1) { |
| 903 | 1018 | delete message.extra.imagemedia; |
| 904 | 1019 | delete message.extra.inline_image; |
| 905 | 1020 | delete message.extra.title; |
| 906 | 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 | 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 | 1037 | return; |
| 921 | 1038 | } |
| 922 | 1039 | |
| 923 | - const mesBlock = $(this).closest('.mes'); | |
| 1040 | + /** @type {ChatMessage} */ | |
| 924 | 1041 | const mesIdmessage = mesBlock.attr('mesid')chat[messageId]; |
| 925 | - const message = chat[mesId]; | |
| 926 | 1042 | |
| 927 | 1043 | if (!message?.extra?.video) { |
| 928 | 1044 | console.warn('Message has no video ornot itfound isfor emptyID', messageId); |
| 929 | 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 | 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 | 2049 | export function initChatUtilities() { |
| 1869 | 2050 | $(document).on('click', '.mes_hide', async function () { |
| 1870 | 2051 | const messageBlock = $(this).closest('.mes'); |
| @@ -1881,13 +2062,17 @@ export function initChatUtilities() { | ||
| 1881 | 2062 | $(document).on('click', '.mes_file_delete', async function () { |
| 1882 | 2063 | const messageBlock = $(this).closest('.mes'); |
| 1883 | 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 | 2070 | $(document).on('click', '.mes_file_open', async function () { |
| 1888 | 2071 | const messageBlock = $(this).closest('.mes'); |
| 1889 | 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 | 2078 | $(document).on('click', '.assistant_note_export', async function () { |
| @@ -2052,16 +2237,55 @@ export function initChatUtilities() { | ||
| 2052 | 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 | 2285 | $('#file_form_input').on('change', async () => { |
| 2061 | 2286 | const fileInput = document.getElementById('file_form_input'); |
| 2062 | 2287 | if (!(fileInput instanceof HTMLInputElement)) return; |
| 2063 | 2288 | const file =await onFileAttach(fileInput.files[0]); |
| 2064 | - await onFileAttach(file); | |
| 2065 | 2289 | }); |
| 2066 | 2290 | $('#file_form').on('reset', function () { |
| 2067 | 2291 | $('#file_form').addClass('displayNone'); |
| @@ -2075,18 +2299,38 @@ export function initChatUtilities() { | ||
| 2075 | 2299 | event.preventDefault(); |
| 2076 | 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 | 2315 | const fileInput = document.getElementById('file_form_input'); |
| 2079 | 2316 | if (!(fileInput instanceof HTMLInputElement)) return; |
| 2080 | 2317 | |
| 2081 | 2318 | // Workaround for Firefox: Use a DataTransfer object to indirectly set fileInput.files |
| 2082 | 2319 | const dataTransfer = new DataTransfer(); |
| 2083 | 2320 | for (let i = 0; i < event.clipboardData.files.length; i++) { |
| 2084 | 2321 | dataTransfer.items.add(event.clipboardData.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 | 2331 | fileInput.files = dataTransfer.files; |
| 2088 | 2332 | await onFileAttach(fileInput.files[0]); |
| 2089 | 2333 | }); |
| 2090 | 2334 | |
| 2091 | 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 | 97 | * @type {{readonly LEFT: 'left', readonly RIGHT: 'right'}} |
| 72 | 98 | */ |
| 73 | 99 | export const SWIPE_DIRECTION = { |
| @@ -10,6 +10,7 @@ import { SlashCommand } from '../../slash-commands/SlashCommand.js'; | ||
| 10 | 10 | import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js'; |
| 11 | 11 | import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 12 | 12 | import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js'; |
| 13 | +import { MEDIA_DISPLAY, MEDIA_TYPE } from '../../constants.js'; | |
| 13 | 14 | export { MODULE_NAME }; |
| 14 | 15 | |
| 15 | 16 | const MODULE_NAME = 'caption'; |
| @@ -119,15 +120,26 @@ async function wrapCaptionTemplate(caption) { | ||
| 119 | 120 | |
| 120 | 121 | /** |
| 121 | 122 | * Appends caption to an existing message. |
| 122 | 123 | * @param {ObjectChatMessage} datamessage Message data |
| 124 | + * @param {number} mediaIndex Index of the image to caption | |
| 123 | 125 | * @returns {Promise<void>} |
| 124 | 126 | */ |
| 125 | 127 | async function captionExistingMessage(datamessage, mediaIndex) { |
| 126 | 128 | if (!Array.isArray(datamessage?.extra?.imagemedia) || message.extra.media.length === 0) { |
| 127 | 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 | 143 | const blob = await imageData.blob(); |
| 132 | 144 | const type = imageData.headers.get('Content-Type'); |
| 133 | 145 | const file = new File([blob], 'image.png', { type }); |
| @@ -140,17 +152,17 @@ async function captionExistingMessage(data) { | ||
| 140 | 152 | |
| 141 | 153 | const wrappedCaption = await wrapCaptionTemplate(caption); |
| 142 | 154 | |
| 143 | 155 | const messageText = String(datamessage.mes).trim(); |
| 144 | 156 | |
| 145 | 157 | if (!messageText) { |
| 146 | 158 | datamessage.extra.inline_image = false; |
| 147 | 159 | datamessage.mes = wrappedCaption; |
| 148 | 160 | data.extramediaAttachment.title = wrappedCaption; |
| 149 | 161 | } |
| 150 | 162 | else { |
| 151 | 163 | datamessage.extra.inline_image = true; |
| 152 | 164 | data.extramediaAttachment.append_title = true; |
| 153 | 165 | data.extramediaAttachment.title = wrappedCaption; |
| 154 | 166 | } |
| 155 | 167 | } |
| 156 | 168 | |
| @@ -163,14 +175,23 @@ async function sendCaptionedMessage(caption, image) { | ||
| 163 | 175 | const messageText = await wrapCaptionTemplate(caption); |
| 164 | 176 | |
| 165 | 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 | 186 | const message = { |
| 167 | 187 | name: context.name1, |
| 168 | 188 | is_user: true, |
| 169 | 189 | send_date: getMessageTimeStamp(), |
| 170 | 190 | mes: messageText, |
| 171 | 191 | extra: { |
| 172 | 192 | imagemedia: image[mediaAttachment], |
| 173 | 193 | titlemedia_display: messageTextMEDIA_DISPLAY.GALLERY, |
| 194 | + media_index: 0, | |
| 174 | 195 | inline_image: !!extension_settings.caption.show_in_chat, |
| 175 | 196 | }, |
| 176 | 197 | }; |
| @@ -365,13 +386,24 @@ function onRefineModeInput() { | ||
| 365 | 386 | */ |
| 366 | 387 | async function captionCommandCallback(args, prompt) { |
| 367 | 388 | const quiet = isTrueBoolean(args?.quiet); |
| 368 | 389 | const mesIdmessageId = args?.mesId ?? args?.id; |
| 390 | + const index = Number(args?.index ?? 0); | |
| 369 | 391 | |
| 370 | 392 | if (!isNaN(Number(mesIdmessageId))) { |
| 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 | 396 | try { |
| 374 | 397 | const fetchResultmediaAttachment = awaitmessage.extra.media[index] fetch(|| message.extra.image)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 | 407 | const blob = await fetchResult.blob(); |
| 376 | 408 | const file = new File([blob], 'image.jpg', { type: blob.type }); |
| 377 | 409 | return await getCaptionForFile(file, prompt, quiet); |
| @@ -636,13 +668,13 @@ jQuery(async function () { | ||
| 636 | 668 | saveSettingsDebounced(); |
| 637 | 669 | }); |
| 638 | 670 | |
| 639 | 671 | const onMessageEvent = async (indexmessageId) => { |
| 640 | 672 | if (!extension_settings.caption.auto_mode) { |
| 641 | 673 | return; |
| 642 | 674 | } |
| 643 | 675 | |
| 644 | 676 | const datamessage = getContext().chat[indexmessageId]; |
| 645 | 677 | await captionExistingMessage(datamessage, 0); |
| 646 | 678 | }; |
| 647 | 679 | |
| 648 | 680 | eventSource.on(event_types.MESSAGE_SENT, onMessageEvent); |
| @@ -651,13 +683,15 @@ jQuery(async function () { | ||
| 651 | 683 | $(document).on('click', '.mes_img_caption', async function () { |
| 652 | 684 | const animationClass = 'fa-fade'; |
| 653 | 685 | const messageBlock = $(this).closest('.mes'); |
| 654 | 686 | const messageImgimageBlock = messageBlock$(this).findclosest('.mes_imgmes_img_container'); |
| 687 | + const messageImg = imageBlock.find('.mes_img'); | |
| 655 | 688 | if (messageImg.hasClass(animationClass)) return; |
| 656 | 689 | messageImg.addClass(animationClass); |
| 657 | 690 | try { |
| 658 | 691 | const indexmessageId = Number(messageBlock.attr('mesid')); |
| 659 | 692 | const dataimageIndex = getContextNumber()imageBlock.chat[attr('data-index]')); |
| 660 | - await captionExistingMessage(data); | |
| 693 | + const data = getContext().chat[messageId]; | |
| 694 | + await captionExistingMessage(data, imageIndex); | |
| 661 | 695 | appendMediaToMessage(data, messageBlock, false); |
| 662 | 696 | await saveChatConditional(); |
| 663 | 697 | } catch (e) { |
| @@ -681,6 +715,12 @@ jQuery(async function () { | ||
| 681 | 715 | typeList: [ARGUMENT_TYPE.NUMBER], |
| 682 | 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 | 725 | unnamedArgumentList: [ |
| 686 | 726 | new SlashCommandArgument( |
| @@ -52,7 +52,7 @@ import { | ||
| 52 | 52 | SlashCommandArgument, |
| 53 | 53 | SlashCommandNamedArgument, |
| 54 | 54 | } from '../../slash-commands/SlashCommandArgument.js'; |
| 55 | 55 | import { debounce_timeout, MEDIA_DISPLAY, MEDIA_TYPE, VIDEO_EXTENSIONS } from '../../constants.js'; |
| 56 | 56 | import { SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js'; |
| 57 | 57 | import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js'; |
| 58 | 58 | import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; |
| @@ -468,6 +468,12 @@ async function loadSettings() { | ||
| 468 | 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 | 477 | $('#sd_source').val(extension_settings.sd.source); |
| 472 | 478 | $('#sd_scale').val(extension_settings.sd.scale).trigger('input'); |
| 473 | 479 | $('#sd_steps').val(extension_settings.sd.steps).trigger('input'); |
| @@ -4065,6 +4071,16 @@ async function sendMessage(prompt, image, generationType, additionalNegativePref | ||
| 4065 | 4071 | const name = context.groupId ? systemUserName : context.name2; |
| 4066 | 4072 | const template = extension_settings.sd.prompts[generationMode.MESSAGE] || '{{prompt}}'; |
| 4067 | 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 | 4084 | const message = { |
| 4069 | 4085 | name: name, |
| 4070 | 4086 | is_user: false, |
| @@ -4072,20 +4088,12 @@ async function sendMessage(prompt, image, generationType, additionalNegativePref | ||
| 4072 | 4088 | send_date: getMessageTimeStamp(), |
| 4073 | 4089 | mes: messageText, |
| 4074 | 4090 | extra: { |
| 4075 | 4091 | imagemedia: image[mediaAttachment], |
| 4076 | 4092 | titlemedia_display: promptMEDIA_DISPLAY.GALLERY, |
| 4077 | 4093 | generationTypemedia_index: generationType0, |
| 4078 | - negative: additionalNegativePrefix, | |
| 4079 | 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 | 4097 | context.chat.push(message); |
| 4090 | 4098 | const messageId = context.chat.length - 1; |
| 4091 | 4099 | await eventSource.emit(event_types.MESSAGE_RECEIVED, messageId, 'extension'); |
| @@ -4215,98 +4223,76 @@ function isValidState() { | ||
| 4215 | 4223 | |
| 4216 | 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 | 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 | 4236 | function setBusyIcon(isBusy) { |
| 4220 | 4237 | $icon.toggleClass('fa-paintbrush'classes.idle, !isBusy); |
| 4221 | 4238 | $icon.toggleClass(busyClassclasses.busy, isBusy); |
| 4222 | 4239 | } |
| 4223 | 4240 | |
| 4224 | 4241 | const busyClassclasses = { busy: 'fa-hourglass', idle: 'fa-paintbrush' }; |
| 4225 | 4242 | const context = getContext(); |
| 4226 | 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 | 4245 | if ($icon.hasClass(busyClassclasses.busy)) { |
| 4238 | 4246 | buttonAbortController?.abort('Aborted by user'); |
| 4239 | 4247 | console.log('Previous image is still being generated...'); |
| 4240 | 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 | - | |
| 4246 | - try { | |
| 4247 | - setBusyIcon(true); | |
| 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 | |
| 4253 | - const generationType = message?.extra?.generationType ?? generationMode.FREE; | |
| 4254 | + /** @type {ChatMessage} */ | |
| 4254 | - console.log('Regenerating an image, using existing prompt:', prompt); | |
| 4255 | + const message = context.chat[messageId]; | |
| 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 | 4257 | if (dimensions!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 | |
| 4276 | - if (typeof message.extra !== 'object' || message.extra === null) { | |
| 4277 | 4263 | message.extra = {}; |
| 4278 | 4264 | } |
| 4279 | 4265 | |
| 4280 | - // Add image to the swipe list if it's not already there | |
| 4266 | + if (!Array.isArray(message.extra.media)) { | |
| 4281 | - if (!Array.isArray(message.extra.image_swipes)) { | |
| 4267 | + message.extra.media = []; | |
| 4282 | - message.extra.image_swipes = []; | |
| 4283 | 4268 | } |
| 4284 | 4269 | |
| 4285 | - const swipes = message.extra.image_swipes; | |
| 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 }; | |
| 4286 | 4274 | |
| 4287 | - if (message.extra.image && !swipes.includes(message.extra.image)) { | |
| 4275 | + buttonAbortController = new AbortController(); | |
| 4288 | - swipes.push(message.extra.image); | |
| 4276 | + const newMediaAttachment = await generateMediaSwipe( | |
| 4277 | + selectedMedia, | |
| 4278 | + message, | |
| 4279 | + () => setBusyIcon(true), | |
| 4280 | + () => setBusyIcon(false), | |
| 4281 | + buttonAbortController, | |
| 4282 | + ); | |
| 4283 | + | |
| 4284 | + if (!newMediaAttachment) { | |
| 4285 | + return; | |
| 4289 | 4286 | } |
| 4290 | 4287 | |
| 4291 | - const isVideoFormat = isVideo(format); | |
| 4292 | - | |
| 4293 | - if (isVideoFormat) { | |
| 4294 | - message.extra.video = image; | |
| 4295 | - } else { | |
| 4296 | - swipes.push(image); | |
| 4297 | - | |
| 4298 | 4288 | // If already contains an image and it's not inline - leave it as is |
| 4299 | 4289 | message.extra.inline_image = !(message.extra.imagemedia.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 | 4295 | return await context.saveChat(); |
| 4309 | - } | |
| 4310 | 4296 | } |
| 4311 | 4297 | |
| 4312 | 4298 | async function onCharacterPromptShareInput() { |
| @@ -4336,97 +4322,61 @@ async function writePromptFields(characterId) { | ||
| 4336 | 4322 | } |
| 4337 | 4323 | |
| 4338 | 4324 | /** |
| 4339 | 4325 | * Switches anGenerates imagea tonew themedia nextattachment orbased previouson onethe inprovided themedia swipeattachment listmetadata. |
| 4340 | 4326 | * @param {objectMediaAttachment} argsmediaAttachment Event- argumentsThe media attachment metadata. |
| 4341 | 4327 | * @param {anyChatMessage} args.message Message- objectThe 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 | 4333 | async function onImageSwipedgenerateMediaSwipe({mediaAttachment, message, elementonStart, directiononComplete, }abortController = new AbortController()) { |
| 4347 | - const context = getContext(); | |
| 4348 | - const animationClass = 'fa-fade'; | |
| 4349 | - const messageImg = element.find('.mes_img'); | |
| 4350 | - | |
| 4351 | - // Current image is already animating | |
| 4352 | - if (messageImg.hasClass(animationClass)) { | |
| 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 | - | |
| 4365 | - if (currentIndex === -1) { | |
| 4366 | - console.warn('Current image not found in the swipes'); | |
| 4367 | - return; | |
| 4368 | - } | |
| 4369 | - | |
| 4370 | - // Switch to previous image or wrap around if at the beginning | |
| 4371 | - if (direction === 'left') { | |
| 4372 | - const newIndex = currentIndex === 0 ? swipes.length - 1 : currentIndex - 1; | |
| 4373 | - message.extra.image = swipes[newIndex]; | |
| 4374 | - | |
| 4375 | - // Update the image in the message | |
| 4376 | - appendMediaToMessage(message, element, false); | |
| 4377 | - } | |
| 4378 | - | |
| 4379 | - // Switch to next image or generate a new one if at the end | |
| 4380 | - if (direction === 'right') { | |
| 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 | 4334 | const stopButton = document.getElementById('sd_stop_gen'); |
| 4387 | 4335 | const stopListener = () => abortController.abort('Aborted by user'); |
| 4388 | 4336 | const generationType = mediaAttachment.generation_type ?? message?.extra?.generationType ?? generationMode.FREE; |
| 4389 | 4337 | const dimensions = setTypeSpecificDimensions(generationType); |
| 4390 | 4338 | const originalSeed extension_settings.sd.original_seed = extension_settings.sd.seed; |
| 4391 | 4339 | extension_settings.sd.seed = extension_settings.sd.seed >= 0 ? Math.round(Math.random() * (Math.pow(2, 32) - 1)) : -1; |
| 4392 | - let imagePath = ''; | |
| 4340 | + | |
| 4341 | + /** @type {MediaAttachment} */ | |
| 4342 | + const result = { | |
| 4343 | + url: '', | |
| 4344 | + type: MEDIA_TYPE.IMAGE, | |
| 4345 | + }; | |
| 4393 | 4346 | |
| 4394 | 4347 | try { |
| 4395 | 4348 | $(stopButton).show(); |
| 4396 | 4349 | eventSource.once(CUSTOM_STOP_EVENT, stopListener); |
| 4397 | - const callback = () => { }; | |
| 4350 | + const callback = (_a, _b, _c, _d, _e, _f, format) => { result.type = isVideo(format) ? MEDIA_TYPE.VIDEO : MEDIA_TYPE.IMAGE; }; | |
| 4398 | 4351 | const hasNegativesavedPrompt = mediaAttachment.title ?? message.extra.negativetitle ?? ''; |
| 4399 | 4352 | const prompt = await refinePrompt(message.extra.titlesavedPrompt, false); |
| 4400 | 4353 | const negativePromptPrefixsavedNegative = hasNegativemediaAttachment.negative ? await? refinePrompt(message.extra.negative, true) :?? ''; |
| 4401 | - message.extra.title = prompt; | |
| 4354 | + const negative = savedNegative ? await refinePrompt(savedNegative, true) : ''; | |
| 4355 | + | |
| 4356 | + const context = getContext(); | |
| 4402 | 4357 | const characterName = context.groupId |
| 4403 | 4358 | ? context.groups[Object.keys(context.groups).filter(x => context.groups[x].id === context.groupId)[0]]?.id?.toString() |
| 4404 | 4359 | : context.characters[context.characterId]?.name; |
| 4405 | 4360 | |
| 4406 | - messageImg.addClass(animationClass); | |
| 4361 | + onStart(); | |
| 4407 | - swipeControls.hide(); | |
| 4362 | + result.url = await sendGenerationRequest(generationType, prompt, negative, characterName, callback, initiators.swipe, abortController.signal); | |
| 4408 | - imagePath = await sendGenerationRequest(generationType, prompt, negativePromptPrefix, characterName, callback, initiators.swipe, abortController.signal); | |
| 4363 | + result.generation_type = generationType; | |
| 4364 | + result.title = prompt; | |
| 4365 | + result.negative = negative; | |
| 4409 | 4366 | } finally { |
| 4367 | + onComplete(); | |
| 4410 | 4368 | $(stopButton).hide(); |
| 4411 | - messageImg.removeClass(animationClass); | |
| 4412 | - swipeControls.show(); | |
| 4413 | 4369 | eventSource.removeListener(CUSTOM_STOP_EVENT, stopListener); |
| 4414 | 4370 | restoreOriginalDimensions(dimensions); |
| 4415 | 4371 | extension_settings.sd.seed = originalSeedextension_settings.sd.original_seed; |
| 4372 | + delete extension_settings.sd.original_seed; | |
| 4416 | 4373 | } |
| 4417 | 4374 | |
| 4418 | 4375 | if (!imagePathresult.url) { |
| 4419 | 4376 | return null; |
| 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 | 4867 | eventSource.on(event_types.CHAT_CHANGED, onChatChanged); |
| 4920 | 4868 | |
| 4921 | 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 | 428 | * Retrieves files from the chat and inserts them into the vector index. |
| 429 | 429 | * @param {objectChatMessage[]} chat Array of chat messages |
| 430 | 430 | * @returns {Promise<void>} |
| 431 | 431 | */ |
| 432 | 432 | async function processFiles(chat) { |
| @@ -443,39 +443,49 @@ async function processFiles(chat) { | ||
| 443 | 443 | } |
| 444 | 444 | |
| 445 | 445 | for (const message of chat) { |
| 446 | 446 | // Message has no filefiles |
| 447 | 447 | if (!Array.isArray(message?.extra?.filefiles) || !message.extra.files.length) { |
| 448 | 448 | continue; |
| 449 | 449 | } |
| 450 | 450 | |
| 451 | 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 | 454 | // Convert kilobytes to string length |
| 456 | 455 | const thresholdLength = settings.size_threshold * 1024; |
| 457 | 456 | |
| 458 | 457 | // File is too small |
| 459 | 458 | if (fileTextallFileText.length < thresholdLength) { |
| 460 | 459 | continue; |
| 461 | 460 | } |
| 462 | 461 | |
| 463 | 462 | message.mes = message.mes.substring(message.extra.fileLength); |
| 464 | 463 | |
| 465 | 464 | const fileNameallFileChunks = message.extra.file.name[]; |
| 466 | - const fileUrl = message.extra.file.url; | |
| 465 | + const queryText = await getQueryText(chat, 'file'); | |
| 466 | + | |
| 467 | + for (const file of message.extra.files) { | |
| 468 | + const fileName = file.name; | |
| 469 | + const fileUrl = file.url; | |
| 467 | 470 | const collectionId = getFileCollectionId(fileUrl); |
| 468 | 471 | const hashesInCollection = await getSavedHashes(collectionId); |
| 469 | 472 | |
| 470 | 473 | // File is already innot thevectorized collectionyet |
| 471 | 474 | if (!hashesInCollection.length) { |
| 475 | + const fileText = file.text || (await getFileAttachment(fileUrl)); | |
| 476 | + if (!fileText) { | |
| 477 | + continue; | |
| 478 | + } | |
| 472 | 479 | await vectorizeFile(fileText, fileName, collectionId, settings.chunk_size, settings.overlap_percent); |
| 473 | 480 | } |
| 474 | 481 | |
| 475 | - const queryText = await getQueryText(chat, 'file'); | |
| 476 | 482 | const fileChunks = await retrieveFileChunks(queryText, collectionId); |
| 483 | + if (fileChunks) { | |
| 484 | + allFileChunks.push(fileChunks); | |
| 485 | + } | |
| 486 | + } | |
| 477 | 487 | |
| 478 | 488 | message.mes = `${fileChunksallFileChunks.join('\n\n')}\n\n${message.mes}`; |
| 479 | 489 | } |
| 480 | 490 | } catch (error) { |
| 481 | 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 | 626 | * Removes the most relevant messages from the chat and displays them in the extension prompt |
| 617 | 627 | * @param {objectChatMessage[]} chat Array of chat messages |
| 618 | 628 | * @param {number} _contextSize Context size (unused) |
| 619 | 629 | * @param {function} _abort Abort function (unused) |
| 620 | 630 | * @param {string} type Generation type |
| @@ -740,13 +750,18 @@ const onChatEvent = debounce(async () => await moduleWorker.update(), debounce_t | ||
| 740 | 750 | |
| 741 | 751 | /** |
| 742 | 752 | * Gets the text to query from the chat |
| 743 | 753 | * @param {objectChatMessage[]} chat Chat messages |
| 744 | 754 | * @param {'file'|'chat'|'world-info'} initiator Initiator of the query |
| 745 | 755 | * @returns {Promise<string>} Text to query |
| 746 | 756 | */ |
| 747 | 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 | 763 | let hashedMessages = chat |
| 749 | 764 | .map(x => ({ text: String(substituteParams(getTextWithoutAttachments(x.mes)), hash: getStringHash(substituteParams(getTextWithoutAttachments(x.mes))), index: chat.indexOf(x) })) |
| 750 | 765 | .filter(x => x.text) |
| 751 | 766 | .reverse() |
| 752 | 767 | .slice(0, settings.query); |
| @@ -1313,7 +1328,7 @@ async function onViewStatsClick() { | ||
| 1313 | 1328 | async function onVectorizeAllFilesClick() { |
| 1314 | 1329 | try { |
| 1315 | 1330 | const dataBank = getDataBankAttachments(); |
| 1316 | 1331 | const chatAttachments = getContext().chat.filter(x => Array.isArray(x.extra?.filefiles)).map(x => x.extra.filefiles).flat(); |
| 1317 | 1332 | const allFiles = [...dataBank, ...chatAttachments]; |
| 1318 | 1333 | |
| 1319 | 1334 | /** |
| @@ -1390,7 +1405,7 @@ async function onVectorizeAllFilesClick() { | ||
| 1390 | 1405 | async function onPurgeFilesClick() { |
| 1391 | 1406 | try { |
| 1392 | 1407 | const dataBank = getDataBankAttachments(); |
| 1393 | 1408 | const chatAttachments = getContext().chat.filter(x => Array.isArray(x.extra?.filefiles)).map(x => x.extra.filefiles).flat(); |
| 1394 | 1409 | const allFiles = [...dataBank, ...chatAttachments]; |
| 1395 | 1410 | |
| 1396 | 1411 | for (const file of allFiles) { |
| @@ -78,6 +78,7 @@ import { | ||
| 78 | 78 | shouldAutoContinue, |
| 79 | 79 | unshallowCharacter, |
| 80 | 80 | chatElement, |
| 81 | + ensureMessageMediaIsArray, | |
| 81 | 82 | } from '../script.js'; |
| 82 | 83 | import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect } from './tags.js'; |
| 83 | 84 | import { FILTER_TYPES, FilterHelper } from './filters.js'; |
| @@ -267,6 +268,7 @@ export async function getGroupChat(groupId, reload = false) { | ||
| 267 | 268 | } else if (Array.isArray(data) && data.length) { |
| 268 | 269 | data[0].is_group = true; |
| 269 | 270 | chat.splice(0, chat.length, ...data); |
| 271 | + chat.forEach(ensureMessageMediaIsArray); | |
| 270 | 272 | chatElement.find('.mes').remove(); |
| 271 | 273 | await printMessages(); |
| 272 | 274 | } |
| @@ -15,6 +15,8 @@ import { | ||
| 15 | 15 | Generate, |
| 16 | 16 | getExtensionPrompt, |
| 17 | 17 | getExtensionPromptMaxDepth, |
| 18 | + getMediaDisplay, | |
| 19 | + getMediaIndex, | |
| 18 | 20 | getRequestHeaders, |
| 19 | 21 | getStoppingStrings, |
| 20 | 22 | is_send_press, |
| @@ -74,7 +76,7 @@ import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js'; | ||
| 74 | 76 | import { t } from './i18n.js'; |
| 75 | 77 | import { ToolManager } from './tool-calling.js'; |
| 76 | 78 | import { accountStorage } from './util/AccountStorage.js'; |
| 77 | 79 | import { COMETAPI_IGNORE_PATTERNS, IGNORE_SYMBOL, MEDIA_DISPLAY, MEDIA_TYPE } from './constants.js'; |
| 78 | 80 | |
| 79 | 81 | export { |
| 80 | 82 | openai_messages_count, |
| @@ -539,10 +541,11 @@ function setOpenAIMessages(chat) { | ||
| 539 | 541 | // Apply the "wrap in quotes" option |
| 540 | 542 | if (role == 'user' && oai_settings.wrap_in_quotes) content = `"${content}"`; |
| 541 | 543 | const name = chat[j]['name']; |
| 542 | 544 | const imagemedia = chat[j]?.extra?.imagemedia; |
| 543 | 545 | const videomediaDisplay = getMediaDisplay(chat[j]?.extra?.video); |
| 546 | + const mediaIndex = getMediaIndex(chat[j]); | |
| 544 | 547 | const invocations = chat[j]?.extra?.tool_invocations; |
| 545 | 548 | messages[i] = { 'role': role, 'content': content, name: name, 'imagemedia': imagemedia, 'videomediaDisplay': videomediaDisplay, 'mediaIndex': mediaIndex, 'invocations': invocations }; |
| 546 | 549 | j++; |
| 547 | 550 | } |
| 548 | 551 | |
| @@ -852,12 +855,35 @@ async function populateChatHistory(messages, prompts, chatCompletion, type = nul | ||
| 852 | 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 | 877 | if (videoInliningArray.isArray(chatPrompt.media) && chatPrompt.videomedia.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 | 889 | if (canUseTools && Array.isArray(chatPrompt.invocations)) { |
| @@ -2905,6 +2931,13 @@ class Message { | ||
| 2905 | 2931 | */ |
| 2906 | 2932 | async addImage(image) { |
| 2907 | 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 | 2941 | const isDataUrl = isDataURL(image); |
| 2909 | 2942 | if (!isDataUrl) { |
| 2910 | 2943 | try { |
| @@ -2921,10 +2954,7 @@ class Message { | ||
| 2921 | 2954 | image = await this.compressImage(image); |
| 2922 | 2955 | |
| 2923 | 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 | 2959 | try { |
| 2930 | 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 | 2973 | async addVideo(video) { |
| 2939 | 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 | 2982 | const isDataUrl = isDataURL(video); |
| 2941 | 2983 | if (!isDataUrl) { |
| 2942 | 2984 | try { |
| @@ -2951,10 +2993,7 @@ class Message { | ||
| 2951 | 2993 | } |
| 2952 | 2994 | |
| 2953 | 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 | 2998 | try { |
| 2960 | 2999 | // Convservative estimate for video token cost without knowing duration |
| @@ -63,6 +63,8 @@ import { fuzzySearchCategories } from './filters.js'; | ||
| 63 | 63 | import { accountStorage } from './util/AccountStorage.js'; |
| 64 | 64 | import { DEFAULT_REASONING_TEMPLATE, loadReasoningTemplates } from './reasoning.js'; |
| 65 | 65 | import { bindModelTemplates } from './chat-templates.js'; |
| 66 | +import { MEDIA_DISPLAY } from './constants.js'; | |
| 67 | +import { t } from './i18n.js'; | |
| 66 | 68 | |
| 67 | 69 | export const toastPositionClasses = [ |
| 68 | 70 | 'toast-top-left', |
| @@ -337,6 +339,7 @@ export const power_user = { | ||
| 337 | 339 | external_media_forbidden_overrides: [], |
| 338 | 340 | pin_styles: true, |
| 339 | 341 | click_to_edit: false, |
| 342 | + media_display: MEDIA_DISPLAY.LIST, | |
| 340 | 343 | }; |
| 341 | 344 | |
| 342 | 345 | let themes = []; |
| @@ -1178,6 +1181,46 @@ function applyFontScale(type) { | ||
| 1178 | 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 | 1224 | function applyTheme(name) { |
| 1182 | 1225 | const theme = themes.find(x => x.name == name); |
| 1183 | 1226 | |
| @@ -1367,14 +1410,25 @@ function applyTheme(name) { | ||
| 1367 | 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 | 1424 | for (const { key, selector, type, action } of themeProperties) { |
| 1373 | 1425 | if (theme[key] !== undefined) { |
| 1374 | 1426 | power_user[key]const oldValue = themepower_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 | 1430 | if (type) applyThemeColor(type); |
| 1377 | 1431 | if (action) action(oldValue, newValue); |
| 1378 | 1432 | } else { |
| 1379 | 1433 | console.debug(`Empty theme key: ${key}`); |
| 1380 | 1434 | } |
| @@ -1714,6 +1768,7 @@ export async function loadPowerUserSettings(settings, data) { | ||
| 1714 | 1768 | $('#forbid_external_media').prop('checked', power_user.forbid_external_media); |
| 1715 | 1769 | $('#pin_styles').prop('checked', power_user.pin_styles); |
| 1716 | 1770 | $('#click_to_edit').prop('checked', power_user.click_to_edit); |
| 1771 | + $('#media_display').val(power_user.media_display); | |
| 1717 | 1772 | |
| 1718 | 1773 | for (const theme of themes) { |
| 1719 | 1774 | const option = document.createElement('option'); |
| @@ -2506,6 +2561,7 @@ function getThemeObject(name) { | ||
| 2506 | 2561 | compact_input_area: power_user.compact_input_area, |
| 2507 | 2562 | show_swipe_num_all_messages: power_user.show_swipe_num_all_messages, |
| 2508 | 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 | 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 | 4188 | $(document).on('click', '#debug_table [data-debug-function]', function () { |
| 4125 | 4189 | const functionId = $(this).data('debug-function'); |
| 4126 | 4190 | const functionRecord = debug_functions.find(f => f.functionId === functionId); |
| @@ -409,7 +409,7 @@ export class ReasoningHandler { | ||
| 409 | 409 | if (!power_user.reasoning.prefix || !power_user.reasoning.suffix) |
| 410 | 410 | return mesChanged; |
| 411 | 411 | |
| 412 | 412 | /** @type {{ mes: string, [key: string]: any}ChatMessage} */ |
| 413 | 413 | const message = chat[messageId]; |
| 414 | 414 | if (!message) return mesChanged; |
| 415 | 415 | |
| @@ -1259,7 +1259,7 @@ export function parseReasoningFromString(str, { strict = true } = {}) { | ||
| 1259 | 1259 | /** |
| 1260 | 1260 | * Parse reasoning in an array of swipe strings if auto-parsing is enabled. |
| 1261 | 1261 | * @param {string[]} swipes Array of swipe strings |
| 1262 | 1262 | * @param {{extra: Partial<ReasoningMessageExtra>}[]} swipeInfoArray Array of swipe info objects |
| 1263 | 1263 | * @param {number?} duration Duration of the reasoning |
| 1264 | 1264 | * @typedef {object} ReasoningMessageExtra Extra reasoning data |
| 1265 | 1265 | * @property {string} reasoning Reasoning block |
| @@ -3866,11 +3866,6 @@ async function addSwipeCallback(args, value) { | ||
| 3866 | 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 | 3869 | if (!Array.isArray(lastMessage.swipes)) { |
| 3875 | 3870 | lastMessage.swipes = [lastMessage.mes]; |
| 3876 | 3871 | lastMessage.swipe_info = [{}]; |
| @@ -40,6 +40,7 @@ export const enumIcons = { | ||
| 40 | 40 | server: '🖥️', |
| 41 | 41 | popup: '🗔', |
| 42 | 42 | image: '🖼️', |
| 43 | + video: '🎥', | |
| 43 | 44 | key: '🔑', |
| 44 | 45 | |
| 45 | 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 | 283 | * All names used in the current chat. |
| 267 | 284 | * |
| 268 | 285 | * @returns {SlashCommandEnumValue[]} |
| @@ -58,6 +58,9 @@ import { | ||
| 58 | 58 | deleteMessage, |
| 59 | 59 | refreshSwipeButtons, |
| 60 | 60 | isSwipingAllowed, |
| 61 | + ensureMessageMediaIsArray, | |
| 62 | + getMediaDisplay, | |
| 63 | + getMediaIndex, | |
| 61 | 64 | } from '../script.js'; |
| 62 | 65 | import { |
| 63 | 66 | extension_settings, |
| @@ -209,6 +212,9 @@ export function getContext() { | ||
| 209 | 212 | humanizedDateTime, |
| 210 | 213 | updateMessageBlock, |
| 211 | 214 | appendMediaToMessage, |
| 215 | + ensureMessageMediaIsArray, | |
| 216 | + getMediaDisplay, | |
| 217 | + getMediaIndex, | |
| 212 | 218 | swipe: { |
| 213 | 219 | left: swipe_left, |
| 214 | 220 | right: swipe_right, |
| @@ -5,8 +5,9 @@ import { getSlashCommandsHelp } from './slash-commands.js'; | ||
| 5 | 5 | import { SlashCommandBrowser } from './slash-commands/SlashCommandBrowser.js'; |
| 6 | 6 | import { renderTemplateAsync } from './templates.js'; |
| 7 | 7 | |
| 8 | -// Initialized in getSystemMessages() | |
| 8 | +/** @type {Record<string, ChatMessage>} */ | |
| 9 | 9 | export const system_messages = {}; |
| 10 | +/** @type {ChatMessage[]} */ | |
| 10 | 11 | export const SAFETY_CHAT = []; |
| 11 | 12 | |
| 12 | 13 | /** |
| @@ -29,6 +30,7 @@ export const system_message_types = { | ||
| 29 | 30 | }; |
| 30 | 31 | |
| 31 | 32 | export async function initSystemMessages() { |
| 33 | + /** @type {Record<string, ChatMessage>} */ | |
| 32 | 34 | const result = { |
| 33 | 35 | help: { |
| 34 | 36 | name: systemUserName, |
| @@ -65,14 +67,15 @@ export async function initSystemMessages() { | ||
| 65 | 67 | is_system: true, |
| 66 | 68 | mes: await renderTemplateAsync('macros'), |
| 67 | 69 | }, |
| 68 | 70 | welcome: { |
| 69 | - { | |
| 70 | 71 | name: systemUserName, |
| 71 | 72 | force_avatar: system_avatar, |
| 72 | 73 | is_user: false, |
| 73 | 74 | is_system: true, |
| 74 | - uses_system_ui: true, | |
| 75 | 75 | mes: await renderTemplateAsync('welcome', { displayVersion }), |
| 76 | + extra: { | |
| 77 | + uses_system_ui: true, | |
| 78 | + }, | |
| 76 | 79 | }, |
| 77 | 80 | empty: { |
| 78 | 81 | name: systemUserName, |
| @@ -93,9 +96,9 @@ export async function initSystemMessages() { | ||
| 93 | 96 | force_avatar: system_avatar, |
| 94 | 97 | is_user: false, |
| 95 | 98 | is_system: true, |
| 96 | - uses_system_ui: true, | |
| 97 | 99 | mes: await renderTemplateAsync('welcomePrompt'), |
| 98 | 100 | extra: { |
| 101 | + uses_system_ui: true, | |
| 99 | 102 | isSmallSys: true, |
| 100 | 103 | }, |
| 101 | 104 | }, |
| @@ -105,8 +108,8 @@ export async function initSystemMessages() { | ||
| 105 | 108 | is_user: false, |
| 106 | 109 | is_system: true, |
| 107 | 110 | mes: await renderTemplateAsync('assistantNote'), |
| 108 | - uses_system_ui: true, | |
| 109 | 111 | extra: { |
| 112 | + uses_system_ui: true, | |
| 110 | 113 | isSmallSys: true, |
| 111 | 114 | }, |
| 112 | 115 | }, |
| @@ -114,12 +117,13 @@ export async function initSystemMessages() { | ||
| 114 | 117 | |
| 115 | 118 | Object.assign(system_messages, result); |
| 116 | 119 | |
| 120 | + /** @type {ChatMessage} */ | |
| 117 | 121 | const safetyMessage = { |
| 118 | 122 | name: systemUserName, |
| 119 | 123 | force_avatar: system_avatar, |
| 120 | 124 | is_system: true, |
| 121 | 125 | is_user: false, |
| 122 | 126 | create_datesend_date: 0getMessageTimeStamp(), |
| 123 | 127 | mes: t`You deleted a character/chat and arrived back here for safety reasons! Pick another character!`, |
| 124 | 128 | }; |
| 125 | 129 | SAFETY_CHAT.splice(0, SAFETY_CHAT.length, safetyMessage); |
| @@ -130,8 +134,8 @@ export async function initSystemMessages() { | ||
| 130 | 134 | * Gets a system message by type. |
| 131 | 135 | * @param {string} type Type of system message |
| 132 | 136 | * @param {string} [text] Text to be sent |
| 133 | 137 | * @param {objectChatMessageExtra} [extra] Additional data to be added to the message |
| 134 | 138 | * @returns {objectChatMessage} System message object |
| 135 | 139 | */ |
| 136 | 140 | export function getSystemMessageByType(type, text, extra = {}) { |
| 137 | 141 | const systemMessage = system_messages[type]; |
| @@ -150,7 +154,7 @@ export function getSystemMessageByType(type, text, extra = {}) { | ||
| 150 | 154 | newMessage.mes = getSlashCommandsHelp(); |
| 151 | 155 | } |
| 152 | 156 | |
| 153 | 157 | if (!newMessage.extra || typeof newMessage.extra !== 'object') { |
| 154 | 158 | newMessage.extra = {}; |
| 155 | 159 | } |
| 156 | 160 | |
| @@ -163,7 +167,7 @@ export function getSystemMessageByType(type, text, extra = {}) { | ||
| 163 | 167 | * Sends a system message to the chat. |
| 164 | 168 | * @param {string} type Type of system message |
| 165 | 169 | * @param {string} [text] Text to be sent |
| 166 | 170 | * @param {objectChatMessageExtra} [extra] Additional data to be added to the message |
| 167 | 171 | */ |
| 168 | 172 | export function sendSystemMessage(type, text, extra = {}) { |
| 169 | 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 | 421 | * Returns a promise that resolves to the file's text. |
| 412 | 422 | * @param {Blob} file The file to read. |
| 413 | 423 | * @returns {Promise<string>} A promise that resolves to the file's text. |
| @@ -1009,7 +1019,7 @@ const dateCache = new Map(); | ||
| 1009 | 1019 | /** |
| 1010 | 1020 | * Cached version of moment() to avoid re-parsing the same date strings. |
| 1011 | 1021 | * Important: Moment objects are mutable, so use clone() before modifying them! |
| 1012 | 1022 | * @param {string|numberMessageTimestamp} timestamp String or number representing a date. |
| 1013 | 1023 | * @returns {import('moment').Moment} Moment object |
| 1014 | 1024 | */ |
| 1015 | 1025 | export function timestampToMoment(timestamp) { |
| @@ -1026,12 +1036,17 @@ export function timestampToMoment(timestamp) { | ||
| 1026 | 1036 | |
| 1027 | 1037 | /** |
| 1028 | 1038 | * Parses a timestamp and returns a moment object representing the parsed date and time. |
| 1029 | 1039 | * @param {string|numberMessageTimestamp} timestamp - The timestamp to parse. It can be a string or a number. |
| 1030 | 1040 | * @returns {string} - If the timestamp is valid, returns an ISO 8601 string. |
| 1031 | 1041 | */ |
| 1032 | 1042 | function parseTimestamp(timestamp) { |
| 1033 | 1043 | if (!timestamp) return; |
| 1034 | 1044 | |
| 1045 | + // Date object | |
| 1046 | + if (timestamp instanceof Date) { | |
| 1047 | + return timestamp.toISOString(); | |
| 1048 | + } | |
| 1049 | + | |
| 1035 | 1050 | // Unix time (legacy TAI / tags) |
| 1036 | 1051 | if (typeof timestamp === 'number' || /^\d+$/.test(timestamp)) { |
| 1037 | 1052 | const unixTime = Number(timestamp); |
| @@ -388,6 +388,11 @@ input[type='checkbox']:focus-visible { | ||
| 388 | 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 | 396 | .mes_text li tt, |
| 392 | 397 | .mes_reasoning li tt { |
| 393 | 398 | display: inline-block; |
| @@ -637,6 +642,16 @@ input[type='checkbox']:focus-visible { | ||
| 637 | 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 | 655 | small { |
| 641 | 656 | color: var(--SmartThemeBodyColor); |
| 642 | 657 | opacity: 0.7; |
| @@ -4952,6 +4967,22 @@ a:hover { | ||
| 4952 | 4967 | } |
| 4953 | 4968 | |
| 4954 | 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 | 4986 | .mes .mes_img_container, |
| 4956 | 4987 | .mes .mes_video_container { |
| 4957 | 4988 | max-width: 100%; |
| @@ -4960,7 +4991,8 @@ a:hover { | ||
| 4960 | 4991 | position: relative; |
| 4961 | 4992 | width: fit-content; |
| 4962 | 4993 | transition: all var(--animation-duration); |
| 4963 | 4994 | paddingborder-radius: 0.5rem5px; |
| 4995 | + overflow: hidden; | |
| 4964 | 4996 | } |
| 4965 | 4997 | |
| 4966 | 4998 | .mes .mes_video_container:has(.mes_video[src]) { |
| @@ -4968,7 +5000,6 @@ a:hover { | ||
| 4968 | 5000 | } |
| 4969 | 5001 | |
| 4970 | 5002 | .mes_img { |
| 4971 | - border-radius: 5px; | |
| 4972 | 5003 | max-width: 100%; |
| 4973 | 5004 | max-height: 40vh; |
| 4974 | 5005 | image-rendering: -webkit-optimize-contrast; |
| @@ -4985,7 +5016,7 @@ a:hover { | ||
| 4985 | 5016 | .mes_img_controls, |
| 4986 | 5017 | .mes_video_controls { |
| 4987 | 5018 | position: absolute; |
| 4988 | 5019 | top: 0.1em; |
| 4989 | 5020 | left: 0; |
| 4990 | 5021 | width: 100%; |
| 4991 | 5022 | display: flex; |
| @@ -4993,14 +5024,16 @@ a:hover { | ||
| 4993 | 5024 | flex-direction: row; |
| 4994 | 5025 | justify-content: space-between; |
| 4995 | 5026 | align-items: center; |
| 4996 | 5027 | padding: 1em10px; |
| 4997 | 5028 | z-index: 1; |
| 4998 | 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 | 5033 | .mes_img_swipes { |
| 5002 | 5034 | top: unset; |
| 5003 | 5035 | bottom: 0.1rem; |
| 5036 | + background: linear-gradient(transparent, rgba(0, 0, 0, 0.8)); | |
| 5004 | 5037 | } |
| 5005 | 5038 | |
| 5006 | 5039 | .mes_img_swipes .right_menu_button, |
| @@ -5036,11 +5069,18 @@ a:hover { | ||
| 5036 | 5069 | .mes_img_container:focus-within .mes_img_swipes, |
| 5037 | 5070 | .mes_img_container:hover .mes_img_controls, |
| 5038 | 5071 | .mes_img_container:focus-within .mes_img_controls, |
| 5039 | 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 | 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 | 5084 | display: flex; |
| 5045 | 5085 | } |
| 5046 | 5086 | |
| @@ -5048,8 +5088,7 @@ body:not(.caption) .mes_img_caption { | ||
| 5048 | 5088 | display: none; |
| 5049 | 5089 | } |
| 5050 | 5090 | |
| 5051 | 5091 | .mes_img_container:not(.img_swipes) .mes_img_swipes, { |
| 5052 | -body:not(.sd) .mes_img_swipes { | |
| 5053 | 5092 | display: none; |
| 5054 | 5093 | } |
| 5055 | 5094 | |
| @@ -5136,7 +5175,6 @@ body:not(.sd) .mes_img_swipes { | ||
| 5136 | 5175 | .mes_video { |
| 5137 | 5176 | max-width: 100%; |
| 5138 | 5177 | max-height: 400px; |
| 5139 | - border-radius: 5px; | |
| 5140 | 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 | 65 | * @typedef {object} DataMaidChatMetadata - The chat metadata object. |
| 61 | 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 | 71 | * @typedef {object} DataMaidMessageExtra - The extra data object. |
| 66 | 72 | * @property {string} [image] - The link to the image, if any - DEPRECATED, use `media` instead. |
| 67 | 73 | * @property {string} [video] - The link to the video, if any - DEPRECATED, use `media` instead. |
| 68 | 74 | * @property {string[]} [image_swipes] - The links to the image swipes, if any - DEPRECATED, use `media` instead. |
| 69 | 75 | * @property {DataMaidFileDataMaidMedia[]} [filemedia] - The filelinks objectto 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 | 174 | const result = []; |
| 167 | 175 | |
| 168 | 176 | try { |
| 169 | 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 | 178 | const knownImages = new Set(); |
| 171 | 179 | for (const message of messages) { |
| 172 | 180 | if (message?.extra?.image) { |
| @@ -180,6 +188,23 @@ export class DataMaidService { | ||
| 180 | 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 | 209 | const knownImageFullPaths = new Set(); |
| 185 | 210 | knownImages.forEach(image => { |
| @@ -221,12 +246,19 @@ export class DataMaidService { | ||
| 221 | 246 | const result = []; |
| 222 | 247 | |
| 223 | 248 | try { |
| 224 | 249 | const messages = await this.#parseAllChats(x => !!x?.extra?.file?.url || (Array.isArray(x?.extra?.files) && x.extra.files.length > 0)); |
| 225 | 250 | const knownFiles = new Set(); |
| 226 | 251 | for (const message of messages) { |
| 227 | 252 | if (message?.extra?.file?.url) { |
| 228 | 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 | 263 | const metadata = await this.#parseAllMetadata(x => Array.isArray(x?.attachments) && x.attachments.length > 0); |
| 232 | 264 | for (const meta of metadata) { |