ui-svelte: incremental rendering of chat messages in the Playground (#520)

add incremental rendering to Playground > Chat
This commit is contained in:
Benson Wong
2026-02-15 11:00:44 -08:00
committed by GitHub
parent d5e52d7d00
commit 3e52144058
4 changed files with 502 additions and 16 deletions
@@ -11,7 +11,16 @@
const systemPromptStore = persistentStore<string>("playground-system-prompt", "");
const temperatureStore = persistentStore<number>("playground-temperature", 0.7);
let messages = $state<ChatMessage[]>([]);
function loadMessages(): ChatMessage[] {
try {
const saved = localStorage.getItem("playground-messages");
return saved ? JSON.parse(saved) : [];
} catch {
return [];
}
}
let messages = $state<ChatMessage[]>(loadMessages());
let userInput = $state("");
let isStreaming = $state(false);
let isReasoning = $state(false);
@@ -24,21 +33,48 @@
let imageError = $state<string | null>(null);
let hasModels = $derived($models.some((m) => !m.unlisted));
let userScrolledUp = $state(false);
// Auto-scroll when messages change
function handleMessagesScroll() {
if (!messagesContainer) return;
const { scrollTop, scrollHeight, clientHeight } = messagesContainer;
// Consider "at bottom" if within 40px of the bottom
userScrolledUp = scrollHeight - scrollTop - clientHeight > 40;
}
// Auto-scroll when messages change — skip if user scrolled up
$effect(() => {
if (messages.length > 0 && messagesContainer) {
if (messages.length > 0 && messagesContainer && !userScrolledUp) {
messagesContainer.scrollTo({
top: messagesContainer.scrollHeight,
behavior: "smooth",
behavior: isStreaming ? "instant" : "smooth",
});
}
});
// Persist messages to localStorage (throttled to once per 2s)
let lastSaveTime = 0;
$effect(() => {
const json = JSON.stringify(messages);
const elapsed = Date.now() - lastSaveTime;
const save = () => {
try { localStorage.setItem("playground-messages", json); } catch {}
lastSaveTime = Date.now();
};
if (elapsed >= 2000) {
save();
return;
}
const timer = setTimeout(save, 2000 - elapsed);
return () => clearTimeout(timer);
});
async function sendMessage() {
const trimmedInput = userInput.trim();
if ((!trimmedInput && attachedImages.length === 0) || !$selectedModelStore || isStreaming) return;
userScrolledUp = false;
// Build message content (multimodal if images attached)
let content: string | ContentPart[];
if (attachedImages.length > 0) {
@@ -321,6 +357,7 @@
<div
class="flex-1 overflow-y-auto mb-4 px-2"
bind:this={messagesContainer}
onscroll={handleMessagesScroll}
>
{#if messages.length === 0}
<div class="h-full flex items-center justify-center text-txtsecondary">
@@ -1,5 +1,6 @@
<script lang="ts">
import { renderMarkdown, escapeHtml } from "../../lib/markdown";
import { renderMarkdown, escapeHtml, renderStreamingMarkdown, createStreamingCache } from "../../lib/markdown";
import type { RenderedBlock } from "../../lib/markdown";
import { Copy, Check, Pencil, X, Save, RefreshCw, ChevronDown, ChevronRight, Brain, Code } from "lucide-svelte";
import { getTextContent, getImageUrls } from "../../lib/types";
import type { ContentPart } from "../../lib/types";
@@ -22,11 +23,17 @@
let hasImages = $derived(imageUrls.length > 0);
let canEdit = $derived(onEdit !== undefined && !hasImages);
let renderedContent = $derived(
role === "assistant" && !isStreaming
? renderMarkdown(textContent)
: escapeHtml(textContent).replace(/\n/g, '<br>')
);
let streamingCache = createStreamingCache();
let renderedParts = $derived.by(() => {
if (role !== "assistant") {
return { blocks: [{ id: -1, html: escapeHtml(textContent).replace(/\n/g, '<br>') }] as RenderedBlock[], pendingHtml: "" };
}
if (!isStreaming) {
streamingCache = createStreamingCache();
return { blocks: [{ id: -1, html: renderMarkdown(textContent) }] as RenderedBlock[], pendingHtml: "" };
}
return renderStreamingMarkdown(textContent, streamingCache);
});
let copied = $state(false);
let showRaw = $state(false);
let isEditing = $state(false);
@@ -113,9 +120,9 @@
<div class="flex {role === 'user' ? 'justify-end' : 'justify-start'} mb-4">
<div
class="relative group max-w-[85%] rounded-lg px-4 py-2 {role === 'user'
? 'bg-primary text-btn-primary-text'
: 'bg-surface border border-gray-200 dark:border-white/10'}"
class="relative group rounded-lg px-4 py-2 {role === 'user'
? 'max-w-[85%] bg-primary text-btn-primary-text'
: 'w-full sm:w-4/5 bg-surface border border-gray-200 dark:border-white/10'}"
>
{#if role === "assistant"}
{#if reasoning_content || isReasoning}
@@ -168,7 +175,10 @@
<div class="whitespace-pre-wrap font-mono text-sm">{textContent}</div>
{:else}
<div class="prose prose-sm dark:prose-invert max-w-none">
{@html renderedContent}
{#each renderedParts.blocks as block (block.id)}
{@html block.html}
{/each}
{@html renderedParts.pendingHtml}
{#if isStreaming && !isReasoning}
<span class="inline-block w-2 h-4 bg-current animate-pulse ml-0.5"></span>
{/if}