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>

de7c113346f05f161237c6d9d71a7437ff4c4e5c

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

Signed
21 files changed, +1422 -584Ignore whitespace
public/css/file-form.css+10 -0
@@ -25,6 +25,16 @@
2525 border-radius: 15px;
2626}
2727
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+
2838.mes_file_container .right_menu_button {
2939 padding-right: 0;
3040}
public/global.d.ts+68 -0
@@ -4,6 +4,8 @@ import { power_user } from './scripts/power-user';
44import { QuickReplyApi } from './scripts/extensions/quick-reply/api/QuickReplyApi';
55import { oai_settings } from './scripts/openai';
66import { textgenerationwebui_settings } from './scripts/textgen-settings';
7+import { FileAttachment } from './scripts/chats';
8+import { ReasoningMessageExtra } from './scripts/reasoning';
79
810declare global {
911 // Custom types
@@ -12,6 +14,72 @@ declare global {
1214 type ReasoningSettings = typeof power_user.reasoning;
1315 type ChatCompletionSettings = typeof oai_settings;
1416 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+ }
1583
1684 // Global namespace modules
1785 interface Window {
public/index.html+37 -20
@@ -4671,6 +4671,13 @@
46714671 <option value="2" data-i18n="Document">Document</option>
46724672 </select>
46734673 </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>
46744681 <div class="flex-container alignItemsBaseline">
46754682 <span data-i18n="Notifications:">Notifications:</span>
46764683 <select id="toastr_position" class="widthNatural flex1 margin0 text_pole">
@@ -7005,6 +7012,8 @@
70057012 <div title="Prompt" class="mes_button mes_prompt fa-solid fa-square-poll-horizontal " data-i18n="[title]Prompt" style="display: none;"></div>
70067013 <div title="Exclude message from prompts" class="mes_button mes_hide fa-solid fa-eye" data-i18n="[title]Exclude message from prompts"></div>
70077014 <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>
70087017 <div title="Embed file or image" class="mes_button mes_embed fa-solid fa-paperclip" data-i18n="[title]Embed file or image"></div>
70097018 <div title="Create checkpoint" class="mes_button mes_create_bookmark fa-regular fa-solid fa-flag-checkered" data-i18n="[title]Create checkpoint"></div>
70107019 <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 @@
70437052 <div class="mes_reasoning"></div>
70447053 </details>
70457054 <div class="mes_text"></div>
70467055 <div class="mes_img_containermes_media_wrapper"></div>
70477056 <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>
70597057 <div class="mes_bias"></div>
70607058 </div>
70617059 <div class="flex-container swipeRightBlock flexFlowColumn flexNoGap">
@@ -7265,9 +7263,9 @@
72657263 </div>
72667264 </div>
72677265
72687266 <!-- chat and inputMedia barTemplates -->
72697267 <div id="message_file_template" class="template_element">
72707268 <div class="mes_media_container mes_file_container">
72717269 <div class="fa-lg fa-solid fa-file-alt mes_file_icon"></div>
72727270 <div class="mes_file_name"></div>
72737271 <div class="mes_file_size"></div>
@@ -7276,15 +7274,34 @@
72767274 </div>
72777275 </div>
72787276
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+
72797288 <div id="message_video_template" class="template_element">
72807289 <div class="mes_media_container mes_video_container">
72817290 <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>
72837292 <div title="Delete" class="right_menu_button fa-lg fa-solid fa-trash-can mes_video_deletemes_media_delete" data-i18n="[title]Delete"></div>
72847293 </div>
72857294 <video class="mes_video" controls preload="metadata"></video>
72867295 </div>
72877296 </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>
72887305 </div>
72897306 <div id="movingDivs">
72907307 <div id="floatingPrompt" class="drawer-content flexGap5">
@@ -7641,8 +7658,8 @@
76417658 <div id="send_form" class="no-connection">
76427659 <form id="file_form" class="wide100p displayNone">
76437660 <div class="file_attached">
76447661 <input id="file_form_input" type="file" multiple hidden>
76457662 <input id="embed_file_input" type="file" multiple hidden>
76467663 <i class="fa-solid fa-file-alt"></i>
76477664 <span class="file_name">File Name</span>
76487665 <span class="file_size">File Size</span>
public/script.js+389 -132
@@ -183,7 +183,7 @@ import {
183183 trimSpaces,
184184 clamp,
185185} from './scripts/utils.js';
186186import { debounce_timeout, GENERATION_TYPE_TRIGGERS, IGNORE_SYMBOL, inject_ids, MEDIA_DISPLAY, MEDIA_TYPE, SWIPE_DIRECTION } from './scripts/constants.js';
187187
188188import { cancelDebouncedMetadataSave, doDailyExtensionUpdatesCheck, extension_settings, initExtensions, loadExtensionSettings, runGenerationInterceptors, saveMetadataDebounced } from './scripts/extensions.js';
189189import { COMMENT_NAME_DEFAULT, CONNECT_API_MAP, executeSlashCommandsOnChatInput, initDefaultSlashCommands, isExecutingCommandsFromChatInput, pauseScriptExecution, stopScriptExecution, UNIQUE_APIS } from './scripts/slash-commands.js';
@@ -314,7 +314,10 @@ export {
314314 getSystemMessageByType,
315315 event_types,
316316 eventSource,
317+ /** @deprecated Use setCharacterSettingsOverrides instead. */
317318 setCharacterSettingsOverrides as setScenarioOverride,
319+ /** @deprecated Use appendMediaToMessage instead. */
320+ appendMediaToMessage as appendImageToMessage,
318321};
319322
320323/**
@@ -367,6 +370,7 @@ export const neutralCharacterName = 'Assistant';
367370let default_user_name = 'User';
368371export let name1 = default_user_name;
369372export let name2 = systemUserName;
373+/** @type {ChatMessage[]} */
370374export let chat = [];
371375export let isSwipingAllowed = true; //false when a swipe is in progress, or swiping is blocked.
372376let chatSaveTimeout;
@@ -1407,30 +1411,45 @@ export async function printMessages() {
14071411 addOneMessage(item, { scroll: false, forceId: i, showSwipes: false });
14081412 }
14091413
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;
14131426
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) {
14161429 if (image instanceof HTMLImageElementcurrentElement.complete) {
1417- if (image.complete) {
1430+ incrementAndCheck();
1431+ } else {
1432+ currentElement.addEventListener('load', incrementAndCheck);
1433+ currentElement.addEventListener('error', incrementAndCheck);
1434+ }
1435+ }
1436+ if (currentElement instanceof HTMLVideoElement) {
1437+ if (currentElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
14181438 incrementAndCheck();
14191439 } else {
14201440 imagecurrentElement.addEventListener('loadloadeddata', incrementAndCheck);
1441+ currentElement.addEventListener('error', incrementAndCheck);
14211442 }
14221443 }
14231444 }
14241445
1425- chatElement.find('.mes').removeClass('last_mes');
1426- chatElement.find('.mes').last().addClass('last_mes');
1427- refreshSwipeButtons();
1428- scrollChatToBottom();
1429- applyStylePins();
1430-
14311446 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) {
14341453 scrollChatToBottom();
14351454 }
14361455 }
@@ -1885,110 +1904,335 @@ export function updateMessageBlock(messageId, message, { rerenderMessage = true
18851904}
18861905
18871906/**
1888- * Appends image or file to the message element.
1907+ * Ensures that the message media properties are arrays, adding getters/setters for single media items.
18891908 * @param {objectChatMessage} mes Message object
1890- * @param {JQuery<HTMLElement>} messageElement Message element
1891- * @param {boolean} [adjustScroll=true] Whether to adjust the scroll position after appending the media
18921909 */
18931910export function appendMediaToMessageensureMessageMediaIsArray(mes, messageElement, adjustScroll = true) {
1894- // Add image to message
1911+ /**
1895- if (mes.extra?.image) {
1912+ * Determines if a property of an object is a plain property (not a getter/setter or non-enumerable).
1896- const container = messageElement.find('.mes_img_container');
1913+ * @param {object} obj Object to check
1897- const chatHeight = chatElement.prop('scrollHeight');
1914+ * @param {string} name Property name
1898- const image = messageElement.find('.mes_img');
1915+ * @returns {boolean} True if the property is a plain property, false otherwise
1899- const text = messageElement.find('.mes_text');
1916+ */
1900- const isInline = !!mes.extra?.inline_image;
1917+ function isPlainObjectProperty(obj, name) {
19011918 const doAdjustScrollhasProperty = Object.hasOwn() =>obj, {name);
19021919 if (!adjustScrollhasProperty) {
1903- return;
1920+ const descriptor = Object.getOwnPropertyDescriptor(obj, name);
1904- }
1921+ return descriptor && descriptor.enumerable && descriptor.configurable && descriptor.writable;
1905- const scrollPosition = chatElement.scrollTop();
1922+ }
1906- const newChatHeight = chatElement.prop('scrollHeight');
1923+ return false;
1907- const diff = newChatHeight - chatHeight;
1924+ }
1908- chatElement.scrollTop(scrollPosition + diff);
1925+
1909- };
1926+ /**
1910- image.off('load').on('load', function () {
1927+ * Determines if a property of an object is a getter (not a plain property).
1911- image.removeAttr('alt');
1928+ * @param {object} obj Object to check
1912- image.removeClass('error');
1929+ * @param {string} name Property name
1913- doAdjustScroll();
1930+ * @returns {boolean} True if the property is a getter, false otherwise
1914- });
1931+ */
1915- image.off('error').on('error', function () {
1932+ function isGetterObjectProperty(obj, name) {
1916- image.attr('alt', '');
1933+ const hasProperty = Object.hasOwn(obj, name);
1917- image.addClass('error');
1934+ if (hasProperty) {
1918- doAdjustScroll();
1935+ const descriptor = Object.getOwnPropertyDescriptor(obj, name);
1936+ return descriptor && typeof descriptor.get === 'function';
1937+ }
1938+ return false;
1939+ }
1940+
1941+ /**
1942+ * Adds a plain property to an object that wraps around an array property.
1943+ * @param {object} obj Object to add property to
1944+ * @param {string} plainProperty Plain property name
1945+ * @param {string} arrayProperty Array property to back the plain property
1946+ * @param {(value: any) => boolean} [filterFn] Optional filter function to apply when getting/setting the plain property
1947+ * @param {(value: any) => any} [mapFn] Optional map function to apply when getting/setting the plain property
1948+ */
1949+ function addArrayAutoWrapper(obj, plainProperty, arrayProperty, filterFn = () => true, mapFn = (t) => t) {
1950+ // If the plain property is already a getter, do nothing.
1951+ const hasGetterProperty = isGetterObjectProperty(obj, plainProperty);
1952+ if (hasGetterProperty) {
1953+ return;
1954+ }
1955+
1956+ // Define the plain property as a getter/setter that wraps around the array property.
1957+ Object.defineProperty(obj, plainProperty, {
1958+ // Getting the plain property returns the first item in the array property, or undefined if the array is empty.
1959+ get: function () {
1960+ console.trace(`Attempting to GET an array-wrapped property '${plainProperty}'. Use the array property '${arrayProperty}' instead.`);
1961+ const array = Array.isArray(this[arrayProperty]) ? this[arrayProperty].filter(filterFn).map(mapFn) : [];
1962+ return array.length > 0 ? array[0] : void 0;
1963+ },
1964+ // Setting the plain property is not supported, as it would be ambiguous.
1965+ set: function () {
1966+ console.trace(`Attempting to SET an array-wrapped property '${plainProperty}'. Use the array property '${arrayProperty}' instead.`);
1967+ },
1968+ // Exclude the property from JSON serialization and from being listed in for...in loops.
1969+ enumerable: false,
1970+ // Make the property non-configurable to prevent deletion or redefinition.
1971+ configurable: false,
19191972 });
1920- image.attr('src', mes.extra?.image);
1973+ }
1921- image.attr('title', mes.extra?.title || mes.title || '');
1922- container.addClass('img_extra');
1923- image.toggleClass('img_inline', isInline);
1924- text.toggleClass('displayNone', !isInline);
1925-
1926- const imageSwipes = mes.extra.image_swipes;
1927- if (Array.isArray(imageSwipes) && imageSwipes.length > 0) {
1928- container.addClass('img_swipes');
1929- const counter = container.find('.mes_img_swipe_counter');
1930- const currentImage = imageSwipes.indexOf(mes.extra.image) + 1;
1931- counter.text(`${currentImage}/${imageSwipes.length}`);
1932-
1933- const swipeLeft = container.find('.mes_img_swipe_left');
1934- swipeLeft.off('click').on('click', function () {
1935- eventSource.emit(event_types.IMAGE_SWIPED, { message: mes, element: messageElement, direction: 'left' });
1936- });
19371974
1938- const swipeRight = container.find('.mes_img_swipe_right');
1975+ /**
1939- swipeRight.off('click').on('click', function () {
1976+ * Migrates image swipes from a single image property to an array.
1940- eventSource.emit(event_types.IMAGE_SWIPED, { message: mes, element: messageElement, direction: 'right' });
1977+ * @param {ChatMessageExtra} obj
1941- });
1978+ */
1979+ function migrateMediaToArray(obj) {
1980+ if (isPlainObjectProperty(obj, 'file')) {
1981+ if (!Array.isArray(obj.files)) {
1982+ obj.files = [];
1983+ }
1984+ const fileValue = obj.file;
1985+ delete obj.file;
1986+ if (fileValue) {
1987+ obj.files.push(fileValue);
1988+ }
19421989 }
1943- } else {
1990+
1944- const container = messageElement.find('.mes_img_container');
1991+ if (Array.isArray(obj.image_swipes)) {
1945- container.removeClass('img_extra img_swipes');
1992+ if (!Array.isArray(obj.media)) {
1946- const text = messageElement.find('.mes_text');
1993+ obj.media = [];
1947- text.removeClass('displayNone');
1948- }
1949-
1950- // Add video to message
1951- if (mes.extra?.video) {
1952- const container = $('#message_video_template .mes_video_container').clone();
1953- messageElement.find('.mes_video_container').remove();
1954- messageElement.find('.mes_block').append(container);
1955- const chatHeight = chatElement.prop('scrollHeight');
1956- const video = container.find('.mes_video');
1957- video.off('loadedmetadata').on('loadedmetadata', function () {
1958- if (!adjustScroll) {
1959- return;
19601994 }
1961- const scrollPosition = chatElement.scrollTop();
1995+ for (const swipe of obj.image_swipes) {
1962- const newChatHeight = chatElement.prop('scrollHeight');
1996+ if (swipe && typeof swipe === 'string') {
1963- const diff = newChatHeight - chatHeight;
1997+ obj.media_display = MEDIA_DISPLAY.GALLERY;
1964- chatElement.scrollTop(scrollPosition + diff);
1998+ obj.media.push({ type: MEDIA_TYPE.IMAGE, url: swipe });
1965- });
1999+ }
2000+ }
2001+ delete obj.image_swipes;
2002+ }
19662003
1967- video.attr('src', mes.extra?.video);
2004+ if (isPlainObjectProperty(obj, 'image')) {
1968- } else {
2005+ if (!Array.isArray(obj.media)) {
1969- messageElement.find('.mes_video_container').remove();
2006+ obj.media = [];
19702007 }
1971-
2008+ const imageValue = obj.image;
1972- // Add file to message
2009+ delete obj.image;
1973- if (mes.extra?.file) {
2010+ if (imageValue && typeof imageValue === 'string') {
1974- messageElement.find('.mes_file_container').remove();
2011+ obj.media.push({ type: MEDIA_TYPE.IMAGE, url: imageValue });
1975- const messageId = messageElement.attr('mesid');
2012+ }
1976- const template = $('#message_file_template .mes_file_container').clone();
2013+ if (obj.media_display === MEDIA_DISPLAY.GALLERY) {
1977- template.find('.mes_file_name').text(mes.extra.file.name);
2014+ const selectedIndex = obj.media.findIndex(t => t.url === imageValue);
1978- template.find('.mes_file_size').text(humanFileSize(mes.extra.file.size));
2015+ if (selectedIndex > -1) {
1979- template.find('.mes_file_download').attr('mesid', messageId);
2016+ obj.media_index = selectedIndex;
1980- template.find('.mes_file_delete').attr('mesid', messageId);
2017+ }
1981- messageElement.find('.mes_block').append(template);
2018+ }
1982- } else {
2019+ obj.media = obj.media.filter((v, i, a) => i === a.findIndex(t => t.url === v.url));
1983- messageElement.find('.mes_file_container').remove();
2020+ }
2021+
2022+ if (isPlainObjectProperty(obj, 'video')) {
2023+ if (!Array.isArray(obj.media)) {
2024+ obj.media = [];
2025+ }
2026+ const videoValue = obj.video;
2027+ delete obj.video;
2028+ if (videoValue && typeof videoValue === 'string') {
2029+ obj.media.push({ type: MEDIA_TYPE.VIDEO, url: videoValue });
2030+ }
2031+ }
19842032 }
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;
19852052}
19862053
19872054/**
1988- * @deprecated Use appendMediaToMessage instead.
2055+ * Gets the media index for a message.
2056+ * @param {ChatMessage} mes Message object
2057+ * @returns {number} Media index
19892058 */
19902059export function appendImageToMessagegetMediaIndex(mes, messageElement) {
1991- appendMediaToMessage(mes, messageElement);
2060+ if (!Array.isArray(mes?.extra?.media)) {
2061+ return 0;
2062+ }
2063+ const value = mes.extra?.media_index;
2064+ if (isNaN(value) || value < 0 || value >= mes.extra.media.length) {
2065+ return 0;
2066+ }
2067+ return value;
2068+}
2069+
2070+/**
2071+ * Appends image or file to the message element.
2072+ * @param {ChatMessage} mes Message object
2073+ * @param {JQuery<HTMLElement>} messageElement Message element
2074+ * @param {boolean} [adjustScroll=true] Whether to adjust the scroll position after appending the media
2075+ */
2076+export function appendMediaToMessage(mes, messageElement, adjustScroll = true) {
2077+ ensureMessageMediaIsArray(mes);
2078+
2079+ const hasMedia = Array.isArray(mes?.extra?.media) && mes.extra.media.length > 0;
2080+ const hasFiles = Array.isArray(mes?.extra?.files) && mes.extra.files.length > 0;
2081+ const mediaDisplay = getMediaDisplay(mes);
2082+ const hideMessageText = hasMedia && mes?.extra?.inline_image === false;
2083+
2084+ const mediaBlocks = [];
2085+ const mediaPromises = [];
2086+
2087+ const chatHeight = adjustScroll && (hasMedia || hasFiles) ? chatElement.prop('scrollHeight') : 0;
2088+ const scrollPosition = chatElement.scrollTop();
2089+ const doAdjustScroll = () => {
2090+ if (!adjustScroll) {
2091+ chatElement.scrollTop(scrollPosition);
2092+ return;
2093+ }
2094+ const newChatHeight = chatElement.prop('scrollHeight');
2095+ const diff = newChatHeight - chatHeight;
2096+ chatElement.scrollTop(scrollPosition + diff);
2097+ };
2098+
2099+ // Set media display attribute
2100+ messageElement.attr('data-media-display', mediaDisplay);
2101+ // Toggle text visibility
2102+ messageElement.find('.mes_text').toggleClass('displayNone', hideMessageText);
2103+
2104+ /**
2105+ * Appends a single image attachment to the message element.
2106+ * @param {MediaAttachment} attachment Image attachment object
2107+ * @param {number} index Index of the image attachment
2108+ * @returns {JQuery<HTMLElement>} The appended image container element
2109+ */
2110+ function appendImageAttachment(attachment, index) {
2111+ const template = $('#message_image_template .mes_img_container').clone();
2112+ template.attr('data-index', index);
2113+
2114+ const image = template.find('.mes_img');
2115+ image.attr('src', attachment.url);
2116+ image.attr('title', attachment.title || mes.extra.title || '');
2117+ mediaPromises.push(new Promise((resolve) => {
2118+ function onLoad() {
2119+ image.removeAttr('alt');
2120+ image.removeClass('error');
2121+ resolve();
2122+ }
2123+ function onError() {
2124+ image.attr('alt', '');
2125+ image.addClass('error');
2126+ resolve();
2127+ }
2128+ if (image.prop('complete')) {
2129+ onLoad();
2130+ } else {
2131+ image.off('load').on('load', onLoad);
2132+ image.off('error').on('error', onError);
2133+ }
2134+ }));
2135+
2136+ mediaBlocks.push(template);
2137+ return template;
2138+ }
2139+
2140+ /**
2141+ * Appends a single video attachment to the message element.
2142+ * @param {MediaAttachment} attachment Video attachment object
2143+ * @param {number} index Index of the video attachment
2144+ * @returns {JQuery<HTMLElement>} The appended video container element
2145+ */
2146+ function appendVideoAttachment(attachment, index) {
2147+ const template = $('#message_video_template .mes_video_container').clone();
2148+ template.attr('data-index', index);
2149+
2150+ const video = template.find('.mes_video');
2151+ video.attr('src', attachment.url);
2152+ video.attr('title', attachment.title || mes.extra.title || '');
2153+ mediaPromises.push(new Promise((resolve) => {
2154+ function onLoad() {
2155+ resolve();
2156+ }
2157+ function onError() {
2158+ video.addClass('error');
2159+ resolve();
2160+ }
2161+ if (video.prop('readyState') >= HTMLMediaElement.HAVE_CURRENT_DATA) {
2162+ onLoad();
2163+ } else {
2164+ video.off('loadeddata').on('loadeddata', onLoad);
2165+ video.off('error').on('error', onError);
2166+ }
2167+ }));
2168+
2169+ mediaBlocks.push(template);
2170+ return template;
2171+ }
2172+
2173+ /**
2174+ * Appends a media attachment to the message element.
2175+ * @param {MediaAttachment} attachment Media attachment object
2176+ * @param {number} index Index of the media attachment
2177+ * @returns {JQuery<HTMLElement>} The appended media container element
2178+ */
2179+ function appendMediaAttachment(attachment, index) {
2180+ if (!attachment.type) {
2181+ attachment.type = MEDIA_TYPE.IMAGE;
2182+ }
2183+ switch (attachment.type) {
2184+ case MEDIA_TYPE.IMAGE:
2185+ return appendImageAttachment(attachment, index);
2186+ case MEDIA_TYPE.VIDEO:
2187+ return appendVideoAttachment(attachment, index);
2188+ }
2189+
2190+ console.warn(`Unknown media type: ${attachment.type}, defaulting to image.`, attachment);
2191+ return appendImageAttachment(attachment, index);
2192+ }
2193+
2194+ // Add media gallery to message
2195+ if (hasMedia && mediaDisplay === MEDIA_DISPLAY.GALLERY) {
2196+ const mediaIndex = getMediaIndex(mes);
2197+ const selectedMedia = mes.extra.media[mediaIndex];
2198+
2199+ const galleryControls = $('#message_gallery_controls .mes_img_swipes').clone();
2200+ const counter = galleryControls.find('.mes_img_swipe_counter');
2201+ counter.text(`${mediaIndex + 1}/${mes.extra.media.length}`);
2202+
2203+ const template = appendMediaAttachment(selectedMedia, mediaIndex);
2204+ template.addClass('img_swipes');
2205+ template.append(galleryControls);
2206+ }
2207+
2208+ // Add media as a list to message
2209+ if (hasMedia && mediaDisplay === MEDIA_DISPLAY.LIST) {
2210+ for (let index = 0; index < mes.extra.media.length; index++) {
2211+ const attachment = mes.extra.media[index];
2212+ appendMediaAttachment(attachment, index);
2213+ }
2214+ }
2215+
2216+ // Remove existing file containers
2217+ messageElement.find('.mes_file_wrapper').empty();
2218+
2219+ // Add files to message
2220+ if (hasFiles) {
2221+ for (let index = 0; index < mes.extra.files.length; index++) {
2222+ const file = mes.extra.files[index];
2223+ const template = $('#message_file_template .mes_file_container').clone();
2224+ template.attr('data-index', index);
2225+ template.find('.mes_file_name').text(file.name).attr('title', file.name);
2226+ template.find('.mes_file_size').text(humanFileSize(file.size)).attr('title', file.size);
2227+ messageElement.find('.mes_file_wrapper').append(template);
2228+ }
2229+ }
2230+
2231+ // TODO: Consider making this awaitable
2232+ Promise.race([Promise.all(mediaPromises), delay(debounce_timeout.short)]).then(() => {
2233+ messageElement.find('.mes_media_wrapper').empty().append(mediaBlocks);
2234+ doAdjustScroll();
2235+ });
19922236}
19932237
19942238export function addCopyToCodeBlocks(messageElement) {
@@ -2013,7 +2257,7 @@ export function addCopyToCodeBlocks(messageElement) {
20132257
20142258/**
20152259 * Adds a single message to the chat.
20162260 * @param {objectChatMessage} mes Message object
20172261 * @param {object} [options] Options
20182262 * @param {string} [options.type='normal'] Message type
20192263 * @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
20652309 avatarImg = mes['force_avatar'];
20662310 }
20672311
20682312 // if mes.extra.uses_system_ui is true, set an override on the sanitizer options
20692313 const sanitizerOverrides = mes.extra?.uses_system_ui ? { MESSAGE_ALLOW_SYSTEM_UI: true } : {};
20702314
20712315 messageText = messageFormatting(
20722316 messageText,
@@ -2215,8 +2459,8 @@ export function formatCharacterAvatar(characterAvatar) {
22152459
22162460/**
22172461 * Formats the title for the generation timer.
22182462 * @param {DateMessageTimestamp} gen_started Date when generation was started
22192463 * @param {DateMessageTimestamp} gen_finished Date when generation was finished
22202464 * @param {number} tokenCount Number of tokens generated (0 if not available)
22212465 * @param {number?} [reasoningDuration=null] Reasoning duration (null if no reasoning was done)
22222466 * @param {number?} [timeToFirstToken=null] Time to first token
@@ -3659,7 +3903,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
36593903 const prevStarted = chat[chat.length - 1]['gen_started'];
36603904
36613905 if (prevFinished && prevStarted) {
36623906 const timePassed = Number(prevFinished) - Number(prevStarted);
36633907 generation_started = new Date(Date.now() - timePassed);
36643908 chat[chat.length - 1]['gen_started'] = generation_started;
36653909 }
@@ -3739,7 +3983,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
37393983 coreChat.pop();
37403984 }
37413985
37423986 coreChat = await Promise.all(coreChat.map(async (/** @type {ChatMessage} */ chatItem, index) => {
37433987 let message = chatItem.mes;
37443988 let regexType = chatItem.is_user ? regex_placement.USER_INPUT : regex_placement.AI_OUTPUT;
37453989 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
37473991 let regexedMessage = getRegexedString(message, regexType, options);
37483992 regexedMessage = await appendFileContent(chatItem, regexedMessage);
37493993
3994+ const titles = [];
37503995 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')}`;
37524007 }
37534008
37544009 return {
@@ -6097,7 +6352,7 @@ export function syncSwipeToMes(messageId = null, swipeId = null) {
60976352/**
60986353 * Saves the image to the message object.
60996354 * @param {ParsedImage} img Image object
61006355 * @param {objectChatMessage} mes Chat message object
61016356 * @typedef {{ image?: string, title?: string, inline?: boolean }} ParsedImage
61026357 */
61036358function saveImageToMessage(img, mes) {
@@ -6105,8 +6360,10 @@ function saveImageToMessage(img, mes) {
61056360 if (!mes.extra || typeof mes.extra !== 'object') {
61066361 mes.extra = {};
61076362 }
6108- mes.extra.image = img.image;
6363+ if (!Array.isArray(mes.extra.media)) {
61096364 mes.extra.titlemedia = img.title[];
6365+ }
6366+ mes.extra.media.push({ url: img.image, type: MEDIA_TYPE.IMAGE, title: img.title });
61106367 mes.extra.inline_image = img.inline;
61116368 }
61126369}
@@ -6732,6 +6989,7 @@ export async function getChat() {
67326989 chat_metadata = chat[0]['chat_metadata'] ?? {};
67336990
67346991 chat.shift();
6992+ chat.forEach(ensureMessageMediaIsArray);
67356993 } else {
67366994 chat_create_date = humanizedDateTime();
67376995 }
@@ -8961,17 +9219,17 @@ export async function swipe(_event, direction, { source, repeated, message = cha
89619219 //Update the swipe_id.
89629220 chat[mesId]['swipe_id'] = newSwipeId;
89639221
89649222 if (chat[mesId].extra && typeof chat[mesId].extra === 'object') {
8965- // if message has memory attached - remove it to allow regen
89669223 delete chat[mesId].extra.memory;
8967-
8968- // ditto for display text
89699224 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;
89749226 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;
89759233 }
89769234 delete chat[mesId].gen_started;
89779235 delete chat[mesId].gen_finished;
@@ -8979,7 +9237,6 @@ export async function swipe(_event, direction, { source, repeated, message = cha
89799237 syncSwipeToMes(mesId, chat[mesId]['swipe_id']);
89809238 }
89819239
8982- //Deepseek-V3.1
89839240 // Helper function to convert transition to promise
89849241 const transitionPromise = (element, properties) => {
89859242 return new Promise((resolve) => {
@@ -9101,7 +9358,7 @@ export async function swipe(_event, direction, { source, repeated, message = cha
91019358 if (run_generate && !is_send_press) {
91029359 is_send_press = true;
91039360 generation = Generate('swipe');
91049361 } else if (parseIntNumber(chat[mesId]['swipe_id']) !== chat[mesId]['swipes'].length) {
91059362 saveChatDebounced();
91069363 }
91079364
@@ -10745,7 +11002,7 @@ jQuery(async function () {
1074511002 const oldScroll = chatElement[0].scrollTop;
1074611003 const clone = structuredClone(chat[this_edit_mes_id]);
1074711004 clone.send_date = Date.now();
1074811005 clone.mes = $(this).closest('.mes').find('.edit_textarea').val().toString();
1074911006
1075011007 if (power_user.trim_spaces) {
1075111008 clone.mes = clone.mes.trim();
public/scripts/bookmarks.js+1 -0
@@ -344,6 +344,7 @@ export async function convertSoloToGroupChat() {
344344
345345 // Save group-chat marker
346346 if (index == 0) {
347+ // @ts-ignore
347348 message.is_group = true;
348349 }
349350
public/scripts/chats.js+407 -163
@@ -26,6 +26,9 @@ import {
2626 printMessages,
2727 clearChat,
2828 refreshSwipeButtons,
29+ getMediaIndex,
30+ getMediaDisplay,
31+ chatElement,
2932} from '../script.js';
3033import { selected_group } from './group-chats.js';
3134import { power_user } from './power-user.js';
@@ -43,6 +46,8 @@ import {
4346 getFileText,
4447 getFileExtension,
4548 convertTextToBase64,
49+ isSameFile,
50+ clamp,
4651} from './utils.js';
4752import { extension_settings, renderExtensionTemplateAsync, saveMetadataDebounced } from './extensions.js';
4853import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
@@ -52,6 +57,7 @@ import { renderTemplateAsync } from './templates.js';
5257import { t } from './i18n.js';
5358import { humanizedDateTime } from './RossAscends-mods.js';
5459import { accountStorage } from './util/AccountStorage.js';
60+import { MEDIA_DISPLAY, MEDIA_TYPE, SWIPE_DIRECTION } from './constants.js';
5561
5662/**
5763 * @typedef {Object} FileAttachment
@@ -187,62 +193,64 @@ export async function unhideChatMessage(messageId, _messageBlock) {
187193
188194/**
189195 * Adds a file attachment to the message.
190196 * @param {objectChatMessage} message Message object
191197 * @returns {Promise<void>} A promise that resolves when file is uploaded.
192198 */
193199export async function populateFileAttachment(message, inputId = 'file_form_input') {
194200 try {
195201 if (!message) return;
196202 if (!message.extra || typeof message.extra !== 'object') message.extra = {};
197203 const fileInput = document.getElementById(inputId);
198204 if (!(fileInput instanceof HTMLInputElement)) return;
199- const file = fileInput.files[0];
205+
200- if (!file) return;
206+ for (const file of fileInput.files) {
201-
207+ const slug = getStringHash(file.name);
202- const slug = getStringHash(file.name);
208+ const fileNamePrefix = `${Date.now()}_${slug}`;
203- const fileNamePrefix = `${Date.now()}_${slug}`;
209+ const fileBase64 = await getBase64Async(file);
204- const fileBase64 = await getBase64Async(file);
210+ let base64Data = fileBase64.split(',')[1];
205- let base64Data = fileBase64.split(',')[1];
211+ const extension = getFileExtension(file);
206- const extension = getFileExtension(file);
212+
207-
213+ const mediaType = MEDIA_TYPE.getFromMime(file.type);
208- // If file is image
214+ if (mediaType) {
209- if (file.type.startsWith('image/')) {
215+ const imageUrl = await saveBase64AsFile(base64Data, name2, fileNamePrefix, extension);
210- const imageUrl = await saveBase64AsFile(base64Data, name2, fileNamePrefix, extension);
216+ if (!Array.isArray(message.extra.media)) {
211217 message.extra.imagemedia = imageUrl[];
212- message.extra.inline_image = true;
218+ }
213- }
219+ message.extra.media.push({ url: imageUrl, type: mediaType });
214- // If file is video
220+ message.extra.media_index = message.extra.media.length - 1;
215- else if (file.type.startsWith('video/')) {
221+ message.extra.inline_image = true;
216- const videoUrl = await saveBase64AsFile(base64Data, name2, fileNamePrefix, extension);
222+ } else {
217- message.extra.video = videoUrl;
223+ const uniqueFileName = `${fileNamePrefix}.txt`;
218- } else {
224+
219- const uniqueFileName = `${fileNamePrefix}.txt`;
225+ if (isConvertible(file.type)) {
220-
226+ try {
221- if (isConvertible(file.type)) {
227+ const converter = getConverter(file.type);
222- try {
228+ const fileText = await converter(file);
223229 const converter base64Data = getConverterconvertTextToBase64(file.typefileText);
224- const fileText = await converter(file);
230+ } catch (error) {
225- base64Data = convertTextToBase64(fileText);
231+ toastr.error(String(error), t`Could not convert file`);
226- } catch (error) {
232+ console.error('Could not convert file', error);
227- toastr.error(String(error), t`Could not convert file`);
233+ }
228- console.error('Could not convert file', error);
229234 }
230- }
231235
232236 const fileUrl = await uploadFileAttachment(uniqueFileName, base64Data);
233237
234238 if (!fileUrl) {
235- return;
239+ continue;
236240 }
237241
238- message.extra.file = {
242+ if (!Array.isArray(message.extra.files)) {
239- url: fileUrl,
243+ message.extra.files = [];
240- size: file.size,
244+ }
241- name: file.name,
242- created: Date.now(),
243- };
244- }
245245
246+ message.extra.files.push({
247+ url: fileUrl,
248+ size: file.size,
249+ name: file.name,
250+ created: Date.now(),
251+ });
252+ }
253+ }
246254 } catch (error) {
247255 console.error('Could not upload file', error);
248256 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) {
314322 */
315323async function validateFile(file) {
316324 const fileText = await file.text();
317325 const isImageisMedia = file.type.startsWith('image/') || file.type.startsWith('video/');
318326 const isBinary = /^[\x00-\x08\x0E-\x1F\x7F-\xFF]*$/.test(fileText);
319327
320328 if (!isImageisMedia && file.size > fileSizeLimit) {
321329 toastr.error(t`File is too big. Maximum size is ${humanFileSize(fileSizeLimit)}.`);
322330 return false;
323331 }
324332
325333 // If file is binary
326334 if (isBinary && !isImageisMedia && !isConvertible(file.type)) {
327335 toastr.error(t`Binary files are not supported. Select a text file or image.`);
328336 return false;
329337 }
@@ -340,22 +348,28 @@ export function hasPendingFileAttachment() {
340348
341349/**
342350 * Displays file information in the message sending form.
343351 * @param {FileFileList} filefileList File object
344352 * @returns {Promise<void>}
345353 */
346354async function onFileAttach(filefileList) {
347355 if (!filefileList || fileList.length === 0) return;
348356
349- const isValid = await validateFile(file);
357+ for (const file of fileList) {
358+ const isValid = await validateFile(file);
350359
351360 // If file is binary
352361 if (!isValid) {
353- $('#file_form').trigger('reset');
362+ toastr.warning(t`File ${file.name} is not supported.`);
354- return;
363+ $('#file_form').trigger('reset');
364+ return;
365+ }
355366 }
356367
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);
359373 $('#file_form').removeClass('displayNone');
360374
361375 // Reset form on chat change (if not on a welcome screen)
@@ -368,10 +382,17 @@ async function onFileAttach(file) {
368382}
369383
370384/**
371385 * Deletes file from a message.
386+ * @param {JQuery<HTMLElement>} messageBlock Message block element
372387 * @param {number} messageId Message ID
388+ * @param {number} fileIndex File index
373389 */
374390async function deleteMessageFile(messageBlock, messageId, fileIndex) {
391+ if (isNaN(messageId) || isNaN(fileIndex)) {
392+ console.warn('Invalid message ID or file index');
393+ return;
394+ }
395+
375396 const confirm = await callGenericPopup('Are you sure you want to delete this file?', POPUP_TYPE.CONFIRM);
376397
377398 if (confirm !== POPUP_RESULT.AFFIRMATIVE) {
@@ -381,26 +402,49 @@ async function deleteMessageFile(messageId) {
381402
382403 const message = chat[messageId];
383404
384405 if (!Array.isArray(message?.extra?.filefiles)) {
385406 console.debug('Message has no filefiles');
386407 return;
387408 }
388409
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);
390417
391- delete message.extra.file;
392- $(`.mes[mesid="${messageId}"] .mes_file_container`).remove();
393418 await saveChatConditional();
394419 await deleteFileFromServer(url);
395-}
396420
421+ appendMediaToMessage(message, messageBlock, false);
422+}
397423
398424/**
399425 * Opens file from message in a modal.
400426 * @param {number} messageId Message ID
427+ * @param {number} fileIndex File index
401428 */
402429async 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];
404448
405449 if (!messageFile) {
406450 console.debug('Message has no file or it is empty');
@@ -429,39 +473,51 @@ function embedMessageFile(messageId, messageBlock) {
429473 .on('change', parseAndUploadEmbed)
430474 .trigger('click');
431475
432476 async function parseAndUploadEmbed(/** @type {JQuery.ChangeEvent} */ e) {
433- const file = e.target.files[0];
477+ if (!(e.target instanceof HTMLInputElement)) return;
434478 if (!filee.target.files.length) return;
435479
436- const isValid = await validateFile(file);
480+ for (const file of e.target.files) {
481+ const isValid = await validateFile(file);
437482
438483 if (!isValid) {
439- $('#file_form').trigger('reset');
484+ toastr.warning(t`File ${file.name} is not supported.`);
440- return;
485+ $('#file_form').trigger('reset');
486+ return;
487+ }
441488 }
442489
443490 await populateFileAttachment(message, 'embed_file_input');
444491 await eventSource.emit(event_types.MESSAGE_FILE_EMBEDDED, messageId);
445492 appendMediaToMessage(message, messageBlock, false);
446493 await saveChatConditional();
447494 }
448495}
449496
450497/**
451498 * Appends file content to the message text.
452499 * @param {objectChatMessage} message Message object
453500 * @param {string} messageText Message text
454501 * @returns {Promise<string>} Message text with file content appended.
455502 */
456503export async function appendFileContent(message, messageText) {
457- if (message.extra?.file) {
504+ if (!message || !message.extra || typeof message.extra !== 'object') {
458- const fileText = message.extra.file.text || (await getFileAttachment(message.extra.file.url));
505+ return messageText;
459-
506+ }
460- if (fileText) {
507+ if (message.extra.fileLength >= 0) {
461- const fileWrapped = `${fileText}\n\n`;
508+ delete message.extra.fileLength;
462- message.extra.fileLength = fileWrapped.length;
509+ }
463- messageText = fileWrapped + messageText;
510+ if (Array.isArray(message.extra?.files) && message.extra.files.length > 0) {
511+ const fileTexts = [];
512+ for (const file of message.extra.files) {
513+ const fileText = file.text || (await getFileAttachment(file.url));
514+ if (fileText) {
515+ fileTexts.push(fileText);
516+ }
464517 }
518+ const mergedFileTexts = fileTexts.join('\n\n') + '\n\n';
519+ message.extra.fileLength = mergedFileTexts.length;
520+ return mergedFileTexts + messageText;
465521 }
466522 return messageText;
467523}
@@ -807,62 +863,119 @@ export function isExternalMediaAllowed() {
807863 return !power_user.forbid_external_media;
808864}
809865
810-function expandMessageImage(event) {
866+/**
811- const mesBlock = $(event.currentTarget).closest('.mes');
867+ * Expands the message media attachment.
812- const mesId = mesBlock.attr('mesid');
868+ * @param {number} messageId Message ID
813- const message = chat[mesId];
869+ * @param {number} mediaIndex Media index
814- const imgSrc = message?.extra?.image;
870+ * @returns {HTMLElement} Enlarged media element
815- const title = message?.extra?.title;
871+ */
872+function expandMessageMedia(messageId, mediaIndex) {
873+ if (isNaN(messageId) || isNaN(mediaIndex)) {
874+ console.warn('Invalid message ID or media index');
875+ return;
876+ }
877+
878+ /** @type {ChatMessage} */
879+ const message = chat[messageId];
816880
817- if (!imgSrc) {
881+ if (!Array.isArray(message?.extra?.media) || message.extra.media.length === 0) {
882+ console.warn('Message has no media to expand');
818883 return;
819884 }
820885
821886 const imgmediaAttachment = documentmessage.createElement('img')extra.media[mediaIndex];
822- img.classList.add('img_enlarged');
887+ const title = mediaAttachment.title || message.extra.title || '';
823- img.src = imgSrc;
824- const imgHolder = document.createElement('div');
825- imgHolder.classList.add('img_enlarged_holder');
826- imgHolder.append(img);
827- const imgContainer = $('<div><pre><code class="img_enlarged_title"></code></pre></div>');
828- imgContainer.prepend(imgHolder);
829- imgContainer.addClass('img_enlarged_container');
830888
831- const codeTitle = imgContainer.find('.img_enlarged_title');
889+ if (!mediaAttachment) {
832- codeTitle.addClass('txt').text(title);
890+ return;
833- const titleEmpty = !title || title.trim().length === 0;
891+ }
834- imgContainer.find('pre').toggle(!titleEmpty);
835- addCopyToCodeBlocks(imgContainer);
836892
837- const popup = new Popup(imgContainer, POPUP_TYPE.DISPLAY, '', { large: true, transparent: true });
893+ /**
894+ * Gets the media element based on its type.
895+ * @returns {HTMLElement} Media element
896+ */
897+ function getMediaElement() {
898+ function getImageElement() {
899+ const img = document.createElement('img');
900+ img.src = mediaAttachment.url;
901+ img.classList.add('img_enlarged');
902+ return img;
903+ }
838904
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+ }
841913
842- img.addEventListener('click', event => {
914+ switch (mediaAttachment.type) {
843- const shouldZoom = !img.classList.contains('zoomed');
915+ case MEDIA_TYPE.IMAGE:
844- img.classList.toggle('zoomed', shouldZoom);
916+ return getImageElement();
845- event.stopPropagation();
917+ case MEDIA_TYPE.VIDEO:
846- });
918+ return getVideoElement();
847- codeTitle[0]?.addEventListener('click', event => {
919+ }
920+
921+ console.warn('Unsupported media type for enlargement:', mediaAttachment.type);
922+ return getImageElement();
923+ }
924+
925+ const mediaElement = getMediaElement();
926+ const mediaHolder = document.createElement('div');
927+ mediaHolder.classList.add('img_enlarged_holder');
928+ mediaHolder.append(mediaElement);
929+ const mediaContainer = document.createElement('div');
930+ mediaContainer.classList.add('img_enlarged_container');
931+ mediaContainer.append(mediaHolder);
932+
933+ mediaElement.addEventListener('click', event => {
934+ const shouldZoom = !mediaElement.classList.contains('zoomed') && mediaElement.nodeName === 'IMG';
935+ mediaElement.classList.toggle('zoomed', shouldZoom);
848936 event.stopPropagation();
849937 });
850938
851- popup.dlg.addEventListener('click', event => {
939+ if (title.trim().length > 0) {
940+ const mediaTitlePre = document.createElement('pre');
941+ const mediaTitleCode = document.createElement('code');
942+ mediaTitleCode.classList.add('img_enlarged_title', 'txt');
943+ mediaTitleCode.textContent = title;
944+ mediaTitlePre.append(mediaTitleCode);
945+ mediaTitleCode.addEventListener('click', event => {
946+ event.stopPropagation();
947+ });
948+ mediaContainer.append(mediaTitlePre);
949+ addCopyToCodeBlocks(mediaContainer);
950+ }
951+
952+ const popup = new Popup(mediaContainer, POPUP_TYPE.DISPLAY, '', { large: true, transparent: true });
953+
954+ popup.dlg.style.width = 'unset';
955+ popup.dlg.style.height = 'unset';
956+ popup.dlg.addEventListener('click', () => {
852957 popup.completeCancelled();
853958 });
854959
855960 popup.show();
856961 return imgmediaElement;
857962}
858963
859-function expandAndZoomMessageImage(event) {
964+/**
860- expandMessageImage(event).click();
965+ * Deletes an image from a message.
861-}
966+ * @param {number} messageId Message ID
967+ * @param {number} mediaIndex Image index
968+ * @param {JQuery<HTMLElement>} messageBlock Message block element
969+ */
970+async function deleteMessageMedia(messageId, mediaIndex, messageBlock) {
971+ if (isNaN(messageId) || isNaN(mediaIndex)) {
972+ console.warn('Invalid message ID or media index');
973+ return;
974+ }
862975
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, '', {
865977 okButton: t`Delete one`,
978+ cancelButton: false,
866979 customButtons: [
867980 {
868981 text: t`Delete all`,
@@ -881,58 +994,64 @@ async function deleteMessageImage() {
881994 return;
882995 }
883996
884- const mesBlock = $(this).closest('.mes');
997+ /** @type {ChatMessage} */
885998 const mesIdmessage = mesBlock.attr('mesid')chat[messageId];
886- const message = chat[mesId];
887999
888- let isLastImage = true;
1000+ if (!Array.isArray(message?.extra?.media)) {
1001+ console.debug('Message has no media');
1002+ return;
1003+ }
8891004
890- if (Array.isArray(message.extra.image_swipes)) {
1005+ if (mediaIndex < 0 || mediaIndex >= message.extra.media.length) {
891- const indexOf = message.extra.image_swipes.indexOf(message.extra.image);
1006+ console.warn('Invalid media index for message');
892- if (indexOf > -1) {
1007+ return;
893- message.extra.image_swipes.splice(indexOf, 1);
1008+ }
894- isLastImage = message.extra.image_swipes.length === 0;
1009+
895- if (!isLastImage) {
1010+ message.extra.media.splice(mediaIndex, 1);
896- const newIndex = Math.min(indexOf, message.extra.image_swipes.length - 1);
1011+
897- message.extra.image = message.extra.image_swipes[newIndex];
1012+ if (message.extra.media_index === mediaIndex) {
898- }
1013+ const newIndex = mediaIndex > 0 ? mediaIndex - 1 : 0;
899- }
1014+ message.extra.media_index = clamp(newIndex, 0, message.extra.media.length - 1);
9001015 }
9011016
9021017 if (isLastImage || value === POPUP_RESULT.CUSTOM1) {
9031018 delete message.extra.imagemedia;
9041019 delete message.extra.inline_image;
9051020 delete message.extra.title;
9061021 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);
9121022 }
9131023
9141024 await saveChatConditional();
1025+ appendMediaToMessage(message, messageBlock, false);
9151026}
9161027
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');
9201037 return;
9211038 }
9221039
923- const mesBlock = $(this).closest('.mes');
1040+ /** @type {ChatMessage} */
9241041 const mesIdmessage = mesBlock.attr('mesid')chat[messageId];
925- const message = chat[mesId];
9261042
9271043 if (!message?.extra?.video) {
9281044 console.warn('Message has no video ornot itfound isfor emptyID', messageId);
9291045 return;
9301046 }
9311047
932- delete message.extra.video;
1048+ if (!message.extra || typeof message.extra !== 'object') {
933- mesBlock.find('.mes_video_container').remove();
1049+ message.extra = {};
1050+ }
9341051
1052+ message.extra.media_display = targetDisplay;
9351053 await saveChatConditional();
1054+ appendMediaToMessage(message, messageBlock, false);
9361055}
9371056
9381057/**
@@ -1865,6 +1984,68 @@ export function addDOMPurifyHooks() {
18651984 });
18661985}
18671986
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+
18682049export function initChatUtilities() {
18692050 $(document).on('click', '.mes_hide', async function () {
18702051 const messageBlock = $(this).closest('.mes');
@@ -1881,13 +2062,17 @@ export function initChatUtilities() {
18812062 $(document).on('click', '.mes_file_delete', async function () {
18822063 const messageBlock = $(this).closest('.mes');
18832064 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);
18852068 });
18862069
18872070 $(document).on('click', '.mes_file_open', async function () {
18882071 const messageBlock = $(this).closest('.mes');
18892072 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);
18912076 });
18922077
18932078 $(document).on('click', '.assistant_note_export', async function () {
@@ -2052,16 +2237,55 @@ export function initChatUtilities() {
20522237 openGlobalStylesPreferenceDialog();
20532238 });
20542239
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+ });
20592284
20602285 $('#file_form_input').on('change', async () => {
20612286 const fileInput = document.getElementById('file_form_input');
20622287 if (!(fileInput instanceof HTMLInputElement)) return;
20632288 const file =await onFileAttach(fileInput.files[0]);
2064- await onFileAttach(file);
20652289 });
20662290 $('#file_form').on('reset', function () {
20672291 $('#file_form').addClass('displayNone');
@@ -2075,18 +2299,38 @@ export function initChatUtilities() {
20752299 event.preventDefault();
20762300 event.stopPropagation();
20772301
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) {
20782315 const fileInput = document.getElementById('file_form_input');
20792316 if (!(fileInput instanceof HTMLInputElement)) return;
20802317
20812318 // Workaround for Firefox: Use a DataTransfer object to indirectly set fileInput.files
20822319 const dataTransfer = new DataTransfer();
20832320 for (let i = 0; i < event.clipboardData.files.length; i++) {
20842321 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+ }
20852329 }
20862330
20872331 fileInput.files = dataTransfer.files;
20882332 await onFileAttach(fileInput.files[0]);
20892333 });
20902334
20912335 eventSource.on(event_types.CHAT_CHANGED, checkForCreatorNotesStyles);
20922336}
public/scripts/constants.js+26 -0
@@ -68,6 +68,32 @@ export const COMETAPI_IGNORE_PATTERNS = [
6868];
6969
7070/**
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+/**
7197 * @type {{readonly LEFT: 'left', readonly RIGHT: 'right'}}
7298 */
7399export const SWIPE_DIRECTION = {
public/scripts/extensions/caption/index.js+65 -25
@@ -10,6 +10,7 @@ import { SlashCommand } from '../../slash-commands/SlashCommand.js';
1010import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
1111import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
1212import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js';
13+import { MEDIA_DISPLAY, MEDIA_TYPE } from '../../constants.js';
1314export { MODULE_NAME };
1415
1516const MODULE_NAME = 'caption';
@@ -119,15 +120,26 @@ async function wrapCaptionTemplate(caption) {
119120
120121/**
121122 * Appends caption to an existing message.
122123 * @param {ObjectChatMessage} datamessage Message data
124+ * @param {number} mediaIndex Index of the image to caption
123125 * @returns {Promise<void>}
124126 */
125127async function captionExistingMessage(datamessage, mediaIndex) {
126128 if (!Array.isArray(datamessage?.extra?.imagemedia) || message.extra.media.length === 0) {
127129 return;
128130 }
129131
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);
131143 const blob = await imageData.blob();
132144 const type = imageData.headers.get('Content-Type');
133145 const file = new File([blob], 'image.png', { type });
@@ -140,17 +152,17 @@ async function captionExistingMessage(data) {
140152
141153 const wrappedCaption = await wrapCaptionTemplate(caption);
142154
143155 const messageText = String(datamessage.mes).trim();
144156
145157 if (!messageText) {
146158 datamessage.extra.inline_image = false;
147159 datamessage.mes = wrappedCaption;
148160 data.extramediaAttachment.title = wrappedCaption;
149161 }
150162 else {
151163 datamessage.extra.inline_image = true;
152164 data.extramediaAttachment.append_title = true;
153165 data.extramediaAttachment.title = wrappedCaption;
154166 }
155167}
156168
@@ -163,14 +175,23 @@ async function sendCaptionedMessage(caption, image) {
163175 const messageText = await wrapCaptionTemplate(caption);
164176
165177 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} */
166186 const message = {
167187 name: context.name1,
168188 is_user: true,
169189 send_date: getMessageTimeStamp(),
170190 mes: messageText,
171191 extra: {
172192 imagemedia: image[mediaAttachment],
173193 titlemedia_display: messageTextMEDIA_DISPLAY.GALLERY,
194+ media_index: 0,
174195 inline_image: !!extension_settings.caption.show_in_chat,
175196 },
176197 };
@@ -365,13 +386,24 @@ function onRefineModeInput() {
365386 */
366387async function captionCommandCallback(args, prompt) {
367388 const quiet = isTrueBoolean(args?.quiet);
368389 const mesIdmessageId = args?.mesId ?? args?.id;
390+ const index = Number(args?.index ?? 0);
369391
370392 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) {
373396 try {
374397 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);
375407 const blob = await fetchResult.blob();
376408 const file = new File([blob], 'image.jpg', { type: blob.type });
377409 return await getCaptionForFile(file, prompt, quiet);
@@ -636,13 +668,13 @@ jQuery(async function () {
636668 saveSettingsDebounced();
637669 });
638670
639671 const onMessageEvent = async (indexmessageId) => {
640672 if (!extension_settings.caption.auto_mode) {
641673 return;
642674 }
643675
644676 const datamessage = getContext().chat[indexmessageId];
645677 await captionExistingMessage(datamessage, 0);
646678 };
647679
648680 eventSource.on(event_types.MESSAGE_SENT, onMessageEvent);
@@ -651,13 +683,15 @@ jQuery(async function () {
651683 $(document).on('click', '.mes_img_caption', async function () {
652684 const animationClass = 'fa-fade';
653685 const messageBlock = $(this).closest('.mes');
654686 const messageImgimageBlock = messageBlock$(this).findclosest('.mes_imgmes_img_container');
687+ const messageImg = imageBlock.find('.mes_img');
655688 if (messageImg.hasClass(animationClass)) return;
656689 messageImg.addClass(animationClass);
657690 try {
658691 const indexmessageId = Number(messageBlock.attr('mesid'));
659692 const dataimageIndex = getContextNumber()imageBlock.chat[attr('data-index]'));
660- await captionExistingMessage(data);
693+ const data = getContext().chat[messageId];
694+ await captionExistingMessage(data, imageIndex);
661695 appendMediaToMessage(data, messageBlock, false);
662696 await saveChatConditional();
663697 } catch (e) {
@@ -681,6 +715,12 @@ jQuery(async function () {
681715 typeList: [ARGUMENT_TYPE.NUMBER],
682716 enumProvider: commonEnumProviders.messages(),
683717 }),
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+ }),
684724 ],
685725 unnamedArgumentList: [
686726 new SlashCommandArgument(
public/scripts/extensions/stable-diffusion/index.js+115 -167
@@ -52,7 +52,7 @@ import {
5252 SlashCommandArgument,
5353 SlashCommandNamedArgument,
5454} from '../../slash-commands/SlashCommandArgument.js';
5555import { debounce_timeout, MEDIA_DISPLAY, MEDIA_TYPE, VIDEO_EXTENSIONS } from '../../constants.js';
5656import { SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';
5757import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js';
5858import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
@@ -468,6 +468,12 @@ async function loadSettings() {
468468 extension_settings.sd.styles = defaultStyles;
469469 }
470470
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+
471477 $('#sd_source').val(extension_settings.sd.source);
472478 $('#sd_scale').val(extension_settings.sd.scale).trigger('input');
473479 $('#sd_steps').val(extension_settings.sd.steps).trigger('input');
@@ -4065,6 +4071,16 @@ async function sendMessage(prompt, image, generationType, additionalNegativePref
40654071 const name = context.groupId ? systemUserName : context.name2;
40664072 const template = extension_settings.sd.prompts[generationMode.MESSAGE] || '{{prompt}}';
40674073 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} */
40684084 const message = {
40694085 name: name,
40704086 is_user: false,
@@ -4072,20 +4088,12 @@ async function sendMessage(prompt, image, generationType, additionalNegativePref
40724088 send_date: getMessageTimeStamp(),
40734089 mes: messageText,
40744090 extra: {
40754091 imagemedia: image[mediaAttachment],
40764092 titlemedia_display: promptMEDIA_DISPLAY.GALLERY,
40774093 generationTypemedia_index: generationType0,
4078- negative: additionalNegativePrefix,
40794094 inline_image: false,
4080- image_swipes: [image],
40814095 },
40824096 };
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- }
40894097 context.chat.push(message);
40904098 const messageId = context.chat.length - 1;
40914099 await eventSource.emit(event_types.MESSAGE_RECEIVED, messageId, 'extension');
@@ -4215,98 +4223,76 @@ function isValidState() {
42154223
42164224let buttonAbortController = null;
42174225
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+ */
42184231async 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+ */
42194236 function setBusyIcon(isBusy) {
42204237 $icon.toggleClass('fa-paintbrush'classes.idle, !isBusy);
42214238 $icon.toggleClass(busyClassclasses.busy, isBusy);
42224239 }
42234240
42244241 const busyClassclasses = { busy: 'fa-hourglass', idle: 'fa-paintbrush' };
42254242 const context = getContext();
42264243 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;
42364244
42374245 if ($icon.hasClass(busyClassclasses.busy)) {
42384246 buttonAbortController?.abort('Aborted by user');
42394247 console.log('Previous image is still being generated...');
42404248 return;
42414249 }
42424250
4243- let dimensions = null;
4251+ const messageElement = $icon.closest('.mes');
4244- buttonAbortController = new AbortController();
4252+ const messageId = Number(messageElement.attr('mesid'));
42454253
4246- try {
4254+ /** @type {ChatMessage} */
4247- setBusyIcon(true);
4255+ const message = context.chat[messageId];
4248- if (hasSavedImage) {
4249- const prompt = await refinePrompt(message.extra.title, false);
4250- const negative = hasSavedNegative ? await refinePrompt(message.extra.negative, true) : '';
4251- message.extra.title = prompt;
4252-
4253- const generationType = message?.extra?.generationType ?? generationMode.FREE;
4254- console.log('Regenerating an image, using existing prompt:', prompt);
4255- dimensions = setTypeSpecificDimensions(generationType);
4256- await sendGenerationRequest(generationType, prompt, negative, characterFileName, saveGeneratedImage, initiators.action, buttonAbortController?.signal);
4257- }
4258- else {
4259- console.log('doing /sd raw last');
4260- await generatePicture(initiators.action, {}, 'raw_last', messageText, saveGeneratedImage);
4261- }
4262- }
4263- catch (error) {
4264- console.error('Could not generate inline image: ', error);
4265- }
4266- finally {
4267- setBusyIcon(false);
42684256
42694257 if (dimensions!message) {
4270- restoreOriginalDimensions(dimensions);
4258+ console.error('Could not find message for SD generation button');
4271- }
4259+ return;
42724260 }
42734261
4274- function saveGeneratedImage(prompt, image, generationType, negative, _initiator, _prefixedPrompt, format) {
4262+ if (!message.extra || typeof message.extra !== 'object') {
4275- // Some message sources may not create the extra object
4263+ message.extra = {};
4276- if (typeof message.extra !== 'object' || message.extra === null) {
4264+ }
4277- message.extra = {};
4278- }
4279-
4280- // Add image to the swipe list if it's not already there
4281- if (!Array.isArray(message.extra.image_swipes)) {
4282- message.extra.image_swipes = [];
4283- }
4284-
4285- const swipes = message.extra.image_swipes;
42864265
42874266 if (message.extra.image && !swipesArray.includesisArray(message.extra.imagemedia)) {
4288- swipes.push(message.extra.image);
4267+ message.extra.media = [];
42894268 }
42904269
4291- const isVideoFormat = isVideo(format);
4270+ /** @type {MediaAttachment} */
4271+ const selectedMedia = message.extra.media.length > 0
4272+ ? (message.extra.media[message.extra.media_index] ?? message.extra.media[message.extra.media.length - 1])
4273+ : { url: '', title: message.mes, type: MEDIA_TYPE.IMAGE, generation_type: generationMode.FREE };
42924274
4293- if (isVideoFormat) {
4275+ buttonAbortController = new AbortController();
4294- message.extra.video = image;
4276+ const newMediaAttachment = await generateMediaSwipe(
4295- } else {
4277+ selectedMedia,
4296- swipes.push(image);
4278+ message,
4279+ () => setBusyIcon(true),
4280+ () => setBusyIcon(false),
4281+ buttonAbortController,
4282+ );
4283+
4284+ if (!newMediaAttachment) {
4285+ return;
4286+ }
42974287
42984288 // If already contains an image and it's not inline - leave it as is
42994289 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;
43024292
4303- message.extra.title = prompt;
4293+ appendMediaToMessage(message, messageElement, false);
4304- message.extra.generationType = generationType;
4305- message.extra.negative = negative;
4306- appendMediaToMessage(message, $mes);
43074294
43084295 return await context.saveChat();
4309- }
43104296}
43114297
43124298async function onCharacterPromptShareInput() {
@@ -4336,97 +4322,61 @@ async function writePromptFields(characterId) {
43364322}
43374323
43384324/**
43394325 * Switches anGenerates imagea tonew themedia nextattachment orbased previouson onethe inprovided themedia swipeattachment listmetadata.
43404326 * @param {objectMediaAttachment} argsmediaAttachment Event- argumentsThe media attachment metadata.
43414327 * @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.
43454332 */
43464333async function onImageSwipedgenerateMediaSwipe({mediaAttachment, message, elementonStart, directiononComplete, }abortController = new AbortController()) {
43474334 const contextstopButton = getContextdocument.getElementById('sd_stop_gen');
4348- const animationClass = 'fa-fade';
4335+ const stopListener = () => abortController.abort('Aborted by user');
4349- const messageImg = element.find('.mes_img');
4336+ const generationType = mediaAttachment.generation_type ?? message?.extra?.generationType ?? generationMode.FREE;
4350-
4337+ const dimensions = setTypeSpecificDimensions(generationType);
4351- // Current image is already animating
4338+ extension_settings.sd.original_seed = extension_settings.sd.seed;
4352- if (messageImg.hasClass(animationClass)) {
4339+ extension_settings.sd.seed = extension_settings.sd.seed >= 0 ? Math.round(Math.random() * (Math.pow(2, 32) - 1)) : -1;
4353- return;
4354- }
4355-
4356- const swipes = message?.extra?.image_swipes;
4357-
4358- if (!Array.isArray(swipes)) {
4359- console.warn('No image swipes found in the message');
4360- return;
4361- }
4362-
4363- const currentIndex = swipes.indexOf(message.extra.image);
43644340
4365- if (currentIndex === -1) {
4341+ /** @type {MediaAttachment} */
4366- console.warn('Current image not found in the swipes');
4342+ const result = {
4367- return;
4343+ url: '',
4368- }
4344+ type: MEDIA_TYPE.IMAGE,
4345+ };
43694346
4370- // Switch to previous image or wrap around if at the beginning
4347+ try {
4371- if (direction === 'left') {
4348+ $(stopButton).show();
4372- const newIndex = currentIndex === 0 ? swipes.length - 1 : currentIndex - 1;
4349+ eventSource.once(CUSTOM_STOP_EVENT, stopListener);
4373- message.extra.image = swipes[newIndex];
4350+ const callback = (_a, _b, _c, _d, _e, _f, format) => { result.type = isVideo(format) ? MEDIA_TYPE.VIDEO : MEDIA_TYPE.IMAGE; };
4351+ const savedPrompt = mediaAttachment.title ?? message.extra.title ?? '';
4352+ const prompt = await refinePrompt(savedPrompt, false);
4353+ const savedNegative = mediaAttachment.negative ?? message.extra.negative ?? '';
4354+ const negative = savedNegative ? await refinePrompt(savedNegative, true) : '';
43744355
4375- // Update the image in the message
4356+ const context = getContext();
4376- appendMediaToMessage(message, element, false);
4357+ const characterName = context.groupId
4358+ ? context.groups[Object.keys(context.groups).filter(x => context.groups[x].id === context.groupId)[0]]?.id?.toString()
4359+ : context.characters[context.characterId]?.name;
4360+
4361+ onStart();
4362+ result.url = await sendGenerationRequest(generationType, prompt, negative, characterName, callback, initiators.swipe, abortController.signal);
4363+ result.generation_type = generationType;
4364+ result.title = prompt;
4365+ result.negative = negative;
4366+ } finally {
4367+ onComplete();
4368+ $(stopButton).hide();
4369+ eventSource.removeListener(CUSTOM_STOP_EVENT, stopListener);
4370+ restoreOriginalDimensions(dimensions);
4371+ extension_settings.sd.seed = extension_settings.sd.original_seed;
4372+ delete extension_settings.sd.original_seed;
43774373 }
43784374
4379- // Switch to next image or generate a new one if at the end
4375+ if (!result.url) {
4380- if (direction === 'right') {
4376+ return null;
4381- const newIndex = currentIndex === swipes.length - 1 ? swipes.length : currentIndex + 1;
4382-
4383- if (newIndex === swipes.length) {
4384- const abortController = new AbortController();
4385- const swipeControls = element.find('.mes_img_swipes');
4386- const stopButton = document.getElementById('sd_stop_gen');
4387- const stopListener = () => abortController.abort('Aborted by user');
4388- const generationType = message?.extra?.generationType ?? generationMode.FREE;
4389- const dimensions = setTypeSpecificDimensions(generationType);
4390- const originalSeed = extension_settings.sd.seed;
4391- extension_settings.sd.seed = extension_settings.sd.seed >= 0 ? Math.round(Math.random() * (Math.pow(2, 32) - 1)) : -1;
4392- let imagePath = '';
4393-
4394- try {
4395- $(stopButton).show();
4396- eventSource.once(CUSTOM_STOP_EVENT, stopListener);
4397- const callback = () => { };
4398- const hasNegative = message.extra.negative;
4399- const prompt = await refinePrompt(message.extra.title, false);
4400- const negativePromptPrefix = hasNegative ? await refinePrompt(message.extra.negative, true) : '';
4401- message.extra.title = prompt;
4402- const characterName = context.groupId
4403- ? context.groups[Object.keys(context.groups).filter(x => context.groups[x].id === context.groupId)[0]]?.id?.toString()
4404- : context.characters[context.characterId]?.name;
4405-
4406- messageImg.addClass(animationClass);
4407- swipeControls.hide();
4408- imagePath = await sendGenerationRequest(generationType, prompt, negativePromptPrefix, characterName, callback, initiators.swipe, abortController.signal);
4409- } finally {
4410- $(stopButton).hide();
4411- messageImg.removeClass(animationClass);
4412- swipeControls.show();
4413- eventSource.removeListener(CUSTOM_STOP_EVENT, stopListener);
4414- restoreOriginalDimensions(dimensions);
4415- extension_settings.sd.seed = originalSeed;
4416- }
4417-
4418- if (!imagePath) {
4419- return;
4420- }
4421-
4422- swipes.push(imagePath);
4423- }
4424-
4425- message.extra.image = swipes[newIndex];
4426- appendMediaToMessage(message, element, false);
44274377 }
44284378
4429- await context.saveChat();
4379+ return result;
44304380}
44314381
44324382/**
@@ -4914,8 +4864,6 @@ jQuery(async () => {
49144864 }
49154865 });
49164866
4917- eventSource.on(event_types.IMAGE_SWIPED, onImageSwiped);
4918-
49194867 eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
49204868
49214869 [event_types.SECRET_WRITTEN, event_types.SECRET_DELETED, event_types.SECRET_ROTATED].forEach(event => {
public/scripts/extensions/vectors/index.js+37 -22
@@ -426,7 +426,7 @@ function getStringHash(str) {
426426
427427/**
428428 * Retrieves files from the chat and inserts them into the vector index.
429429 * @param {objectChatMessage[]} chat Array of chat messages
430430 * @returns {Promise<void>}
431431 */
432432async function processFiles(chat) {
@@ -443,39 +443,49 @@ async function processFiles(chat) {
443443 }
444444
445445 for (const message of chat) {
446446 // Message has no filefiles
447447 if (!Array.isArray(message?.extra?.filefiles) || !message.extra.files.length) {
448448 continue;
449449 }
450450
451451 // 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();
454453
455454 // Convert kilobytes to string length
456455 const thresholdLength = settings.size_threshold * 1024;
457456
458457 // File is too small
459458 if (fileTextallFileText.length < thresholdLength) {
460459 continue;
461460 }
462461
463462 message.mes = message.mes.substring(message.extra.fileLength);
464463
465464 const fileNameallFileChunks = message.extra.file.name[];
466- const fileUrl = message.extra.file.url;
465+ const queryText = await getQueryText(chat, 'file');
467- const collectionId = getFileCollectionId(fileUrl);
468- const hashesInCollection = await getSavedHashes(collectionId);
469466
470- // File is already in the collection
467+ for (const file of message.extra.files) {
471- if (!hashesInCollection.length) {
468+ const fileName = file.name;
472- await vectorizeFile(fileText, fileName, collectionId, settings.chunk_size, settings.overlap_percent);
469+ const fileUrl = file.url;
473- }
470+ const collectionId = getFileCollectionId(fileUrl);
471+ const hashesInCollection = await getSavedHashes(collectionId);
472+
473+ // File is not vectorized yet
474+ if (!hashesInCollection.length) {
475+ const fileText = file.text || (await getFileAttachment(fileUrl));
476+ if (!fileText) {
477+ continue;
478+ }
479+ await vectorizeFile(fileText, fileName, collectionId, settings.chunk_size, settings.overlap_percent);
480+ }
474481
475482 const queryTextfileChunks = await getQueryTextretrieveFileChunks(chatqueryText, 'file'collectionId);
476- const fileChunks = await retrieveFileChunks(queryText, collectionId);
483+ if (fileChunks) {
484+ allFileChunks.push(fileChunks);
485+ }
486+ }
477487
478488 message.mes = `${fileChunksallFileChunks.join('\n\n')}\n\n${message.mes}`;
479489 }
480490 } catch (error) {
481491 console.error('Vectors: Failed to retrieve files', error);
@@ -614,7 +624,7 @@ async function vectorizeFile(fileText, fileName, collectionId, chunkSize, overla
614624
615625/**
616626 * Removes the most relevant messages from the chat and displays them in the extension prompt
617627 * @param {objectChatMessage[]} chat Array of chat messages
618628 * @param {number} _contextSize Context size (unused)
619629 * @param {function} _abort Abort function (unused)
620630 * @param {string} type Generation type
@@ -740,13 +750,18 @@ const onChatEvent = debounce(async () => await moduleWorker.update(), debounce_t
740750
741751/**
742752 * Gets the text to query from the chat
743753 * @param {objectChatMessage[]} chat Chat messages
744754 * @param {'file'|'chat'|'world-info'} initiator Initiator of the query
745755 * @returns {Promise<string>} Text to query
746756 */
747757async 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+
748763 let hashedMessages = chat
749764 .map(x => ({ text: String(substituteParams(getTextWithoutAttachments(x.mes)), hash: getStringHash(substituteParams(getTextWithoutAttachments(x.mes))), index: chat.indexOf(x) }))
750765 .filter(x => x.text)
751766 .reverse()
752767 .slice(0, settings.query);
@@ -1313,7 +1328,7 @@ async function onViewStatsClick() {
13131328async function onVectorizeAllFilesClick() {
13141329 try {
13151330 const dataBank = getDataBankAttachments();
13161331 const chatAttachments = getContext().chat.filter(x => Array.isArray(x.extra?.filefiles)).map(x => x.extra.filefiles).flat();
13171332 const allFiles = [...dataBank, ...chatAttachments];
13181333
13191334 /**
@@ -1390,7 +1405,7 @@ async function onVectorizeAllFilesClick() {
13901405async function onPurgeFilesClick() {
13911406 try {
13921407 const dataBank = getDataBankAttachments();
13931408 const chatAttachments = getContext().chat.filter(x => Array.isArray(x.extra?.filefiles)).map(x => x.extra.filefiles).flat();
13941409 const allFiles = [...dataBank, ...chatAttachments];
13951410
13961411 for (const file of allFiles) {
public/scripts/group-chats.js+2 -0
@@ -78,6 +78,7 @@ import {
7878 shouldAutoContinue,
7979 unshallowCharacter,
8080 chatElement,
81+ ensureMessageMediaIsArray,
8182} from '../script.js';
8283import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect } from './tags.js';
8384import { FILTER_TYPES, FilterHelper } from './filters.js';
@@ -267,6 +268,7 @@ export async function getGroupChat(groupId, reload = false) {
267268 } else if (Array.isArray(data) && data.length) {
268269 data[0].is_group = true;
269270 chat.splice(0, chat.length, ...data);
271+ chat.forEach(ensureMessageMediaIsArray);
270272 chatElement.find('.mes').remove();
271273 await printMessages();
272274 }
public/scripts/openai.js+55 -16
@@ -15,6 +15,8 @@ import {
1515 Generate,
1616 getExtensionPrompt,
1717 getExtensionPromptMaxDepth,
18+ getMediaDisplay,
19+ getMediaIndex,
1820 getRequestHeaders,
1921 getStoppingStrings,
2022 is_send_press,
@@ -74,7 +76,7 @@ import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js';
7476import { t } from './i18n.js';
7577import { ToolManager } from './tool-calling.js';
7678import { accountStorage } from './util/AccountStorage.js';
7779import { COMETAPI_IGNORE_PATTERNS, IGNORE_SYMBOL, MEDIA_DISPLAY, MEDIA_TYPE } from './constants.js';
7880
7981export {
8082 openai_messages_count,
@@ -539,10 +541,11 @@ function setOpenAIMessages(chat) {
539541 // Apply the "wrap in quotes" option
540542 if (role == 'user' && oai_settings.wrap_in_quotes) content = `"${content}"`;
541543 const name = chat[j]['name'];
542544 const imagemedia = chat[j]?.extra?.imagemedia;
543545 const videomediaDisplay = getMediaDisplay(chat[j]?.extra?.video);
546+ const mediaIndex = getMediaIndex(chat[j]);
544547 const invocations = chat[j]?.extra?.tool_invocations;
545548 messages[i] = { 'role': role, 'content': content, name: name, 'imagemedia': imagemedia, 'videomediaDisplay': videomediaDisplay, 'mediaIndex': mediaIndex, 'invocations': invocations };
546549 j++;
547550 }
548551
@@ -852,12 +855,35 @@ async function populateChatHistory(messages, prompts, chatCompletion, type = nul
852855 await chatMessage.setName(messageName);
853856 }
854857
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+ }
857875 }
858876
859877 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+ }
861887 }
862888
863889 if (canUseTools && Array.isArray(chatPrompt.invocations)) {
@@ -2905,6 +2931,13 @@ class Message {
29052931 */
29062932 async addImage(image) {
29072933 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+
29082941 const isDataUrl = isDataURL(image);
29092942 if (!isDataUrl) {
29102943 try {
@@ -2921,10 +2954,7 @@ class Message {
29212954 image = await this.compressImage(image);
29222955
29232956 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- ];
29282958
29292959 try {
29302960 const tokens = await this.getImageTokenCost(image, quality);
@@ -2935,8 +2965,20 @@ class Message {
29352965 }
29362966 }
29372967
2968+ /**
2969+ * Adds a video to the message.
2970+ * @param {string} video Video URL or Data URL.
2971+ * @returns {Promise<void>}
2972+ */
29382973 async addVideo(video) {
29392974 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+
29402982 const isDataUrl = isDataURL(video);
29412983 if (!isDataUrl) {
29422984 try {
@@ -2951,10 +2993,7 @@ class Message {
29512993 }
29522994
29532995 // 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- ];
29582997
29592998 try {
29602999 // Convservative estimate for video token cost without knowing duration
public/scripts/power-user.js+67 -3
@@ -63,6 +63,8 @@ import { fuzzySearchCategories } from './filters.js';
6363import { accountStorage } from './util/AccountStorage.js';
6464import { DEFAULT_REASONING_TEMPLATE, loadReasoningTemplates } from './reasoning.js';
6565import { bindModelTemplates } from './chat-templates.js';
66+import { MEDIA_DISPLAY } from './constants.js';
67+import { t } from './i18n.js';
6668
6769export const toastPositionClasses = [
6870 'toast-top-left',
@@ -337,6 +339,7 @@ export const power_user = {
337339 external_media_forbidden_overrides: [],
338340 pin_styles: true,
339341 click_to_edit: false,
342+ media_display: MEDIA_DISPLAY.LIST,
340343};
341344
342345let themes = [];
@@ -1178,6 +1181,46 @@ function applyFontScale(type) {
11781181 $('#font_scale').val(power_user.font_scale);
11791182}
11801183
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+
11811224function applyTheme(name) {
11821225 const theme = themes.find(x => x.name == name);
11831226
@@ -1367,14 +1410,25 @@ function applyTheme(name) {
13671410 $('#click_to_edit').prop('checked', power_user.click_to_edit);
13681411 },
13691412 },
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+ },
13701422 ];
13711423
13721424 for (const { key, selector, type, action } of themeProperties) {
13731425 if (theme[key] !== undefined) {
13741426 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);
13761430 if (type) applyThemeColor(type);
13771431 if (action) action(oldValue, newValue);
13781432 } else {
13791433 console.debug(`Empty theme key: ${key}`);
13801434 }
@@ -1714,6 +1768,7 @@ export async function loadPowerUserSettings(settings, data) {
17141768 $('#forbid_external_media').prop('checked', power_user.forbid_external_media);
17151769 $('#pin_styles').prop('checked', power_user.pin_styles);
17161770 $('#click_to_edit').prop('checked', power_user.click_to_edit);
1771+ $('#media_display').val(power_user.media_display);
17171772
17181773 for (const theme of themes) {
17191774 const option = document.createElement('option');
@@ -2506,6 +2561,7 @@ function getThemeObject(name) {
25062561 compact_input_area: power_user.compact_input_area,
25072562 show_swipe_num_all_messages: power_user.show_swipe_num_all_messages,
25082563 click_to_edit: power_user.click_to_edit,
2564+ media_display: power_user.media_display,
25092565 };
25102566}
25112567
@@ -4121,6 +4177,14 @@ jQuery(() => {
41214177 await exportTheme();
41224178 });
41234179
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+
41244188 $(document).on('click', '#debug_table [data-debug-function]', function () {
41254189 const functionId = $(this).data('debug-function');
41264190 const functionRecord = debug_functions.find(f => f.functionId === functionId);
public/scripts/reasoning.js+2 -2
@@ -409,7 +409,7 @@ export class ReasoningHandler {
409409 if (!power_user.reasoning.prefix || !power_user.reasoning.suffix)
410410 return mesChanged;
411411
412412 /** @type {{ mes: string, [key: string]: any}ChatMessage} */
413413 const message = chat[messageId];
414414 if (!message) return mesChanged;
415415
@@ -1259,7 +1259,7 @@ export function parseReasoningFromString(str, { strict = true } = {}) {
12591259/**
12601260 * Parse reasoning in an array of swipe strings if auto-parsing is enabled.
12611261 * @param {string[]} swipes Array of swipe strings
12621262 * @param {{extra: Partial<ReasoningMessageExtra>}[]} swipeInfoArray Array of swipe info objects
12631263 * @param {number?} duration Duration of the reasoning
12641264 * @typedef {object} ReasoningMessageExtra Extra reasoning data
12651265 * @property {string} reasoning Reasoning block
public/scripts/slash-commands.js+0 -5
@@ -3866,11 +3866,6 @@ async function addSwipeCallback(args, value) {
38663866 return '';
38673867 }
38683868
3869- if (lastMessage.extra?.image) {
3870- toastr.warning(t`Can't add swipes to message containing an image.`);
3871- return '';
3872- }
3873-
38743869 if (!Array.isArray(lastMessage.swipes)) {
38753870 lastMessage.swipes = [lastMessage.mes];
38763871 lastMessage.swipe_info = [{}];
public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js+17 -0
@@ -40,6 +40,7 @@ export const enumIcons = {
4040 server: '🖥️',
4141 popup: '🗔',
4242 image: '🖼️',
43+ video: '🎥',
4344 key: '🔑',
4445
4546 true: '✔️',
@@ -263,6 +264,22 @@ export const commonEnumProviders = {
263264 },
264265
265266 /**
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+ /**
266283 * All names used in the current chat.
267284 *
268285 * @returns {SlashCommandEnumValue[]}
public/scripts/st-context.js+6 -0
@@ -58,6 +58,9 @@ import {
5858 deleteMessage,
5959 refreshSwipeButtons,
6060 isSwipingAllowed,
61+ ensureMessageMediaIsArray,
62+ getMediaDisplay,
63+ getMediaIndex,
6164} from '../script.js';
6265import {
6366 extension_settings,
@@ -209,6 +212,9 @@ export function getContext() {
209212 humanizedDateTime,
210213 updateMessageBlock,
211214 appendMediaToMessage,
215+ ensureMessageMediaIsArray,
216+ getMediaDisplay,
217+ getMediaIndex,
212218 swipe: {
213219 left: swipe_left,
214220 right: swipe_right,
public/scripts/system-messages.js+15 -11
@@ -5,8 +5,9 @@ import { getSlashCommandsHelp } from './slash-commands.js';
55import { SlashCommandBrowser } from './slash-commands/SlashCommandBrowser.js';
66import { renderTemplateAsync } from './templates.js';
77
8-// Initialized in getSystemMessages()
8+/** @type {Record<string, ChatMessage>} */
99export const system_messages = {};
10+/** @type {ChatMessage[]} */
1011export const SAFETY_CHAT = [];
1112
1213/**
@@ -29,6 +30,7 @@ export const system_message_types = {
2930};
3031
3132export async function initSystemMessages() {
33+ /** @type {Record<string, ChatMessage>} */
3234 const result = {
3335 help: {
3436 name: systemUserName,
@@ -65,14 +67,15 @@ export async function initSystemMessages() {
6567 is_system: true,
6668 mes: await renderTemplateAsync('macros'),
6769 },
6870 welcome: {
69- {
7071 name: systemUserName,
7172 force_avatar: system_avatar,
7273 is_user: false,
7374 is_system: true,
74- uses_system_ui: true,
7575 mes: await renderTemplateAsync('welcome', { displayVersion }),
76+ extra: {
77+ uses_system_ui: true,
78+ },
7679 },
7780 empty: {
7881 name: systemUserName,
@@ -93,9 +96,9 @@ export async function initSystemMessages() {
9396 force_avatar: system_avatar,
9497 is_user: false,
9598 is_system: true,
96- uses_system_ui: true,
9799 mes: await renderTemplateAsync('welcomePrompt'),
98100 extra: {
101+ uses_system_ui: true,
99102 isSmallSys: true,
100103 },
101104 },
@@ -105,8 +108,8 @@ export async function initSystemMessages() {
105108 is_user: false,
106109 is_system: true,
107110 mes: await renderTemplateAsync('assistantNote'),
108- uses_system_ui: true,
109111 extra: {
112+ uses_system_ui: true,
110113 isSmallSys: true,
111114 },
112115 },
@@ -114,12 +117,13 @@ export async function initSystemMessages() {
114117
115118 Object.assign(system_messages, result);
116119
120+ /** @type {ChatMessage} */
117121 const safetyMessage = {
118122 name: systemUserName,
119123 force_avatar: system_avatar,
120124 is_system: true,
121125 is_user: false,
122126 create_datesend_date: 0getMessageTimeStamp(),
123127 mes: t`You deleted a character/chat and arrived back here for safety reasons! Pick another character!`,
124128 };
125129 SAFETY_CHAT.splice(0, SAFETY_CHAT.length, safetyMessage);
@@ -130,8 +134,8 @@ export async function initSystemMessages() {
130134 * Gets a system message by type.
131135 * @param {string} type Type of system message
132136 * @param {string} [text] Text to be sent
133137 * @param {objectChatMessageExtra} [extra] Additional data to be added to the message
134138 * @returns {objectChatMessage} System message object
135139 */
136140export function getSystemMessageByType(type, text, extra = {}) {
137141 const systemMessage = system_messages[type];
@@ -150,7 +154,7 @@ export function getSystemMessageByType(type, text, extra = {}) {
150154 newMessage.mes = getSlashCommandsHelp();
151155 }
152156
153157 if (!newMessage.extra || typeof newMessage.extra !== 'object') {
154158 newMessage.extra = {};
155159 }
156160
@@ -163,7 +167,7 @@ export function getSystemMessageByType(type, text, extra = {}) {
163167 * Sends a system message to the chat.
164168 * @param {string} type Type of system message
165169 * @param {string} [text] Text to be sent
166170 * @param {objectChatMessageExtra} [extra] Additional data to be added to the message
167171 */
168172export function sendSystemMessage(type, text, extra = {}) {
169173 const newMessage = getSystemMessageByType(type, text, extra);
public/scripts/utils.js+17 -2
@@ -408,6 +408,16 @@ export async function urlContentToDataUri(url, params) {
408408}
409409
410410/**
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+/**
411421 * Returns a promise that resolves to the file's text.
412422 * @param {Blob} file The file to read.
413423 * @returns {Promise<string>} A promise that resolves to the file's text.
@@ -1009,7 +1019,7 @@ const dateCache = new Map();
10091019/**
10101020 * Cached version of moment() to avoid re-parsing the same date strings.
10111021 * Important: Moment objects are mutable, so use clone() before modifying them!
10121022 * @param {string|numberMessageTimestamp} timestamp String or number representing a date.
10131023 * @returns {import('moment').Moment} Moment object
10141024 */
10151025export function timestampToMoment(timestamp) {
@@ -1026,12 +1036,17 @@ export function timestampToMoment(timestamp) {
10261036
10271037/**
10281038 * Parses a timestamp and returns a moment object representing the parsed date and time.
10291039 * @param {string|numberMessageTimestamp} timestamp - The timestamp to parse. It can be a string or a number.
10301040 * @returns {string} - If the timestamp is valid, returns an ISO 8601 string.
10311041 */
10321042function parseTimestamp(timestamp) {
10331043 if (!timestamp) return;
10341044
1045+ // Date object
1046+ if (timestamp instanceof Date) {
1047+ return timestamp.toISOString();
1048+ }
1049+
10351050 // Unix time (legacy TAI / tags)
10361051 if (typeof timestamp === 'number' || /^\d+$/.test(timestamp)) {
10371052 const unixTime = Number(timestamp);
public/style.css+48 -10
@@ -388,6 +388,11 @@ input[type='checkbox']:focus-visible {
388388 margin-bottom: 10px;
389389}
390390
391+.mes_text p:last-child,
392+.mes_reasoning p:last-child {
393+ margin-bottom: 0;
394+}
395+
391396.mes_text li tt,
392397.mes_reasoning li tt {
393398 display: inline-block;
@@ -637,6 +642,16 @@ input[type='checkbox']:focus-visible {
637642 display: flex;
638643}
639644
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+
640655small {
641656 color: var(--SmartThemeBodyColor);
642657 opacity: 0.7;
@@ -4952,6 +4967,22 @@ a:hover {
49524967}
49534968
49544969/* 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+
49554986.mes .mes_img_container,
49564987.mes .mes_video_container {
49574988 max-width: 100%;
@@ -4960,7 +4991,8 @@ a:hover {
49604991 position: relative;
49614992 width: fit-content;
49624993 transition: all var(--animation-duration);
49634994 paddingborder-radius: 0.5rem5px;
4995+ overflow: hidden;
49644996}
49654997
49664998.mes .mes_video_container:has(.mes_video[src]) {
@@ -4968,7 +5000,6 @@ a:hover {
49685000}
49695001
49705002.mes_img {
4971- border-radius: 5px;
49725003 max-width: 100%;
49735004 max-height: 40vh;
49745005 image-rendering: -webkit-optimize-contrast;
@@ -4985,7 +5016,7 @@ a:hover {
49855016.mes_img_controls,
49865017.mes_video_controls {
49875018 position: absolute;
49885019 top: 0.1em;
49895020 left: 0;
49905021 width: 100%;
49915022 display: flex;
@@ -4993,14 +5024,16 @@ a:hover {
49935024 flex-direction: row;
49945025 justify-content: space-between;
49955026 align-items: center;
49965027 padding: 1em10px;
49975028 z-index: 1;
49985029 transition: opacity var(--animation-duration) ease-in-out;
5030+ background: linear-gradient(rgba(0, 0, 0, 0.8), transparent);
49995031}
50005032
50015033.mes_img_swipes {
50025034 top: unset;
50035035 bottom: 0.1rem;
5036+ background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
50045037}
50055038
50065039.mes_img_swipes .right_menu_button,
@@ -5036,11 +5069,18 @@ a:hover {
50365069.mes_img_container:focus-within .mes_img_swipes,
50375070.mes_img_container:hover .mes_img_controls,
50385071.mes_img_container:focus-within .mes_img_controls,
50395072.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 {
50405075 opacity: 1;
50415076}
50425077
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 {
50445084 display: flex;
50455085}
50465086
@@ -5048,8 +5088,7 @@ body:not(.caption) .mes_img_caption {
50485088 display: none;
50495089}
50505090
50515091.mes_img_container:not(.img_swipes) .mes_img_swipes, {
5052-body:not(.sd) .mes_img_swipes {
50535092 display: none;
50545093}
50555094
@@ -5136,7 +5175,6 @@ body:not(.sd) .mes_img_swipes {
51365175.mes_video {
51375176 max-width: 100%;
51385177 max-height: 400px;
5139- border-radius: 5px;
51405178 background: #000;
51415179}
51425180
src/endpoints/data-maid.js+38 -6
@@ -57,16 +57,24 @@ const sha256 = str => crypto.createHash('sha256').update(str).digest('hex');
5757 */
5858
5959/**
60+ * @typedef {object} DataMaidMedia - The media object.
61+ * @property {string} url - The media URL
62+ */
63+
64+/**
6065 * @typedef {object} DataMaidChatMetadata - The chat metadata object.
6166 * @property {DataMaidFile[]} [attachments] - The array of attachments, if any.
67+ * @property {string[]} [chat_backgrounds] - The array of chat background image links, if any.
6268 */
6369
6470/**
6571 * @typedef {object} DataMaidMessageExtra - The extra data object.
6672 * @property {string} [image] - The link to the image, if any - DEPRECATED, use `media` instead.
6773 * @property {string} [video] - The link to the video, if any - DEPRECATED, use `media` instead.
6874 * @property {string[]} [image_swipes] - The links to the image swipes, if any - DEPRECATED, use `media` instead.
6975 * @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.
7078 */
7179
7280/**
@@ -166,7 +174,7 @@ export class DataMaidService {
166174 const result = [];
167175
168176 try {
169177 const messages = await this.#parseAllChats(x => !!x?.extra?.image || !!x?.extra?.video || Array.isArray(x?.extra?.image_swipes) || Array.isArray(x?.extra?.media));
170178 const knownImages = new Set();
171179 for (const message of messages) {
172180 if (message?.extra?.image) {
@@ -180,6 +188,23 @@ export class DataMaidService {
180188 knownImages.add(swipe);
181189 }
182190 }
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+ }
183208 }
184209 const knownImageFullPaths = new Set();
185210 knownImages.forEach(image => {
@@ -221,12 +246,19 @@ export class DataMaidService {
221246 const result = [];
222247
223248 try {
224249 const messages = await this.#parseAllChats(x => !!x?.extra?.file?.url || (Array.isArray(x?.extra?.files) && x.extra.files.length > 0));
225250 const knownFiles = new Set();
226251 for (const message of messages) {
227252 if (message?.extra?.file?.url) {
228253 knownFiles.add(message.extra.file.url);
229254 }
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+ }
230262 }
231263 const metadata = await this.#parseAllMetadata(x => Array.isArray(x?.attachments) && x.attachments.length > 0);
232264 for (const meta of metadata) {