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:
@@ -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<string, string>();
|
||||
|
||||
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}
|
||||
{#if message && message.length > 10000}
|
||||
<!-- Delayed rendering for long messages -->
|
||||
{#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}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $ShowVN}
|
||||
|
||||
@@ -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('')
|
||||
@@ -42,6 +46,107 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
@@ -431,13 +546,20 @@
|
||||
<PlaygroundMenu />
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="h-full w-full flex flex-col-reverse overflow-y-auto relative default-chat-screen" onscroll={(e) => {
|
||||
//@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
|
||||
}
|
||||
}}>
|
||||
<div class="h-full w-full flex flex-col-reverse overflow-y-auto relative default-chat-screen"
|
||||
bind:this={chatContainerRef}
|
||||
onscroll={updateVisibleMessages}>
|
||||
|
||||
<!-- 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">
|
||||
{#if DBState.db.useChatSticker && currentCharacter.type !== 'group'}
|
||||
<div onclick={()=>{toggleStickers = !toggleStickers}}
|
||||
@@ -639,7 +761,9 @@
|
||||
)} {send}/>
|
||||
{/if}
|
||||
|
||||
{#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'}
|
||||
<Chat
|
||||
@@ -682,7 +806,21 @@
|
||||
messageGenerationInfo={chat.generationInfo}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Simplified message placeholder for when scrolling -->
|
||||
<div class="flex max-w-full justify-center opacity-30">
|
||||
<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">
|
||||
<div class="h-8 w-8 bg-textcolor2 rounded-full opacity-30"></div>
|
||||
<span class="flex flex-col ml-4 w-full max-w-full min-w-0">
|
||||
<div class="h-6 w-32 bg-textcolor2 rounded-md opacity-30 mb-2"></div>
|
||||
<div class="h-4 w-full bg-textcolor2 rounded-md opacity-30"></div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/key}
|
||||
|
||||
{#if DBState.db.characters[$selectedCharID].chats[DBState.db.characters[$selectedCharID].chatPage].message.length <= loadPages}
|
||||
{#if DBState.db.characters[$selectedCharID].type !== 'group' }
|
||||
<Chat
|
||||
|
||||
@@ -16,26 +16,70 @@ export interface Messagec extends Message{
|
||||
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){
|
||||
let db = getDatabase()
|
||||
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){
|
||||
return data.trim()
|
||||
}
|
||||
|
||||
let a:Messagec[] = []
|
||||
for(let i=0;i<arg.length;i++){
|
||||
const m = arg[i]
|
||||
a.unshift({
|
||||
const startIndex = Math.max(0, arg.length - loadPages);
|
||||
|
||||
// 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,
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user