Improved `printMessages` performance on large chats by reducing DOM updates. (#4947) * Improved printMessages performance by reducing DOM updates. Refactored part of addOneMessage into createMessageElement. Before: Rendered 1000 messages in 25.529199999809265 seconds. After: Rendered 1000 messages in 5.088 seconds. * Fixed mistakes: https://github.com/SillyTavern/SillyTavern/pull/4947#pullrequestreview-3624592007 * Formatting fix * Fixed scroll and last_mes order.. * Refactored printMessages logic into redisplayChat. * Passed `fade`. * Cleanup. * WIP. Removed getMessageFromTemplate Refactored addOneMessage and updateMessageElement. * Minor changes to better match https://github.com/SillyTavern/SillyTavern/pull/4985 * If insert is false, `newMessage` may not be new, so I renamed `newMessage` to `messageElement` and `newMessageId` to `messageId`. I also rearranged some constants to improve readability. * Accept 0 as valid insertAfter/insertBefore targets * Renamed forceId to messageId for clarity. * Change forceId to undefined by default. * Swapped `forceId` to `messageId` in addOneMessage. * Added adjustMediaScroll to updateMessageElement. * Revert "Change forceId to undefined by default." This reverts commit cbda7eb3fe0a2aa46e0988e82260910c62bc5034. --------- Co-authored-by: user <user@exmaple.com> Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
Signed| @@ -1397,7 +1397,7 @@ export async function showMoreMessages(messagesToLoad = null) { | |||
| 1397 | const firstId = clamp(messageId - count, 0, Infinity); | 1397 | const firstId = clamp(messageId - count, 0, Infinity); |
| 1398 | const messageElements = []; | 1398 | const messageElements = []; |
| 1399 | chat.slice(firstId, messageId).forEach((message, id) => { | 1399 | chat.slice(firstId, messageId).forEach((message, id) => { |
| 1400 | messageElements.push(addOneMessage(message, { scroll: false, forceId: firstId + id, showSwipes: false, insert: false })); | 1400 | messageElements.push(updateMessageElement(message, { messageId: firstId + id })); |
| 1401 | }); | 1401 | }); |
| 1402 | // This could be faster: https://developer.mozilla.org/en-US/docs/Web/API/Element/insertAdjacentElement | 1402 | // This could be faster: https://developer.mozilla.org/en-US/docs/Web/API/Element/insertAdjacentElement |
| 1403 | // Fallback to chatElement if the button isn't where it's expected to be. | 1403 | // Fallback to chatElement if the button isn't where it's expected to be. |
| @@ -1458,7 +1458,7 @@ export async function redisplayChat({ targetChat = chat, startIndex = 0, fade = | |||
| 1458 | if (messages.length > 0) { | 1458 | if (messages.length > 0) { |
| 1459 | const newMessageElements = messages.map((message, offset) => { | 1459 | const newMessageElements = messages.map((message, offset) => { |
| 1460 | const i = startIndex + offset; | 1460 | const i = startIndex + offset; |
| 1461 | const messageElement = addOneMessage(message, { scroll: false, forceId: i, showSwipes: false, insert: false }); | 1461 | const messageElement = updateMessageElement(message, { messageId: i }); |
| 1462 | 1462 | ||
| 1463 | return messageElement[0]; | 1463 | return messageElement[0]; |
| 1464 | }); | 1464 | }); |
| @@ -2430,16 +2430,15 @@ function getMessageTextHTML(message, { messageId = chat.indexOf(message) }) { | |||
| 2430 | * Adds a single message to the chat. | 2430 | * Adds a single message to the chat. |
| 2431 | * @param {ChatMessage} mes Message object | 2431 | * @param {ChatMessage} mes Message object |
| 2432 | * @param {object} [options] Options | 2432 | * @param {object} [options] Options |
| 2433 | * @param {string} [options.type='normal'] Message type | 2433 | * @param {string} [options.type=undefined|'swipe'] Deprecated. Use updateMessageElement instead. |
| 2434 | * @param {number} [options.insertAfter=null] Message ID to insert the new message after | 2434 | * @param {number} [options.insertAfter=null] Message ID to insert the new message after |
| 2435 | * @param {boolean} [options.scroll=true] Whether to scroll to the new message | 2435 | * @param {boolean} [options.scroll=true] Whether to scroll to the new message |
| 2436 | * @param {number} [options.insertBefore=null] Message ID to insert the new message before | 2436 | * @param {number} [options.insertBefore=null] Message ID to insert the new message before |
| 2437 | * @param {number} [options.forceId=null] Force the message ID | 2437 | * @param {number} [options.forceId=null] Force the message ID |
| 2438 | * @param {boolean} [options.showSwipes=true] Whether to refresh the swipe buttons. | 2438 | * @param {boolean} [options.showSwipes=true] Whether to refresh the swipe buttons. |
| 2439 | * @param {boolean} [options.insert=true] Whether to insert the message into the DOM. | ||
| 2440 | * @returns {JQuery<HTMLElement>} The newly added message element | 2439 | * @returns {JQuery<HTMLElement>} The newly added message element |
| 2441 | */ | 2440 | */ |
| 2442 | export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll = true, insertBefore = null, forceId = null, showSwipes = true, insert = true } = {}) { | 2441 | export function addOneMessage(mes, { type = undefined, insertAfter = null, scroll = true, insertBefore = null, forceId = null, showSwipes = true } = {}) { |
| 2443 | // Callers push the new message to chat before calling addOneMessage | 2442 | // Callers push the new message to chat before calling addOneMessage |
| 2444 | const messageId = (() => { | 2443 | const messageId = (() => { |
| 2445 | if (typeof forceId === 'number') { | 2444 | if (typeof forceId === 'number') { |
| @@ -2458,6 +2457,55 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll | |||
| 2458 | return chat.length - 1; | 2457 | return chat.length - 1; |
| 2459 | })(); | 2458 | })(); |
| 2460 | 2459 | ||
| 2460 | let messageElement; | ||
| 2461 | |||
| 2462 | if (type === 'swipe') { | ||
| 2463 | // Forbidden black magic | ||
| 2464 | // This allows to use "continue" on user messages | ||
| 2465 | mes.swipe_id ??= 0; | ||
| 2466 | mes.swipes ??= [mes.mes]; | ||
| 2467 | //This keeps listeners intact. | ||
| 2468 | messageElement = chatElement.find(`[mesid="${messageId}"]`); | ||
| 2469 | updateMessageElement(mes, { messageId, messageElement, adjustMediaScroll: scroll ? SCROLL_BEHAVIOR.ADJUST : SCROLL_BEHAVIOR.NONE }); | ||
| 2470 | } else { | ||
| 2471 | messageElement = updateMessageElement(mes, { messageId, adjustMediaScroll: scroll ? SCROLL_BEHAVIOR.ADJUST : SCROLL_BEHAVIOR.NONE }); | ||
| 2472 | if (typeof insertAfter === 'number' && insertAfter >= 0) { | ||
| 2473 | const target = chatElement.find(`.mes[mesid="${insertAfter}"]`); | ||
| 2474 | $(messageElement).insertAfter(target); | ||
| 2475 | } else if (typeof insertBefore === 'number' && insertBefore >= 0) { | ||
| 2476 | const target = chatElement.find(`.mes[mesid="${insertBefore}"]`); | ||
| 2477 | $(messageElement).insertBefore(target); | ||
| 2478 | } else { | ||
| 2479 | chatElement.append(messageElement); | ||
| 2480 | } | ||
| 2481 | } | ||
| 2482 | |||
| 2483 | |||
| 2484 | //last_mes should always be updated. | ||
| 2485 | chatElement.find('.mes').removeClass('last_mes'); | ||
| 2486 | chatElement.find('.mes').last().addClass('last_mes'); | ||
| 2487 | |||
| 2488 | if (showSwipes) refreshSwipeButtons(); | ||
| 2489 | // Don't scroll if not inserting last | ||
| 2490 | if (!insertAfter && !insertBefore && scroll) { | ||
| 2491 | scrollChatToBottom({ waitForFrame: true }); | ||
| 2492 | } | ||
| 2493 | |||
| 2494 | applyCharacterTagsToMessageDivs({ mesIds: messageId }); | ||
| 2495 | updateEditArrowClasses(); | ||
| 2496 | return messageElement; | ||
| 2497 | } | ||
| 2498 | |||
| 2499 | /** | ||
| 2500 | * Creates the element of a single message as if it were the last message or at forceMesId | ||
| 2501 | * @param {ChatMessage} mes Message object | ||
| 2502 | * @param {object} [options] Options | ||
| 2503 | * @param {number} [options.messageId=chat.length - 1] Force the message ID | ||
| 2504 | * @param {JQuery<HTMLElement>} [options.messageElement=messageTemplate.clone()] This message element will be updated with the ChatMessage object. | ||
| 2505 | * @param {SCROLL_BEHAVIOR} [options.adjustMediaScroll=SCROLL_BEHAVIOR.NONE] Scroll behavior option passed to appendMediaToMessage. | ||
| 2506 | * @returns {JQuery<HTMLElement>} Rendered HTMLElement. | ||
| 2507 | */ | ||
| 2508 | export function updateMessageElement(mes, { messageId = chat.length - 1, messageElement = messageTemplate.clone(), adjustMediaScroll = SCROLL_BEHAVIOR.NONE } = {}) { | ||
| 2461 | 2509 | ||
| 2462 | let avatarImg = getThumbnailUrl('persona', user_avatar); | 2510 | let avatarImg = getThumbnailUrl('persona', user_avatar); |
| 2463 | 2511 | ||
| @@ -2487,19 +2535,6 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll | |||
| 2487 | const tokenCount = mes.extra?.token_count; | 2535 | const tokenCount = mes.extra?.token_count; |
| 2488 | const { timerValue, timerTitle } = formatGenerationTimer(mes.gen_started, mes.gen_finished, mes.extra?.token_count, mes.extra?.reasoning_duration, mes.extra?.time_to_first_token); | 2536 | const { timerValue, timerTitle } = formatGenerationTimer(mes.gen_started, mes.gen_finished, mes.extra?.token_count, mes.extra?.reasoning_duration, mes.extra?.time_to_first_token); |
| 2489 | 2537 | ||
| 2490 | let messageElement; | ||
| 2491 | |||
| 2492 | if (type === 'swipe') { | ||
| 2493 | // Forbidden black magic | ||
| 2494 | // This allows to use "continue" on user messages | ||
| 2495 | mes.swipe_id ??= 0; | ||
| 2496 | mes.swipes ??= [mes.mes]; | ||
| 2497 | //This keeps listeners intact. | ||
| 2498 | messageElement = chatElement.find(`[mesid="${messageId}"]`); | ||
| 2499 | } else { | ||
| 2500 | messageElement = messageTemplate.clone(); | ||
| 2501 | } | ||
| 2502 | |||
| 2503 | messageElement.attr({ | 2538 | messageElement.attr({ |
| 2504 | 'mesid': messageId, | 2539 | 'mesid': messageId, |
| 2505 | 'swipeid': mes.swipe_id ?? 0, | 2540 | 'swipeid': mes.swipe_id ?? 0, |
| @@ -2533,18 +2568,6 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll | |||
| 2533 | insertSVGIcon(messageElement, mes.extra); | 2568 | insertSVGIcon(messageElement, mes.extra); |
| 2534 | } | 2569 | } |
| 2535 | 2570 | ||
| 2536 | if (type !== 'swipe' && insert) { | ||
| 2537 | if (typeof insertAfter === 'number' && insertAfter >= 0) { | ||
| 2538 | const target = chatElement.find(`.mes[mesid="${insertAfter}"]`); | ||
| 2539 | $(messageElement).insertAfter(target); | ||
| 2540 | } else if (typeof insertBefore === 'number' && insertBefore >= 0) { | ||
| 2541 | const target = chatElement.find(`.mes[mesid="${insertBefore}"]`); | ||
| 2542 | $(messageElement).insertBefore(target); | ||
| 2543 | } else { | ||
| 2544 | chatElement.append(messageElement); | ||
| 2545 | } | ||
| 2546 | } | ||
| 2547 | |||
| 2548 | if (mes?.extra?.isSmallSys === true) { | 2571 | if (mes?.extra?.isSmallSys === true) { |
| 2549 | messageElement.addClass('smallSysMes'); | 2572 | messageElement.addClass('smallSysMes'); |
| 2550 | } | 2573 | } |
| @@ -2560,7 +2583,7 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll | |||
| 2560 | $(this).parent().html('<div class="missing-avatar fa-solid fa-user-slash"></div>'); | 2583 | $(this).parent().html('<div class="missing-avatar fa-solid fa-user-slash"></div>'); |
| 2561 | }); | 2584 | }); |
| 2562 | 2585 | ||
| 2563 | appendMediaToMessage(mes, messageElement, scroll ? SCROLL_BEHAVIOR.ADJUST : SCROLL_BEHAVIOR.NONE); | 2586 | appendMediaToMessage(mes, messageElement, adjustMediaScroll); |
| 2564 | messageElement.find('.mes_text').html(messageHTML); | 2587 | messageElement.find('.mes_text').html(messageHTML); |
| 2565 | addCopyToCodeBlocks(messageElement); | 2588 | addCopyToCodeBlocks(messageElement); |
| 2566 | 2589 | ||
| @@ -2569,26 +2592,6 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll | |||
| 2569 | updateSwipeCounter(messageId, { message: mes, messageElement }); | 2592 | updateSwipeCounter(messageId, { message: mes, messageElement }); |
| 2570 | } | 2593 | } |
| 2571 | 2594 | ||
| 2572 | // The caller should handle the rest after adding a message to DOM. | ||
| 2573 | if (!insert) { | ||
| 2574 | return messageElement; | ||
| 2575 | } | ||
| 2576 | |||
| 2577 | //last_mes should always be updated. | ||
| 2578 | chatElement.find('.mes').removeClass('last_mes'); | ||
| 2579 | chatElement.find('.mes').last().addClass('last_mes'); | ||
| 2580 | if (showSwipes) { | ||
| 2581 | refreshSwipeButtons(); | ||
| 2582 | } | ||
| 2583 | |||
| 2584 | // Don't scroll if not inserting last | ||
| 2585 | if (!insertAfter && !insertBefore && scroll) { | ||
| 2586 | scrollChatToBottom({ waitForFrame: true }); | ||
| 2587 | } | ||
| 2588 | |||
| 2589 | applyCharacterTagsToMessageDivs({ mesIds: messageId }); | ||
| 2590 | updateEditArrowClasses(); | ||
| 2591 | |||
| 2592 | return messageElement; | 2595 | return messageElement; |
| 2593 | } | 2596 | } |
| 2594 | 2597 | ||
| @@ -11703,7 +11706,7 @@ jQuery(async function () { | |||
| 11703 | } | 11706 | } |
| 11704 | 11707 | ||
| 11705 | chat.splice(Number(this_edit_mes_id) + 1, 0, clone); | 11708 | chat.splice(Number(this_edit_mes_id) + 1, 0, clone); |
| 11706 | const newMessageElement = addOneMessage(clone, { insert: false }); | 11709 | const newMessageElement = updateMessageElement(clone); |
| 11707 | this_edit_mes_element.after(newMessageElement); | 11710 | this_edit_mes_element.after(newMessageElement); |
| 11708 | 11711 | ||
| 11709 | updateViewMessageIds(); | 11712 | updateViewMessageIds(); |
| @@ -54,6 +54,7 @@ import { | |||
| 54 | system_avatar, | 54 | system_avatar, |
| 55 | system_message_types, | 55 | system_message_types, |
| 56 | this_chid, | 56 | this_chid, |
| 57 | updateMessageElement, | ||
| 57 | } from '../script.js'; | 58 | } from '../script.js'; |
| 58 | import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; | 59 | import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; |
| 59 | import { SlashCommandParserError } from './slash-commands/SlashCommandParserError.js'; | 60 | import { SlashCommandParserError } from './slash-commands/SlashCommandParserError.js'; |
| @@ -4642,7 +4643,7 @@ async function messageRoleCallback(args, role) { | |||
| 4642 | await eventSource.emit(event_types.MESSAGE_EDITED, modifyAt); | 4643 | await eventSource.emit(event_types.MESSAGE_EDITED, modifyAt); |
| 4643 | const existingMessage = chatElement.find(`.mes[mesid="${modifyAt}"]`); | 4644 | const existingMessage = chatElement.find(`.mes[mesid="${modifyAt}"]`); |
| 4644 | if (existingMessage.length) { | 4645 | if (existingMessage.length) { |
| 4645 | const newMessageElement = addOneMessage(message, { forceId: modifyAt, insert: false, scroll: false }); | 4646 | const newMessageElement = updateMessageElement(message, { messageId: modifyAt }); |
| 4646 | existingMessage.after(newMessageElement); | 4647 | existingMessage.after(newMessageElement); |
| 4647 | existingMessage.remove(); | 4648 | existingMessage.remove(); |
| 4648 | } | 4649 | } |
| @@ -4708,7 +4709,7 @@ async function messageNameCallback(args, name) { | |||
| 4708 | await eventSource.emit(event_types.MESSAGE_EDITED, modifyAt); | 4709 | await eventSource.emit(event_types.MESSAGE_EDITED, modifyAt); |
| 4709 | const existingMessage = chatElement.find(`.mes[mesid="${modifyAt}"]`); | 4710 | const existingMessage = chatElement.find(`.mes[mesid="${modifyAt}"]`); |
| 4710 | if (existingMessage.length) { | 4711 | if (existingMessage.length) { |
| 4711 | const newMessageElement = addOneMessage(message, { forceId: modifyAt, insert: false, scroll: false }); | 4712 | const newMessageElement = updateMessageElement(message, { messageId: modifyAt }); |
| 4712 | existingMessage.after(newMessageElement); | 4713 | existingMessage.after(newMessageElement); |
| 4713 | existingMessage.remove(); | 4714 | existingMessage.remove(); |
| 4714 | } | 4715 | } |