From f7ea95aeea2e0adfb595e8ffd796bb2888430680 Mon Sep 17 00:00:00 2001 From: Junha Heo Date: Wed, 26 Feb 2025 11:32:20 +0900 Subject: [PATCH] feat: Optimize chat rendering and performance with caching and lazy loading - Implement markdown parsing cache in Chat.svelte to reduce redundant processing - Add virtual scrolling and asynchronous loading in DefaultChatScreen.svelte - Create message form cache mechanism in util.ts to improve rendering efficiency - Introduce loading state and performance optimizations for large chat histories --- src/lib/ChatScreens/Chat.svelte | 54 ++++- src/lib/ChatScreens/ChatScreen.svelte | 21 ++ src/lib/ChatScreens/DefaultChatScreen.svelte | 236 +++++++++++++++---- src/ts/util.ts | 54 ++++- 4 files changed, 305 insertions(+), 60 deletions(-) diff --git a/src/lib/ChatScreens/Chat.svelte b/src/lib/ChatScreens/Chat.svelte index 07a8a74c..d976170e 100644 --- a/src/lib/ChatScreens/Chat.svelte +++ b/src/lib/ChatScreens/Chat.svelte @@ -130,9 +130,22 @@ $effect.pre(() => { blankMessage = (message === '{{none}}' || message === '{{blank}}' || message === '') && idx === -1 }); + // Static map for caching markdown parsing results + const markdownCache = new Map(); + const markParsing = async (data: string, charArg?: string | simpleCharacterArgument, mode?: "normal" | "back", chatID?: number, translateText?:boolean, tries?:number) => { let lastParsedQueue = '' try { + // Create cache key + const cacheKey = `${data}-${JSON.stringify(charArg)}-${mode}-${chatID}-${translateText}`; + + // Use cached result if available and not retranslating + if (markdownCache.has(cacheKey) && !retranslate) { + lastParsedQueue = markdownCache.get(cacheKey); + lastCharArg = charArg; + lastChatId = chatID; + return lastParsedQueue; + } if((!isEqual(lastCharArg, charArg)) || (chatID !== lastChatId)){ lastParsedQueue = '' lastCharArg = charArg @@ -184,6 +197,7 @@ const marked = await ParseMarkdown(data, charArg, mode, chatID, getCbsCondition()) lastParsedQueue = marked lastCharArg = charArg + markdownCache.set(cacheKey, marked); return marked } else if(!DBState.db.legacyTranslation){ @@ -193,6 +207,7 @@ translating = false lastParsedQueue = translated lastCharArg = charArg + markdownCache.set(cacheKey, translated); return translated } else{ @@ -202,6 +217,7 @@ translating = false lastParsedQueue = translated lastCharArg = charArg + markdownCache.set(cacheKey, translated); return translated } } @@ -209,12 +225,12 @@ const marked = await ParseMarkdown(data, charArg, mode, chatID, getCbsCondition()) lastParsedQueue = marked lastCharArg = charArg + markdownCache.set(cacheKey, marked); return marked } } catch (error) { //retry if(tries > 2){ - alertError(`Error while parsing chat message: ${translateText}, ${error.message}, ${error.stack}`) return data } @@ -225,6 +241,17 @@ } } + // Limit cache size (runs periodically) + function cleanupMarkdownCache() { + if (markdownCache.size > 100) { + const keys = Array.from(markdownCache.keys()); + // Delete the oldest 50 items + for (let i = 0; i < 50; i++) { + markdownCache.delete(keys[i]); + } + } + } + $effect.pre(() => { displaya(message) }); @@ -235,6 +262,10 @@ unsubscribers.push(ReloadGUIPointer.subscribe((v) => { displaya(message) })) + + // Clean up cache every 3 minutes + const cacheCleanupInterval = setInterval(cleanupMarkdownCache, 180000); + return () => clearInterval(cacheCleanupInterval); }) onDestroy(()=>{ @@ -313,11 +344,22 @@ style:line-height="{(DBState.db.lineHeight ?? 1.25) * (DBState.db.zoomsize / 100)}rem" > {#key $ReloadGUIPointer} - {#await markParsing(msgDisplay, character, 'normal', idx, translated)} - {@html lastParsed} - {:then md} - {@html md} - {/await} + {#if message && message.length > 10000} + + {#await new Promise(resolve => setTimeout(() => resolve(true), 10)) then _} + {#await markParsing(msgDisplay, character, 'normal', idx, translated)} + {@html lastParsed} + {:then md} + {@html md} + {/await} + {/await} + {:else} + {#await markParsing(msgDisplay, character, 'normal', idx, translated)} + {@html lastParsed} + {:then md} + {@html md} + {/await} + {/if} {/key} {/if} diff --git a/src/lib/ChatScreens/ChatScreen.svelte b/src/lib/ChatScreens/ChatScreen.svelte index 2f4acba4..55458a2e 100644 --- a/src/lib/ChatScreens/ChatScreen.svelte +++ b/src/lib/ChatScreens/ChatScreen.svelte @@ -5,6 +5,7 @@ import { CharEmotion, ShowVN, selectedCharID } from "../../ts/stores.svelte"; import ResizeBox from './ResizeBox.svelte' import DefaultChatScreen from "./DefaultChatScreen.svelte"; + import { clearMessageFormCache } from "../../ts/util"; import defaultWallpaper from '../../etc/bg.jpg' import ChatList from "../Others/ChatList.svelte"; import TransitionImage from "./TransitionImage.svelte"; @@ -14,6 +15,7 @@ import ModuleChatMenu from "../Setting/Pages/Module/ModuleChatMenu.svelte"; let openChatList = $state(false) let openModuleList = $state(false) + let lastCharacterId = $state(-1); const wallPaper = `background: url(${defaultWallpaper})` const externalStyles = @@ -31,6 +33,25 @@ } })() }); + + $effect.pre(() => { + if ($selectedCharID !== lastCharacterId) { + if (lastCharacterId >= 0) { + clearMessageFormCache(lastCharacterId); + + setTimeout(() => { + if (window.gc) { + try { + window.gc(); + } catch (e) { + console.log("GC not available"); + } + } + }, 100); + } + lastCharacterId = $selectedCharID; + } + }); {#if $ShowVN} diff --git a/src/lib/ChatScreens/DefaultChatScreen.svelte b/src/lib/ChatScreens/DefaultChatScreen.svelte index 991d9410..ed07c1d5 100644 --- a/src/lib/ChatScreens/DefaultChatScreen.svelte +++ b/src/lib/ChatScreens/DefaultChatScreen.svelte @@ -28,6 +28,10 @@ import { getInlayAsset } from 'src/ts/process/files/inlays'; import PlaygroundMenu from '../Playground/PlaygroundMenu.svelte'; import { ConnectionOpenStore } from 'src/ts/sync/multiuser'; + import { onMount, onDestroy } from "svelte"; + import AutoresizeArea from "../UI/GUI/TextAreaResizable.svelte"; + import { alertConfirm, alertClear } from "../../ts/alert"; + import { clearMessageFormCache } from "../../ts/util"; let messageInput:string = $state('') let messageInputTranslate:string = $state('') @@ -41,6 +45,107 @@ let currentCharacter:character|groupChat = $state(DBState.db.characters[$selectedCharID]) let toggleStickers:boolean = $state(false) let fileInput:string[] = $state([]) + + // Virtual scroll related states + let visibleStartIndex = $state(0); + let visibleEndIndex = $state(30); + let chatContainerRef: HTMLElement = $state(null); + let scrollPosition = $state(0); + let isScrolling = $state(false); + let scrollingTimeoutId: number; + + // Data loading state + let isInitialLoading = $state(true); + + // Function for optimization + function updateVisibleMessages(e?: Event) { + if (!chatContainerRef) return; + + const el = chatContainerRef; + const scrollTop = el.scrollTop; + const containerHeight = el.clientHeight; + + // Save scroll position + scrollPosition = scrollTop; + + // Update scrolling state + isScrolling = true; + clearTimeout(scrollingTimeoutId); + scrollingTimeoutId = window.setTimeout(() => { + isScrolling = false; + }, 200); + + // Optimized infinite scroll logic + const scrolled = el.scrollHeight - containerHeight + scrollTop; + if (scrolled < 100 && DBState.db.characters[$selectedCharID].chats[DBState.db.characters[$selectedCharID].chatPage].message.length > loadPages) { + loadPages += 15; + } + + // Limit maximum number of messages to display if there are many + if (DBState.db.characters[$selectedCharID].chats[DBState.db.characters[$selectedCharID].chatPage].message.length > 200) { + // Load only the most recent 200 messages in memory + const messageCount = DBState.db.characters[$selectedCharID].chats[DBState.db.characters[$selectedCharID].chatPage].message.length; + visibleStartIndex = Math.max(0, messageCount - loadPages); + visibleEndIndex = messageCount; + } else { + visibleStartIndex = 0; + visibleEndIndex = loadPages; + } + } + + // Asynchronous chat data loading function + async function loadChatDataAsync() { + isInitialLoading = true; + + // Small delay to allow UI rendering + await new Promise(resolve => setTimeout(resolve, 0)); + + // Check character change and clear cache + if(lastCharId !== $selectedCharID) { + clearMessageFormCache(lastCharId); + lastCharId = $selectedCharID; + rerolls = []; + rerollid = -1; + } + + // Process bulk data loading in background + await new Promise(resolve => setTimeout(resolve, 0)); + + // Process chat data asynchronously by chunks + const messages = DBState.db.characters[$selectedCharID].chats[DBState.db.characters[$selectedCharID].chatPage].message; + + if (messages.length > 100) { + // Process large message sets in chunks asynchronously + const chunkSize = 30; + let processedChunks = 0; + + for (let i = 0; i < messages.length; i += chunkSize) { + const chunk = messages.slice(i, i + chunkSize); + // Process each chunk + await new Promise(resolve => { + setTimeout(() => { + // Only prepare work here + // Actual rendering will be done later in messageForm + processedChunks++; + resolve(null); + }, 0); + }); + + // Determine initial load message count based on progress + loadPages = Math.min(messages.length, Math.max(30, processedChunks * chunkSize)); + + // Show all of the last 30 messages + if (i >= Math.max(0, messages.length - 30)) { + loadPages = messages.length; + } + } + } else { + loadPages = messages.length; + } + + isInitialLoading = false; + updateVisibleMessages(); + } async function send(){ return sendMain(false) @@ -418,6 +523,16 @@ $effect.pre(() => { currentCharacter = DBState.db.characters[$selectedCharID] }); + + // Load chat data when character or chat page changes + $effect.pre(() => { + const charId = $selectedCharID; + const chatPage = DBState.db.characters[$selectedCharID]?.chatPage; + + if (charId >= 0) { + loadChatDataAsync(); + } + }); @@ -431,13 +546,20 @@ {/if} {:else} -
{ - //@ts-ignore - const scrolled = (e.target.scrollHeight - e.target.clientHeight + e.target.scrollTop) - if(scrolled < 100 && DBState.db.characters[$selectedCharID].chats[DBState.db.characters[$selectedCharID].chatPage].message.length > loadPages){ - loadPages += 15 - } - }}> +
+ + + {#if isInitialLoading} +
+
+
+

{language.loading}

+
+
+ {/if} +
{#if DBState.db.useChatSticker && currentCharacter.type !== 'group'}
{toggleStickers = !toggleStickers}} @@ -639,50 +761,66 @@ )} {send}/> {/if} - {#each messageForm(DBState.db.characters[$selectedCharID].chats[DBState.db.characters[$selectedCharID].chatPage].message, loadPages) as chat, i} - {#if chat.role === 'char'} - {#if DBState.db.characters[$selectedCharID].type !== 'group'} - + {#key loadPages} + {#each messageForm(DBState.db.characters[$selectedCharID].chats[DBState.db.characters[$selectedCharID].chatPage].message, loadPages) as chat, i} + {#if !isScrolling || i < 20} + {#if chat.role === 'char'} + {#if DBState.db.characters[$selectedCharID].type !== 'group'} + + {:else} + + {/if} + {:else} + + {/if} {:else} - + +
+
+
+ +
+
+
+
+
{/if} - {:else} - - {/if} - {/each} + {/each} + {/key} + {#if DBState.db.characters[$selectedCharID].chats[DBState.db.characters[$selectedCharID].chatPage].message.length <= loadPages} {#if DBState.db.characters[$selectedCharID].type !== 'group' } >(); + +// Function to initialize the cache (called when changing characters) +export function clearMessageFormCache(charId?: number) { + if (charId !== undefined) { + messageFormCacheMap.delete(charId); + } else { + messageFormCacheMap.clear(); + } +} + export function messageForm(arg:Message[], loadPages:number){ let db = getDatabase() let selectedChar = get(selectedCharID) + + // Map for message caching (maintained as a static variable) + const messageCache = messageFormCacheMap.get(selectedChar) || new Map(); + function reformatContent(data:string){ return data.trim() } let a:Messagec[] = [] - for(let i=0;i= startIndex; i--){ + const m = arg[i]; + const cacheKey = `${m.role}-${i}-${m.chatId || 'none'}-${m.data.length}`; + + // Use cached result if available + if (messageCache.has(cacheKey)) { + a.push(messageCache.get(cacheKey)); + continue; + } + + // Create new if not cached + const processed: Messagec = { role: m.role, data: reformatContent(m.data), index: i, saying: m.saying, chatId: m.chatId ?? 'none', generationInfo: m.generationInfo, - }) + }; + + // Store in cache + messageCache.set(cacheKey, processed); + a.push(processed); } - return a.slice(0, loadPages) + + // Update cache map + messageFormCacheMap.set(selectedChar, messageCache); + + // Limit cache size (maximum 1000 entries) + if (messageCache.size > 1000) { + const keys = Array.from(messageCache.keys()); + for (let i = 0; i < keys.length - 1000; i++) { + messageCache.delete(keys[i]); + } + } + + return a; } export function sleep(ms: number) {