Optimize chat rendering and performance with caching and lazy loading (#773)

# PR Checklist
- [ ] Have you checked if it works normally in all models? *Ignore this
if it doesn't use models.*
- [x] Have you checked if it works normally in all web, local, and node
hosted versions? If it doesn't, have you blocked it in those versions?
- [x] Have you added type definitions?

# Description
Enhance chat application efficiency by implementing caching for markdown
parsing and message forms, along with lazy loading and virtual scrolling
for improved performance in large chat histories. Introduce a cleanup
mechanism for cache management.
This commit is contained in:
kwaroran
2025-03-05 05:39:00 +09:00
committed by GitHub
4 changed files with 305 additions and 60 deletions

View File

@@ -130,9 +130,22 @@
$effect.pre(() => { $effect.pre(() => {
blankMessage = (message === '{{none}}' || message === '{{blank}}' || message === '') && idx === -1 blankMessage = (message === '{{none}}' || message === '{{blank}}' || message === '') && idx === -1
}); });
// Static map for caching markdown parsing results
const markdownCache = new Map<string, string>();
const markParsing = async (data: string, charArg?: string | simpleCharacterArgument, mode?: "normal" | "back", chatID?: number, translateText?:boolean, tries?:number) => { const markParsing = async (data: string, charArg?: string | simpleCharacterArgument, mode?: "normal" | "back", chatID?: number, translateText?:boolean, tries?:number) => {
let lastParsedQueue = '' let lastParsedQueue = ''
try { 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)){ if((!isEqual(lastCharArg, charArg)) || (chatID !== lastChatId)){
lastParsedQueue = '' lastParsedQueue = ''
lastCharArg = charArg lastCharArg = charArg
@@ -184,6 +197,7 @@
const marked = await ParseMarkdown(data, charArg, mode, chatID, getCbsCondition()) const marked = await ParseMarkdown(data, charArg, mode, chatID, getCbsCondition())
lastParsedQueue = marked lastParsedQueue = marked
lastCharArg = charArg lastCharArg = charArg
markdownCache.set(cacheKey, marked);
return marked return marked
} }
else if(!DBState.db.legacyTranslation){ else if(!DBState.db.legacyTranslation){
@@ -193,6 +207,7 @@
translating = false translating = false
lastParsedQueue = translated lastParsedQueue = translated
lastCharArg = charArg lastCharArg = charArg
markdownCache.set(cacheKey, translated);
return translated return translated
} }
else{ else{
@@ -202,6 +217,7 @@
translating = false translating = false
lastParsedQueue = translated lastParsedQueue = translated
lastCharArg = charArg lastCharArg = charArg
markdownCache.set(cacheKey, translated);
return translated return translated
} }
} }
@@ -209,12 +225,12 @@
const marked = await ParseMarkdown(data, charArg, mode, chatID, getCbsCondition()) const marked = await ParseMarkdown(data, charArg, mode, chatID, getCbsCondition())
lastParsedQueue = marked lastParsedQueue = marked
lastCharArg = charArg lastCharArg = charArg
markdownCache.set(cacheKey, marked);
return marked return marked
} }
} catch (error) { } catch (error) {
//retry //retry
if(tries > 2){ if(tries > 2){
alertError(`Error while parsing chat message: ${translateText}, ${error.message}, ${error.stack}`) alertError(`Error while parsing chat message: ${translateText}, ${error.message}, ${error.stack}`)
return data 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(() => { $effect.pre(() => {
displaya(message) displaya(message)
}); });
@@ -235,6 +262,10 @@
unsubscribers.push(ReloadGUIPointer.subscribe((v) => { unsubscribers.push(ReloadGUIPointer.subscribe((v) => {
displaya(message) displaya(message)
})) }))
// Clean up cache every 3 minutes
const cacheCleanupInterval = setInterval(cleanupMarkdownCache, 180000);
return () => clearInterval(cacheCleanupInterval);
}) })
onDestroy(()=>{ onDestroy(()=>{
@@ -313,11 +344,22 @@
style:line-height="{(DBState.db.lineHeight ?? 1.25) * (DBState.db.zoomsize / 100)}rem" style:line-height="{(DBState.db.lineHeight ?? 1.25) * (DBState.db.zoomsize / 100)}rem"
> >
{#key $ReloadGUIPointer} {#key $ReloadGUIPointer}
{#await markParsing(msgDisplay, character, 'normal', idx, translated)} {#if message && message.length > 10000}
{@html lastParsed} <!-- Delayed rendering for long messages -->
{:then md} {#await new Promise(resolve => setTimeout(() => resolve(true), 10)) then _}
{@html md} {#await markParsing(msgDisplay, character, 'normal', idx, translated)}
{/await} {@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} {/key}
</span> </span>
{/if} {/if}

View File

@@ -5,6 +5,7 @@
import { CharEmotion, ShowVN, selectedCharID } from "../../ts/stores.svelte"; import { CharEmotion, ShowVN, selectedCharID } from "../../ts/stores.svelte";
import ResizeBox from './ResizeBox.svelte' import ResizeBox from './ResizeBox.svelte'
import DefaultChatScreen from "./DefaultChatScreen.svelte"; import DefaultChatScreen from "./DefaultChatScreen.svelte";
import { clearMessageFormCache } from "../../ts/util";
import defaultWallpaper from '../../etc/bg.jpg' import defaultWallpaper from '../../etc/bg.jpg'
import ChatList from "../Others/ChatList.svelte"; import ChatList from "../Others/ChatList.svelte";
import TransitionImage from "./TransitionImage.svelte"; import TransitionImage from "./TransitionImage.svelte";
@@ -14,6 +15,7 @@
import ModuleChatMenu from "../Setting/Pages/Module/ModuleChatMenu.svelte"; import ModuleChatMenu from "../Setting/Pages/Module/ModuleChatMenu.svelte";
let openChatList = $state(false) let openChatList = $state(false)
let openModuleList = $state(false) let openModuleList = $state(false)
let lastCharacterId = $state(-1);
const wallPaper = `background: url(${defaultWallpaper})` const wallPaper = `background: url(${defaultWallpaper})`
const externalStyles = 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;
}
});
</script> </script>
{#if $ShowVN} {#if $ShowVN}

View File

@@ -28,6 +28,10 @@
import { getInlayAsset } from 'src/ts/process/files/inlays'; import { getInlayAsset } from 'src/ts/process/files/inlays';
import PlaygroundMenu from '../Playground/PlaygroundMenu.svelte'; import PlaygroundMenu from '../Playground/PlaygroundMenu.svelte';
import { ConnectionOpenStore } from 'src/ts/sync/multiuser'; 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 messageInput:string = $state('')
let messageInputTranslate:string = $state('') let messageInputTranslate:string = $state('')
@@ -41,6 +45,107 @@
let currentCharacter:character|groupChat = $state(DBState.db.characters[$selectedCharID]) let currentCharacter:character|groupChat = $state(DBState.db.characters[$selectedCharID])
let toggleStickers:boolean = $state(false) let toggleStickers:boolean = $state(false)
let fileInput:string[] = $state([]) 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(){ async function send(){
return sendMain(false) return sendMain(false)
@@ -418,6 +523,16 @@
$effect.pre(() => { $effect.pre(() => {
currentCharacter = DBState.db.characters[$selectedCharID] 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();
}
});
</script> </script>
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -431,13 +546,20 @@
<PlaygroundMenu /> <PlaygroundMenu />
{/if} {/if}
{:else} {:else}
<div class="h-full w-full flex flex-col-reverse overflow-y-auto relative default-chat-screen" onscroll={(e) => { <div class="h-full w-full flex flex-col-reverse overflow-y-auto relative default-chat-screen"
//@ts-ignore bind:this={chatContainerRef}
const scrolled = (e.target.scrollHeight - e.target.clientHeight + e.target.scrollTop) onscroll={updateVisibleMessages}>
if(scrolled < 100 && DBState.db.characters[$selectedCharID].chats[DBState.db.characters[$selectedCharID].chatPage].message.length > loadPages){
loadPages += 15 <!-- Add loading screen -->
} {#if isInitialLoading}
}}> <div class="absolute inset-0 flex items-center justify-center bg-darkbg bg-opacity-30 z-10">
<div class="flex flex-col items-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-textcolor"></div>
<p class="text-textcolor mt-4">{language.loading}</p>
</div>
</div>
{/if}
<div class="flex items-stretch mt-2 mb-2 w-full"> <div class="flex items-stretch mt-2 mb-2 w-full">
{#if DBState.db.useChatSticker && currentCharacter.type !== 'group'} {#if DBState.db.useChatSticker && currentCharacter.type !== 'group'}
<div onclick={()=>{toggleStickers = !toggleStickers}} <div onclick={()=>{toggleStickers = !toggleStickers}}
@@ -639,50 +761,66 @@
)} {send}/> )} {send}/>
{/if} {/if}
{#each messageForm(DBState.db.characters[$selectedCharID].chats[DBState.db.characters[$selectedCharID].chatPage].message, loadPages) as chat, i} {#key loadPages}
{#if chat.role === 'char'} {#each messageForm(DBState.db.characters[$selectedCharID].chats[DBState.db.characters[$selectedCharID].chatPage].message, loadPages) as chat, i}
{#if DBState.db.characters[$selectedCharID].type !== 'group'} {#if !isScrolling || i < 20}
<Chat {#if chat.role === 'char'}
idx={chat.index} {#if DBState.db.characters[$selectedCharID].type !== 'group'}
name={DBState.db.characters[$selectedCharID].name} <Chat
message={chat.data} idx={chat.index}
img={getCharImage(DBState.db.characters[$selectedCharID].image, 'css')} name={DBState.db.characters[$selectedCharID].name}
rerollIcon={i === 0} message={chat.data}
onReroll={reroll} img={getCharImage(DBState.db.characters[$selectedCharID].image, 'css')}
unReroll={unReroll} rerollIcon={i === 0}
isLastMemory={DBState.db.characters[$selectedCharID].chats[DBState.db.characters[$selectedCharID].chatPage].lastMemory === (chat.chatId ?? 'none') && DBState.db.showMemoryLimit} onReroll={reroll}
character={createSimpleCharacter(DBState.db.characters[$selectedCharID])} unReroll={unReroll}
largePortrait={DBState.db.characters[$selectedCharID].largePortrait} isLastMemory={DBState.db.characters[$selectedCharID].chats[DBState.db.characters[$selectedCharID].chatPage].lastMemory === (chat.chatId ?? 'none') && DBState.db.showMemoryLimit}
messageGenerationInfo={chat.generationInfo} character={createSimpleCharacter(DBState.db.characters[$selectedCharID])}
/> largePortrait={DBState.db.characters[$selectedCharID].largePortrait}
messageGenerationInfo={chat.generationInfo}
/>
{:else}
<Chat
idx={chat.index}
name={findCharacterbyId(chat.saying).name}
rerollIcon={i === 0}
message={chat.data}
onReroll={reroll}
unReroll={unReroll}
img={getCharImage(findCharacterbyId(chat.saying).image, 'css')}
isLastMemory={DBState.db.characters[$selectedCharID].chats[DBState.db.characters[$selectedCharID].chatPage].lastMemory === (chat.chatId ?? 'none') && DBState.db.showMemoryLimit}
character={chat.saying}
largePortrait={findCharacterbyId(chat.saying).largePortrait}
messageGenerationInfo={chat.generationInfo}
/>
{/if}
{:else}
<Chat
character={createSimpleCharacter(DBState.db.characters[$selectedCharID])}
idx={chat.index}
name={chat.name ?? currentUsername}
message={chat.data}
img={$ConnectionOpenStore ? '' : getCharImage(userIcon, 'css')}
isLastMemory={DBState.db.characters[$selectedCharID].chats[DBState.db.characters[$selectedCharID].chatPage].lastMemory === (chat.chatId ?? 'none') && DBState.db.showMemoryLimit}
largePortrait={userIconProtrait}
messageGenerationInfo={chat.generationInfo}
/>
{/if}
{:else} {:else}
<Chat <!-- Simplified message placeholder for when scrolling -->
idx={chat.index} <div class="flex max-w-full justify-center opacity-30">
name={findCharacterbyId(chat.saying).name} <div class="text-textcolor mt-1 ml-4 mr-4 mb-1 p-2 bg-transparent flex-grow border-t-gray-900 border-opacity-30 border-transparent flexium items-start max-w-full">
rerollIcon={i === 0} <div class="h-8 w-8 bg-textcolor2 rounded-full opacity-30"></div>
message={chat.data} <span class="flex flex-col ml-4 w-full max-w-full min-w-0">
onReroll={reroll} <div class="h-6 w-32 bg-textcolor2 rounded-md opacity-30 mb-2"></div>
unReroll={unReroll} <div class="h-4 w-full bg-textcolor2 rounded-md opacity-30"></div>
img={getCharImage(findCharacterbyId(chat.saying).image, 'css')} </span>
isLastMemory={DBState.db.characters[$selectedCharID].chats[DBState.db.characters[$selectedCharID].chatPage].lastMemory === (chat.chatId ?? 'none') && DBState.db.showMemoryLimit} </div>
character={chat.saying} </div>
largePortrait={findCharacterbyId(chat.saying).largePortrait}
messageGenerationInfo={chat.generationInfo}
/>
{/if} {/if}
{:else} {/each}
<Chat {/key}
character={createSimpleCharacter(DBState.db.characters[$selectedCharID])}
idx={chat.index}
name={chat.name ?? currentUsername}
message={chat.data}
img={$ConnectionOpenStore ? '' : getCharImage(userIcon, 'css')}
isLastMemory={DBState.db.characters[$selectedCharID].chats[DBState.db.characters[$selectedCharID].chatPage].lastMemory === (chat.chatId ?? 'none') && DBState.db.showMemoryLimit}
largePortrait={userIconProtrait}
messageGenerationInfo={chat.generationInfo}
/>
{/if}
{/each}
{#if DBState.db.characters[$selectedCharID].chats[DBState.db.characters[$selectedCharID].chatPage].message.length <= loadPages} {#if DBState.db.characters[$selectedCharID].chats[DBState.db.characters[$selectedCharID].chatPage].message.length <= loadPages}
{#if DBState.db.characters[$selectedCharID].type !== 'group' } {#if DBState.db.characters[$selectedCharID].type !== 'group' }
<Chat <Chat

View File

@@ -16,26 +16,70 @@ export interface Messagec extends Message{
index: number index: number
} }
// Map to store message caches for each character
const messageFormCacheMap = new Map<number, Map<string, Messagec>>();
// 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){ export function messageForm(arg:Message[], loadPages:number){
let db = getDatabase() let db = getDatabase()
let selectedChar = get(selectedCharID) let selectedChar = get(selectedCharID)
// Map for message caching (maintained as a static variable)
const messageCache = messageFormCacheMap.get(selectedChar) || new Map<string, Messagec>();
function reformatContent(data:string){ function reformatContent(data:string){
return data.trim() return data.trim()
} }
let a:Messagec[] = [] let a:Messagec[] = []
for(let i=0;i<arg.length;i++){ const startIndex = Math.max(0, arg.length - loadPages);
const m = arg[i]
a.unshift({ // Process only necessary messages (up to loadPages)
for(let i = arg.length - 1; 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, role: m.role,
data: reformatContent(m.data), data: reformatContent(m.data),
index: i, index: i,
saying: m.saying, saying: m.saying,
chatId: m.chatId ?? 'none', chatId: m.chatId ?? 'none',
generationInfo: m.generationInfo, 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) { export function sleep(ms: number) {