fix(editor): preserve container link references while streaming

This commit is contained in:
ooooooo 2026-07-23 17:29:45 +08:00 committed by GitHub
parent d01cf2f9b5
commit ad2d52e3ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 614 additions and 38 deletions

View File

@ -234,7 +234,9 @@ const MESSAGE_SCROLL_BUTTON_HIDE_THRESHOLD_PX = 48;
let messageScrollViewport: HTMLElement | null = null;
let messageTouchStartY: number | null = null;
let lastMessageScrollTop = 0;
const STREAM_RENDER_INTERVAL_MS = 33;
let assistantDeltaFrame: number | null = null;
let lastAssistantFlushAt = 0;
let pendingAssistantDelta = "";
let pendingAssistantReasoning = "";
let pendingAssistantIndex = -1;
@ -682,6 +684,7 @@ function changeDatabase(value: string) {
function flushAssistantDeltas() {
assistantDeltaFrame = null;
lastAssistantFlushAt = performance.now();
const msg = messages.value[pendingAssistantIndex];
if (!msg) return;
if (pendingAssistantReasoning) {
@ -697,12 +700,22 @@ function flushAssistantDeltas() {
scrollToBottom();
}
function runAssistantDeltaFrame() {
// Markdown is rendered live, so keep the refresh rate under the frame rate:
// a repaint every STREAM_RENDER_INTERVAL_MS still reads as continuous typing.
if (performance.now() - lastAssistantFlushAt < STREAM_RENDER_INTERVAL_MS) {
assistantDeltaFrame = requestAnimationFrame(runAssistantDeltaFrame);
return;
}
flushAssistantDeltas();
}
function scheduleAssistantDeltaFlush(assistantIdx: number) {
pendingAssistantIndex = assistantIdx;
if (assistantDeltaFrame !== null) return;
// Providers can emit many tiny chunks. Render once per animation frame so
// Providers can emit many tiny chunks. Batch them on an animation frame so
// Markdown parsing, highlighting, and layout do not run for every token.
assistantDeltaFrame = requestAnimationFrame(flushAssistantDeltas);
assistantDeltaFrame = requestAnimationFrame(runAssistantDeltaFrame);
}
function appendAssistantDelta(assistantIdx: number, delta: string) {
@ -1645,6 +1658,7 @@ function clearMessages() {
conversationId.value = "";
historyIndex.value = -1;
draftBeforeHistory.value = "";
messageRenderer.value.clear();
}
async function persistConversation() {
@ -1675,6 +1689,8 @@ async function setConversationListOpen(open: boolean) {
function selectConversation(conv: AiConversation) {
conversationId.value = conv.id;
// Drop the previous conversation's rendered Markdown instead of keeping it until the LRU evicts it.
messageRenderer.value.clear();
messages.value = conv.messages.map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
@ -1816,6 +1832,15 @@ const messageRenderer = computed(() => {
});
});
/**
* Renders Markdown live while the answer streams in. The renderer reuses the
* already-finished segments, so a frame only re-parses the growing tail.
*/
function renderMessageSegments(msg: ChatMessage) {
const streaming = isGenerating.value && msg === messages.value[messages.value.length - 1];
return messageRenderer.value.render(msg.content, { streaming });
}
function onMarkdownClick(event: MouseEvent) {
handleAiMarkdownLinkClick(event, openExternalUrl);
}
@ -1990,8 +2015,7 @@ async function openExternalUrl(url: string) {
</div>
</div>
</div>
<div v-if="isGenerating && msg === messages[messages.length - 1]" class="whitespace-pre-wrap break-words leading-relaxed">{{ msg.content }}</div>
<template v-else v-for="(seg, j) in messageRenderer.render(msg.content)" :key="j">
<template v-for="(seg, j) in renderMessageSegments(msg)" :key="j">
<div v-if="seg.type === 'text'" class="ai-markdown whitespace-normal" @click.capture="onMarkdownClick">
<div v-html="seg.html" />
</div>
@ -2000,14 +2024,16 @@ async function openExternalUrl(url: string) {
<component :is="seg.isSql ? Database : Terminal" class="h-3 w-3 mr-1.5" />
<span>{{ seg.lang }}</span>
<span class="flex-1" />
<!-- `pending` means the closing fence is still missing, so the code is truncated: never offer to run or apply it. -->
<Loader2 v-if="seg.pending && isGenerating" class="h-3 w-3 animate-spin text-zinc-400" />
<div class="flex items-center gap-1.5">
<button v-if="seg.isSql && !isRedisConnection" class="rounded p-0.5 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200" :title="t('ai.tempRunSql')" @click="tempRunSql(seg.content)">
<button v-if="!seg.pending && seg.isSql && !isRedisConnection" class="rounded p-0.5 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200" :title="t('ai.tempRunSql')" @click="tempRunSql(seg.content)">
<FlaskConical class="h-3.5 w-3.5" />
</button>
<button v-if="seg.isSql || isRedisConnection" class="rounded p-0.5 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200" :title="t('ai.executeSql')" @click="executeSql(seg.content)">
<button v-if="!seg.pending && (seg.isSql || isRedisConnection)" class="rounded p-0.5 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200" :title="t('ai.executeSql')" @click="executeSql(seg.content)">
<Play class="h-3.5 w-3.5" />
</button>
<button v-if="seg.isSql || isRedisConnection" class="rounded p-0.5 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200" :title="t('ai.apply')" @click="applySql(seg.content)">
<button v-if="!seg.pending && (seg.isSql || isRedisConnection)" class="rounded p-0.5 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200" :title="t('ai.apply')" @click="applySql(seg.content)">
<Replace class="h-3.5 w-3.5" />
</button>
<button

View File

@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { createAiMessageRenderer } from "@/lib/ai/aiMessageRender";
import { createAiMessageRenderer, splitStreamingTextBlocks } from "@/lib/ai/aiMessageRender";
import { formatAiInlineMarkdown } from "@/lib/ai/aiMarkdown";
describe("createAiMessageRenderer", () => {
it("caches completed short messages", () => {
@ -21,4 +22,242 @@ describe("createAiMessageRenderer", () => {
expect(markdown).toHaveBeenCalledTimes(2);
});
it("renders Markdown for a streaming message", () => {
const markdown = vi.fn((text: string) => `<p>${text}</p>`);
const renderer = createAiMessageRenderer({ markdown });
const segments = renderer.render("**bold**", { streaming: true });
expect(segments).toEqual([{ type: "text", content: "**bold**", html: "<p>**bold**</p>" }]);
});
it("re-renders only the growing tail while streaming", () => {
const markdown = vi.fn((text: string) => `<p>${text}</p>`);
const renderer = createAiMessageRenderer({ markdown });
const first = renderer.render("intro\n\n```sql\nSELECT 1\n```\n\ntai", { streaming: true });
markdown.mockClear();
const second = renderer.render("intro\n\n```sql\nSELECT 1\n```\n\ntail", { streaming: true });
expect(markdown).toHaveBeenCalledTimes(1);
expect(markdown).toHaveBeenCalledWith("\ntail");
expect(second[0]).toBe(first[0]);
expect(second[1]).toBe(first[1]);
});
it("skips highlighting an unfinished code block and highlights it once closed", () => {
const markdown = (text: string) => `<p>${text}</p>`;
const highlightCode = vi.fn((content: string) => `<span>${content}</span>`);
const renderer = createAiMessageRenderer({ markdown, highlightCode });
const [streamed] = renderer.render("```sql\nSELECT 1", { streaming: true });
expect(streamed).toEqual({ type: "code", content: "SELECT 1", html: "SELECT 1", lang: "SQL", isSql: true, pending: true });
expect(highlightCode).not.toHaveBeenCalled();
const [closed] = renderer.render("```sql\nSELECT 1\n```");
expect(closed).toEqual({ type: "code", content: "SELECT 1", html: "<span>SELECT 1</span>", lang: "SQL", isSql: true, pending: false });
});
it("keeps a truncated code block pending after the stream stops", () => {
const markdown = (text: string) => `<p>${text}</p>`;
const highlightCode = (content: string) => `<span>${content}</span>`;
const renderer = createAiMessageRenderer({ markdown, highlightCode });
// A cancelled or truncated answer leaves the fence open: the code must stay non-executable.
const [truncated] = renderer.render("```sql\nDELETE FROM users WHE");
expect(truncated).toMatchObject({ type: "code", pending: true, html: "<span>DELETE FROM users WHE</span>" });
});
it("keeps a closed code block interactive while later text still streams", () => {
const markdown = (text: string) => `<p>${text}</p>`;
const highlightCode = (content: string) => `<span>${content}</span>`;
const renderer = createAiMessageRenderer({ markdown, highlightCode });
const [code] = renderer.render("```sql\nSELECT 1\n```\n\nexpl", { streaming: true });
expect(code).toMatchObject({ type: "code", pending: false, html: "<span>SELECT 1</span>" });
});
it("re-parses only the last paragraph of a long streaming answer", () => {
const markdown = vi.fn((text: string) => `<p>${text}</p>`);
const renderer = createAiMessageRenderer({ markdown });
const paragraph = "查询计划说明".repeat(60);
const head = `${paragraph}\n\n${paragraph}\n\n`;
renderer.render(`${head}结论:需要索引`, { streaming: true });
markdown.mockClear();
renderer.render(`${head}结论:需要索引。`, { streaming: true });
expect(markdown).toHaveBeenCalledTimes(1);
expect(markdown).toHaveBeenCalledWith("结论:需要索引。");
});
it("does not split a streaming list across blocks", () => {
const markdown = vi.fn((text: string) => `<p>${text}</p>`);
const renderer = createAiMessageRenderer({ markdown });
const intro = "步骤说明".repeat(80);
const content = `${intro}\n\n1. 第一步\n\n2. 第二步`;
const segments = renderer.render(content, { streaming: true });
// The list must stay in one block, otherwise the ordered list restarts mid-stream.
expect(segments).toHaveLength(1);
expect(markdown).toHaveBeenCalledWith(content);
});
it("evicts cached renders once the character budget is exceeded", () => {
const markdown = vi.fn((text: string) => `<p>${text}</p>`);
// Half of the budget goes to the message cache, so two ~90 char entries do not fit together.
const renderer = createAiMessageRenderer({ markdown, maxCacheChars: 300 });
const first = "a".repeat(40);
const second = "b".repeat(40);
renderer.render(first);
renderer.render(second);
markdown.mockClear();
renderer.render(first);
expect(markdown).toHaveBeenCalledTimes(1);
});
it("does not cache an entry that alone exceeds the budget", () => {
const markdown = vi.fn((text: string) => `<p>${text}</p>`);
const renderer = createAiMessageRenderer({ markdown, maxCacheChars: 10 });
renderer.render("hello");
renderer.render("hello");
expect(markdown).toHaveBeenCalledTimes(2);
});
it("drops cached renders on clear", () => {
const markdown = vi.fn((text: string) => `<p>${text}</p>`);
const renderer = createAiMessageRenderer({ markdown });
renderer.render("hello");
renderer.clear();
renderer.render("hello");
expect(markdown).toHaveBeenCalledTimes(2);
});
it("keeps reusing stable blocks past the shared cache limits", () => {
const markdown = vi.fn((text: string) => `<p>${text}</p>`);
// Far more stable blocks than the shared caches can hold.
const renderer = createAiMessageRenderer({ markdown, maxEntries: 2, maxSegmentEntries: 2, maxCacheChars: 200 });
const head = Array.from({ length: 40 }, (_, i) => `${i}:${"内容".repeat(150)}`).join("\n\n") + "\n\n";
renderer.render(`${head}`, { streaming: true });
markdown.mockClear();
renderer.render(`${head}尾巴`, { streaming: true });
expect(markdown).toHaveBeenCalledTimes(1);
});
it("drops the streaming blocks when another answer starts", () => {
const markdown = vi.fn((text: string) => `<p>${text}</p>`);
const renderer = createAiMessageRenderer({ markdown });
const head = `${"内容".repeat(200)}\n\n`;
renderer.render(`${head}`, { streaming: true });
renderer.render("另一个回答", { streaming: true });
markdown.mockClear();
renderer.render(`${head}`, { streaming: true });
expect(markdown).toHaveBeenCalledTimes(2);
});
it("does not cache streaming versions as finished messages", () => {
const markdown = vi.fn((text: string) => `<p>${text}</p>`);
const renderer = createAiMessageRenderer({ markdown, maxEntries: 2 });
renderer.render("a", { streaming: true });
renderer.render("ab", { streaming: true });
renderer.render("abc", { streaming: true });
markdown.mockClear();
renderer.render("abc");
renderer.render("abc");
expect(markdown).toHaveBeenCalledTimes(1);
});
});
describe("splitStreamingTextBlocks", () => {
const long = "内容".repeat(200);
it("keeps short text in a single live block", () => {
expect(splitStreamingTextBlocks("hello\n\nworld")).toEqual(["hello\n\nworld"]);
});
it("splits on a blank line before a new paragraph", () => {
expect(splitStreamingTextBlocks(`${long}\n\n结论`)).toEqual([`${long}\n\n`, "结论"]);
});
it("keeps block markers attached to the block above them", () => {
for (const marker of ["- 列表项", "1. 第一步", "> 引用", "| a | b |", " 缩进续行", "```", "[1]: https://example.com"]) {
expect(splitStreamingTextBlocks(`${long}\n\n${marker}`)).toEqual([`${long}\n\n${marker}`]);
}
});
it("does not split inside a tilde or annotated fence", () => {
// parseAiMessage only extracts plain ``` fences, so these stay in the text segment.
expect(splitStreamingTextBlocks(`${long}\n\n~~~sql\nSELECT 1\n\nSELECT 2\n~~~`)).toHaveLength(1);
expect(splitStreamingTextBlocks("````\n" + long + "\n\n还有内容\n````")).toHaveLength(1);
});
it("splits again after a fence closes", () => {
const blocks = splitStreamingTextBlocks(`~~~sql\nSELECT 1\n\nSELECT 2\n~~~\n\n${long}\n\n结论`);
expect(blocks).toHaveLength(2);
expect(blocks[1]).toBe("结论");
});
it("does not split a message that defines link references", () => {
expect(splitStreamingTextBlocks(`[1]: https://example.com\n\n${long}\n\n见 [文档][1]`)).toHaveLength(1);
// The label may escape its closing bracket or wrap onto the next line.
expect(splitStreamingTextBlocks(`[a\\]b]: https://example.com\n\n${long}\n\n见 [文档][a\\]b]`)).toHaveLength(1);
expect(splitStreamingTextBlocks(`[a\nb]: https://example.com\n\n${long}\n\n见 [文档][a b]`)).toHaveLength(1);
});
it("does not split a message containing raw HTML blocks", () => {
expect(splitStreamingTextBlocks(`${long}\n\n<!-- 注释开始\n\n注释结束 -->`)).toHaveLength(1);
expect(splitStreamingTextBlocks(`${long}\n\n<pre>\n\n还有内容\n</pre>`)).toHaveLength(1);
expect(splitStreamingTextBlocks(`<!DOCTYPE html ${long}\n\n# 标题`)).toHaveLength(1);
});
it("does not split when the next block has not arrived yet", () => {
expect(splitStreamingTextBlocks(`${long}\n\n`)).toEqual([`${long}\n\n`]);
});
it("preserves the original text when joined", () => {
const content = `${long}\n\n第二段${long}\n\n第三段`;
expect(splitStreamingTextBlocks(content).join("")).toBe(content);
});
it("renders the same HTML whether the text is split or not", () => {
const pad = "这里是一段较长的说明文字,用来把块撑到切分阈值以上。".repeat(12);
const samples = [
`${pad}\n\n结论加索引`,
`${pad}\n\n## 小标题\n\n正文内容`,
`${pad}\n\n- 列表项一\n- 列表项二`,
`${pad}\n\n1. 第一步\n\n2. 第二步`,
`${pad}\n\n| a | b |\n| --- | --- |\n| 1 | 2 |`,
`${pad}\n\n> 引用内容\n\n> 第二段引用`,
`${pad}\n\n~~~sql\nSELECT 1\n\nSELECT 2\n~~~`,
`${pad}\n\n 缩进代码\n\n 第二行`,
`[1]: https://example.com\n\n${pad}\n\n见 [文档][1]`,
`${pad}\n\n**加粗**开头的段落\n\n${pad}\n\n最后一段`,
`${pad}\n\n\n\n多个空行分隔\n\n结尾`,
`# 标题\n\n${pad}\n\n![图片](https://example.com/a.png)`,
];
for (const sample of samples) {
const blocks = splitStreamingTextBlocks(sample);
expect(blocks.join("")).toBe(sample);
expect(blocks.map(formatAiInlineMarkdown).join("")).toBe(formatAiInlineMarkdown(sample));
}
});
});

View File

@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { splitStreamingTextBlocks } from "@/lib/ai/aiMessageRender";
import { formatAiInlineMarkdown } from "@/lib/ai/aiMarkdown";
const FRAGMENTS = [
"这是一段普通的中文说明文字,用于填充内容长度。",
"This is a plain english paragraph used as filler content.",
"# 一级标题",
"## 二级标题",
"- 列表项 A\n- 列表项 B",
"* 星号列表\n* 第二项",
"1. 第一步\n2. 第二步",
"3) 括号序号",
"> 引用一行\n> 引用第二行",
"| a | b |\n| --- | --- |\n| 1 | 2 |",
"~~~sql\nSELECT 1\n~~~",
"````\nnested ``` fence\n````",
"```js title=x\nlet a = 1\n```",
" 缩进代码块",
"***",
"---",
"___",
"**加粗** 与 *斜体* 与 `行内代码`",
"[链接](https://example.com) 与 ![图片](https://example.com/a.png)",
"见 [文档][ref]",
"<div>html 块</div>",
"term\n: 定义",
"行尾两空格 \n下一行",
"Setext 标题\n===",
"Setext 二级\n---",
"- [ ] 任务\n- [x] 完成",
"脚注引用[^1]",
"[^1]: 脚注内容",
"= 等号开头",
"~波浪线开头",
": 冒号开头",
"\\[转义括号]",
"第一段结束。",
];
/** Constructs that reach across blank lines; one of them disables splitting for the whole message. */
const CROSS_BLOCK_FRAGMENTS = [
"[ref]: https://example.com",
"[多行\n标签]: https://example.com",
"[a\\]b]: https://example.com",
"<!-- 注释 -->",
"<!-- 跨空行注释\n\n仍在注释里 -->",
"<!DOCTYPE html>",
"<!FOO bar>",
"<?php echo 1; ?>",
"<![CDATA[ x ]]>",
"<pre>raw</pre>",
"<script>var a = 1;</script>",
"<style>a { color: red }</style>",
"[^1]: 脚注内容",
];
const FILLER = "这里是用于把块撑过切分阈值的填充文字,内容本身没有特殊含义。";
describe("splitStreamingTextBlocks link reference definitions", () => {
it.each([
["blockquote", "> [ref]: https://example.com"],
["list", "- [ref]: https://example.com"],
["nested list", "- outer\n - inner\n\n [ref]: https://example.com"],
["nested mixed containers", "> - > 1. [ref]: https://example.com"],
])("keeps definitions inside %s joined with later references", (_, definition) => {
const doc = `${definition}\n\n${FILLER.repeat(8)}\n\nSee [ref].`;
const blocks = splitStreamingTextBlocks(doc);
expect(blocks.map(formatAiInlineMarkdown).join("")).toBe(formatAiInlineMarkdown(doc));
expect(blocks).toEqual([doc]);
});
});
function makeRandom(seed: number) {
let state = seed >>> 0;
return () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 0x100000000;
};
}
describe("splitStreamingTextBlocks fuzz", () => {
it("keeps split rendering identical to whole rendering", () => {
const random = makeRandom(20260723);
const failures: string[] = [];
let splitCases = 0;
for (let iteration = 0; iteration < 4000; iteration++) {
const count = 3 + Math.floor(random() * 8);
const parts: string[] = [];
for (let i = 0; i < count; i++) {
// Cross-block constructs are rarer so that most documents still reach the split path.
const pool = random() < 0.06 ? CROSS_BLOCK_FRAGMENTS : FRAGMENTS;
const fragment = pool[Math.floor(random() * pool.length)];
// Blocks are only split once they pass the size threshold, so pad most of them.
parts.push(random() < 0.7 ? `${FILLER.repeat(2 + Math.floor(random() * 4))}\n${fragment}` : fragment);
}
const doc = parts.join(random() < 0.85 ? "\n\n" : "\n\n\n");
const blocks = splitStreamingTextBlocks(doc);
if (blocks.length < 2) continue;
splitCases++;
if (blocks.join("") !== doc) failures.push(`join mismatch: ${JSON.stringify(doc.slice(0, 200))}`);
const split = blocks.map(formatAiInlineMarkdown).join("");
const whole = formatAiInlineMarkdown(doc);
if (split !== whole) failures.push(`render mismatch (${blocks.length} blocks): ${JSON.stringify(doc.slice(0, 300))}`);
}
expect(failures).toEqual([]);
// Guards against the corpus degenerating into cases that are never split at all.
expect(splitCases).toBeGreaterThan(1500);
});
});

View File

@ -10,6 +10,8 @@ export interface AiMessageCodeSegment {
lang: string;
html: string;
isSql: boolean;
/** True while the closing fence is missing, i.e. the code is incomplete and must not be executed. */
pending: boolean;
}
export type AiMessageRenderSegment = AiMessageTextSegment | AiMessageCodeSegment;
@ -18,17 +20,39 @@ interface MessageSegment {
type: "text" | "code";
content: string;
lang?: string;
/** Code segments only: whether the closing fence has arrived. */
closed?: boolean;
}
export interface AiMessageRenderOptions {
/** Set while the message is still streaming: the trailing segment keeps growing. */
streaming?: boolean;
}
export interface AiMessageRendererOptions {
maxEntries?: number;
maxCacheableChars?: number;
maxSegmentEntries?: number;
maxCacheChars?: number;
markdown: (text: string) => string;
highlightCode?: (content: string, lang: string) => string;
}
const DEFAULT_MAX_ENTRIES = 100;
const DEFAULT_MAX_CACHEABLE_CHARS = 20_000;
const DEFAULT_MAX_SEGMENT_ENTRIES = 300;
const DEFAULT_MAX_CACHE_CHARS = 400_000;
/** Streaming text is flushed into a cached block only once it is long enough to be worth a cache entry. */
const STREAM_BLOCK_MIN_CHARS = 240;
const BLANK_LINE_RE = /\n{2,}/g;
const FENCE_LINE_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
// Definitions inside block containers still apply to the whole document. Container indentation
// may exceed three columns, so this prefix is intentionally conservative: a false positive only
// disables streaming splits, while a false negative changes reference-link rendering.
const LINK_REFERENCE_RE = /^(?:[ \t]*(?:>[ \t]?|(?:[*+-]|\d{1,9}[.)])[ \t]+))*[ \t]*\[(?:\\.|[^\]\\]){1,999}\]:/m;
// Raw HTML blocks stay open across blank lines, which no block boundary may cut:
// comments, processing instructions, declarations, CDATA and the raw-text elements.
const RAW_HTML_BLOCK_RE = /<!--|<\?|<!\[CDATA\[|<![A-Za-z]|<\/?(?:script|style|pre|textarea)\b/i;
const SQL_LANGUAGES = new Map([
["sql", "SQL"],
["mysql", "MYSQL"],
@ -48,52 +72,225 @@ const SHELL_LANGUAGES = new Map([
]);
const SQL_LANGUAGE_LABELS = new Set(SQL_LANGUAGES.values());
interface SegmentRenderFlags {
/** The fence is still open, so the code is incomplete. */
pending: boolean;
/** The segment is the growing tail of a streaming message. */
live: boolean;
}
export function createAiMessageRenderer(options: AiMessageRendererOptions) {
const maxEntries = Math.max(1, Math.floor(options.maxEntries ?? DEFAULT_MAX_ENTRIES));
const maxCacheableChars = Math.max(0, Math.floor(options.maxCacheableChars ?? DEFAULT_MAX_CACHEABLE_CHARS));
const cache = new Map<string, AiMessageRenderSegment[]>();
const maxSegmentEntries = Math.max(1, Math.floor(options.maxSegmentEntries ?? DEFAULT_MAX_SEGMENT_ENTRIES));
// Split the budget so the two caches together stay under the configured character count.
const maxCacheChars = Math.max(2, Math.floor(options.maxCacheChars ?? DEFAULT_MAX_CACHE_CHARS));
const cache = createRenderCache<AiMessageRenderSegment[]>(maxEntries, Math.floor(maxCacheChars / 2));
const segmentCache = createRenderCache<AiMessageRenderSegment>(maxSegmentEntries, Math.floor(maxCacheChars / 2));
// Bounded by the one answer being streamed, and dropped as soon as another answer starts.
const streamBlocks = new Map<string, AiMessageRenderSegment>();
let streamContent = "";
function render(content: string): AiMessageRenderSegment[] {
const cacheable = content.length <= maxCacheableChars;
const cached = cacheable ? cache.get(content) : undefined;
if (cached) {
cache.delete(content);
cache.set(content, cached);
return cached;
function renderSegment(segment: MessageSegment, flags: SegmentRenderFlags): AiMessageRenderSegment {
if (segment.type === "text") {
return { type: "text", content: segment.content, html: options.markdown(segment.content) };
}
const lang = normalizeAiCodeLanguage(segment.lang);
// Highlighting a block that is still streaming is wasted work: it is re-highlighted once the fence closes.
const highlighted = flags.live && flags.pending ? undefined : options.highlightCode?.(segment.content, lang);
return {
type: "code",
content: segment.content,
html: highlighted ?? escapeHtml(segment.content),
lang,
isSql: isSqlAiCodeLanguage(lang),
pending: flags.pending,
};
}
const rendered = parseAiMessage(content).map((segment): AiMessageRenderSegment => {
if (segment.type === "text") {
return { type: "text", content: segment.content, html: options.markdown(segment.content) };
}
const lang = normalizeAiCodeLanguage(segment.lang);
return {
type: "code",
content: segment.content,
html: options.highlightCode?.(segment.content, lang) ?? escapeHtml(segment.content),
lang,
isSql: isSqlAiCodeLanguage(lang),
};
function renderCachedSegment(segment: MessageSegment, flags: SegmentRenderFlags): AiMessageRenderSegment {
if (segment.content.length > maxCacheableChars) return renderSegment(segment, flags);
// Length-prefixed so no field separator can be forged by segment content.
const key = `${segment.type}|${segment.lang ?? ""}|${flags.pending ? 1 : 0}|${segment.content.length}|${segment.content}`;
const cached = segmentCache.get(key);
if (cached) return cached;
const rendered = renderSegment(segment, flags);
segmentCache.set(key, rendered, segment.content.length + rendered.html.length);
return rendered;
}
/**
* Blocks of the message being streamed right now. They are held apart from the shared caches:
* an LRU sized for finished messages would evict them while the same message is still growing.
*/
function renderStreamingBlock(block: string): AiMessageRenderSegment {
const cached = streamBlocks.get(block);
if (cached) return cached;
const rendered = renderSegment({ type: "text", content: block }, { pending: false, live: false });
streamBlocks.set(block, rendered);
return rendered;
}
function renderTail(segment: MessageSegment): AiMessageRenderSegment[] {
const pending = segment.type === "code" && segment.closed !== true;
// Only the trailing block of a streaming message keeps changing. Blocks before it are final,
// so they are rendered once and reused by reference, which keeps both parsing and DOM patches small.
if (segment.type === "text") {
const blocks = splitStreamingTextBlocks(segment.content);
const live = blocks[blocks.length - 1];
return [...blocks.slice(0, -1).map(renderStreamingBlock), renderSegment({ type: "text", content: live }, { pending: false, live: true })];
}
return [renderSegment(segment, { pending, live: true })];
}
function render(content: string, renderOptions: AiMessageRenderOptions = {}): AiMessageRenderSegment[] {
const streaming = renderOptions.streaming === true;
if (streaming) {
// A content that no longer extends the previous one belongs to another answer.
if (!content.startsWith(streamContent)) streamBlocks.clear();
streamContent = content;
}
const cacheable = !streaming && content.length <= maxCacheableChars;
const cached = cacheable ? cache.get(content) : undefined;
if (cached) return cached;
const segments = parseAiMessage(content);
const lastIndex = segments.length - 1;
const rendered = segments.flatMap((segment, index): AiMessageRenderSegment[] => {
if (streaming && index === lastIndex) return renderTail(segment);
const pending = segment.type === "code" && segment.closed !== true;
return [renderCachedSegment(segment, { pending, live: false })];
});
if (cacheable) {
cache.set(content, rendered);
while (cache.size > maxEntries) {
const oldestKey = cache.keys().next().value;
if (oldestKey === undefined) break;
cache.delete(oldestKey);
}
}
// Charge for the HTML the entry pins, not just for the source text.
if (cacheable) cache.set(content, rendered, content.length + rendered.reduce((sum, segment) => sum + segment.html.length, 0));
return rendered;
}
function clear() {
cache.clear();
segmentCache.clear();
streamBlocks.clear();
streamContent = "";
}
return { render, clear };
}
/**
* Splits the streaming tail into stable blocks plus the block that is still growing.
* Always returns at least one entry; the last one is the live block.
*
* Each block is parsed on its own, so a boundary is only taken where Markdown cannot
* carry state across it. Anything ambiguous stays joined and is simply re-parsed.
*/
export function splitStreamingTextBlocks(content: string): string[] {
// Link reference definitions apply to the whole document, and raw HTML blocks can span any
// number of blank lines: neither survives being parsed block by block.
if (LINK_REFERENCE_RE.test(content) || RAW_HTML_BLOCK_RE.test(content)) return [content];
const blocks: string[] = [];
let buffer = "";
let index = 0;
let fence: FenceState = null;
BLANK_LINE_RE.lastIndex = 0;
for (let match = BLANK_LINE_RE.exec(content); match; match = BLANK_LINE_RE.exec(content)) {
const nextIndex = match.index + match[0].length;
const chunk = content.slice(index, nextIndex);
// Fences that parseAiMessage leaves in the text (`~~~`, longer or annotated backtick
// runs) may contain blank lines, so a boundary inside one would cut the block open.
fence = trackFenceState(chunk, fence);
buffer += chunk;
index = nextIndex;
// A blank line only ends a block when what follows starts a new one; list items,
// indented continuations, and quotes may still belong to the block before them.
if (!fence && buffer.length >= STREAM_BLOCK_MIN_CHARS && startsNewMarkdownBlock(content.slice(nextIndex))) {
blocks.push(buffer);
buffer = "";
}
}
blocks.push(buffer + content.slice(index));
return blocks;
}
function startsNewMarkdownBlock(rest: string): boolean {
// An empty rest means the next block has not been streamed yet, so the boundary is not decidable.
if (!rest) return false;
const first = rest[0];
// Whitespace continues the previous block (indented code or a lazy list continuation).
if (/\s/.test(first)) return false;
// Block markers that can resume the construct above the blank line.
if ("-*+>|=~:`[".includes(first)) return false;
return !/^\d+[.)]/.test(rest);
}
type FenceState = { marker: string; length: number } | null;
function trackFenceState(chunk: string, state: FenceState): FenceState {
for (const line of chunk.split("\n")) {
const match = FENCE_LINE_RE.exec(line);
if (!match) continue;
const marker = match[1][0];
const length = match[1].length;
if (!state) {
// A backtick info string cannot contain backticks, so such a line is not an opening fence.
if (marker === "`" && match[2].includes("`")) continue;
state = { marker, length };
} else if (marker === state.marker && length >= state.length && !match[2].trim()) {
state = null;
}
}
return state;
}
interface RenderCacheEntry<T> {
value: T;
size: number;
}
function createRenderCache<T>(maxEntries: number, maxChars: number) {
const entries = new Map<string, RenderCacheEntry<T>>();
let totalChars = 0;
return {
get(key: string): T | undefined {
const entry = entries.get(key);
if (!entry) return undefined;
entries.delete(key);
entries.set(key, entry);
return entry.value;
},
set(key: string, value: T, size: number) {
const previous = entries.get(key);
if (previous) {
totalChars -= previous.size;
entries.delete(key);
}
// An entry that alone busts the budget is never worth keeping.
if (size > maxChars) return;
entries.set(key, { value, size });
totalChars += size;
// Bound both the entry count and the retained characters: a few large answers can hold
// far more memory than many small ones.
while (entries.size > maxEntries || totalChars > maxChars) {
const oldest = entries.entries().next().value;
if (!oldest) break;
entries.delete(oldest[0]);
totalChars -= oldest[1].size;
}
},
clear() {
entries.clear();
totalChars = 0;
},
};
}
export function parseAiMessage(text: string): MessageSegment[] {
const segments: MessageSegment[] = [];
const lines = text.split("\n");
@ -109,9 +306,10 @@ export function parseAiMessage(text: string): MessageSegment[] {
codeLines.push(lines[i]);
i++;
}
if (i < lines.length) i++;
const closed = i < lines.length;
if (closed) i++;
const content = codeLines.join("\n").trim();
if (content) segments.push({ type: "code", lang, content });
if (content) segments.push({ type: "code", lang, content, closed });
} else {
const textLines: string[] = [];
while (i < lines.length && !/^```([a-zA-Z0-9_+.-]*)\s*$/.test(lines[i])) {