mirror of
https://github.com/mostlygeek/llama-swap.git
synced 2026-06-09 06:46:34 +02:00
ui-svelte: incremental rendering of chat messages in the Playground (#520)
add incremental rendering to Playground > Chat
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderMarkdown, escapeHtml } from "./markdown";
|
||||
import { renderMarkdown, escapeHtml, splitCompleteBlocks, closePendingBlock, normalizeLatexDelimiters, renderStreamingMarkdown, createStreamingCache } from "./markdown";
|
||||
|
||||
describe("renderMarkdown", () => {
|
||||
describe("basic markdown", () => {
|
||||
@@ -130,6 +130,35 @@ More text here.
|
||||
expect(result).toContain("katex");
|
||||
expect(result).toContain("sqrt");
|
||||
});
|
||||
|
||||
it("renders \\[...\\] display math", () => {
|
||||
const result = renderMarkdown("\\[\nx^2 + y^2 = z^2\n\\]");
|
||||
expect(result).toContain("katex");
|
||||
});
|
||||
|
||||
it("renders \\(...\\) inline math", () => {
|
||||
const result = renderMarkdown("The equation \\(E = mc^2\\) is famous.");
|
||||
expect(result).toContain("katex");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeLatexDelimiters", () => {
|
||||
it("converts \\[...\\] to $$...$$", () => {
|
||||
expect(normalizeLatexDelimiters("\\[\nx^2\n\\]")).toBe("$$\nx^2\n$$");
|
||||
});
|
||||
|
||||
it("converts \\(...\\) to $...$", () => {
|
||||
expect(normalizeLatexDelimiters("\\(x^2\\)")).toBe("$x^2$");
|
||||
});
|
||||
|
||||
it("leaves $$ and $ delimiters unchanged", () => {
|
||||
const text = "$$x^2$$ and $y$";
|
||||
expect(normalizeLatexDelimiters(text)).toBe(text);
|
||||
});
|
||||
|
||||
it("handles multiple occurrences", () => {
|
||||
expect(normalizeLatexDelimiters("\\(a\\) and \\(b\\)")).toBe("$a$ and $b$");
|
||||
});
|
||||
});
|
||||
|
||||
describe("escapeHtml", () => {
|
||||
@@ -158,3 +187,237 @@ More text here.
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitCompleteBlocks", () => {
|
||||
it("returns everything as pending when no blank line", () => {
|
||||
const result = splitCompleteBlocks("Hello world");
|
||||
expect(result.complete).toBe("");
|
||||
expect(result.pending).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("returns empty for empty input", () => {
|
||||
const result = splitCompleteBlocks("");
|
||||
expect(result.complete).toBe("");
|
||||
expect(result.pending).toBe("");
|
||||
});
|
||||
|
||||
it("splits on blank line between paragraphs", () => {
|
||||
const result = splitCompleteBlocks("First paragraph.\n\nSecond paragraph");
|
||||
expect(result.complete).toBe("First paragraph.\n");
|
||||
expect(result.pending).toBe("Second paragraph");
|
||||
});
|
||||
|
||||
it("splits multiple paragraphs at last blank line", () => {
|
||||
const result = splitCompleteBlocks("Para 1.\n\nPara 2.\n\nPara 3");
|
||||
expect(result.complete).toBe("Para 1.\n\nPara 2.\n");
|
||||
expect(result.pending).toBe("Para 3");
|
||||
});
|
||||
|
||||
it("treats closed code fence as complete boundary", () => {
|
||||
const text = "```js\nconst x = 1;\n```\nMore text";
|
||||
const result = splitCompleteBlocks(text);
|
||||
expect(result.complete).toBe("```js\nconst x = 1;\n```");
|
||||
expect(result.pending).toBe("More text");
|
||||
});
|
||||
|
||||
it("treats unclosed code fence as pending", () => {
|
||||
const text = "Done paragraph.\n\n```js\nconst x = 1;";
|
||||
const result = splitCompleteBlocks(text);
|
||||
expect(result.complete).toBe("Done paragraph.\n");
|
||||
expect(result.pending).toBe("```js\nconst x = 1;");
|
||||
});
|
||||
|
||||
it("does not split on blank lines inside code fences", () => {
|
||||
const text = "```\nline1\n\nline2\n```";
|
||||
const result = splitCompleteBlocks(text);
|
||||
expect(result.complete).toBe("```\nline1\n\nline2\n```");
|
||||
expect(result.pending).toBe("");
|
||||
});
|
||||
|
||||
it("handles tilde fences", () => {
|
||||
const text = "~~~py\nprint('hi')\n~~~\nAfter";
|
||||
const result = splitCompleteBlocks(text);
|
||||
expect(result.complete).toBe("~~~py\nprint('hi')\n~~~");
|
||||
expect(result.pending).toBe("After");
|
||||
});
|
||||
|
||||
it("does not close backtick fence with tilde fence", () => {
|
||||
const text = "```\ncode\n~~~\nstill code";
|
||||
const result = splitCompleteBlocks(text);
|
||||
// The ~~~ should not close a backtick fence, so everything from ``` onward is pending
|
||||
expect(result.complete).toBe("");
|
||||
expect(result.pending).toBe("```\ncode\n~~~\nstill code");
|
||||
});
|
||||
|
||||
it("treats closed math block as complete boundary", () => {
|
||||
const text = "$$\nx^2\n$$\nAfter";
|
||||
const result = splitCompleteBlocks(text);
|
||||
expect(result.complete).toBe("$$\nx^2\n$$");
|
||||
expect(result.pending).toBe("After");
|
||||
});
|
||||
|
||||
it("treats unclosed math block as pending", () => {
|
||||
const text = "Before.\n\n$$\nx^2";
|
||||
const result = splitCompleteBlocks(text);
|
||||
expect(result.complete).toBe("Before.\n");
|
||||
expect(result.pending).toBe("$$\nx^2");
|
||||
});
|
||||
|
||||
it("treats closed \\[...\\] math block as complete boundary", () => {
|
||||
const text = "\\[\nx^2\n\\]\nAfter";
|
||||
const result = splitCompleteBlocks(text);
|
||||
expect(result.complete).toBe("\\[\nx^2\n\\]");
|
||||
expect(result.pending).toBe("After");
|
||||
});
|
||||
|
||||
it("treats unclosed \\[ math block as pending", () => {
|
||||
const text = "Before.\n\n\\[\nx^2";
|
||||
const result = splitCompleteBlocks(text);
|
||||
expect(result.complete).toBe("Before.\n");
|
||||
expect(result.pending).toBe("\\[\nx^2");
|
||||
});
|
||||
|
||||
it("handles trailing blank line making everything complete", () => {
|
||||
const text = "Hello world.\n";
|
||||
const result = splitCompleteBlocks(text);
|
||||
// Last line is empty string after split, which is a blank line
|
||||
expect(result.complete).toBe("Hello world.\n");
|
||||
expect(result.pending).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("closePendingBlock", () => {
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(closePendingBlock("")).toBe("");
|
||||
});
|
||||
|
||||
it("returns plain text unchanged", () => {
|
||||
expect(closePendingBlock("Hello world")).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("closes an open backtick code fence", () => {
|
||||
const result = closePendingBlock("```python\nprint('hi')");
|
||||
expect(result).toBe("```python\nprint('hi')\n```");
|
||||
});
|
||||
|
||||
it("closes an open tilde code fence", () => {
|
||||
const result = closePendingBlock("~~~js\nconst x = 1;");
|
||||
expect(result).toBe("~~~js\nconst x = 1;\n~~~");
|
||||
});
|
||||
|
||||
it("does not modify already-closed code fence", () => {
|
||||
const text = "```py\ncode\n```";
|
||||
expect(closePendingBlock(text)).toBe(text);
|
||||
});
|
||||
|
||||
it("closes an open math block", () => {
|
||||
const result = closePendingBlock("$$\nx^2 + y^2");
|
||||
expect(result).toBe("$$\nx^2 + y^2\n$$");
|
||||
});
|
||||
|
||||
it("does not modify already-closed math block", () => {
|
||||
const text = "$$\nx^2\n$$";
|
||||
expect(closePendingBlock(text)).toBe(text);
|
||||
});
|
||||
|
||||
it("closes an open \\[ math block with \\]", () => {
|
||||
const result = closePendingBlock("\\[\nx^2 + y^2");
|
||||
expect(result).toBe("\\[\nx^2 + y^2\n\\]");
|
||||
});
|
||||
|
||||
it("does not modify already-closed \\[...\\] math block", () => {
|
||||
const text = "\\[\nx^2\n\\]";
|
||||
expect(closePendingBlock(text)).toBe(text);
|
||||
});
|
||||
|
||||
it("closes code fence when preceded by regular text", () => {
|
||||
const result = closePendingBlock("Some text\n```\ncode");
|
||||
expect(result).toBe("Some text\n```\ncode\n```");
|
||||
});
|
||||
|
||||
it("leaves headers unchanged", () => {
|
||||
expect(closePendingBlock("## Hello")).toBe("## Hello");
|
||||
});
|
||||
|
||||
it("leaves tables unchanged", () => {
|
||||
const table = "| a | b |\n| --- | --- |\n| 1 | 2 |";
|
||||
expect(closePendingBlock(table)).toBe(table);
|
||||
});
|
||||
|
||||
it("leaves lists unchanged", () => {
|
||||
expect(closePendingBlock("- item 1\n- item 2")).toBe("- item 1\n- item 2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderStreamingMarkdown", () => {
|
||||
it("renders complete blocks and pending as markdown", () => {
|
||||
const cache = createStreamingCache();
|
||||
const text = "# Hello\n\nWorld";
|
||||
const { blocks, pendingHtml } = renderStreamingMarkdown(text, cache);
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0].html).toContain("<h1>Hello</h1>");
|
||||
expect(pendingHtml).toContain("World");
|
||||
expect(pendingHtml).toContain("<p>");
|
||||
});
|
||||
|
||||
it("preserves existing blocks when complete portion is unchanged", () => {
|
||||
const cache = createStreamingCache();
|
||||
renderStreamingMarkdown("# Hello\n\nWor", cache);
|
||||
const firstBlocks = cache.blocks;
|
||||
|
||||
const { blocks } = renderStreamingMarkdown("# Hello\n\nWorld", cache);
|
||||
// Same block array reference — nothing changed in the complete section
|
||||
expect(blocks).toBe(firstBlocks);
|
||||
expect(cache.completeKey).toBe("# Hello\n");
|
||||
});
|
||||
|
||||
it("appends a new block when a new section completes", () => {
|
||||
const cache = createStreamingCache();
|
||||
renderStreamingMarkdown("# Hello\n\nParagraph", cache);
|
||||
expect(cache.blocks).toHaveLength(1);
|
||||
const firstBlock = cache.blocks[0];
|
||||
|
||||
renderStreamingMarkdown("# Hello\n\nParagraph.\n\nMore", cache);
|
||||
expect(cache.blocks).toHaveLength(2);
|
||||
// First block is preserved with the same id and html
|
||||
expect(cache.blocks[0].id).toBe(firstBlock.id);
|
||||
expect(cache.blocks[0].html).toBe(firstBlock.html);
|
||||
// Second block contains the new paragraph
|
||||
expect(cache.blocks[1].html).toContain("Paragraph.");
|
||||
});
|
||||
|
||||
it("assigns unique stable ids to each block", () => {
|
||||
const cache = createStreamingCache();
|
||||
renderStreamingMarkdown("A.\n\nB.\n\nC", cache);
|
||||
expect(cache.blocks).toHaveLength(1);
|
||||
const id0 = cache.blocks[0].id;
|
||||
|
||||
renderStreamingMarkdown("A.\n\nB.\n\nC.\n\nD", cache);
|
||||
expect(cache.blocks).toHaveLength(2);
|
||||
expect(cache.blocks[0].id).toBe(id0);
|
||||
expect(cache.blocks[1].id).toBe(id0 + 1);
|
||||
});
|
||||
|
||||
it("renders pending code block with syntax highlighting", () => {
|
||||
const cache = createStreamingCache();
|
||||
const text = "Done.\n\n```python\nprint('hello')";
|
||||
const { pendingHtml } = renderStreamingMarkdown(text, cache);
|
||||
expect(pendingHtml).toContain("<code");
|
||||
expect(pendingHtml).toContain("hljs");
|
||||
});
|
||||
|
||||
it("renders pending table as markdown", () => {
|
||||
const cache = createStreamingCache();
|
||||
const text = "Done.\n\n| a | b |\n| --- | --- |\n| 1 | 2 |";
|
||||
const { pendingHtml } = renderStreamingMarkdown(text, cache);
|
||||
expect(pendingHtml).toContain("<table>");
|
||||
expect(pendingHtml).toContain("<td>");
|
||||
});
|
||||
|
||||
it("renders pending portion through markdown pipeline", () => {
|
||||
const cache = createStreamingCache();
|
||||
const text = "Done.\n\nSome **bold** text";
|
||||
const { pendingHtml } = renderStreamingMarkdown(text, cache);
|
||||
expect(pendingHtml).toContain("<strong>bold</strong>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,13 +69,189 @@ const processor = unified()
|
||||
.use(rehypeHighlight)
|
||||
.use(rehypeStringify, { allowDangerousHtml: true });
|
||||
|
||||
export function splitCompleteBlocks(text: string): { complete: string; pending: string } {
|
||||
if (!text) {
|
||||
return { complete: "", pending: "" };
|
||||
}
|
||||
|
||||
const lines = text.split("\n");
|
||||
let lastCompleteBoundary = -1; // index of last line that ends a complete block
|
||||
let inFence = false;
|
||||
let fenceChar = "";
|
||||
let inMathBlock = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const trimmed = lines[i].trimEnd();
|
||||
|
||||
if (inFence) {
|
||||
// Check for closing fence: same character, at least 3, no other content
|
||||
if (new RegExp(`^\\s*${fenceChar.replace(/~/g, "\\~")}{3,}\\s*$`).test(trimmed)) {
|
||||
inFence = false;
|
||||
fenceChar = "";
|
||||
lastCompleteBoundary = i;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inMathBlock) {
|
||||
if (trimmed === "$$" || trimmed === "\\]") {
|
||||
inMathBlock = false;
|
||||
lastCompleteBoundary = i;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for opening fence
|
||||
const fenceMatch = trimmed.match(/^(\s*)(```|~~~)/);
|
||||
if (fenceMatch) {
|
||||
// Check if it's an opening fence (may have language info after)
|
||||
// A line with just ``` or ~~~ could be opening or closing, but since we're not in a fence it's opening
|
||||
fenceChar = fenceMatch[2][0]; // '`' or '~'
|
||||
inFence = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for opening math block
|
||||
if (trimmed === "$$" || trimmed === "\\[") {
|
||||
inMathBlock = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Outside fences/math: blank line marks a complete boundary
|
||||
if (trimmed === "") {
|
||||
lastCompleteBoundary = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastCompleteBoundary < 0) {
|
||||
return { complete: "", pending: text };
|
||||
}
|
||||
|
||||
const completeLines = lines.slice(0, lastCompleteBoundary + 1);
|
||||
const pendingLines = lines.slice(lastCompleteBoundary + 1);
|
||||
|
||||
return {
|
||||
complete: completeLines.join("\n"),
|
||||
pending: pendingLines.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
export function closePendingBlock(pending: string): string {
|
||||
if (!pending) return "";
|
||||
|
||||
const lines = pending.split("\n");
|
||||
let inFence = false;
|
||||
let fenceStr = "";
|
||||
let inMathBlock = false;
|
||||
let mathClose = "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trimEnd();
|
||||
|
||||
if (inFence) {
|
||||
if (new RegExp(`^\\s*${fenceStr[0] === "~" ? "~~~" : "\\`\\`\\`"}\\s*$`).test(trimmed)) {
|
||||
inFence = false;
|
||||
fenceStr = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inMathBlock) {
|
||||
if (trimmed === "$$" || trimmed === "\\]") {
|
||||
inMathBlock = false;
|
||||
mathClose = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const fenceMatch = trimmed.match(/^(\s*)(```|~~~)/);
|
||||
if (fenceMatch) {
|
||||
fenceStr = fenceMatch[2];
|
||||
inFence = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed === "$$") {
|
||||
inMathBlock = true;
|
||||
mathClose = "$$";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed === "\\[") {
|
||||
inMathBlock = true;
|
||||
mathClose = "\\]";
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (inFence) return pending + "\n" + fenceStr;
|
||||
if (inMathBlock) return pending + "\n" + mathClose;
|
||||
return pending;
|
||||
}
|
||||
|
||||
export interface RenderedBlock {
|
||||
id: number;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export interface StreamingCache {
|
||||
blocks: RenderedBlock[];
|
||||
nextId: number;
|
||||
completeKey: string;
|
||||
}
|
||||
|
||||
export function createStreamingCache(): StreamingCache {
|
||||
return { blocks: [], nextId: 0, completeKey: "" };
|
||||
}
|
||||
|
||||
export function renderStreamingMarkdown(
|
||||
text: string,
|
||||
cache: StreamingCache,
|
||||
): { blocks: RenderedBlock[]; pendingHtml: string } {
|
||||
const { complete, pending } = splitCompleteBlocks(text);
|
||||
|
||||
if (complete) {
|
||||
if (cache.completeKey !== complete) {
|
||||
if (complete.startsWith(cache.completeKey) && cache.completeKey.length > 0) {
|
||||
// Complete section grew — render only the new part as a new block
|
||||
const newPart = complete.slice(cache.completeKey.length);
|
||||
cache.blocks = [...cache.blocks, { id: cache.nextId++, html: renderMarkdown(newPart) }];
|
||||
} else {
|
||||
// Complete section changed unexpectedly — re-render as single block
|
||||
cache.blocks = [{ id: cache.nextId++, html: renderMarkdown(complete) }];
|
||||
}
|
||||
cache.completeKey = complete;
|
||||
}
|
||||
} else if (cache.blocks.length > 0) {
|
||||
cache.blocks = [];
|
||||
cache.completeKey = "";
|
||||
}
|
||||
|
||||
let pendingHtml = "";
|
||||
if (pending) {
|
||||
const closed = closePendingBlock(pending);
|
||||
pendingHtml = renderMarkdown(closed);
|
||||
}
|
||||
|
||||
return { blocks: cache.blocks, pendingHtml };
|
||||
}
|
||||
|
||||
// Convert \[...\] to $$...$$ and \(...\) to $...$
|
||||
export function normalizeLatexDelimiters(text: string): string {
|
||||
// Display math: \[...\] → $$...$$ (may span multiple lines)
|
||||
text = text.replace(/\\\[([\s\S]*?)\\\]/g, (_match, inner) => `$$${inner}$$`);
|
||||
// Inline math: \(...\) → $...$
|
||||
text = text.replace(/\\\(([\s\S]*?)\\\)/g, (_match, inner) => `$${inner}$`);
|
||||
return text;
|
||||
}
|
||||
|
||||
export function renderMarkdown(content: string): string {
|
||||
if (!content) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
const result = processor.processSync(content);
|
||||
const result = processor.processSync(normalizeLatexDelimiters(content));
|
||||
return String(result);
|
||||
} catch {
|
||||
// Fallback to escaped plain text if markdown parsing fails
|
||||
|
||||
Reference in New Issue
Block a user