perf(chat): coalesce stream events per frame and pace text release
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
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Split streaming markdown into a stable prefix and a live tail.
|
||||
*
|
||||
* While an answer streams, only the tail can still change; everything before
|
||||
* the last completed block is final. Rendering the prefix through a memoised
|
||||
* component means each frame re-parses a paragraph, not the whole answer.
|
||||
*
|
||||
* The cut is only allowed at a blank line where both sides parse the same on
|
||||
* their own as they would together: never inside a fenced code block, never
|
||||
* between two items of the same list (a second `<ul>` would add margin that
|
||||
* the settled render does not have), and never inside a table or blockquote.
|
||||
*/
|
||||
|
||||
export type StableMarkdownSplit = Readonly<{
|
||||
stable: string;
|
||||
tail: string;
|
||||
}>;
|
||||
|
||||
const FENCE = /^\s{0,3}(`{3,}|~{3,})/;
|
||||
const LIST_ITEM = /^\s{0,3}(?:[-*+]|\d{1,9}[.)])\s/;
|
||||
const INDENTED = /^\s{2,}\S/;
|
||||
const TABLE_ROW = /^\s{0,3}\|/;
|
||||
|
||||
function lastNonBlank(lines: readonly string[]): string | undefined {
|
||||
return [...lines].reverse().find((line) => line.trim().length > 0);
|
||||
}
|
||||
|
||||
/** A blank line does not end a list or a table when the next block continues it. */
|
||||
function continuesPreviousBlock(previous: readonly string[], next: string): boolean {
|
||||
const last = lastNonBlank(previous);
|
||||
if (last === undefined) return false;
|
||||
const previousIsList = LIST_ITEM.test(last) || INDENTED.test(last);
|
||||
const nextIsList = LIST_ITEM.test(next) || INDENTED.test(next);
|
||||
if (previousIsList && nextIsList) return true;
|
||||
return TABLE_ROW.test(last) && TABLE_ROW.test(next);
|
||||
}
|
||||
|
||||
export function splitStableMarkdown(text: string): StableMarkdownSplit {
|
||||
const lines = text.split("\n");
|
||||
let insideFence = false;
|
||||
let cut = -1;
|
||||
let currentBlock: string[] = [];
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index] ?? "";
|
||||
if (FENCE.test(line)) insideFence = !insideFence;
|
||||
if (insideFence) {
|
||||
currentBlock.push(line);
|
||||
continue;
|
||||
}
|
||||
if (line.trim().length > 0) {
|
||||
currentBlock.push(line);
|
||||
continue;
|
||||
}
|
||||
// Blank line: the block that just ended is complete only if a non-blank
|
||||
// line follows later, and the cut is safe only when the next block does
|
||||
// not continue the previous one.
|
||||
const nextIndex = lines.findIndex((candidate, at) => at > index && candidate.trim().length > 0);
|
||||
if (nextIndex < 0) break;
|
||||
const next = lines[nextIndex] ?? "";
|
||||
if (currentBlock.length > 0 && !continuesPreviousBlock(currentBlock, next)) cut = index;
|
||||
currentBlock = [];
|
||||
}
|
||||
|
||||
if (cut < 0) return { stable: "", tail: text };
|
||||
const stable = lines.slice(0, cut).join("\n");
|
||||
const tail = lines.slice(cut + 1).join("\n");
|
||||
return { stable, tail };
|
||||
}
|
||||
Reference in New Issue
Block a user