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:
co-authored by
Claude Fable 5.1
parent
02f06255c1
commit
ad9dba5c79
@@ -11,6 +11,7 @@ import {
|
||||
type ChatTranscriptProps,
|
||||
} from "../src/components/chat-transcript.tsx";
|
||||
import type { ChatMessage } from "../src/lib/chat-message-view.ts";
|
||||
import { createStreamFrameBuffer, type StreamFrameScheduler } from "../src/lib/stream-frame-buffer.ts";
|
||||
import { streamingChatMessageView } from "../src/lib/chat-message-view.ts";
|
||||
import {
|
||||
disableHomeStreamingRenderProbe,
|
||||
@@ -71,7 +72,51 @@ test("the split architecture renders settled history once while streaming tokens
|
||||
disableHomeStreamingRenderProbe();
|
||||
|
||||
assert.equal(split.settledListRenders, 1);
|
||||
assert.equal(split.streamingRowRenders, tokens.length);
|
||||
// Former assertion: `streamingRowRenders === tokens.length`. That was a snapshot of the
|
||||
// status quo (one commit per network token), not the goal; this test drives renders by
|
||||
// hand, so the count equals the number of hand-driven renders and must never exceed it.
|
||||
assert.ok(split.streamingRowRenders <= tokens.length);
|
||||
assert.ok(split.streamingRowRenders >= 1);
|
||||
assert.equal(unsplit.unsplitListRenders, tokens.length);
|
||||
assert.ok(unsplit.settledRowRenders > split.settledRowRenders);
|
||||
});
|
||||
|
||||
test("frame coalescing renders the streaming row once per frame, not once per token", () => {
|
||||
const messages: ChatMessage[] = [{ role: "user", text: "请继续说明这个月的安排。" }];
|
||||
const frames: Array<() => void> = [];
|
||||
const scheduler: StreamFrameScheduler = {
|
||||
requestFrame(callback) {
|
||||
frames.push(callback);
|
||||
return frames.length;
|
||||
},
|
||||
cancelFrame() { frames.length = 0; },
|
||||
requestTimeout() { return 0; },
|
||||
cancelTimeout() {},
|
||||
hidden: () => false,
|
||||
};
|
||||
|
||||
resetHomeStreamingRenderProbe();
|
||||
enableHomeStreamingRenderProbe();
|
||||
const buffer = createStreamFrameBuffer<null>({
|
||||
initialMeta: null,
|
||||
scheduler,
|
||||
flush: (frame) => {
|
||||
const streamingMessage = streamingChatMessageView(messages, true, frame.answer);
|
||||
assert.ok(streamingMessage);
|
||||
renderToString(createElement(StreamingMessageEntry, { message: streamingMessage }));
|
||||
},
|
||||
});
|
||||
// 200 one-character tokens arrive four per frame across fifty frames.
|
||||
let answer = "";
|
||||
for (let index = 0; index < 200; index += 1) {
|
||||
answer += "字";
|
||||
buffer.setAnswer(answer);
|
||||
if (index % 4 === 3) for (const callback of frames.splice(0)) callback();
|
||||
}
|
||||
buffer.settle();
|
||||
const probe = homeStreamingRenderProbeSnapshot();
|
||||
disableHomeStreamingRenderProbe();
|
||||
|
||||
assert.ok(probe.streamingRowRenders <= 51, `rendered ${probe.streamingRowRenders} times for 200 tokens`);
|
||||
assert.ok(probe.streamingRowRenders * 3 <= 200);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user