fix(editor): preserve container link references while streaming
This commit is contained in:
parent
d01cf2f9b5
commit
ad2d52e3ca
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
];
|
||||
|
||||
for (const sample of samples) {
|
||||
const blocks = splitStreamingTextBlocks(sample);
|
||||
expect(blocks.join("")).toBe(sample);
|
||||
expect(blocks.map(formatAiInlineMarkdown).join("")).toBe(formatAiInlineMarkdown(sample));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) 与 ",
|
||||
"见 [文档][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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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])) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue