webui: add lazy markdown loading for improved performance

Add LazyMarkdownContent component that defers markdown rendering until
messages scroll into view. This significantly improves load times for
conversations with many messages (500+ messages would previously timeout).

Changes:
- New LazyMarkdownContent.svelte using IntersectionObserver
- ChatMessageAssistant uses lazy loading for completed messages,
  eager rendering for streaming messages
- ChatMessageUser and ChatMessageSystem use lazy loading
- Short messages (<200 chars) render immediately for responsiveness

The component shows a lightweight text placeholder until the message
enters the viewport (with 200px margin for preloading), then renders
full markdown with syntax highlighting, KaTeX, etc.
This commit is contained in:
Shem 2026-02-12 11:41:34 +00:00
parent f486ce9f30
commit 4836df97b9
5 changed files with 108 additions and 4 deletions

View File

@ -5,6 +5,7 @@
ChatMessageStatistics,
ChatMessageThinkingBlock,
CopyToClipboardIcon,
LazyMarkdownContent,
MarkdownContent,
ModelsSelector
} from '$lib/components/app';
@ -240,7 +241,11 @@
{:else if message.role === 'assistant'}
{#if config().disableReasoningFormat}
<pre class="raw-output">{messageContent || ''}</pre>
{:else if message.timestamp && !isLoading()}
<!-- Completed message: use lazy loading for performance with many messages -->
<LazyMarkdownContent content={messageContent || ''} />
{:else}
<!-- Streaming message: render immediately -->
<MarkdownContent content={messageContent || ''} />
{/if}
{:else}

View File

@ -2,7 +2,7 @@
import { Check, X } from '@lucide/svelte';
import { Card } from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { MarkdownContent } from '$lib/components/app';
import { LazyMarkdownContent } from '$lib/components/app';
import { INPUT_CLASSES } from '$lib/constants/input-classes';
import { config } from '$lib/stores/settings.svelte';
import ChatMessageActions from './ChatMessageActions.svelte';
@ -145,7 +145,7 @@
>
{#if currentConfig.renderUserContentAsMarkdown}
<div bind:this={messageElement} class="text-md {isExpanded ? 'cursor-text' : ''}">
<MarkdownContent class="markdown-system-content" content={message.content} />
<LazyMarkdownContent class="markdown-system-content" content={message.content} />
</div>
{:else}
<span

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { Card } from '$lib/components/ui/card';
import { ChatAttachmentsList, MarkdownContent } from '$lib/components/app';
import { ChatAttachmentsList, LazyMarkdownContent, MarkdownContent } from '$lib/components/app';
import { config } from '$lib/stores/settings.svelte';
import ChatMessageActions from './ChatMessageActions.svelte';
import ChatMessageEditForm from './ChatMessageEditForm.svelte';
@ -128,7 +128,7 @@
>
{#if currentConfig.renderUserContentAsMarkdown}
<div bind:this={messageElement} class="text-md">
<MarkdownContent
<LazyMarkdownContent
class="markdown-user-content text-primary-foreground"
content={message.content}
/>

View File

@ -62,6 +62,7 @@ export { default as BadgeModality } from './misc/BadgeModality.svelte';
export { default as ConversationSelection } from './misc/ConversationSelection.svelte';
export { default as CopyToClipboardIcon } from './misc/CopyToClipboardIcon.svelte';
export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.svelte';
export { default as LazyMarkdownContent } from './misc/LazyMarkdownContent.svelte';
export { default as MarkdownContent } from './misc/MarkdownContent.svelte';
export { default as RemoveButton } from './misc/RemoveButton.svelte';
export { default as SearchInput } from './misc/SearchInput.svelte';

View File

@ -0,0 +1,98 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import MarkdownContent from './MarkdownContent.svelte';
interface Props {
content: string;
class?: string;
/** If true, render immediately without waiting for intersection */
eager?: boolean;
/** Placeholder height estimate for layout stability */
placeholderHeight?: string;
}
let { content, class: className = '', eager = false, placeholderHeight = 'auto' }: Props = $props();
let containerRef = $state<HTMLDivElement>();
let isVisible = $state(false);
let hasBeenVisible = $state(false);
let observer: IntersectionObserver | null = null;
// Initialize based on eager prop
$effect(() => {
if (eager) {
isVisible = true;
hasBeenVisible = true;
}
});
onMount(() => {
if (eager) {
hasBeenVisible = true;
isVisible = true;
return;
}
if (!containerRef) {
return;
}
observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
isVisible = true;
hasBeenVisible = true;
// Once visible, no need to observe anymore
observer?.disconnect();
}
}
},
{
// Start loading slightly before element comes into view
rootMargin: '200px 0px',
threshold: 0
}
);
observer.observe(containerRef);
});
onDestroy(() => {
observer?.disconnect();
});
// If content is very short, render eagerly (no point lazy loading)
$effect(() => {
if (content.length < 200 && !hasBeenVisible) {
hasBeenVisible = true;
isVisible = true;
}
});
</script>
<div
bind:this={containerRef}
class={className}
style:min-height={!hasBeenVisible ? placeholderHeight : 'auto'}
>
{#if hasBeenVisible}
<MarkdownContent {content} class={className} />
{:else}
<!-- Lightweight placeholder: show raw text truncated -->
<div class="placeholder-content">
{content.slice(0, 500)}{content.length > 500 ? '...' : ''}
</div>
{/if}
</div>
<style>
.placeholder-content {
white-space: pre-wrap;
word-break: break-word;
color: var(--muted-foreground);
font-size: 0.875rem;
line-height: 1.5;
opacity: 0.7;
}
</style>