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
+264 -1
View File
@@ -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>");
});
});