import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { createElement } from "react"; import { renderToString } from "react-dom/server"; import { StreamingMarkdown } from "../src/components/chat-message-content.tsx"; import { splitStableMarkdown } from "../src/lib/chat-markdown-split.ts"; const contentSource = readFileSync(new URL("../src/components/chat-message-content.tsx", import.meta.url), "utf8"); const messageRowSource = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8"); test("the stable prefix ends at the last completed paragraph and the tail keeps streaming", () => { assert.deepEqual(splitStableMarkdown("只有一段还没写完"), { stable: "", tail: "只有一段还没写完" }); assert.deepEqual(splitStableMarkdown("第一段。\n\n第二段还在"), { stable: "第一段。", tail: "第二段还在" }); assert.deepEqual( splitStableMarkdown("## 标题\n\n第一段。\n\n第二段。\n\n第三"), { stable: "## 标题\n\n第一段。\n\n第二段。", tail: "第三" }, ); }); test("the cut never lands inside a fence, between list items, or inside a table", () => { const fenced = "前言。\n\n```txt\n第一行\n\n第二行"; assert.deepEqual(splitStableMarkdown(fenced), { stable: "前言。", tail: "```txt\n第一行\n\n第二行" }); const looseList = "- 甲\n\n- 乙\n\n- 丙还在"; assert.deepEqual(splitStableMarkdown(looseList), { stable: "", tail: looseList }); const listThenParagraph = "- 甲\n- 乙\n\n总结一下"; assert.deepEqual(splitStableMarkdown(listThenParagraph), { stable: "- 甲\n- 乙", tail: "总结一下" }); const orderedListContinues = "1. 甲\n\n2. 乙\n\n 缩进的补充"; assert.deepEqual(splitStableMarkdown(orderedListContinues), { stable: "", tail: orderedListContinues }); const table = "说明。\n\n| 技法 | 状态 |\n| --- | --- |\n| 甲 | 已执行 |\n\n> 引用"; assert.deepEqual( splitStableMarkdown(table), { stable: "说明。\n\n| 技法 | 状态 |\n| --- | --- |\n| 甲 | 已执行 |", tail: "> 引用" }, ); }); test("growing the tail keeps the prefix string identical so the memoised prefix is not re-parsed", () => { const before = splitStableMarkdown("第一段。\n\n第二段。\n\n第三段正在"); const after = splitStableMarkdown("第一段。\n\n第二段。\n\n第三段正在写,还没有换段"); assert.equal(before.stable, after.stable); assert.notEqual(before.tail, after.tail); // Only once a new paragraph completes does the prefix move forward. const later = splitStableMarkdown("第一段。\n\n第二段。\n\n第三段写完了。\n\n第四"); assert.equal(later.stable, "第一段。\n\n第二段。\n\n第三段写完了。"); }); test("the streaming renderer parses the prefix and the tail as two separate documents", () => { const calls: string[] = []; const renderMarkdown = (text: string) => { calls.push(text); return createElement("p", null, text); }; renderToString(createElement(StreamingMarkdown, { text: "第一段。\n\n第二段。\n\n第三段还在", renderMarkdown, })); // Server rendering evaluates the tail in the parent and the memoised prefix as a child, // so compare as a set: what matters is that neither call sees the whole answer. assert.deepEqual([...calls].sort(), ["第一段。\n\n第二段。", "第三段还在"].sort()); // The prefix component is memoised on its text, so an unchanged prefix costs no parse. assert.match(contentSource, /const StableMarkdownPrefix = memo\(function StableMarkdownPrefix/); assert.match(contentSource, /splitStableMarkdown\(text\)/); assert.match(contentSource, /streaming\s*\?\s*