ad9dba5c79
Every NDJSON event used to commit its own React update and re-parse the whole partial answer through react-markdown, so long replies grew quadratically slower. Stream events now land in a frame buffer that flushes at most once per animation frame, releases answer and thinking text at a steady pace with a twelve-frame catch-up, and settles synchronously on completion, failure and abort. Streaming markdown is split at the last completed block so only the tail is re-parsed each frame. Applied to both the consultation hook and the rectification chat. BUG-473 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
75 lines
3.9 KiB
TypeScript
75 lines
3.9 KiB
TypeScript
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*<StreamingMarkdown/);
|
|
// Settled answers still parse once as one document.
|
|
assert.match(contentSource, /: renderMarkdown\s*\?\s*renderMarkdown\(spoken\)/);
|
|
assert.match(messageRowSource, /streaming=\{message\.state !== "settled"\}/);
|
|
});
|