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 @@
25 border-radius: 15px;25 border-radius: 15px;
26}26}
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
28.mes_file_container .right_menu_button {38.mes_file_container .right_menu_button {
29 padding-right: 0;39 padding-right: 0;
30}40}
public/global.d.ts+68 -0
@@ -4,6 +4,8 @@ import { power_user } from './scripts/power-user';
4import { QuickReplyApi } from './scripts/extensions/quick-reply/api/QuickReplyApi';4import { QuickReplyApi } from './scripts/extensions/quick-reply/api/QuickReplyApi';
5import { oai_settings } from './scripts/openai';5import { oai_settings } from './scripts/openai';
6import { textgenerationwebui_settings } from './scripts/textgen-settings';6import { textgenerationwebui_settings } from './scripts/textgen-settings';
7import { FileAttachment } from './scripts/chats';
8import { ReasoningMessageExtra } from './scripts/reasoning';
79
8declare global {10declare global {
9 // Custom types11 // Custom types
@@ -12,6 +14,72 @@ declare global {
12 type ReasoningSettings = typeof power_user.reasoning;14 type ReasoningSettings = typeof power_user.reasoning;
13 type ChatCompletionSettings = typeof oai_settings;15 type ChatCompletionSettings = typeof oai_settings;
14 type TextCompletionSettings = typeof textgenerationwebui_settings;16 type TextCompletionSettings = typeof textgenerationwebui_settings;
17 type MessageTimestamp = string | number | Date;
18
19 interface ChatMessage {
20 name?: string;
21 mes?: string;
22 title?: string;
23 gen_started?: MessageTimestamp;
24 gen_finished?: MessageTimestamp;
25 send_date?: MessageTimestamp;
26 is_user?: boolean;
27 is_system?: boolean;
28 force_avatar?: string;
29 original_avatar?: string;
30 swipes?: string[];
31 swipe_info?: Record<string, any>;
32 swipe_id?: number;
33 extra?: ChatMessageExtra & Partial<ReasoningMessageExtra> & Record<string, any>;
34 };
35
36 interface ChatMessageExtra {
37 bias?: string;
38 uses_system_ui?: boolean;
39 memory?: string;
40 display_text?: string;
41 reasoning_display_text?: string;
42 tool_invocations?: any[];
43 title?: string;
44 isSmallSys?: boolean;
45 token_count?: number;
46 files?: FileAttachment[];
47 inline_image?: boolean;
48 media_display?: string;
49 media_index?: number;
50 media?: MediaAttachment[],
51 /** @deprecated Use `files` instead */
52 file?: FileAttachment;
53 /** @deprecated Use `media` instead */
54 image?: string;
55 /** @deprecated Use `media` instead */
56 video?: string;
57 /** @deprecated Use `media` with `media_display = 'gallery'` instead */
58 image_swipes?: string[];
59 /** @deprecated Use `MediaAttachment.append_title` instead */
60 append_title?: boolean;
61 /** @deprecated Use `MediaAttachment.generation_type` instead */
62 generationType?: number;
63 /** @deprecated Use `MediaAttachment.negative` instead */
64 negative?: string;
65 }
66
67 type MediaAttachment = MediaAttachmentProps & ImageGenerationAttachmentProps & ImageCaptionAttachmentProps;
68
69 interface MediaAttachmentProps {
70 url: string;
71 title?: string;
72 type: string;
73 }
74
75 interface ImageGenerationAttachmentProps {
76 generation_type?: number;
77 negative?: string;
78 }
79
80 interface ImageCaptionAttachmentProps {
81 append_title?: boolean;
82 }
1583
16 // Global namespace modules84 // Global namespace modules
17 interface Window {85 interface Window {
public/index.html+37 -20
@@ -4671,6 +4671,13 @@
4671 <option value="2" data-i18n="Document">Document</option>4671 <option value="2" data-i18n="Document">Document</option>
4672 </select>4672 </select>
4673 </div>4673 </div>
4674 <div class="flex-container alignitemscenter" title="Default display style for media attachments in chat messages. Extensions can override this setting." data-i18n="[title]Default display style for media attachments in chat messages. Extensions can override this setting.">
4675 <span data-i18n="Media Style:">Media Style:</span>
4676 <select id="media_display" class="widthNatural flex1 margin0 text_pole">
4677 <option value="list" data-i18n="List">List</option>
4678 <option value="gallery" data-i18n="Gallery">Gallery</option>
4679 </select>
4680 </div>
4674 <div class="flex-container alignItemsBaseline">4681 <div class="flex-container alignItemsBaseline">
4675 <span data-i18n="Notifications:">Notifications:</span>4682 <span data-i18n="Notifications:">Notifications:</span>
4676 <select id="toastr_position" class="widthNatural flex1 margin0 text_pole">4683 <select id="toastr_position" class="widthNatural flex1 margin0 text_pole">
@@ -7005,6 +7012,8 @@
7005 <div title="Prompt" class="mes_button mes_prompt fa-solid fa-square-poll-horizontal " data-i18n="[title]Prompt" style="display: none;"></div>7012 <div title="Prompt" class="mes_button mes_prompt fa-solid fa-square-poll-horizontal " data-i18n="[title]Prompt" style="display: none;"></div>
7006 <div title="Exclude message from prompts" class="mes_button mes_hide fa-solid fa-eye" data-i18n="[title]Exclude message from prompts"></div>7013 <div title="Exclude message from prompts" class="mes_button mes_hide fa-solid fa-eye" data-i18n="[title]Exclude message from prompts"></div>
7007 <div title="Include message in prompts" class="mes_button mes_unhide fa-solid fa-eye-slash" data-i18n="[title]Include message in prompts"></div>7014 <div title="Include message in prompts" class="mes_button mes_unhide fa-solid fa-eye-slash" data-i18n="[title]Include message in prompts"></div>
7015 <div title="Toggle media display style" class="mes_button mes_media_gallery fa-solid fa-photo-film" data-i18n="[title]Toggle media display style"></div>
7016 <div title="Toggle media display style" class="mes_button mes_media_list fa-solid fa-table-cells-large" data-i18n="[title]Toggle media display style"></div>
7008 <div title="Embed file or image" class="mes_button mes_embed fa-solid fa-paperclip" data-i18n="[title]Embed file or image"></div>7017 <div title="Embed file or image" class="mes_button mes_embed fa-solid fa-paperclip" data-i18n="[title]Embed file or image"></div>
7009 <div title="Create checkpoint" class="mes_button mes_create_bookmark fa-regular fa-solid fa-flag-checkered" data-i18n="[title]Create checkpoint"></div>7018 <div title="Create checkpoint" class="mes_button mes_create_bookmark fa-regular fa-solid fa-flag-checkered" data-i18n="[title]Create checkpoint"></div>
7010 <div title="Create branch" class="mes_button mes_create_branch fa-regular fa-code-branch" data-i18n="[title]Create Branch"></div>7019 <div title="Create branch" class="mes_button mes_create_branch fa-regular fa-code-branch" data-i18n="[title]Create Branch"></div>
@@ -7043,19 +7052,8 @@
7043 <div class="mes_reasoning"></div>7052 <div class="mes_reasoning"></div>
7044 </details>7053 </details>
7045 <div class="mes_text"></div>7054 <div class="mes_text"></div>
7046 <div class="mes_img_container">7055 <div class="mes_media_wrapper"></div>
7047 <div class="mes_img_controls">7056 <div class="mes_file_wrapper"></div>
7048 <div title="Expand and zoom" class="right_menu_button fa-lg fa-solid fa-magnifying-glass mes_img_enlarge" data-i18n="[title]Expand and zoom"></div>
7049 <div title="Caption" class="right_menu_button fa-lg fa-solid fa-envelope-open-text mes_img_caption" data-i18n="[title]Caption"></div>
7050 <div title="Delete" class="right_menu_button fa-lg fa-solid fa-trash-can mes_img_delete" data-i18n="[title]Delete"></div>
7051 </div>
7052 <div class="mes_img_swipes">
7053 <div title="Swipe left" class="right_menu_button fa-lg fa-solid fa-chevron-left mes_img_swipe_left" data-i18n="[title]Swipe left"></div>
7054 <div class="mes_img_swipe_counter">1/1</div>
7055 <div title="Swipe right" class="right_menu_button fa-lg fa-solid fa-chevron-right mes_img_swipe_right" data-i18n="[title]Swipe right"></div>
7056 </div>
7057 <img class="mes_img" src="" />
7058 </div>
7059 <div class="mes_bias"></div>7057 <div class="mes_bias"></div>
7060 </div>7058 </div>
7061 <div class="flex-container swipeRightBlock flexFlowColumn flexNoGap">7059 <div class="flex-container swipeRightBlock flexFlowColumn flexNoGap">
@@ -7265,9 +7263,9 @@
7265 </div>7263 </div>
7266 </div>7264 </div>
72677265
7268 <!-- chat and input bar -->7266 <!-- Media Templates -->
7269 <div id="message_file_template" class="template_element">7267 <div id="message_file_template" class="template_element">
7270 <div class="mes_file_container">7268 <div class="mes_media_container mes_file_container">
7271 <div class="fa-lg fa-solid fa-file-alt mes_file_icon"></div>7269 <div class="fa-lg fa-solid fa-file-alt mes_file_icon"></div>
7272 <div class="mes_file_name"></div>7270 <div class="mes_file_name"></div>
7273 <div class="mes_file_size"></div>7271 <div class="mes_file_size"></div>
@@ -7276,15 +7274,34 @@
7276 </div>7274 </div>
7277 </div>7275 </div>
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
7279 <div id="message_video_template" class="template_element">7288 <div id="message_video_template" class="template_element">
7280 <div class="mes_video_container">7289 <div class="mes_media_container mes_video_container">
7281 <div class="mes_video_controls">7290 <div class="mes_video_controls">
7282 <div><!-- Placeholder --></div>7291 <div title="Expand and zoom" class="right_menu_button fa-lg fa-solid fa-magnifying-glass mes_media_enlarge" data-i18n="[title]Expand and zoom"></div>
7283 <div title="Delete" class="right_menu_button fa-lg fa-solid fa-trash-can mes_video_delete" data-i18n="[title]Delete"></div>7292 <div title="Delete" class="right_menu_button fa-lg fa-solid fa-trash-can mes_media_delete" data-i18n="[title]Delete"></div>
7284 </div>7293 </div>
7285 <video class="mes_video" controls preload="metadata"></video>7294 <video class="mes_video" controls preload="metadata"></video>
7286 </div>7295 </div>
7287 </div>7296 </div>
7297
7298 <div id="message_gallery_controls" class="template_element">
7299 <div class="mes_img_swipes">
7300 <div title="Swipe left" class="right_menu_button fa-lg fa-solid fa-chevron-left mes_img_swipe_left" data-i18n="[title]Swipe left"></div>
7301 <div class="mes_img_swipe_counter">1/1</div>
7302 <div title="Swipe right" class="right_menu_button fa-lg fa-solid fa-chevron-right mes_img_swipe_right" data-i18n="[title]Swipe right"></div>
7303 </div>
7304 </div>
7288 </div>7305 </div>
7289 <div id="movingDivs">7306 <div id="movingDivs">
7290 <div id="floatingPrompt" class="drawer-content flexGap5">7307 <div id="floatingPrompt" class="drawer-content flexGap5">
@@ -7641,8 +7658,8 @@
7641 <div id="send_form" class="no-connection">7658 <div id="send_form" class="no-connection">
7642 <form id="file_form" class="wide100p displayNone">7659 <form id="file_form" class="wide100p displayNone">
7643 <div class="file_attached">7660 <div class="file_attached">
7644 <input id="file_form_input" type="file" hidden>7661 <input id="file_form_input" type="file" multiple hidden>
7645 <input id="embed_file_input" type="file" hidden>7662 <input id="embed_file_input" type="file" multiple hidden>
7646 <i class="fa-solid fa-file-alt"></i>7663 <i class="fa-solid fa-file-alt"></i>
7647 <span class="file_name">File Name</span>7664 <span class="file_name">File Name</span>
7648 <span class="file_size">File Size</span>7665 <span class="file_size">File Size</span>
public/script.js+389 -132
@@ -183,7 +183,7 @@ import {
183 trimSpaces,183 trimSpaces,
184 clamp,184 clamp,
185} from './scripts/utils.js';185} from './scripts/utils.js';
186import { debounce_timeout, GENERATION_TYPE_TRIGGERS, IGNORE_SYMBOL, inject_ids, SWIPE_DIRECTION } from './scripts/constants.js';186import { debounce_timeout, GENERATION_TYPE_TRIGGERS, IGNORE_SYMBOL, inject_ids, MEDIA_DISPLAY, MEDIA_TYPE, SWIPE_DIRECTION } from './scripts/constants.js';
187187
188import { cancelDebouncedMetadataSave, doDailyExtensionUpdatesCheck, extension_settings, initExtensions, loadExtensionSettings, runGenerationInterceptors, saveMetadataDebounced } from './scripts/extensions.js';188import { cancelDebouncedMetadataSave, doDailyExtensionUpdatesCheck, extension_settings, initExtensions, loadExtensionSettings, runGenerationInterceptors, saveMetadataDebounced } from './scripts/extensions.js';
189import { COMMENT_NAME_DEFAULT, CONNECT_API_MAP, executeSlashCommandsOnChatInput, initDefaultSlashCommands, isExecutingCommandsFromChatInput, pauseScriptExecution, stopScriptExecution, UNIQUE_APIS } from './scripts/slash-commands.js';189import { COMMENT_NAME_DEFAULT, CONNECT_API_MAP, executeSlashCommandsOnChatInput, initDefaultSlashCommands, isExecutingCommandsFromChatInput, pauseScriptExecution, stopScriptExecution, UNIQUE_APIS } from './scripts/slash-commands.js';
@@ -314,7 +314,10 @@ export {
314 getSystemMessageByType,314 getSystemMessageByType,
315 event_types,315 event_types,
316 eventSource,316 eventSource,
317 /** @deprecated Use setCharacterSettingsOverrides instead. */
317 setCharacterSettingsOverrides as setScenarioOverride,318 setCharacterSettingsOverrides as setScenarioOverride,
319 /** @deprecated Use appendMediaToMessage instead. */
320 appendMediaToMessage as appendImageToMessage,
318};321};
319322
320/**323/**
@@ -367,6 +370,7 @@ export const neutralCharacterName = 'Assistant';
367let default_user_name = 'User';370let default_user_name = 'User';
368export let name1 = default_user_name;371export let name1 = default_user_name;
369export let name2 = systemUserName;372export let name2 = systemUserName;
373/** @type {ChatMessage[]} */
370export let chat = [];374export let chat = [];
371export let isSwipingAllowed = true; //false when a swipe is in progress, or swiping is blocked.375export let isSwipingAllowed = true; //false when a swipe is in progress, or swiping is blocked.
372let chatSaveTimeout;376let chatSaveTimeout;
@@ -1407,30 +1411,45 @@ export async function printMessages() {
1407 addOneMessage(item, { scroll: false, forceId: i, showSwipes: false });1411 addOneMessage(item, { scroll: false, forceId: i, showSwipes: false });
1408 }1412 }
14091413
1410 // Scroll to bottom when all images are loaded1414 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
1422function 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) {
1416 if (image instanceof HTMLImageElement) {1429 if (currentElement.complete) {
1417 if (image.complete) {1430 incrementAndCheck();
1431 } else {
1432 currentElement.addEventListener('load', incrementAndCheck);
1433 currentElement.addEventListener('error', incrementAndCheck);
1434 }
1435 }
1436 if (currentElement instanceof HTMLVideoElement) {
1437 if (currentElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
1418 incrementAndCheck();1438 incrementAndCheck();
1419 } else {1439 } else {
1420 image.addEventListener('load', incrementAndCheck);1440 currentElement.addEventListener('loadeddata', incrementAndCheck);
1441 currentElement.addEventListener('error', incrementAndCheck);
1421 }1442 }
1422 }1443 }
1423 }1444 }
14241445
1425 chatElement.find('.mes').removeClass('last_mes');
1426 chatElement.find('.mes').last().addClass('last_mes');
1427 refreshSwipeButtons();
1428 scrollChatToBottom();
1429 applyStylePins();
1430
1431 function incrementAndCheck() {1446 function incrementAndCheck() {
1432 imagesLoaded++;1447 const MAX_DELAY = 1000; // 1 second
1433 if (imagesLoaded === images.length) {1448 if ((Date.now() - started) > MAX_DELAY) {
1449 return;
1450 }
1451 mediaLoaded++;
1452 if (mediaLoaded === media.length) {
1434 scrollChatToBottom();1453 scrollChatToBottom();
1435 }1454 }
1436 }1455 }
@@ -1885,110 +1904,335 @@ export function updateMessageBlock(messageId, message, { rerenderMessage = true
1885}1904}
18861905
1887/**1906/**
1888 * Appends image or file to the message element.1907 * Ensures that the message media properties are arrays, adding getters/setters for single media items.
1889 * @param {object} mes Message object1908 * @param {ChatMessage} mes Message object
1890 * @param {JQuery<HTMLElement>} messageElement Message element
1891 * @param {boolean} [adjustScroll=true] Whether to adjust the scroll position after appending the media
1892 */1909 */
1893export function appendMediaToMessage(mes, messageElement, adjustScroll = true) {1910export function ensureMessageMediaIsArray(mes) {
1894 // Add image to message1911 /**
1895 if (mes.extra?.image) {1912 * Determines if a property of an object is a plain property (not a getter/setter or non-enumerable).
1896 const container = messageElement.find('.mes_img_container');1913 * @param {object} obj Object to check
1897 const chatHeight = chatElement.prop('scrollHeight');1914 * @param {string} name Property name
1898 const image = messageElement.find('.mes_img');1915 * @returns {boolean} True if the property is a plain property, false otherwise
1899 const text = messageElement.find('.mes_text');1916 */
1900 const isInline = !!mes.extra?.inline_image;1917 function isPlainObjectProperty(obj, name) {
1901 const doAdjustScroll = () => {1918 const hasProperty = Object.hasOwn(obj, name);
1902 if (!adjustScroll) {1919 if (hasProperty) {
1903 return;1920 const descriptor = Object.getOwnPropertyDescriptor(obj, name);
1904 }1921 return descriptor && descriptor.enumerable && descriptor.configurable && descriptor.writable;
1905 const scrollPosition = chatElement.scrollTop();1922 }
1906 const newChatHeight = chatElement.prop('scrollHeight');1923 return false;
1907 const diff = newChatHeight - chatHeight;1924 }
1908 chatElement.scrollTop(scrollPosition + diff);1925
1909 };1926 /**
1910 image.off('load').on('load', function () {1927 * Determines if a property of an object is a getter (not a plain property).
1911 image.removeAttr('alt');1928 * @param {object} obj Object to check
1912 image.removeClass('error');1929 * @param {string} name Property name
1913 doAdjustScroll();1930 * @returns {boolean} True if the property is a getter, false otherwise
1914 });1931 */
1915 image.off('error').on('error', function () {1932 function isGetterObjectProperty(obj, name) {
1916 image.attr('alt', '');1933 const hasProperty = Object.hasOwn(obj, name);
1917 image.addClass('error');1934 if (hasProperty) {
1918 doAdjustScroll();1935 const descriptor = Object.getOwnPropertyDescriptor(obj, name);
1936 return descriptor && typeof descriptor.get === 'function';
1937 }
1938 return false;
1939 }
1940
1941 /**
1942 * Adds a plain property to an object that wraps around an array property.
1943 * @param {object} obj Object to add property to
1944 * @param {string} plainProperty Plain property name
1945 * @param {string} arrayProperty Array property to back the plain property
1946 * @param {(value: any) => boolean} [filterFn] Optional filter function to apply when getting/setting the plain property
1947 * @param {(value: any) => any} [mapFn] Optional map function to apply when getting/setting the plain property
1948 */
1949 function addArrayAutoWrapper(obj, plainProperty, arrayProperty, filterFn = () => true, mapFn = (t) => t) {
1950 // If the plain property is already a getter, do nothing.
1951 const hasGetterProperty = isGetterObjectProperty(obj, plainProperty);
1952 if (hasGetterProperty) {
1953 return;
1954 }
1955
1956 // Define the plain property as a getter/setter that wraps around the array property.
1957 Object.defineProperty(obj, plainProperty, {
1958 // Getting the plain property returns the first item in the array property, or undefined if the array is empty.
1959 get: function () {
1960 console.trace(`Attempting to GET an array-wrapped property '${plainProperty}'. Use the array property '${arrayProperty}' instead.`);
1961 const array = Array.isArray(this[arrayProperty]) ? this[arrayProperty].filter(filterFn).map(mapFn) : [];
1962 return array.length > 0 ? array[0] : void 0;
1963 },
1964 // Setting the plain property is not supported, as it would be ambiguous.
1965 set: function () {
1966 console.trace(`Attempting to SET an array-wrapped property '${plainProperty}'. Use the array property '${arrayProperty}' instead.`);
1967 },
1968 // Exclude the property from JSON serialization and from being listed in for...in loops.
1969 enumerable: false,
1970 // Make the property non-configurable to prevent deletion or redefinition.
1971 configurable: false,
1919 });1972 });
1920 image.attr('src', mes.extra?.image);1973 }
1921 image.attr('title', mes.extra?.title || mes.title || '');
1922 container.addClass('img_extra');
1923 image.toggleClass('img_inline', isInline);
1924 text.toggleClass('displayNone', !isInline);
1925
1926 const imageSwipes = mes.extra.image_swipes;
1927 if (Array.isArray(imageSwipes) && imageSwipes.length > 0) {
1928 container.addClass('img_swipes');
1929 const counter = container.find('.mes_img_swipe_counter');
1930 const currentImage = imageSwipes.indexOf(mes.extra.image) + 1;
1931 counter.text(`${currentImage}/${imageSwipes.length}`);
1932
1933 const swipeLeft = container.find('.mes_img_swipe_left');
1934 swipeLeft.off('click').on('click', function () {
1935 eventSource.emit(event_types.IMAGE_SWIPED, { message: mes, element: messageElement, direction: 'left' });
1936 });
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 }
1942 }1989 }
1943 } else {1990
1944 const container = messageElement.find('.mes_img_container');1991 if (Array.isArray(obj.image_swipes)) {
1945 container.removeClass('img_extra img_swipes');1992 if (!Array.isArray(obj.media)) {
1946 const text = messageElement.find('.mes_text');1993 obj.media = [];
1947 text.removeClass('displayNone');
1948 }
1949
1950 // Add video to message
1951 if (mes.extra?.video) {
1952 const container = $('#message_video_template .mes_video_container').clone();
1953 messageElement.find('.mes_video_container').remove();
1954 messageElement.find('.mes_block').append(container);
1955 const chatHeight = chatElement.prop('scrollHeight');
1956 const video = container.find('.mes_video');
1957 video.off('loadedmetadata').on('loadedmetadata', function () {
1958 if (!adjustScroll) {
1959 return;
1960 }1994 }
1961 const scrollPosition = chatElement.scrollTop();1995 for (const swipe of obj.image_swipes) {
1962 const newChatHeight = chatElement.prop('scrollHeight');1996 if (swipe && typeof swipe === 'string') {
1963 const diff = newChatHeight - chatHeight;1997 obj.media_display = MEDIA_DISPLAY.GALLERY;
1964 chatElement.scrollTop(scrollPosition + diff);1998 obj.media.push({ type: MEDIA_TYPE.IMAGE, url: swipe });
1965 });1999 }
2000 }
2001 delete obj.image_swipes;
2002 }
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 = [];
1970 }2007 }
19712008 const imageValue = obj.image;
1972 // Add file to message2009 delete obj.image;
1973 if (mes.extra?.file) {2010 if (imageValue && typeof imageValue === 'string') {
1974 messageElement.find('.mes_file_container').remove();2011 obj.media.push({ type: MEDIA_TYPE.IMAGE, url: imageValue });
1975 const messageId = messageElement.attr('mesid');2012 }
1976 const template = $('#message_file_template .mes_file_container').clone();2013 if (obj.media_display === MEDIA_DISPLAY.GALLERY) {
1977 template.find('.mes_file_name').text(mes.extra.file.name);2014 const selectedIndex = obj.media.findIndex(t => t.url === imageValue);
1978 template.find('.mes_file_size').text(humanFileSize(mes.extra.file.size));2015 if (selectedIndex > -1) {
1979 template.find('.mes_file_download').attr('mesid', messageId);2016 obj.media_index = selectedIndex;
1980 template.find('.mes_file_delete').attr('mesid', messageId);2017 }
1981 messageElement.find('.mes_block').append(template);2018 }
1982 } else {2019 obj.media = obj.media.filter((v, i, a) => i === a.findIndex(t => t.url === v.url));
1983 messageElement.find('.mes_file_container').remove();2020 }
2021
2022 if (isPlainObjectProperty(obj, 'video')) {
2023 if (!Array.isArray(obj.media)) {
2024 obj.media = [];
2025 }
2026 const videoValue = obj.video;
2027 delete obj.video;
2028 if (videoValue && typeof videoValue === 'string') {
2029 obj.media.push({ type: MEDIA_TYPE.VIDEO, url: videoValue });
2030 }
2031 }
1984 }2032 }
2033
2034 if (!mes || !mes.extra || typeof mes.extra !== 'object') {
2035 return;
2036 }
2037
2038 migrateMediaToArray(mes.extra);
2039 addArrayAutoWrapper(mes.extra, 'file', 'files');
2040 addArrayAutoWrapper(mes.extra, 'image', 'media', (t) => t.type === MEDIA_TYPE.IMAGE, (t) => t.url);
2041 addArrayAutoWrapper(mes.extra, 'video', 'media', (t) => t.type === MEDIA_TYPE.VIDEO, (t) => t.url);
2042}
2043
2044/**
2045 * Gets the media display setting for a message.
2046 * @param {ChatMessage} mes Message object
2047 * @returns {MEDIA_DISPLAY} Media display setting
2048 */
2049export function getMediaDisplay(mes) {
2050 const value = mes?.extra?.media_display || power_user.media_display || MEDIA_DISPLAY.LIST;
2051 return Object.values(MEDIA_DISPLAY).includes(value) ? value : MEDIA_DISPLAY.LIST;
1985}2052}
19862053
1987/**2054/**
1988 * @deprecated Use appendMediaToMessage instead.2055 * Gets the media index for a message.
2056 * @param {ChatMessage} mes Message object
2057 * @returns {number} Media index
1989 */2058 */
1990export function appendImageToMessage(mes, messageElement) {2059export function getMediaIndex(mes) {
1991 appendMediaToMessage(mes, messageElement);2060 if (!Array.isArray(mes?.extra?.media)) {
2061 return 0;
2062 }
2063 const value = mes.extra?.media_index;
2064 if (isNaN(value) || value < 0 || value >= mes.extra.media.length) {
2065 return 0;
2066 }
2067 return value;
2068}
2069
2070/**
2071 * Appends image or file to the message element.
2072 * @param {ChatMessage} mes Message object
2073 * @param {JQuery<HTMLElement>} messageElement Message element
2074 * @param {boolean} [adjustScroll=true] Whether to adjust the scroll position after appending the media
2075 */
2076export function appendMediaToMessage(mes, messageElement, adjustScroll = true) {
2077 ensureMessageMediaIsArray(mes);
2078
2079 const hasMedia = Array.isArray(mes?.extra?.media) && mes.extra.media.length > 0;
2080 const hasFiles = Array.isArray(mes?.extra?.files) && mes.extra.files.length > 0;
2081 const mediaDisplay = getMediaDisplay(mes);
2082 const hideMessageText = hasMedia && mes?.extra?.inline_image === false;
2083
2084 const mediaBlocks = [];
2085 const mediaPromises = [];
2086
2087 const chatHeight = adjustScroll && (hasMedia || hasFiles) ? chatElement.prop('scrollHeight') : 0;
2088 const scrollPosition = chatElement.scrollTop();
2089 const doAdjustScroll = () => {
2090 if (!adjustScroll) {
2091 chatElement.scrollTop(scrollPosition);
2092 return;
2093 }
2094 const newChatHeight = chatElement.prop('scrollHeight');
2095 const diff = newChatHeight - chatHeight;
2096 chatElement.scrollTop(scrollPosition + diff);
2097 };
2098
2099 // Set media display attribute
2100 messageElement.attr('data-media-display', mediaDisplay);
2101 // Toggle text visibility
2102 messageElement.find('.mes_text').toggleClass('displayNone', hideMessageText);
2103
2104 /**
2105 * Appends a single image attachment to the message element.
2106 * @param {MediaAttachment} attachment Image attachment object
2107 * @param {number} index Index of the image attachment
2108 * @returns {JQuery<HTMLElement>} The appended image container element
2109 */
2110 function appendImageAttachment(attachment, index) {
2111 const template = $('#message_image_template .mes_img_container').clone();
2112 template.attr('data-index', index);
2113
2114 const image = template.find('.mes_img');
2115 image.attr('src', attachment.url);
2116 image.attr('title', attachment.title || mes.extra.title || '');
2117 mediaPromises.push(new Promise((resolve) => {
2118 function onLoad() {
2119 image.removeAttr('alt');
2120 image.removeClass('error');
2121 resolve();
2122 }
2123 function onError() {
2124 image.attr('alt', '');
2125 image.addClass('error');
2126 resolve();
2127 }
2128 if (image.prop('complete')) {
2129 onLoad();
2130 } else {
2131 image.off('load').on('load', onLoad);
2132 image.off('error').on('error', onError);
2133 }
2134 }));
2135
2136 mediaBlocks.push(template);
2137 return template;
2138 }
2139
2140 /**
2141 * Appends a single video attachment to the message element.
2142 * @param {MediaAttachment} attachment Video attachment object
2143 * @param {number} index Index of the video attachment
2144 * @returns {JQuery<HTMLElement>} The appended video container element
2145 */
2146 function appendVideoAttachment(attachment, index) {
2147 const template = $('#message_video_template .mes_video_container').clone();
2148 template.attr('data-index', index);
2149
2150 const video = template.find('.mes_video');
2151 video.attr('src', attachment.url);
2152 video.attr('title', attachment.title || mes.extra.title || '');
2153 mediaPromises.push(new Promise((resolve) => {
2154 function onLoad() {
2155 resolve();
2156 }
2157 function onError() {
2158 video.addClass('error');
2159 resolve();
2160 }
2161 if (video.prop('readyState') >= HTMLMediaElement.HAVE_CURRENT_DATA) {
2162 onLoad();
2163 } else {
2164 video.off('loadeddata').on('loadeddata', onLoad);
2165 video.off('error').on('error', onError);
2166 }
2167 }));
2168
2169 mediaBlocks.push(template);
2170 return template;
2171 }
2172
2173 /**
2174 * Appends a media attachment to the message element.
2175 * @param {MediaAttachment} attachment Media attachment object
2176 * @param {number} index Index of the media attachment
2177 * @returns {JQuery<HTMLElement>} The appended media container element
2178 */
2179 function appendMediaAttachment(attachment, index) {
2180 if (!attachment.type) {
2181 attachment.type = MEDIA_TYPE.IMAGE;
2182 }
2183 switch (attachment.type) {
2184 case MEDIA_TYPE.IMAGE:
2185 return appendImageAttachment(attachment, index);
2186 case MEDIA_TYPE.VIDEO:
2187 return appendVideoAttachment(attachment, index);
2188 }
2189
2190 console.warn(`Unknown media type: ${attachment.type}, defaulting to image.`, attachment);
2191 return appendImageAttachment(attachment, index);
2192 }
2193
2194 // Add media gallery to message
2195 if (hasMedia && mediaDisplay === MEDIA_DISPLAY.GALLERY) {
2196 const mediaIndex = getMediaIndex(mes);
2197 const selectedMedia = mes.extra.media[mediaIndex];
2198
2199 const galleryControls = $('#message_gallery_controls .mes_img_swipes').clone();
2200 const counter = galleryControls.find('.mes_img_swipe_counter');
2201 counter.text(`${mediaIndex + 1}/${mes.extra.media.length}`);
2202
2203 const template = appendMediaAttachment(selectedMedia, mediaIndex);
2204 template.addClass('img_swipes');
2205 template.append(galleryControls);
2206 }
2207
2208 // Add media as a list to message
2209 if (hasMedia && mediaDisplay === MEDIA_DISPLAY.LIST) {
2210 for (let index = 0; index < mes.extra.media.length; index++) {
2211 const attachment = mes.extra.media[index];
2212 appendMediaAttachment(attachment, index);
2213 }
2214 }
2215
2216 // Remove existing file containers
2217 messageElement.find('.mes_file_wrapper').empty();
2218
2219 // Add files to message
2220 if (hasFiles) {
2221 for (let index = 0; index < mes.extra.files.length; index++) {
2222 const file = mes.extra.files[index];
2223 const template = $('#message_file_template .mes_file_container').clone();
2224 template.attr('data-index', index);
2225 template.find('.mes_file_name').text(file.name).attr('title', file.name);
2226 template.find('.mes_file_size').text(humanFileSize(file.size)).attr('title', file.size);
2227 messageElement.find('.mes_file_wrapper').append(template);
2228 }
2229 }
2230
2231 // TODO: Consider making this awaitable
2232 Promise.race([Promise.all(mediaPromises), delay(debounce_timeout.short)]).then(() => {
2233 messageElement.find('.mes_media_wrapper').empty().append(mediaBlocks);
2234 doAdjustScroll();
2235 });
1992}2236}
19932237
1994export function addCopyToCodeBlocks(messageElement) {2238export function addCopyToCodeBlocks(messageElement) {
@@ -2013,7 +2257,7 @@ export function addCopyToCodeBlocks(messageElement) {
20132257
2014/**2258/**
2015 * Adds a single message to the chat.2259 * Adds a single message to the chat.
2016 * @param {object} mes Message object2260 * @param {ChatMessage} mes Message object
2017 * @param {object} [options] Options2261 * @param {object} [options] Options
2018 * @param {string} [options.type='normal'] Message type2262 * @param {string} [options.type='normal'] Message type
2019 * @param {number} [options.insertAfter=null] Message ID to insert the new message after2263 * @param {number} [options.insertAfter=null] Message ID to insert the new message after
@@ -2065,8 +2309,8 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll
2065 avatarImg = mes['force_avatar'];2309 avatarImg = mes['force_avatar'];
2066 }2310 }
20672311
2068 // if mes.uses_system_ui is true, set an override on the sanitizer options2312 // if mes.extra.uses_system_ui is true, set an override on the sanitizer options
2069 const sanitizerOverrides = mes.uses_system_ui ? { MESSAGE_ALLOW_SYSTEM_UI: true } : {};2313 const sanitizerOverrides = mes.extra?.uses_system_ui ? { MESSAGE_ALLOW_SYSTEM_UI: true } : {};
20702314
2071 messageText = messageFormatting(2315 messageText = messageFormatting(
2072 messageText,2316 messageText,
@@ -2215,8 +2459,8 @@ export function formatCharacterAvatar(characterAvatar) {
22152459
2216/**2460/**
2217 * Formats the title for the generation timer.2461 * Formats the title for the generation timer.
2218 * @param {Date} gen_started Date when generation was started2462 * @param {MessageTimestamp} gen_started Date when generation was started
2219 * @param {Date} gen_finished Date when generation was finished2463 * @param {MessageTimestamp} gen_finished Date when generation was finished
2220 * @param {number} tokenCount Number of tokens generated (0 if not available)2464 * @param {number} tokenCount Number of tokens generated (0 if not available)
2221 * @param {number?} [reasoningDuration=null] Reasoning duration (null if no reasoning was done)2465 * @param {number?} [reasoningDuration=null] Reasoning duration (null if no reasoning was done)
2222 * @param {number?} [timeToFirstToken=null] Time to first token2466 * @param {number?} [timeToFirstToken=null] Time to first token
@@ -3659,7 +3903,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
3659 const prevStarted = chat[chat.length - 1]['gen_started'];3903 const prevStarted = chat[chat.length - 1]['gen_started'];
36603904
3661 if (prevFinished && prevStarted) {3905 if (prevFinished && prevStarted) {
3662 const timePassed = prevFinished - prevStarted;3906 const timePassed = Number(prevFinished) - Number(prevStarted);
3663 generation_started = new Date(Date.now() - timePassed);3907 generation_started = new Date(Date.now() - timePassed);
3664 chat[chat.length - 1]['gen_started'] = generation_started;3908 chat[chat.length - 1]['gen_started'] = generation_started;
3665 }3909 }
@@ -3739,7 +3983,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
3739 coreChat.pop();3983 coreChat.pop();
3740 }3984 }
37413985
3742 coreChat = await Promise.all(coreChat.map(async (chatItem, index) => {3986 coreChat = await Promise.all(coreChat.map(async (/** @type {ChatMessage} */ chatItem, index) => {
3743 let message = chatItem.mes;3987 let message = chatItem.mes;
3744 let regexType = chatItem.is_user ? regex_placement.USER_INPUT : regex_placement.AI_OUTPUT;3988 let regexType = chatItem.is_user ? regex_placement.USER_INPUT : regex_placement.AI_OUTPUT;
3745 let options = { isPrompt: true, depth: (coreChat.length - index - (isContinue ? 2 : 1)) };3989 let options = { isPrompt: true, depth: (coreChat.length - index - (isContinue ? 2 : 1)) };
@@ -3747,8 +3991,19 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
3747 let regexedMessage = getRegexedString(message, regexType, options);3991 let regexedMessage = getRegexedString(message, regexType, options);
3748 regexedMessage = await appendFileContent(chatItem, regexedMessage);3992 regexedMessage = await appendFileContent(chatItem, regexedMessage);
37493993
3994 const titles = [];
3750 if (chatItem?.extra?.append_title && chatItem?.extra?.title) {3995 if (chatItem?.extra?.append_title && chatItem?.extra?.title) {
3751 regexedMessage = `${regexedMessage}\n\n${chatItem.extra.title}`;3996 titles.push(chatItem.extra.title);
3997 }
3998 if (Array.isArray(chatItem?.extra?.media)) {
3999 for (const mediaItem of chatItem.extra.media) {
4000 if (mediaItem?.title && mediaItem?.append_title) {
4001 titles.push(mediaItem.title);
4002 }
4003 }
4004 }
4005 if (titles.length > 0) {
4006 regexedMessage = `${regexedMessage}\n\n${titles.join('\n\n')}`;
3752 }4007 }
37534008
3754 return {4009 return {
@@ -6097,7 +6352,7 @@ export function syncSwipeToMes(messageId = null, swipeId = null) {
6097/**6352/**
6098 * Saves the image to the message object.6353 * Saves the image to the message object.
6099 * @param {ParsedImage} img Image object6354 * @param {ParsedImage} img Image object
6100 * @param {object} mes Chat message object6355 * @param {ChatMessage} mes Chat message object
6101 * @typedef {{ image?: string, title?: string, inline?: boolean }} ParsedImage6356 * @typedef {{ image?: string, title?: string, inline?: boolean }} ParsedImage
6102 */6357 */
6103function saveImageToMessage(img, mes) {6358function saveImageToMessage(img, mes) {
@@ -6105,8 +6360,10 @@ function saveImageToMessage(img, mes) {
6105 if (!mes.extra || typeof mes.extra !== 'object') {6360 if (!mes.extra || typeof mes.extra !== 'object') {
6106 mes.extra = {};6361 mes.extra = {};
6107 }6362 }
6108 mes.extra.image = img.image;6363 if (!Array.isArray(mes.extra.media)) {
6109 mes.extra.title = img.title;6364 mes.extra.media = [];
6365 }
6366 mes.extra.media.push({ url: img.image, type: MEDIA_TYPE.IMAGE, title: img.title });
6110 mes.extra.inline_image = img.inline;6367 mes.extra.inline_image = img.inline;
6111 }6368 }
6112}6369}
@@ -6732,6 +6989,7 @@ export async function getChat() {
6732 chat_metadata = chat[0]['chat_metadata'] ?? {};6989 chat_metadata = chat[0]['chat_metadata'] ?? {};
67336990
6734 chat.shift();6991 chat.shift();
6992 chat.forEach(ensureMessageMediaIsArray);
6735 } else {6993 } else {
6736 chat_create_date = humanizedDateTime();6994 chat_create_date = humanizedDateTime();
6737 }6995 }
@@ -8961,17 +9219,17 @@ export async function swipe(_event, direction, { source, repeated, message = cha
8961 //Update the swipe_id.9219 //Update the swipe_id.
8962 chat[mesId]['swipe_id'] = newSwipeId;9220 chat[mesId]['swipe_id'] = newSwipeId;
89639221
8964 if (chat[mesId].extra) {9222 if (chat[mesId].extra && typeof chat[mesId].extra === 'object') {
8965 // if message has memory attached - remove it to allow regen
8966 delete chat[mesId].extra.memory;9223 delete chat[mesId].extra.memory;
8967
8968 // ditto for display text
8969 delete chat[mesId].extra.display_text;9224 delete chat[mesId].extra.display_text;
89709225 delete chat[mesId].extra.media;
8971 delete chat[mesId].extra.image;
8972 delete chat[mesId].extra.image_swipes;
8973 delete chat[mesId].extra.video;
8974 delete chat[mesId].extra.inline_image;9226 delete chat[mesId].extra.inline_image;
9227 delete chat[mesId].extra.files;
9228 delete chat[mesId].extra.fileLength;
9229 delete chat[mesId].extra.generationType;
9230 delete chat[mesId].extra.negative;
9231 delete chat[mesId].extra.title;
9232 delete chat[mesId].extra.append_title;
8975 }9233 }
8976 delete chat[mesId].gen_started;9234 delete chat[mesId].gen_started;
8977 delete chat[mesId].gen_finished;9235 delete chat[mesId].gen_finished;
@@ -8979,7 +9237,6 @@ export async function swipe(_event, direction, { source, repeated, message = cha
8979 syncSwipeToMes(mesId, chat[mesId]['swipe_id']);9237 syncSwipeToMes(mesId, chat[mesId]['swipe_id']);
8980 }9238 }
89819239
8982 //Deepseek-V3.1
8983 // Helper function to convert transition to promise9240 // Helper function to convert transition to promise
8984 const transitionPromise = (element, properties) => {9241 const transitionPromise = (element, properties) => {
8985 return new Promise((resolve) => {9242 return new Promise((resolve) => {
@@ -9101,7 +9358,7 @@ export async function swipe(_event, direction, { source, repeated, message = cha
9101 if (run_generate && !is_send_press) {9358 if (run_generate && !is_send_press) {
9102 is_send_press = true;9359 is_send_press = true;
9103 generation = Generate('swipe');9360 generation = Generate('swipe');
9104 } else if (parseInt(chat[mesId]['swipe_id']) !== chat[mesId]['swipes'].length) {9361 } else if (Number(chat[mesId]['swipe_id']) !== chat[mesId]['swipes'].length) {
9105 saveChatDebounced();9362 saveChatDebounced();
9106 }9363 }
91079364
@@ -10745,7 +11002,7 @@ jQuery(async function () {
10745 const oldScroll = chatElement[0].scrollTop;11002 const oldScroll = chatElement[0].scrollTop;
10746 const clone = structuredClone(chat[this_edit_mes_id]);11003 const clone = structuredClone(chat[this_edit_mes_id]);
10747 clone.send_date = Date.now();11004 clone.send_date = Date.now();
10748 clone.mes = $(this).closest('.mes').find('.edit_textarea').val();11005 clone.mes = $(this).closest('.mes').find('.edit_textarea').val().toString();
1074911006
10750 if (power_user.trim_spaces) {11007 if (power_user.trim_spaces) {
10751 clone.mes = clone.mes.trim();11008 clone.mes = clone.mes.trim();
public/scripts/bookmarks.js+1 -0
@@ -344,6 +344,7 @@ export async function convertSoloToGroupChat() {
344344
345 // Save group-chat marker345 // Save group-chat marker
346 if (index == 0) {346 if (index == 0) {
347 // @ts-ignore
347 message.is_group = true;348 message.is_group = true;
348 }349 }
349350
public/scripts/chats.js+407 -163
@@ -26,6 +26,9 @@ import {
26 printMessages,26 printMessages,
27 clearChat,27 clearChat,
28 refreshSwipeButtons,28 refreshSwipeButtons,
29 getMediaIndex,
30 getMediaDisplay,
31 chatElement,
29} from '../script.js';32} from '../script.js';
30import { selected_group } from './group-chats.js';33import { selected_group } from './group-chats.js';
31import { power_user } from './power-user.js';34import { power_user } from './power-user.js';
@@ -43,6 +46,8 @@ import {
43 getFileText,46 getFileText,
44 getFileExtension,47 getFileExtension,
45 convertTextToBase64,48 convertTextToBase64,
49 isSameFile,
50 clamp,
46} from './utils.js';51} from './utils.js';
47import { extension_settings, renderExtensionTemplateAsync, saveMetadataDebounced } from './extensions.js';52import { extension_settings, renderExtensionTemplateAsync, saveMetadataDebounced } from './extensions.js';
48import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';53import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
@@ -52,6 +57,7 @@ import { renderTemplateAsync } from './templates.js';
52import { t } from './i18n.js';57import { t } from './i18n.js';
53import { humanizedDateTime } from './RossAscends-mods.js';58import { humanizedDateTime } from './RossAscends-mods.js';
54import { accountStorage } from './util/AccountStorage.js';59import { accountStorage } from './util/AccountStorage.js';
60import { MEDIA_DISPLAY, MEDIA_TYPE, SWIPE_DIRECTION } from './constants.js';
5561
56/**62/**
57 * @typedef {Object} FileAttachment63 * @typedef {Object} FileAttachment
@@ -187,62 +193,64 @@ export async function unhideChatMessage(messageId, _messageBlock) {
187193
188/**194/**
189 * Adds a file attachment to the message.195 * Adds a file attachment to the message.
190 * @param {object} message Message object196 * @param {ChatMessage} message Message object
191 * @returns {Promise<void>} A promise that resolves when file is uploaded.197 * @returns {Promise<void>} A promise that resolves when file is uploaded.
192 */198 */
193export async function populateFileAttachment(message, inputId = 'file_form_input') {199export async function populateFileAttachment(message, inputId = 'file_form_input') {
194 try {200 try {
195 if (!message) return;201 if (!message) return;
196 if (!message.extra) message.extra = {};202 if (!message.extra || typeof message.extra !== 'object') message.extra = {};
197 const fileInput = document.getElementById(inputId);203 const fileInput = document.getElementById(inputId);
198 if (!(fileInput instanceof HTMLInputElement)) return;204 if (!(fileInput instanceof HTMLInputElement)) return;
199 const file = fileInput.files[0];205
200 if (!file) return;206 for (const file of fileInput.files) {
201207 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
207213 const mediaType = MEDIA_TYPE.getFromMime(file.type);
208 // If file is image214 if (mediaType) {
209 if (file.type.startsWith('image/')) {215 const imageUrl = await saveBase64AsFile(base64Data, name2, fileNamePrefix, extension);
210 const imageUrl = await saveBase64AsFile(base64Data, name2, fileNamePrefix, extension);216 if (!Array.isArray(message.extra.media)) {
211 message.extra.image = imageUrl;217 message.extra.media = [];
212 message.extra.inline_image = true;218 }
213 }219 message.extra.media.push({ url: imageUrl, type: mediaType });
214 // If file is video220 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)) {
220226 try {
221 if (isConvertible(file.type)) {227 const converter = getConverter(file.type);
222 try {228 const fileText = await converter(file);
223 const converter = getConverter(file.type);229 base64Data = convertTextToBase64(fileText);
224 const fileText = await converter(file);230 } catch (error) {
225 base64Data = convertTextToBase64(fileText);231 toastr.error(String(error), t`Could not convert file`);
226 } catch (error) {232 console.error('Could not convert file', error);
227 toastr.error(String(error), t`Could not convert file`);233 }
228 console.error('Could not convert file', error);
229 }234 }
230 }
231235
232 const fileUrl = await uploadFileAttachment(uniqueFileName, base64Data);236 const fileUrl = await uploadFileAttachment(uniqueFileName, base64Data);
233237
234 if (!fileUrl) {238 if (!fileUrl) {
235 return;239 continue;
236 }240 }
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 }
246 } catch (error) {254 } catch (error) {
247 console.error('Could not upload file', error);255 console.error('Could not upload file', error);
248 toastr.error(t`Either the file is corrupted or its format is not supported.`, t`Could not upload the file`);256 toastr.error(t`Either the file is corrupted or its format is not supported.`, t`Could not upload the file`);
@@ -314,16 +322,16 @@ export async function getFileAttachment(url) {
314 */322 */
315async function validateFile(file) {323async function validateFile(file) {
316 const fileText = await file.text();324 const fileText = await file.text();
317 const isImage = file.type.startsWith('image/');325 const isMedia = file.type.startsWith('image/') || file.type.startsWith('video/');
318 const isBinary = /^[\x00-\x08\x0E-\x1F\x7F-\xFF]*$/.test(fileText);326 const isBinary = /^[\x00-\x08\x0E-\x1F\x7F-\xFF]*$/.test(fileText);
319327
320 if (!isImage && file.size > fileSizeLimit) {328 if (!isMedia && file.size > fileSizeLimit) {
321 toastr.error(t`File is too big. Maximum size is ${humanFileSize(fileSizeLimit)}.`);329 toastr.error(t`File is too big. Maximum size is ${humanFileSize(fileSizeLimit)}.`);
322 return false;330 return false;
323 }331 }
324332
325 // If file is binary333 // If file is binary
326 if (isBinary && !isImage && !isConvertible(file.type)) {334 if (isBinary && !isMedia && !isConvertible(file.type)) {
327 toastr.error(t`Binary files are not supported. Select a text file or image.`);335 toastr.error(t`Binary files are not supported. Select a text file or image.`);
328 return false;336 return false;
329 }337 }
@@ -340,22 +348,28 @@ export function hasPendingFileAttachment() {
340348
341/**349/**
342 * Displays file information in the message sending form.350 * Displays file information in the message sending form.
343 * @param {File} file File object351 * @param {FileList} fileList File object
344 * @returns {Promise<void>}352 * @returns {Promise<void>}
345 */353 */
346async function onFileAttach(file) {354async function onFileAttach(fileList) {
347 if (!file) return;355 if (!fileList || fileList.length === 0) return;
348356
349 const isValid = await validateFile(file);357 for (const file of fileList) {
358 const isValid = await validateFile(file);
350359
351 // If file is binary360 // If file is binary
352 if (!isValid) {361 if (!isValid) {
353 $('#file_form').trigger('reset');362 toastr.warning(t`File ${file.name} is not supported.`);
354 return;363 $('#file_form').trigger('reset');
364 return;
365 }
355 }366 }
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);
359 $('#file_form').removeClass('displayNone');373 $('#file_form').removeClass('displayNone');
360374
361 // Reset form on chat change (if not on a welcome screen)375 // Reset form on chat change (if not on a welcome screen)
@@ -368,10 +382,17 @@ async function onFileAttach(file) {
368}382}
369383
370/**384/**
371 * Deletes file from message.385 * Deletes file from a message.
386 * @param {JQuery<HTMLElement>} messageBlock Message block element
372 * @param {number} messageId Message ID387 * @param {number} messageId Message ID
388 * @param {number} fileIndex File index
373 */389 */
374async function deleteMessageFile(messageId) {390async function deleteMessageFile(messageBlock, messageId, fileIndex) {
391 if (isNaN(messageId) || isNaN(fileIndex)) {
392 console.warn('Invalid message ID or file index');
393 return;
394 }
395
375 const confirm = await callGenericPopup('Are you sure you want to delete this file?', POPUP_TYPE.CONFIRM);396 const confirm = await callGenericPopup('Are you sure you want to delete this file?', POPUP_TYPE.CONFIRM);
376397
377 if (confirm !== POPUP_RESULT.AFFIRMATIVE) {398 if (confirm !== POPUP_RESULT.AFFIRMATIVE) {
@@ -381,26 +402,49 @@ async function deleteMessageFile(messageId) {
381402
382 const message = chat[messageId];403 const message = chat[messageId];
383404
384 if (!message?.extra?.file) {405 if (!Array.isArray(message?.extra?.files)) {
385 console.debug('Message has no file');406 console.debug('Message has no files');
386 return;407 return;
387 }408 }
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();
393 await saveChatConditional();418 await saveChatConditional();
394 await deleteFileFromServer(url);419 await deleteFileFromServer(url);
395}
396420
421 appendMediaToMessage(message, messageBlock, false);
422}
397423
398/**424/**
399 * Opens file from message in a modal.425 * Opens file from message in a modal.
400 * @param {number} messageId Message ID426 * @param {number} messageId Message ID
427 * @param {number} fileIndex File index
401 */428 */
402async function viewMessageFile(messageId) {429async 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
405 if (!messageFile) {449 if (!messageFile) {
406 console.debug('Message has no file or it is empty');450 console.debug('Message has no file or it is empty');
@@ -429,39 +473,51 @@ function embedMessageFile(messageId, messageBlock) {
429 .on('change', parseAndUploadEmbed)473 .on('change', parseAndUploadEmbed)
430 .trigger('click');474 .trigger('click');
431475
432 async function parseAndUploadEmbed(e) {476 async function parseAndUploadEmbed(/** @type {JQuery.ChangeEvent} */ e) {
433 const file = e.target.files[0];477 if (!(e.target instanceof HTMLInputElement)) return;
434 if (!file) return;478 if (!e.target.files.length) return;
435479
436 const isValid = await validateFile(file);480 for (const file of e.target.files) {
481 const isValid = await validateFile(file);
437482
438 if (!isValid) {483 if (!isValid) {
439 $('#file_form').trigger('reset');484 toastr.warning(t`File ${file.name} is not supported.`);
440 return;485 $('#file_form').trigger('reset');
486 return;
487 }
441 }488 }
442489
443 await populateFileAttachment(message, 'embed_file_input');490 await populateFileAttachment(message, 'embed_file_input');
444 await eventSource.emit(event_types.MESSAGE_FILE_EMBEDDED, messageId);491 await eventSource.emit(event_types.MESSAGE_FILE_EMBEDDED, messageId);
445 appendMediaToMessage(message, messageBlock);492 appendMediaToMessage(message, messageBlock, false);
446 await saveChatConditional();493 await saveChatConditional();
447 }494 }
448}495}
449496
450/**497/**
451 * Appends file content to the message text.498 * Appends file content to the message text.
452 * @param {object} message Message object499 * @param {ChatMessage} message Message object
453 * @param {string} messageText Message text500 * @param {string} messageText Message text
454 * @returns {Promise<string>} Message text with file content appended.501 * @returns {Promise<string>} Message text with file content appended.
455 */502 */
456export async function appendFileContent(message, messageText) {503export 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;
459506 }
460 if (fileText) {507 if (message.extra.fileLength >= 0) {
461 const fileWrapped = `${fileText}\n\n`;508 delete message.extra.fileLength;
462 message.extra.fileLength = fileWrapped.length;509 }
463 messageText = fileWrapped + messageText;510 if (Array.isArray(message.extra?.files) && message.extra.files.length > 0) {
511 const fileTexts = [];
512 for (const file of message.extra.files) {
513 const fileText = file.text || (await getFileAttachment(file.url));
514 if (fileText) {
515 fileTexts.push(fileText);
516 }
464 }517 }
518 const mergedFileTexts = fileTexts.join('\n\n') + '\n\n';
519 message.extra.fileLength = mergedFileTexts.length;
520 return mergedFileTexts + messageText;
465 }521 }
466 return messageText;522 return messageText;
467}523}
@@ -807,62 +863,119 @@ export function isExternalMediaAllowed() {
807 return !power_user.forbid_external_media;863 return !power_user.forbid_external_media;
808}864}
809865
810function 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 */
872function 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');
818 return;883 return;
819 }884 }
820885
821 const img = document.createElement('img');886 const mediaAttachment = message.extra.media[mediaIndex];
822 img.classList.add('img_enlarged');887 const title = mediaAttachment.title || message.extra.title || '';
823 img.src = imgSrc;
824 const imgHolder = document.createElement('div');
825 imgHolder.classList.add('img_enlarged_holder');
826 imgHolder.append(img);
827 const imgContainer = $('<div><pre><code class="img_enlarged_title"></code></pre></div>');
828 imgContainer.prepend(imgHolder);
829 imgContainer.addClass('img_enlarged_container');
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);
848 event.stopPropagation();936 event.stopPropagation();
849 });937 });
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', () => {
852 popup.completeCancelled();957 popup.completeCancelled();
853 });958 });
854959
855 popup.show();960 popup.show();
856 return img;961 return mediaElement;
857}962}
858963
859function 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 */
970async function deleteMessageMedia(messageId, mediaIndex, messageBlock) {
971 if (isNaN(messageId) || isNaN(mediaIndex)) {
972 console.warn('Invalid message ID or media index');
973 return;
974 }
862975
863async function deleteMessageImage() {976 const value = await Popup.show.confirm(t`Delete media from message?`, t`This action can't be undone.`, {
864 const value = await callGenericPopup('<h3>Delete image from message?<br>This action can\'t be undone.</h3>', POPUP_TYPE.TEXT, '', {
865 okButton: t`Delete one`,977 okButton: t`Delete one`,
978 cancelButton: false,
866 customButtons: [979 customButtons: [
867 {980 {
868 text: t`Delete all`,981 text: t`Delete all`,
@@ -881,58 +994,64 @@ async function deleteMessageImage() {
881 return;994 return;
882 }995 }
883996
884 const mesBlock = $(this).closest('.mes');997 /** @type {ChatMessage} */
885 const mesId = mesBlock.attr('mesid');998 const message = chat[messageId];
886 const message = chat[mesId];
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);
900 }1015 }
9011016
902 if (isLastImage || value === POPUP_RESULT.CUSTOM1) {1017 if (value === POPUP_RESULT.CUSTOM1) {
903 delete message.extra.image;1018 delete message.extra.media;
904 delete message.extra.inline_image;1019 delete message.extra.inline_image;
905 delete message.extra.title;1020 delete message.extra.title;
906 delete message.extra.append_title;1021 delete message.extra.append_title;
907 delete message.extra.image_swipes;
908 mesBlock.find('.mes_img_container').removeClass('img_extra');
909 mesBlock.find('.mes_img').attr('src', '');
910 } else {
911 appendMediaToMessage(message, mesBlock);
912 }1022 }
9131023
914 await saveChatConditional();1024 await saveChatConditional();
1025 appendMediaToMessage(message, messageBlock, false);
915}1026}
9161027
917async 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 */
1034async function switchMessageMediaDisplay(messageId, messageBlock, targetDisplay) {
1035 if (isNaN(messageId)) {
1036 console.warn('Invalid message ID');
920 return;1037 return;
921 }1038 }
9221039
923 const mesBlock = $(this).closest('.mes');1040 /** @type {ChatMessage} */
924 const mesId = mesBlock.attr('mesid');1041 const message = chat[messageId];
925 const message = chat[mesId];
9261042
927 if (!message?.extra?.video) {1043 if (!message) {
928 console.warn('Message has no video or it is empty');1044 console.warn('Message not found for ID', messageId);
929 return;1045 return;
930 }1046 }
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;
935 await saveChatConditional();1053 await saveChatConditional();
1054 appendMediaToMessage(message, messageBlock, false);
936}1055}
9371056
938/**1057/**
@@ -1865,6 +1984,68 @@ export function addDOMPurifyHooks() {
1865 });1984 });
1866}1985}
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 */
1994async 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
1868export function initChatUtilities() {2049export function initChatUtilities() {
1869 $(document).on('click', '.mes_hide', async function () {2050 $(document).on('click', '.mes_hide', async function () {
1870 const messageBlock = $(this).closest('.mes');2051 const messageBlock = $(this).closest('.mes');
@@ -1881,13 +2062,17 @@ export function initChatUtilities() {
1881 $(document).on('click', '.mes_file_delete', async function () {2062 $(document).on('click', '.mes_file_delete', async function () {
1882 const messageBlock = $(this).closest('.mes');2063 const messageBlock = $(this).closest('.mes');
1883 const messageId = Number(messageBlock.attr('mesid'));2064 const messageId = Number(messageBlock.attr('mesid'));
1884 await deleteMessageFile(messageId);2065 const fileBlock = $(this).closest('.mes_file_container');
2066 const fileIndex = Number(fileBlock.attr('data-index'));
2067 await deleteMessageFile(messageBlock, messageId, fileIndex);
1885 });2068 });
18862069
1887 $(document).on('click', '.mes_file_open', async function () {2070 $(document).on('click', '.mes_file_open', async function () {
1888 const messageBlock = $(this).closest('.mes');2071 const messageBlock = $(this).closest('.mes');
1889 const messageId = Number(messageBlock.attr('mesid'));2072 const messageId = Number(messageBlock.attr('mesid'));
1890 await viewMessageFile(messageId);2073 const fileBlock = $(this).closest('.mes_file_container');
2074 const fileIndex = Number(fileBlock.attr('data-index'));
2075 await viewMessageFile(messageId, fileIndex);
1891 });2076 });
18922077
1893 $(document).on('click', '.assistant_note_export', async function () {2078 $(document).on('click', '.assistant_note_export', async function () {
@@ -2052,16 +2237,55 @@ export function initChatUtilities() {
2052 openGlobalStylesPreferenceDialog();2237 openGlobalStylesPreferenceDialog();
2053 });2238 });
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
2060 $('#file_form_input').on('change', async () => {2285 $('#file_form_input').on('change', async () => {
2061 const fileInput = document.getElementById('file_form_input');2286 const fileInput = document.getElementById('file_form_input');
2062 if (!(fileInput instanceof HTMLInputElement)) return;2287 if (!(fileInput instanceof HTMLInputElement)) return;
2063 const file = fileInput.files[0];2288 await onFileAttach(fileInput.files);
2064 await onFileAttach(file);
2065 });2289 });
2066 $('#file_form').on('reset', function () {2290 $('#file_form').on('reset', function () {
2067 $('#file_form').addClass('displayNone');2291 $('#file_form').addClass('displayNone');
@@ -2075,18 +2299,38 @@ export function initChatUtilities() {
2075 event.preventDefault();2299 event.preventDefault();
2076 event.stopPropagation();2300 event.stopPropagation();
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) {
2078 const fileInput = document.getElementById('file_form_input');2315 const fileInput = document.getElementById('file_form_input');
2079 if (!(fileInput instanceof HTMLInputElement)) return;2316 if (!(fileInput instanceof HTMLInputElement)) return;
20802317
2081 // Workaround for Firefox: Use a DataTransfer object to indirectly set fileInput.files2318 // Workaround for Firefox: Use a DataTransfer object to indirectly set fileInput.files
2082 const dataTransfer = new DataTransfer();2319 const dataTransfer = new DataTransfer();
2083 for (let i = 0; i < event.clipboardData.files.length; i++) {2320 for (let i = 0; i < files.length; i++) {
2084 dataTransfer.items.add(event.clipboardData.files[i]);2321 dataTransfer.items.add(files[i]);
2322 }
2323
2324 // Preserve existing non-duplicate files in the input
2325 for (const file of fileInput.files) {
2326 if (!Array.from(dataTransfer.files).some(f => isSameFile(f, file))) {
2327 dataTransfer.items.add(file);
2328 }
2085 }2329 }
20862330
2087 fileInput.files = dataTransfer.files;2331 fileInput.files = dataTransfer.files;
2088 await onFileAttach(fileInput.files[0]);2332 await onFileAttach(fileInput.files);
2089 });2333 }
20902334
2091 eventSource.on(event_types.CHAT_CHANGED, checkForCreatorNotesStyles);2335 eventSource.on(event_types.CHAT_CHANGED, checkForCreatorNotesStyles);
2092}2336}
public/scripts/constants.js+26 -0
@@ -68,6 +68,32 @@ export const COMETAPI_IGNORE_PATTERNS = [
68];68];
6969
70/**70/**
71 * @enum {string}
72 * @readonly
73 */
74export const MEDIA_DISPLAY = {
75 LIST: 'list',
76 GALLERY: 'gallery',
77};
78
79/**
80 * @readonly
81 */
82export const MEDIA_TYPE = {
83 getFromMime: (/** @type {string} */ mimeType) => {
84 if (mimeType.startsWith('image/')) {
85 return MEDIA_TYPE.IMAGE;
86 }
87 if (mimeType.startsWith('video/')) {
88 return MEDIA_TYPE.VIDEO;
89 }
90 return null;
91 },
92 IMAGE: 'image',
93 VIDEO: 'video',
94};
95
96/**
71 * @type {{readonly LEFT: 'left', readonly RIGHT: 'right'}}97 * @type {{readonly LEFT: 'left', readonly RIGHT: 'right'}}
72 */98 */
73export const SWIPE_DIRECTION = {99export const SWIPE_DIRECTION = {
public/scripts/extensions/caption/index.js+65 -25
@@ -10,6 +10,7 @@ import { SlashCommand } from '../../slash-commands/SlashCommand.js';
10import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';10import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
11import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';11import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
12import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js';12import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js';
13import { MEDIA_DISPLAY, MEDIA_TYPE } from '../../constants.js';
13export { MODULE_NAME };14export { MODULE_NAME };
1415
15const MODULE_NAME = 'caption';16const MODULE_NAME = 'caption';
@@ -119,15 +120,26 @@ async function wrapCaptionTemplate(caption) {
119120
120/**121/**
121 * Appends caption to an existing message.122 * Appends caption to an existing message.
122 * @param {Object} data Message data123 * @param {ChatMessage} message Message data
124 * @param {number} mediaIndex Index of the image to caption
123 * @returns {Promise<void>}125 * @returns {Promise<void>}
124 */126 */
125async function captionExistingMessage(data) {127async function captionExistingMessage(message, mediaIndex) {
126 if (!(data?.extra?.image)) {128 if (!Array.isArray(message?.extra?.media) || message.extra.media.length === 0) {
127 return;129 return;
128 }130 }
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);
131 const blob = await imageData.blob();143 const blob = await imageData.blob();
132 const type = imageData.headers.get('Content-Type');144 const type = imageData.headers.get('Content-Type');
133 const file = new File([blob], 'image.png', { type });145 const file = new File([blob], 'image.png', { type });
@@ -140,17 +152,17 @@ async function captionExistingMessage(data) {
140152
141 const wrappedCaption = await wrapCaptionTemplate(caption);153 const wrappedCaption = await wrapCaptionTemplate(caption);
142154
143 const messageText = String(data.mes).trim();155 const messageText = String(message.mes).trim();
144156
145 if (!messageText) {157 if (!messageText) {
146 data.extra.inline_image = false;158 message.extra.inline_image = false;
147 data.mes = wrappedCaption;159 message.mes = wrappedCaption;
148 data.extra.title = wrappedCaption;160 mediaAttachment.title = wrappedCaption;
149 }161 }
150 else {162 else {
151 data.extra.inline_image = true;163 message.extra.inline_image = true;
152 data.extra.append_title = true;164 mediaAttachment.append_title = true;
153 data.extra.title = wrappedCaption;165 mediaAttachment.title = wrappedCaption;
154 }166 }
155}167}
156168
@@ -163,14 +175,23 @@ async function sendCaptionedMessage(caption, image) {
163 const messageText = await wrapCaptionTemplate(caption);175 const messageText = await wrapCaptionTemplate(caption);
164176
165 const context = getContext();177 const context = getContext();
178
179 /** @type {MediaAttachment} */
180 const mediaAttachment = {
181 url: image,
182 type: MEDIA_TYPE.IMAGE,
183 title: messageText,
184 };
185 /** @type {ChatMessage} */
166 const message = {186 const message = {
167 name: context.name1,187 name: context.name1,
168 is_user: true,188 is_user: true,
169 send_date: getMessageTimeStamp(),189 send_date: getMessageTimeStamp(),
170 mes: messageText,190 mes: messageText,
171 extra: {191 extra: {
172 image: image,192 media: [mediaAttachment],
173 title: messageText,193 media_display: MEDIA_DISPLAY.GALLERY,
194 media_index: 0,
174 inline_image: !!extension_settings.caption.show_in_chat,195 inline_image: !!extension_settings.caption.show_in_chat,
175 },196 },
176 };197 };
@@ -365,13 +386,24 @@ function onRefineModeInput() {
365 */386 */
366async function captionCommandCallback(args, prompt) {387async function captionCommandCallback(args, prompt) {
367 const quiet = isTrueBoolean(args?.quiet);388 const quiet = isTrueBoolean(args?.quiet);
368 const mesId = args?.mesId ?? args?.id;389 const messageId = args?.mesId ?? args?.id;
390 const index = Number(args?.index ?? 0);
369391
370 if (!isNaN(Number(mesId))) {392 if (!isNaN(Number(messageId))) {
371 const message = getContext().chat[mesId];393 /** @type {ChatMessage} */
372 if (message?.extra?.image) {394 const message = getContext().chat[messageId];
395 if (Array.isArray(message?.extra?.media) && message.extra.media.length > 0) {
373 try {396 try {
374 const fetchResult = await fetch(message.extra.image);397 const mediaAttachment = message.extra.media[index] || message.extra.media[0];
398 if (!mediaAttachment || !mediaAttachment.url) {
399 toastr.error('The specified message does not contain an image.');
400 return '';
401 }
402 if (mediaAttachment.type === MEDIA_TYPE.VIDEO) {
403 toastr.error('The specified media is a video. Captioning videos is not supported.');
404 return '';
405 }
406 const fetchResult = await fetch(mediaAttachment.url);
375 const blob = await fetchResult.blob();407 const blob = await fetchResult.blob();
376 const file = new File([blob], 'image.jpg', { type: blob.type });408 const file = new File([blob], 'image.jpg', { type: blob.type });
377 return await getCaptionForFile(file, prompt, quiet);409 return await getCaptionForFile(file, prompt, quiet);
@@ -636,13 +668,13 @@ jQuery(async function () {
636 saveSettingsDebounced();668 saveSettingsDebounced();
637 });669 });
638670
639 const onMessageEvent = async (index) => {671 const onMessageEvent = async (messageId) => {
640 if (!extension_settings.caption.auto_mode) {672 if (!extension_settings.caption.auto_mode) {
641 return;673 return;
642 }674 }
643675
644 const data = getContext().chat[index];676 const message = getContext().chat[messageId];
645 await captionExistingMessage(data);677 await captionExistingMessage(message, 0);
646 };678 };
647679
648 eventSource.on(event_types.MESSAGE_SENT, onMessageEvent);680 eventSource.on(event_types.MESSAGE_SENT, onMessageEvent);
@@ -651,13 +683,15 @@ jQuery(async function () {
651 $(document).on('click', '.mes_img_caption', async function () {683 $(document).on('click', '.mes_img_caption', async function () {
652 const animationClass = 'fa-fade';684 const animationClass = 'fa-fade';
653 const messageBlock = $(this).closest('.mes');685 const messageBlock = $(this).closest('.mes');
654 const messageImg = messageBlock.find('.mes_img');686 const imageBlock = $(this).closest('.mes_img_container');
687 const messageImg = imageBlock.find('.mes_img');
655 if (messageImg.hasClass(animationClass)) return;688 if (messageImg.hasClass(animationClass)) return;
656 messageImg.addClass(animationClass);689 messageImg.addClass(animationClass);
657 try {690 try {
658 const index = Number(messageBlock.attr('mesid'));691 const messageId = Number(messageBlock.attr('mesid'));
659 const data = getContext().chat[index];692 const imageIndex = Number(imageBlock.attr('data-index'));
660 await captionExistingMessage(data);693 const data = getContext().chat[messageId];
694 await captionExistingMessage(data, imageIndex);
661 appendMediaToMessage(data, messageBlock, false);695 appendMediaToMessage(data, messageBlock, false);
662 await saveChatConditional();696 await saveChatConditional();
663 } catch (e) {697 } catch (e) {
@@ -681,6 +715,12 @@ jQuery(async function () {
681 typeList: [ARGUMENT_TYPE.NUMBER],715 typeList: [ARGUMENT_TYPE.NUMBER],
682 enumProvider: commonEnumProviders.messages(),716 enumProvider: commonEnumProviders.messages(),
683 }),717 }),
718 SlashCommandNamedArgument.fromProps({
719 name: 'index',
720 description: 'index of the image in the message to caption (starting from 0)',
721 typeList: [ARGUMENT_TYPE.NUMBER],
722 enumProvider: commonEnumProviders.messageMedia(),
723 }),
684 ],724 ],
685 unnamedArgumentList: [725 unnamedArgumentList: [
686 new SlashCommandArgument(726 new SlashCommandArgument(
public/scripts/extensions/stable-diffusion/index.js+115 -167
@@ -52,7 +52,7 @@ import {
52 SlashCommandArgument,52 SlashCommandArgument,
53 SlashCommandNamedArgument,53 SlashCommandNamedArgument,
54} from '../../slash-commands/SlashCommandArgument.js';54} from '../../slash-commands/SlashCommandArgument.js';
55import { debounce_timeout, VIDEO_EXTENSIONS } from '../../constants.js';55import { debounce_timeout, MEDIA_DISPLAY, MEDIA_TYPE, VIDEO_EXTENSIONS } from '../../constants.js';
56import { SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';56import { SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';
57import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js';57import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js';
58import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';58import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
@@ -468,6 +468,12 @@ async function loadSettings() {
468 extension_settings.sd.styles = defaultStyles;468 extension_settings.sd.styles = defaultStyles;
469 }469 }
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
471 $('#sd_source').val(extension_settings.sd.source);477 $('#sd_source').val(extension_settings.sd.source);
472 $('#sd_scale').val(extension_settings.sd.scale).trigger('input');478 $('#sd_scale').val(extension_settings.sd.scale).trigger('input');
473 $('#sd_steps').val(extension_settings.sd.steps).trigger('input');479 $('#sd_steps').val(extension_settings.sd.steps).trigger('input');
@@ -4065,6 +4071,16 @@ async function sendMessage(prompt, image, generationType, additionalNegativePref
4065 const name = context.groupId ? systemUserName : context.name2;4071 const name = context.groupId ? systemUserName : context.name2;
4066 const template = extension_settings.sd.prompts[generationMode.MESSAGE] || '{{prompt}}';4072 const template = extension_settings.sd.prompts[generationMode.MESSAGE] || '{{prompt}}';
4067 const messageText = substituteParamsExtended(template, { char: name, prompt: prompt, prefixedPrompt: prefixedPrompt });4073 const messageText = substituteParamsExtended(template, { char: name, prompt: prompt, prefixedPrompt: prefixedPrompt });
4074 const mediaType = isVideo(format) ? MEDIA_TYPE.VIDEO : MEDIA_TYPE.IMAGE;
4075 /** @type {MediaAttachment} */
4076 const mediaAttachment = {
4077 url: image,
4078 type: mediaType,
4079 title: prompt,
4080 generation_type: generationType,
4081 negative: additionalNegativePrefix,
4082 };
4083 /** @type {ChatMessage} */
4068 const message = {4084 const message = {
4069 name: name,4085 name: name,
4070 is_user: false,4086 is_user: false,
@@ -4072,20 +4088,12 @@ async function sendMessage(prompt, image, generationType, additionalNegativePref
4072 send_date: getMessageTimeStamp(),4088 send_date: getMessageTimeStamp(),
4073 mes: messageText,4089 mes: messageText,
4074 extra: {4090 extra: {
4075 image: image,4091 media: [mediaAttachment],
4076 title: prompt,4092 media_display: MEDIA_DISPLAY.GALLERY,
4077 generationType: generationType,4093 media_index: 0,
4078 negative: additionalNegativePrefix,
4079 inline_image: false,4094 inline_image: false,
4080 image_swipes: [image],
4081 },4095 },
4082 };4096 };
4083 if (isVideo(format)) {
4084 message.extra.video = image;
4085 delete message.extra.image;
4086 delete message.extra.image_swipes;
4087 delete message.extra.inline_image;
4088 }
4089 context.chat.push(message);4097 context.chat.push(message);
4090 const messageId = context.chat.length - 1;4098 const messageId = context.chat.length - 1;
4091 await eventSource.emit(event_types.MESSAGE_RECEIVED, messageId, 'extension');4099 await eventSource.emit(event_types.MESSAGE_RECEIVED, messageId, 'extension');
@@ -4215,98 +4223,76 @@ function isValidState() {
42154223
4216let buttonAbortController = null;4224let 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 */
4218async function sdMessageButton(e) {4231async function sdMessageButton(e) {
4232 /**
4233 * Sets the icon to indicate busy or idle state.
4234 * @param {boolean} isBusy Whether the icon should indicate a busy state.
4235 */
4219 function setBusyIcon(isBusy) {4236 function setBusyIcon(isBusy) {
4220 $icon.toggleClass('fa-paintbrush', !isBusy);4237 $icon.toggleClass(classes.idle, !isBusy);
4221 $icon.toggleClass(busyClass, isBusy);4238 $icon.toggleClass(classes.busy, isBusy);
4222 }4239 }
42234240
4224 const busyClass = 'fa-hourglass';4241 const classes = { busy: 'fa-hourglass', idle: 'fa-paintbrush' };
4225 const context = getContext();4242 const context = getContext();
4226 const $icon = $(e.currentTarget);4243 const $icon = $(e.currentTarget);
4227 const $mes = $icon.closest('.mes');
4228 const message_id = $mes.attr('mesid');
4229 const message = context.chat[message_id];
4230 const characterFileName = context.groupId
4231 ? context.groups[Object.keys(context.groups).filter(x => context.groups[x].id === context.groupId)[0]]?.id?.toString()
4232 : context.characters[context.characterId]?.name;
4233 const messageText = message?.mes;
4234 const hasSavedImage = message?.extra?.image && message?.extra?.title;
4235 const hasSavedNegative = message?.extra?.negative;
42364244
4237 if ($icon.hasClass(busyClass)) {4245 if ($icon.hasClass(classes.busy)) {
4238 buttonAbortController?.abort('Aborted by user');4246 buttonAbortController?.abort('Aborted by user');
4239 console.log('Previous image is still being generated...');4247 console.log('Previous image is still being generated...');
4240 return;4248 return;
4241 }4249 }
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
4269 if (dimensions) {4257 if (!message) {
4270 restoreOriginalDimensions(dimensions);4258 console.error('Could not find message for SD generation button');
4271 }4259 return;
4272 }4260 }
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 object4263 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
4287 if (message.extra.image && !swipes.includes(message.extra.image)) {4266 if (!Array.isArray(message.extra.media)) {
4288 swipes.push(message.extra.image);4267 message.extra.media = [];
4289 }4268 }
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
4298 // If already contains an image and it's not inline - leave it as is4288 // If already contains an image and it's not inline - leave it as is
4299 message.extra.inline_image = !(message.extra.image && !message.extra.inline_image);4289 message.extra.inline_image = !(message.extra.media.length && !message.extra.inline_image);
4300 message.extra.image = image;4290 message.extra.media.push(newMediaAttachment);
4301 }4291 message.extra.media_index = message.extra.media.length - 1;
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
4308 return context.saveChat();4295 await context.saveChat();
4309 }
4310}4296}
43114297
4312async function onCharacterPromptShareInput() {4298async function onCharacterPromptShareInput() {
@@ -4336,97 +4322,61 @@ async function writePromptFields(characterId) {
4336}4322}
43374323
4338/**4324/**
4339 * Switches an image to the next or previous one in the swipe list.4325 * Generates a new media attachment based on the provided media attachment metadata.
4340 * @param {object} args Event arguments4326 * @param {MediaAttachment} mediaAttachment - The media attachment metadata.
4341 * @param {any} args.message Message object4327 * @param {ChatMessage} message - The chat message containing the media attachment.
4342 * @param {JQuery<HTMLElement>} args.element Message element4328 * @param {Function} onStart - Callback function to be called when generation starts.
4343 * @param {string} args.direction Swipe direction4329 * @param {Function} onComplete - Callback function to be called when generation completes.
4344 * @returns {Promise<void>}4330 * @param {AbortController} abortController - An AbortController to handle cancellation of the generation process.
4331 * @returns {Promise<MediaAttachment|null>} - A promise that resolves to the newly generated media attachment, or null if generation failed or was aborted.
4345 */4332 */
4346async function onImageSwiped({ message, element, direction }) {4333async function generateMediaSwipe(mediaAttachment, message, onStart, onComplete, abortController = new AbortController()) {
4347 const context = getContext();4334 const stopButton = document.getElementById('sd_stop_gen');
4348 const animationClass = 'fa-fade';4335 const stopListener = () => abortController.abort('Aborted by user');
4349 const messageImg = element.find('.mes_img');4336 const generationType = mediaAttachment.generation_type ?? message?.extra?.generationType ?? generationMode.FREE;
43504337 const dimensions = setTypeSpecificDimensions(generationType);
4351 // Current image is already animating4338 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 beginning4347 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 message4356 const context = getContext();
4376 appendMediaToMessage(message, element, false);4357 const characterName = context.groupId
4358 ? context.groups[Object.keys(context.groups).filter(x => context.groups[x].id === context.groupId)[0]]?.id?.toString()
4359 : context.characters[context.characterId]?.name;
4360
4361 onStart();
4362 result.url = await sendGenerationRequest(generationType, prompt, negative, characterName, callback, initiators.swipe, abortController.signal);
4363 result.generation_type = generationType;
4364 result.title = prompt;
4365 result.negative = negative;
4366 } finally {
4367 onComplete();
4368 $(stopButton).hide();
4369 eventSource.removeListener(CUSTOM_STOP_EVENT, stopListener);
4370 restoreOriginalDimensions(dimensions);
4371 extension_settings.sd.seed = extension_settings.sd.original_seed;
4372 delete extension_settings.sd.original_seed;
4377 }4373 }
43784374
4379 // Switch to next image or generate a new one if at the end4375 if (!result.url) {
4380 if (direction === 'right') {4376 return null;
4381 const newIndex = currentIndex === swipes.length - 1 ? swipes.length : currentIndex + 1;
4382
4383 if (newIndex === swipes.length) {
4384 const abortController = new AbortController();
4385 const swipeControls = element.find('.mes_img_swipes');
4386 const stopButton = document.getElementById('sd_stop_gen');
4387 const stopListener = () => abortController.abort('Aborted by user');
4388 const generationType = message?.extra?.generationType ?? generationMode.FREE;
4389 const dimensions = setTypeSpecificDimensions(generationType);
4390 const originalSeed = extension_settings.sd.seed;
4391 extension_settings.sd.seed = extension_settings.sd.seed >= 0 ? Math.round(Math.random() * (Math.pow(2, 32) - 1)) : -1;
4392 let imagePath = '';
4393
4394 try {
4395 $(stopButton).show();
4396 eventSource.once(CUSTOM_STOP_EVENT, stopListener);
4397 const callback = () => { };
4398 const hasNegative = message.extra.negative;
4399 const prompt = await refinePrompt(message.extra.title, false);
4400 const negativePromptPrefix = hasNegative ? await refinePrompt(message.extra.negative, true) : '';
4401 message.extra.title = prompt;
4402 const characterName = context.groupId
4403 ? context.groups[Object.keys(context.groups).filter(x => context.groups[x].id === context.groupId)[0]]?.id?.toString()
4404 : context.characters[context.characterId]?.name;
4405
4406 messageImg.addClass(animationClass);
4407 swipeControls.hide();
4408 imagePath = await sendGenerationRequest(generationType, prompt, negativePromptPrefix, characterName, callback, initiators.swipe, abortController.signal);
4409 } finally {
4410 $(stopButton).hide();
4411 messageImg.removeClass(animationClass);
4412 swipeControls.show();
4413 eventSource.removeListener(CUSTOM_STOP_EVENT, stopListener);
4414 restoreOriginalDimensions(dimensions);
4415 extension_settings.sd.seed = originalSeed;
4416 }
4417
4418 if (!imagePath) {
4419 return;
4420 }
4421
4422 swipes.push(imagePath);
4423 }
4424
4425 message.extra.image = swipes[newIndex];
4426 appendMediaToMessage(message, element, false);
4427 }4377 }
44284378
4429 await context.saveChat();4379 return result;
4430}4380}
44314381
4432/**4382/**
@@ -4914,8 +4864,6 @@ jQuery(async () => {
4914 }4864 }
4915 });4865 });
49164866
4917 eventSource.on(event_types.IMAGE_SWIPED, onImageSwiped);
4918
4919 eventSource.on(event_types.CHAT_CHANGED, onChatChanged);4867 eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
49204868
4921 [event_types.SECRET_WRITTEN, event_types.SECRET_DELETED, event_types.SECRET_ROTATED].forEach(event => {4869 [event_types.SECRET_WRITTEN, event_types.SECRET_DELETED, event_types.SECRET_ROTATED].forEach(event => {
public/scripts/extensions/vectors/index.js+37 -22
@@ -426,7 +426,7 @@ function getStringHash(str) {
426426
427/**427/**
428 * Retrieves files from the chat and inserts them into the vector index.428 * Retrieves files from the chat and inserts them into the vector index.
429 * @param {object[]} chat Array of chat messages429 * @param {ChatMessage[]} chat Array of chat messages
430 * @returns {Promise<void>}430 * @returns {Promise<void>}
431 */431 */
432async function processFiles(chat) {432async function processFiles(chat) {
@@ -443,39 +443,49 @@ async function processFiles(chat) {
443 }443 }
444444
445 for (const message of chat) {445 for (const message of chat) {
446 // Message has no file446 // Message has no files
447 if (!message?.extra?.file) {447 if (!Array.isArray(message?.extra?.files) || !message.extra.files.length) {
448 continue;448 continue;
449 }449 }
450450
451 // Trim file inserted by the script451 // 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
455 // Convert kilobytes to string length454 // Convert kilobytes to string length
456 const thresholdLength = settings.size_threshold * 1024;455 const thresholdLength = settings.size_threshold * 1024;
457456
458 // File is too small457 // File is too small
459 if (fileText.length < thresholdLength) {458 if (allFileText.length < thresholdLength) {
460 continue;459 continue;
461 }460 }
462461
463 message.mes = message.mes.substring(message.extra.fileLength);462 message.mes = message.mes.substring(message.extra.fileLength);
464463
465 const fileName = message.extra.file.name;464 const allFileChunks = [];
466 const fileUrl = message.extra.file.url;465 const queryText = await getQueryText(chat, 'file');
467 const collectionId = getFileCollectionId(fileUrl);
468 const hashesInCollection = await getSavedHashes(collectionId);
469466
470 // File is already in the collection467 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
475 const queryText = await getQueryText(chat, 'file');482 const fileChunks = await retrieveFileChunks(queryText, collectionId);
476 const fileChunks = await retrieveFileChunks(queryText, collectionId);483 if (fileChunks) {
484 allFileChunks.push(fileChunks);
485 }
486 }
477487
478 message.mes = `${fileChunks}\n\n${message.mes}`;488 message.mes = `${allFileChunks.join('\n\n')}\n\n${message.mes}`;
479 }489 }
480 } catch (error) {490 } catch (error) {
481 console.error('Vectors: Failed to retrieve files', error);491 console.error('Vectors: Failed to retrieve files', error);
@@ -614,7 +624,7 @@ async function vectorizeFile(fileText, fileName, collectionId, chunkSize, overla
614624
615/**625/**
616 * Removes the most relevant messages from the chat and displays them in the extension prompt626 * Removes the most relevant messages from the chat and displays them in the extension prompt
617 * @param {object[]} chat Array of chat messages627 * @param {ChatMessage[]} chat Array of chat messages
618 * @param {number} _contextSize Context size (unused)628 * @param {number} _contextSize Context size (unused)
619 * @param {function} _abort Abort function (unused)629 * @param {function} _abort Abort function (unused)
620 * @param {string} type Generation type630 * @param {string} type Generation type
@@ -740,13 +750,18 @@ const onChatEvent = debounce(async () => await moduleWorker.update(), debounce_t
740750
741/**751/**
742 * Gets the text to query from the chat752 * Gets the text to query from the chat
743 * @param {object[]} chat Chat messages753 * @param {ChatMessage[]} chat Chat messages
744 * @param {'file'|'chat'|'world-info'} initiator Initiator of the query754 * @param {'file'|'chat'|'world-info'} initiator Initiator of the query
745 * @returns {Promise<string>} Text to query755 * @returns {Promise<string>} Text to query
746 */756 */
747async function getQueryText(chat, initiator) {757async function getQueryText(chat, initiator) {
758 const getTextWithoutAttachments = (x) => {
759 const fileLength = x?.extra?.fileLength || 0;
760 return String(x?.mes || '').substring(fileLength).trim();
761 };
762
748 let hashedMessages = chat763 let hashedMessages = chat
749 .map(x => ({ text: String(substituteParams(x.mes)), hash: getStringHash(substituteParams(x.mes)), index: chat.indexOf(x) }))764 .map(x => ({ text: substituteParams(getTextWithoutAttachments(x)), hash: getStringHash(substituteParams(getTextWithoutAttachments(x))), index: chat.indexOf(x) }))
750 .filter(x => x.text)765 .filter(x => x.text)
751 .reverse()766 .reverse()
752 .slice(0, settings.query);767 .slice(0, settings.query);
@@ -1313,7 +1328,7 @@ async function onViewStatsClick() {
1313async function onVectorizeAllFilesClick() {1328async function onVectorizeAllFilesClick() {
1314 try {1329 try {
1315 const dataBank = getDataBankAttachments();1330 const dataBank = getDataBankAttachments();
1316 const chatAttachments = getContext().chat.filter(x => x.extra?.file).map(x => x.extra.file);1331 const chatAttachments = getContext().chat.filter(x => Array.isArray(x.extra?.files)).map(x => x.extra.files).flat();
1317 const allFiles = [...dataBank, ...chatAttachments];1332 const allFiles = [...dataBank, ...chatAttachments];
13181333
1319 /**1334 /**
@@ -1390,7 +1405,7 @@ async function onVectorizeAllFilesClick() {
1390async function onPurgeFilesClick() {1405async function onPurgeFilesClick() {
1391 try {1406 try {
1392 const dataBank = getDataBankAttachments();1407 const dataBank = getDataBankAttachments();
1393 const chatAttachments = getContext().chat.filter(x => x.extra?.file).map(x => x.extra.file);1408 const chatAttachments = getContext().chat.filter(x => Array.isArray(x.extra?.files)).map(x => x.extra.files).flat();
1394 const allFiles = [...dataBank, ...chatAttachments];1409 const allFiles = [...dataBank, ...chatAttachments];
13951410
1396 for (const file of allFiles) {1411 for (const file of allFiles) {
public/scripts/group-chats.js+2 -0
@@ -78,6 +78,7 @@ import {
78 shouldAutoContinue,78 shouldAutoContinue,
79 unshallowCharacter,79 unshallowCharacter,
80 chatElement,80 chatElement,
81 ensureMessageMediaIsArray,
81} from '../script.js';82} from '../script.js';
82import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect } from './tags.js';83import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect } from './tags.js';
83import { FILTER_TYPES, FilterHelper } from './filters.js';84import { FILTER_TYPES, FilterHelper } from './filters.js';
@@ -267,6 +268,7 @@ export async function getGroupChat(groupId, reload = false) {
267 } else if (Array.isArray(data) && data.length) {268 } else if (Array.isArray(data) && data.length) {
268 data[0].is_group = true;269 data[0].is_group = true;
269 chat.splice(0, chat.length, ...data);270 chat.splice(0, chat.length, ...data);
271 chat.forEach(ensureMessageMediaIsArray);
270 chatElement.find('.mes').remove();272 chatElement.find('.mes').remove();
271 await printMessages();273 await printMessages();
272 }274 }
public/scripts/openai.js+55 -16
@@ -15,6 +15,8 @@ import {
15 Generate,15 Generate,
16 getExtensionPrompt,16 getExtensionPrompt,
17 getExtensionPromptMaxDepth,17 getExtensionPromptMaxDepth,
18 getMediaDisplay,
19 getMediaIndex,
18 getRequestHeaders,20 getRequestHeaders,
19 getStoppingStrings,21 getStoppingStrings,
20 is_send_press,22 is_send_press,
@@ -74,7 +76,7 @@ import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js';
74import { t } from './i18n.js';76import { t } from './i18n.js';
75import { ToolManager } from './tool-calling.js';77import { ToolManager } from './tool-calling.js';
76import { accountStorage } from './util/AccountStorage.js';78import { accountStorage } from './util/AccountStorage.js';
77import { COMETAPI_IGNORE_PATTERNS, IGNORE_SYMBOL } from './constants.js';79import { COMETAPI_IGNORE_PATTERNS, IGNORE_SYMBOL, MEDIA_DISPLAY, MEDIA_TYPE } from './constants.js';
7880
79export {81export {
80 openai_messages_count,82 openai_messages_count,
@@ -539,10 +541,11 @@ function setOpenAIMessages(chat) {
539 // Apply the "wrap in quotes" option541 // Apply the "wrap in quotes" option
540 if (role == 'user' && oai_settings.wrap_in_quotes) content = `"${content}"`;542 if (role == 'user' && oai_settings.wrap_in_quotes) content = `"${content}"`;
541 const name = chat[j]['name'];543 const name = chat[j]['name'];
542 const image = chat[j]?.extra?.image;544 const media = chat[j]?.extra?.media;
543 const video = chat[j]?.extra?.video;545 const mediaDisplay = getMediaDisplay(chat[j]);
546 const mediaIndex = getMediaIndex(chat[j]);
544 const invocations = chat[j]?.extra?.tool_invocations;547 const invocations = chat[j]?.extra?.tool_invocations;
545 messages[i] = { 'role': role, 'content': content, name: name, 'image': image, 'video': video, 'invocations': invocations };548 messages[i] = { 'role': role, 'content': content, name: name, 'media': media, 'mediaDisplay': mediaDisplay, 'mediaIndex': mediaIndex, 'invocations': invocations };
546 j++;549 j++;
547 }550 }
548551
@@ -852,12 +855,35 @@ async function populateChatHistory(messages, prompts, chatCompletion, type = nul
852 await chatMessage.setName(messageName);855 await chatMessage.setName(messageName);
853 }856 }
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 }
857 }875 }
858876
859 if (videoInlining && chatPrompt.video) {877 if (Array.isArray(chatPrompt.media) && chatPrompt.media.length) {
860 await chatMessage.addVideo(chatPrompt.video);878 if (chatPrompt.mediaDisplay === MEDIA_DISPLAY.LIST) {
879 for (const media of chatPrompt.media) {
880 await inlineMediaAttachment(media);
881 }
882 }
883 if (chatPrompt.mediaDisplay === MEDIA_DISPLAY.GALLERY) {
884 const media = chatPrompt.media[chatPrompt.mediaIndex];
885 await inlineMediaAttachment(media);
886 }
861 }887 }
862888
863 if (canUseTools && Array.isArray(chatPrompt.invocations)) {889 if (canUseTools && Array.isArray(chatPrompt.invocations)) {
@@ -2905,6 +2931,13 @@ class Message {
2905 */2931 */
2906 async addImage(image) {2932 async addImage(image) {
2907 const textContent = this.content;2933 const textContent = this.content;
2934 if (!Array.isArray(this.content)) {
2935 this.content = [];
2936 if (typeof textContent === 'string') {
2937 this.content.push({ type: 'text', text: textContent });
2938 }
2939 }
2940
2908 const isDataUrl = isDataURL(image);2941 const isDataUrl = isDataURL(image);
2909 if (!isDataUrl) {2942 if (!isDataUrl) {
2910 try {2943 try {
@@ -2921,10 +2954,7 @@ class Message {
2921 image = await this.compressImage(image);2954 image = await this.compressImage(image);
29222955
2923 const quality = oai_settings.inline_image_quality || default_settings.inline_image_quality;2956 const quality = oai_settings.inline_image_quality || default_settings.inline_image_quality;
2924 this.content = [2957 this.content.push({ type: 'image_url', image_url: { 'url': image, 'detail': quality } });
2925 { type: 'text', text: textContent },
2926 { type: 'image_url', image_url: { 'url': image, 'detail': quality } },
2927 ];
29282958
2929 try {2959 try {
2930 const tokens = await this.getImageTokenCost(image, quality);2960 const tokens = await this.getImageTokenCost(image, quality);
@@ -2935,8 +2965,20 @@ class Message {
2935 }2965 }
2936 }2966 }
29372967
2968 /**
2969 * Adds a video to the message.
2970 * @param {string} video Video URL or Data URL.
2971 * @returns {Promise<void>}
2972 */
2938 async addVideo(video) {2973 async addVideo(video) {
2939 const textContent = this.content;2974 const textContent = this.content;
2975 if (!Array.isArray(this.content)) {
2976 this.content = [];
2977 if (typeof textContent === 'string') {
2978 this.content.push({ type: 'text', text: textContent });
2979 }
2980 }
2981
2940 const isDataUrl = isDataURL(video);2982 const isDataUrl = isDataURL(video);
2941 if (!isDataUrl) {2983 if (!isDataUrl) {
2942 try {2984 try {
@@ -2951,10 +2993,7 @@ class Message {
2951 }2993 }
29522994
2953 // Note: No compression for videos (unlike images)2995 // Note: No compression for videos (unlike images)
2954 this.content = [2996 this.content.push({ type: 'video_url', video_url: { 'url': video } });
2955 { type: 'text', text: textContent },
2956 { type: 'video_url', video_url: { 'url': video } },
2957 ];
29582997
2959 try {2998 try {
2960 // Convservative estimate for video token cost without knowing duration2999 // 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';
63import { accountStorage } from './util/AccountStorage.js';63import { accountStorage } from './util/AccountStorage.js';
64import { DEFAULT_REASONING_TEMPLATE, loadReasoningTemplates } from './reasoning.js';64import { DEFAULT_REASONING_TEMPLATE, loadReasoningTemplates } from './reasoning.js';
65import { bindModelTemplates } from './chat-templates.js';65import { bindModelTemplates } from './chat-templates.js';
66import { MEDIA_DISPLAY } from './constants.js';
67import { t } from './i18n.js';
6668
67export const toastPositionClasses = [69export const toastPositionClasses = [
68 'toast-top-left',70 'toast-top-left',
@@ -337,6 +339,7 @@ export const power_user = {
337 external_media_forbidden_overrides: [],339 external_media_forbidden_overrides: [],
338 pin_styles: true,340 pin_styles: true,
339 click_to_edit: false,341 click_to_edit: false,
342 media_display: MEDIA_DISPLAY.LIST,
340};343};
341344
342let themes = [];345let themes = [];
@@ -1178,6 +1181,46 @@ function applyFontScale(type) {
1178 $('#font_scale').val(power_user.font_scale);1181 $('#font_scale').val(power_user.font_scale);
1179}1182}
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 */
1188function 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 */
1213function 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
1181function applyTheme(name) {1224function applyTheme(name) {
1182 const theme = themes.find(x => x.name == name);1225 const theme = themes.find(x => x.name == name);
11831226
@@ -1367,14 +1410,25 @@ function applyTheme(name) {
1367 $('#click_to_edit').prop('checked', power_user.click_to_edit);1410 $('#click_to_edit').prop('checked', power_user.click_to_edit);
1368 },1411 },
1369 },1412 },
1413 {
1414 key: 'media_display',
1415 action: (oldValue, newValue) => {
1416 $('#media_display').val(power_user.media_display);
1417 if (oldValue !== newValue) {
1418 showMediaDisplayReloadPrompt();
1419 }
1420 },
1421 },
1370 ];1422 ];
13711423
1372 for (const { key, selector, type, action } of themeProperties) {1424 for (const { key, selector, type, action } of themeProperties) {
1373 if (theme[key] !== undefined) {1425 if (theme[key] !== undefined) {
1374 power_user[key] = theme[key];1426 const oldValue = power_user[key];
1375 if (selector) $(selector).attr('color', power_user[key]);1427 const newValue = theme[key];
1428 power_user[key] = newValue;
1429 if (selector) $(selector).attr('color', newValue);
1376 if (type) applyThemeColor(type);1430 if (type) applyThemeColor(type);
1377 if (action) action();1431 if (action) action(oldValue, newValue);
1378 } else {1432 } else {
1379 console.debug(`Empty theme key: ${key}`);1433 console.debug(`Empty theme key: ${key}`);
1380 }1434 }
@@ -1714,6 +1768,7 @@ export async function loadPowerUserSettings(settings, data) {
1714 $('#forbid_external_media').prop('checked', power_user.forbid_external_media);1768 $('#forbid_external_media').prop('checked', power_user.forbid_external_media);
1715 $('#pin_styles').prop('checked', power_user.pin_styles);1769 $('#pin_styles').prop('checked', power_user.pin_styles);
1716 $('#click_to_edit').prop('checked', power_user.click_to_edit);1770 $('#click_to_edit').prop('checked', power_user.click_to_edit);
1771 $('#media_display').val(power_user.media_display);
17171772
1718 for (const theme of themes) {1773 for (const theme of themes) {
1719 const option = document.createElement('option');1774 const option = document.createElement('option');
@@ -2506,6 +2561,7 @@ function getThemeObject(name) {
2506 compact_input_area: power_user.compact_input_area,2561 compact_input_area: power_user.compact_input_area,
2507 show_swipe_num_all_messages: power_user.show_swipe_num_all_messages,2562 show_swipe_num_all_messages: power_user.show_swipe_num_all_messages,
2508 click_to_edit: power_user.click_to_edit,2563 click_to_edit: power_user.click_to_edit,
2564 media_display: power_user.media_display,
2509 };2565 };
2510}2566}
25112567
@@ -4121,6 +4177,14 @@ jQuery(() => {
4121 await exportTheme();4177 await exportTheme();
4122 });4178 });
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
4124 $(document).on('click', '#debug_table [data-debug-function]', function () {4188 $(document).on('click', '#debug_table [data-debug-function]', function () {
4125 const functionId = $(this).data('debug-function');4189 const functionId = $(this).data('debug-function');
4126 const functionRecord = debug_functions.find(f => f.functionId === functionId);4190 const functionRecord = debug_functions.find(f => f.functionId === functionId);
public/scripts/reasoning.js+2 -2
@@ -409,7 +409,7 @@ export class ReasoningHandler {
409 if (!power_user.reasoning.prefix || !power_user.reasoning.suffix)409 if (!power_user.reasoning.prefix || !power_user.reasoning.suffix)
410 return mesChanged;410 return mesChanged;
411411
412 /** @type {{ mes: string, [key: string]: any}} */412 /** @type {ChatMessage} */
413 const message = chat[messageId];413 const message = chat[messageId];
414 if (!message) return mesChanged;414 if (!message) return mesChanged;
415415
@@ -1259,7 +1259,7 @@ export function parseReasoningFromString(str, { strict = true } = {}) {
1259/**1259/**
1260 * Parse reasoning in an array of swipe strings if auto-parsing is enabled.1260 * Parse reasoning in an array of swipe strings if auto-parsing is enabled.
1261 * @param {string[]} swipes Array of swipe strings1261 * @param {string[]} swipes Array of swipe strings
1262 * @param {{extra: ReasoningMessageExtra}[]} swipeInfoArray Array of swipe info objects1262 * @param {{extra: Partial<ReasoningMessageExtra>}[]} swipeInfoArray Array of swipe info objects
1263 * @param {number?} duration Duration of the reasoning1263 * @param {number?} duration Duration of the reasoning
1264 * @typedef {object} ReasoningMessageExtra Extra reasoning data1264 * @typedef {object} ReasoningMessageExtra Extra reasoning data
1265 * @property {string} reasoning Reasoning block1265 * @property {string} reasoning Reasoning block
public/scripts/slash-commands.js+0 -5
@@ -3866,11 +3866,6 @@ async function addSwipeCallback(args, value) {
3866 return '';3866 return '';
3867 }3867 }
38683868
3869 if (lastMessage.extra?.image) {
3870 toastr.warning(t`Can't add swipes to message containing an image.`);
3871 return '';
3872 }
3873
3874 if (!Array.isArray(lastMessage.swipes)) {3869 if (!Array.isArray(lastMessage.swipes)) {
3875 lastMessage.swipes = [lastMessage.mes];3870 lastMessage.swipes = [lastMessage.mes];
3876 lastMessage.swipe_info = [{}];3871 lastMessage.swipe_info = [{}];
public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js+17 -0
@@ -40,6 +40,7 @@ export const enumIcons = {
40 server: '🖥️',40 server: '🖥️',
41 popup: '🗔',41 popup: '🗔',
42 image: '🖼️',42 image: '🖼️',
43 video: '🎥',
43 key: '🔑',44 key: '🔑',
4445
45 true: '✔️',46 true: '✔️',
@@ -263,6 +264,22 @@ export const commonEnumProviders = {
263 },264 },
264265
265 /**266 /**
267 * Media items attached to a specific message
268 * @returns {(executor:SlashCommandExecutor, scope:SlashCommandScope) => SlashCommandEnumValue[]}
269 */
270 messageMedia: () => (executor, _scope) => {
271 const messageId = Number(executor.namedArgumentList.find(it => ['mesId', 'id'].includes(it.name))?.value || '');
272 if (isNaN(messageId) || messageId === null || messageId < 0 || messageId >= chat.length) {
273 return [];
274 }
275 const message = chat[messageId];
276 if (!Array.isArray(message?.extra?.media)) {
277 return [];
278 }
279 return message.extra.media.map((media, index) => new SlashCommandEnumValue(index.toString(), media.title || message.extra.title || '[Untitled]', enumTypes.enum, enumIcons[media.type] || enumIcons.file));
280 },
281
282 /**
266 * All names used in the current chat.283 * All names used in the current chat.
267 *284 *
268 * @returns {SlashCommandEnumValue[]}285 * @returns {SlashCommandEnumValue[]}
public/scripts/st-context.js+6 -0
@@ -58,6 +58,9 @@ import {
58 deleteMessage,58 deleteMessage,
59 refreshSwipeButtons,59 refreshSwipeButtons,
60 isSwipingAllowed,60 isSwipingAllowed,
61 ensureMessageMediaIsArray,
62 getMediaDisplay,
63 getMediaIndex,
61} from '../script.js';64} from '../script.js';
62import {65import {
63 extension_settings,66 extension_settings,
@@ -209,6 +212,9 @@ export function getContext() {
209 humanizedDateTime,212 humanizedDateTime,
210 updateMessageBlock,213 updateMessageBlock,
211 appendMediaToMessage,214 appendMediaToMessage,
215 ensureMessageMediaIsArray,
216 getMediaDisplay,
217 getMediaIndex,
212 swipe: {218 swipe: {
213 left: swipe_left,219 left: swipe_left,
214 right: swipe_right,220 right: swipe_right,
public/scripts/system-messages.js+15 -11
@@ -5,8 +5,9 @@ import { getSlashCommandsHelp } from './slash-commands.js';
5import { SlashCommandBrowser } from './slash-commands/SlashCommandBrowser.js';5import { SlashCommandBrowser } from './slash-commands/SlashCommandBrowser.js';
6import { renderTemplateAsync } from './templates.js';6import { renderTemplateAsync } from './templates.js';
77
8// Initialized in getSystemMessages()8/** @type {Record<string, ChatMessage>} */
9export const system_messages = {};9export const system_messages = {};
10/** @type {ChatMessage[]} */
10export const SAFETY_CHAT = [];11export const SAFETY_CHAT = [];
1112
12/**13/**
@@ -29,6 +30,7 @@ export const system_message_types = {
29};30};
3031
31export async function initSystemMessages() {32export async function initSystemMessages() {
33 /** @type {Record<string, ChatMessage>} */
32 const result = {34 const result = {
33 help: {35 help: {
34 name: systemUserName,36 name: systemUserName,
@@ -65,14 +67,15 @@ export async function initSystemMessages() {
65 is_system: true,67 is_system: true,
66 mes: await renderTemplateAsync('macros'),68 mes: await renderTemplateAsync('macros'),
67 },69 },
68 welcome:70 welcome: {
69 {
70 name: systemUserName,71 name: systemUserName,
71 force_avatar: system_avatar,72 force_avatar: system_avatar,
72 is_user: false,73 is_user: false,
73 is_system: true,74 is_system: true,
74 uses_system_ui: true,
75 mes: await renderTemplateAsync('welcome', { displayVersion }),75 mes: await renderTemplateAsync('welcome', { displayVersion }),
76 extra: {
77 uses_system_ui: true,
78 },
76 },79 },
77 empty: {80 empty: {
78 name: systemUserName,81 name: systemUserName,
@@ -93,9 +96,9 @@ export async function initSystemMessages() {
93 force_avatar: system_avatar,96 force_avatar: system_avatar,
94 is_user: false,97 is_user: false,
95 is_system: true,98 is_system: true,
96 uses_system_ui: true,
97 mes: await renderTemplateAsync('welcomePrompt'),99 mes: await renderTemplateAsync('welcomePrompt'),
98 extra: {100 extra: {
101 uses_system_ui: true,
99 isSmallSys: true,102 isSmallSys: true,
100 },103 },
101 },104 },
@@ -105,8 +108,8 @@ export async function initSystemMessages() {
105 is_user: false,108 is_user: false,
106 is_system: true,109 is_system: true,
107 mes: await renderTemplateAsync('assistantNote'),110 mes: await renderTemplateAsync('assistantNote'),
108 uses_system_ui: true,
109 extra: {111 extra: {
112 uses_system_ui: true,
110 isSmallSys: true,113 isSmallSys: true,
111 },114 },
112 },115 },
@@ -114,12 +117,13 @@ export async function initSystemMessages() {
114117
115 Object.assign(system_messages, result);118 Object.assign(system_messages, result);
116119
120 /** @type {ChatMessage} */
117 const safetyMessage = {121 const safetyMessage = {
118 name: systemUserName,122 name: systemUserName,
119 force_avatar: system_avatar,123 force_avatar: system_avatar,
120 is_system: true,124 is_system: true,
121 is_user: false,125 is_user: false,
122 create_date: 0,126 send_date: getMessageTimeStamp(),
123 mes: t`You deleted a character/chat and arrived back here for safety reasons! Pick another character!`,127 mes: t`You deleted a character/chat and arrived back here for safety reasons! Pick another character!`,
124 };128 };
125 SAFETY_CHAT.splice(0, SAFETY_CHAT.length, safetyMessage);129 SAFETY_CHAT.splice(0, SAFETY_CHAT.length, safetyMessage);
@@ -130,8 +134,8 @@ export async function initSystemMessages() {
130 * Gets a system message by type.134 * Gets a system message by type.
131 * @param {string} type Type of system message135 * @param {string} type Type of system message
132 * @param {string} [text] Text to be sent136 * @param {string} [text] Text to be sent
133 * @param {object} [extra] Additional data to be added to the message137 * @param {ChatMessageExtra} [extra] Additional data to be added to the message
134 * @returns {object} System message object138 * @returns {ChatMessage} System message object
135 */139 */
136export function getSystemMessageByType(type, text, extra = {}) {140export function getSystemMessageByType(type, text, extra = {}) {
137 const systemMessage = system_messages[type];141 const systemMessage = system_messages[type];
@@ -150,7 +154,7 @@ export function getSystemMessageByType(type, text, extra = {}) {
150 newMessage.mes = getSlashCommandsHelp();154 newMessage.mes = getSlashCommandsHelp();
151 }155 }
152156
153 if (!newMessage.extra) {157 if (!newMessage.extra || typeof newMessage.extra !== 'object') {
154 newMessage.extra = {};158 newMessage.extra = {};
155 }159 }
156160
@@ -163,7 +167,7 @@ export function getSystemMessageByType(type, text, extra = {}) {
163 * Sends a system message to the chat.167 * Sends a system message to the chat.
164 * @param {string} type Type of system message168 * @param {string} type Type of system message
165 * @param {string} [text] Text to be sent169 * @param {string} [text] Text to be sent
166 * @param {object} [extra] Additional data to be added to the message170 * @param {ChatMessageExtra} [extra] Additional data to be added to the message
167 */171 */
168export function sendSystemMessage(type, text, extra = {}) {172export function sendSystemMessage(type, text, extra = {}) {
169 const newMessage = getSystemMessageByType(type, text, extra);173 const newMessage = getSystemMessageByType(type, text, extra);
public/scripts/utils.js+17 -2
@@ -408,6 +408,16 @@ export async function urlContentToDataUri(url, params) {
408}408}
409409
410/**410/**
411 * Fuzzily compares two files for equality. Only checks attributes, not contents.
412 * @param {File} a First file
413 * @param {File} b Second file
414 * @returns {boolean} True if the files are probably the same, false otherwise.
415 */
416export function isSameFile(a, b) {
417 return a.lastModified === b.lastModified && a.name === b.name && a.size === b.size && a.type === b.type;
418}
419
420/**
411 * Returns a promise that resolves to the file's text.421 * Returns a promise that resolves to the file's text.
412 * @param {Blob} file The file to read.422 * @param {Blob} file The file to read.
413 * @returns {Promise<string>} A promise that resolves to the file's text.423 * @returns {Promise<string>} A promise that resolves to the file's text.
@@ -1009,7 +1019,7 @@ const dateCache = new Map();
1009/**1019/**
1010 * Cached version of moment() to avoid re-parsing the same date strings.1020 * Cached version of moment() to avoid re-parsing the same date strings.
1011 * Important: Moment objects are mutable, so use clone() before modifying them!1021 * Important: Moment objects are mutable, so use clone() before modifying them!
1012 * @param {string|number} timestamp String or number representing a date.1022 * @param {MessageTimestamp} timestamp String or number representing a date.
1013 * @returns {import('moment').Moment} Moment object1023 * @returns {import('moment').Moment} Moment object
1014 */1024 */
1015export function timestampToMoment(timestamp) {1025export function timestampToMoment(timestamp) {
@@ -1026,12 +1036,17 @@ export function timestampToMoment(timestamp) {
10261036
1027/**1037/**
1028 * Parses a timestamp and returns a moment object representing the parsed date and time.1038 * Parses a timestamp and returns a moment object representing the parsed date and time.
1029 * @param {string|number} timestamp - The timestamp to parse. It can be a string or a number.1039 * @param {MessageTimestamp} timestamp - The timestamp to parse. It can be a string or a number.
1030 * @returns {string} - If the timestamp is valid, returns an ISO 8601 string.1040 * @returns {string} - If the timestamp is valid, returns an ISO 8601 string.
1031 */1041 */
1032function parseTimestamp(timestamp) {1042function parseTimestamp(timestamp) {
1033 if (!timestamp) return;1043 if (!timestamp) return;
10341044
1045 // Date object
1046 if (timestamp instanceof Date) {
1047 return timestamp.toISOString();
1048 }
1049
1035 // Unix time (legacy TAI / tags)1050 // Unix time (legacy TAI / tags)
1036 if (typeof timestamp === 'number' || /^\d+$/.test(timestamp)) {1051 if (typeof timestamp === 'number' || /^\d+$/.test(timestamp)) {
1037 const unixTime = Number(timestamp);1052 const unixTime = Number(timestamp);
public/style.css+48 -10
@@ -388,6 +388,11 @@ input[type='checkbox']:focus-visible {
388 margin-bottom: 10px;388 margin-bottom: 10px;
389}389}
390390
391.mes_text p:last-child,
392.mes_reasoning p:last-child {
393 margin-bottom: 0;
394}
395
391.mes_text li tt,396.mes_text li tt,
392.mes_reasoning li tt {397.mes_reasoning li tt {
393 display: inline-block;398 display: inline-block;
@@ -637,6 +642,16 @@ input[type='checkbox']:focus-visible {
637 display: flex;642 display: flex;
638}643}
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
640small {655small {
641 color: var(--SmartThemeBodyColor);656 color: var(--SmartThemeBodyColor);
642 opacity: 0.7;657 opacity: 0.7;
@@ -4952,6 +4967,22 @@ a:hover {
4952}4967}
49534968
4954/* Message images/video */4969/* Message images/video */
4970.mes .mes_media_wrapper:empty {
4971 display: none;
4972}
4973
4974.mes .mes_media_wrapper {
4975 display: flex;
4976 flex-direction: row;
4977 align-items: center;
4978 flex-wrap: wrap;
4979 gap: 0.5em;
4980}
4981
4982.mes_media_wrapper:not(:empty)~.mes_file_wrapper:not(:empty) {
4983 margin-top: 0.5em;
4984}
4985
4955.mes .mes_img_container,4986.mes .mes_img_container,
4956.mes .mes_video_container {4987.mes .mes_video_container {
4957 max-width: 100%;4988 max-width: 100%;
@@ -4960,7 +4991,8 @@ a:hover {
4960 position: relative;4991 position: relative;
4961 width: fit-content;4992 width: fit-content;
4962 transition: all var(--animation-duration);4993 transition: all var(--animation-duration);
4963 padding: 0.5rem;4994 border-radius: 5px;
4995 overflow: hidden;
4964}4996}
49654997
4966.mes .mes_video_container:has(.mes_video[src]) {4998.mes .mes_video_container:has(.mes_video[src]) {
@@ -4968,7 +5000,6 @@ a:hover {
4968}5000}
49695001
4970.mes_img {5002.mes_img {
4971 border-radius: 5px;
4972 max-width: 100%;5003 max-width: 100%;
4973 max-height: 40vh;5004 max-height: 40vh;
4974 image-rendering: -webkit-optimize-contrast;5005 image-rendering: -webkit-optimize-contrast;
@@ -4985,7 +5016,7 @@ a:hover {
4985.mes_img_controls,5016.mes_img_controls,
4986.mes_video_controls {5017.mes_video_controls {
4987 position: absolute;5018 position: absolute;
4988 top: 0.1em;5019 top: 0;
4989 left: 0;5020 left: 0;
4990 width: 100%;5021 width: 100%;
4991 display: flex;5022 display: flex;
@@ -4993,14 +5024,16 @@ a:hover {
4993 flex-direction: row;5024 flex-direction: row;
4994 justify-content: space-between;5025 justify-content: space-between;
4995 align-items: center;5026 align-items: center;
4996 padding: 1em;5027 padding: 10px;
4997 z-index: 1;5028 z-index: 1;
4998 transition: opacity var(--animation-duration) ease-in-out;5029 transition: opacity var(--animation-duration) ease-in-out;
5030 background: linear-gradient(rgba(0, 0, 0, 0.8), transparent);
4999}5031}
50005032
5001.mes_img_swipes {5033.mes_img_swipes {
5002 top: unset;5034 top: unset;
5003 bottom: 0.1rem;5035 bottom: 0;
5036 background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
5004}5037}
50055038
5006.mes_img_swipes .right_menu_button,5039.mes_img_swipes .right_menu_button,
@@ -5036,11 +5069,18 @@ a:hover {
5036.mes_img_container:focus-within .mes_img_swipes,5069.mes_img_container:focus-within .mes_img_swipes,
5037.mes_img_container:hover .mes_img_controls,5070.mes_img_container:hover .mes_img_controls,
5038.mes_img_container:focus-within .mes_img_controls,5071.mes_img_container:focus-within .mes_img_controls,
5039.mes_video_container:hover .mes_video_controls {5072.mes_video_container:hover .mes_video_controls,
5073.mes_video_container:has(.mes_video.error) .mes_img_swipes,
5074.mes_video_container:hover .mes_img_swipes {
5040 opacity: 1;5075 opacity: 1;
5041}5076}
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 {
5044 display: flex;5084 display: flex;
5045}5085}
50465086
@@ -5048,8 +5088,7 @@ body:not(.caption) .mes_img_caption {
5048 display: none;5088 display: none;
5049}5089}
50505090
5051.mes_img_container:not(.img_swipes) .mes_img_swipes,5091.mes_img_container:not(.img_swipes) .mes_img_swipes {
5052body:not(.sd) .mes_img_swipes {
5053 display: none;5092 display: none;
5054}5093}
50555094
@@ -5136,7 +5175,6 @@ body:not(.sd) .mes_img_swipes {
5136.mes_video {5175.mes_video {
5137 max-width: 100%;5176 max-width: 100%;
5138 max-height: 400px;5177 max-height: 400px;
5139 border-radius: 5px;
5140 background: #000;5178 background: #000;
5141}5179}
51425180
src/endpoints/data-maid.js+38 -6
@@ -57,16 +57,24 @@ const sha256 = str => crypto.createHash('sha256').update(str).digest('hex');
57 */57 */
5858
59/**59/**
60 * @typedef {object} DataMaidMedia - The media object.
61 * @property {string} url - The media URL
62 */
63
64/**
60 * @typedef {object} DataMaidChatMetadata - The chat metadata object.65 * @typedef {object} DataMaidChatMetadata - The chat metadata object.
61 * @property {DataMaidFile[]} [attachments] - The array of attachments, if any.66 * @property {DataMaidFile[]} [attachments] - The array of attachments, if any.
67 * @property {string[]} [chat_backgrounds] - The array of chat background image links, if any.
62 */68 */
6369
64/**70/**
65 * @typedef {object} DataMaidMessageExtra - The extra data object.71 * @typedef {object} DataMaidMessageExtra - The extra data object.
66 * @property {string} [image] - The link to the image, if any.72 * @property {string} [image] - The link to the image, if any - DEPRECATED, use `media` instead.
67 * @property {string} [video] - The link to the video, if any.73 * @property {string} [video] - The link to the video, if any - DEPRECATED, use `media` instead.
68 * @property {string[]} [image_swipes] - The links to the image swipes, if any.74 * @property {string[]} [image_swipes] - The links to the image swipes, if any - DEPRECATED, use `media` instead.
69 * @property {DataMaidFile} [file] - The file object, if any.75 * @property {DataMaidMedia[]} [media] - The links to the media, if any.
76 * @property {DataMaidFile} [file] - The file object, if any - DEPRECATED, use `files` instead.
77 * @property {DataMaidFile[]} [files] - The array of file objects, if any.
70 */78 */
7179
72/**80/**
@@ -166,7 +174,7 @@ export class DataMaidService {
166 const result = [];174 const result = [];
167175
168 try {176 try {
169 const messages = await this.#parseAllChats(x => !!x?.extra?.image || !!x?.extra?.video || Array.isArray(x?.extra?.image_swipes));177 const messages = await this.#parseAllChats(x => !!x?.extra?.image || !!x?.extra?.video || Array.isArray(x?.extra?.image_swipes) || Array.isArray(x?.extra?.media));
170 const knownImages = new Set();178 const knownImages = new Set();
171 for (const message of messages) {179 for (const message of messages) {
172 if (message?.extra?.image) {180 if (message?.extra?.image) {
@@ -180,6 +188,23 @@ export class DataMaidService {
180 knownImages.add(swipe);188 knownImages.add(swipe);
181 }189 }
182 }190 }
191 if (Array.isArray(message?.extra?.media)) {
192 for (const media of message.extra.media) {
193 if (media?.url) {
194 knownImages.add(media.url);
195 }
196 }
197 }
198 }
199 const metadata = await this.#parseAllMetadata(x => Array.isArray(x?.chat_backgrounds) && x.chat_backgrounds.length > 0);
200 for (const meta of metadata) {
201 if (Array.isArray(meta?.chat_backgrounds)) {
202 for (const background of meta.chat_backgrounds) {
203 if (background) {
204 knownImages.add(background);
205 }
206 }
207 }
183 }208 }
184 const knownImageFullPaths = new Set();209 const knownImageFullPaths = new Set();
185 knownImages.forEach(image => {210 knownImages.forEach(image => {
@@ -221,12 +246,19 @@ export class DataMaidService {
221 const result = [];246 const result = [];
222247
223 try {248 try {
224 const messages = await this.#parseAllChats(x => !!x?.extra?.file?.url);249 const messages = await this.#parseAllChats(x => !!x?.extra?.file?.url || (Array.isArray(x?.extra?.files) && x.extra.files.length > 0));
225 const knownFiles = new Set();250 const knownFiles = new Set();
226 for (const message of messages) {251 for (const message of messages) {
227 if (message?.extra?.file?.url) {252 if (message?.extra?.file?.url) {
228 knownFiles.add(message.extra.file.url);253 knownFiles.add(message.extra.file.url);
229 }254 }
255 if (Array.isArray(message?.extra?.files)) {
256 for (const file of message.extra.files) {
257 if (file?.url) {
258 knownFiles.add(file.url);
259 }
260 }
261 }
230 }262 }
231 const metadata = await this.#parseAllMetadata(x => Array.isArray(x?.attachments) && x.attachments.length > 0);263 const metadata = await this.#parseAllMetadata(x => Array.isArray(x?.attachments) && x.attachments.length > 0);
232 for (const meta of metadata) {264 for (const meta of metadata) {