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:
Jesse_Chen
2026-09-02 04:29:01 +00:00
co-authored by Claude Fable 5.1
parent 02f06255c1
commit ad9dba5c79
10 changed files with 778 additions and 93 deletions
@@ -1,10 +1,11 @@
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { memo, useEffect, useState, type ReactNode } from "react";
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
import { plainParagraphs } from "@/components/chat-message-paragraphs";
import { TechniqueAuditDisclosure } from "@/components/technique-audit-disclosure";
import { splitStableMarkdown } from "@/lib/chat-markdown-split";
import type { TechniqueAuditRow } from "@/lib/consultation-agent-events";
import {
resolveTechniqueAuditRows,
@@ -41,14 +42,56 @@ function useMarkdownRenderer() {
return renderer;
}
function renderProse(text: string, renderMarkdown: MarkdownRenderer | undefined): ReactNode {
if (!text) return null;
return renderMarkdown
? renderMarkdown(text)
: (plainParagraphs(text) ?? []).map((paragraph, index) => (
<p key={index}>{paragraph}</p>
));
}
/**
* The completed part of a streaming answer. `memo` keeps React from calling the
* markdown parser again while `text` is unchanged, so a frame that only grew
* the tail costs one small parse instead of one over the whole answer.
*/
const StableMarkdownPrefix = memo(function StableMarkdownPrefix({
text,
renderMarkdown,
}: Readonly<{
text: string;
renderMarkdown: MarkdownRenderer | undefined;
}>) {
return <>{renderProse(text, renderMarkdown)}</>;
});
export function StreamingMarkdown({
text,
renderMarkdown,
}: Readonly<{
text: string;
renderMarkdown: MarkdownRenderer | undefined;
}>) {
const split = splitStableMarkdown(text);
return (
<>
{split.stable ? <StableMarkdownPrefix text={split.stable} renderMarkdown={renderMarkdown} /> : null}
{renderProse(split.tail, renderMarkdown)}
</>
);
}
export function ChatMessageContent({
text,
auditRows,
vargaSentence,
streaming = false,
}: {
text: string;
auditRows?: readonly TechniqueAuditRow[];
vargaSentence?: string | null;
streaming?: boolean;
}) {
const renderMarkdown = useMarkdownRenderer();
const split = splitSpokenAnswerAndTechniqueAudit(text);
@@ -60,11 +103,13 @@ export function ChatMessageContent({
<div className="message-answer">
{spoken ? (
<div className="message-markdown">
{renderMarkdown
? renderMarkdown(spoken)
: (plainParagraphs(spoken) ?? []).map((paragraph, index) => (
<p key={index}>{paragraph}</p>
))}
{streaming
? <StreamingMarkdown text={spoken} renderMarkdown={renderMarkdown} />
: renderMarkdown
? renderMarkdown(spoken)
: (plainParagraphs(spoken) ?? []).map((paragraph, index) => (
<p key={index}>{paragraph}</p>
))}
</div>
) : null}
{vargaSentence ? <p className="message-varga-sentence">{vargaSentence}</p> : null}
@@ -116,6 +116,7 @@ export function ChatMessageRow({
text={message.text}
auditRows={message.agentExecutionReceipt?.techniqueAuditTable}
vargaSentence={showThinkingPanel ? null : vargaSentence}
streaming={message.state !== "settled"}
/>
)
: null;
@@ -4,7 +4,8 @@ import { ArrowUp, Square } from "lucide-react";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { parseAgentReply } from "@/lib/agent-reply";
import { nextActivityView, type ChatMessage, type ChatMessageView } from "@/lib/chat-message-view";
import { nextActivityView, type AgentActivityView, type ChatMessage, type ChatMessageView } from "@/lib/chat-message-view";
import { createStreamFrameBuffer } from "@/lib/stream-frame-buffer";
import {
completeActivityTrace,
completeActivityTraceStep,
@@ -494,6 +495,30 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
let activityReceiptState = createRectificationActivityReceiptState();
let completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
let completedTurnId: string | undefined;
let currentActivity: AgentActivityView | undefined = {
phase: "evidence-validation",
label: "正在处理…",
startedAt: Date.now(),
};
// Every stream event lands in `frames`; it commits at most once per animation
// frame and releases text at a steady pace. The loop below never calls
// setMessages for a live turn directly except on `attempt.reset`.
const frames = createStreamFrameBuffer<null>({
initialMeta: null,
flush: (frame) => {
const text = frame.answer;
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? {
...message,
text,
activityTrace,
completedReceipt,
state: text.trim() ? "streaming" : "thinking",
activity: currentActivity,
}
: message));
},
});
const abortController = new AbortController();
runAbort.current = abortController;
try {
@@ -569,35 +594,29 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
if (event.type === "answer.delta" && typeof event.text === "string") {
raw = event.replace === true ? event.text : raw + event.text;
activityTrace = freezeLiveThink(activityTrace);
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? {
...message,
text: raw,
activityTrace,
state: raw.trim() ? "streaming" : "thinking",
activity: nextActivityView(message.activity, {
phase: "answer-composition",
label: "正在组织回答…",
}),
}
: message));
currentActivity = nextActivityView(currentActivity, {
phase: "answer-composition",
label: "正在组织回答…",
});
frames.setAnswer(raw);
} else if (event.type === "activity.changed" && isPublicRectificationActivity(event.activity)) {
const activity = event.activity;
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? {
...message,
activity: nextActivityView(message.activity, {
phase: "evidence-validation",
label: RECTIFICATION_ACTIVITY_PROGRESS_LABELS[activity],
}),
}
: message));
currentActivity = nextActivityView(currentActivity, {
phase: "evidence-validation",
label: RECTIFICATION_ACTIVITY_PROGRESS_LABELS[activity],
});
frames.touch();
} else if (event.type === "attempt.reset") {
raw = "";
activityTrace = emptyActivityTrace();
activityReceiptState = createRectificationActivityReceiptState();
completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
completedTurnId = undefined;
currentActivity = nextActivityView(undefined, {
phase: "evidence-validation",
label: "正在处理…",
});
frames.reset();
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? {
...message,
@@ -608,10 +627,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
completedReceipt: undefined,
failed: false,
turnId: undefined,
activity: nextActivityView(undefined, {
phase: "evidence-validation",
label: "正在处理…",
}),
activity: currentActivity,
}
: message));
} else if (event.type === "run.failed") {
@@ -634,17 +650,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
tool,
RECTIFICATION_TOOL_PROGRESS_LABELS[tool],
);
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? {
...message,
activityTrace,
activity: nextActivityView(message.activity, {
phase: rectificationToolActivityPhase(tool),
label: RECTIFICATION_TOOL_PROGRESS_LABELS[tool],
completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps),
}),
}
: message));
currentActivity = nextActivityView(currentActivity, {
phase: rectificationToolActivityPhase(tool),
label: RECTIFICATION_TOOL_PROGRESS_LABELS[tool],
completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps),
});
frames.touch();
continue;
}
if (event.status !== "completed" && event.status !== "failed") continue;
@@ -661,21 +672,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
: [],
});
completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? {
...message,
activityTrace,
completedReceipt,
activity: nextActivityView(message.activity, {
phase: rectificationToolActivityPhase(tool),
label: RECTIFICATION_TOOL_DONE_LABELS[tool],
completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps),
}),
}
: message));
currentActivity = nextActivityView(currentActivity, {
phase: rectificationToolActivityPhase(tool),
label: RECTIFICATION_TOOL_DONE_LABELS[tool],
completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps),
});
frames.touch();
}
}
}
frames.settle();
const parsed = completed && !streamFailed ? parseAgentReply(raw) : { text: "", title: undefined };
const succeeded = completed && !streamFailed && Boolean(parsed.text);
@@ -722,6 +728,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
await loadCaseSnapshot();
}
} catch (caught) {
frames.settle();
const aborted = caught instanceof DOMException
? caught.name === "AbortError"
: caught instanceof Error && caught.name === "AbortError";
@@ -757,6 +764,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
: [];
}));
} finally {
frames.dispose();
if (runAbort.current === abortController) runAbort.current = null;
setPending(false);
}
+42 -37
View File
@@ -22,6 +22,7 @@ import {
reduceConsultationTimeline,
} from "@/lib/consultation-run-timeline";
import { createNdjsonParser, type AgentExecutionReceipt, type ConsultationAgentPublicEvent } from "@/lib/consultation-agent-events";
import { createStreamFrameBuffer } from "@/lib/stream-frame-buffer";
import {
applyThinkingSectionProgress,
upsertThinkingSection,
@@ -659,6 +660,29 @@ export function useConsultationRun(params: ConsultationRunParams) {
let thinkingSections: PublicThinkingSection[] = [];
let streamedThinking = "";
let timelineState = emptyConsultationTimeline();
let currentActivity: AgentActivityView | undefined;
// Every stream event lands in `frames`; it commits at most once per animation
// frame and releases text at a steady pace. Nothing below calls
// setStreamingReply directly while the response body is being read.
const frames = createStreamFrameBuffer<null>({
initialMeta: null,
flush: (frame) => {
const partialReply = parseAgentReply(frame.answer).text;
latestPartialReply = partialReply;
thinkingSections = applyThinkingSectionProgress(thinkingSections, partialReply);
setStreamingReply({
sessionId,
text: partialReply,
thinkingText: frame.thinking.trim() || undefined,
thinkingSections: thinkingSections.length ? thinkingSections : undefined,
timeline: timelineState.rows,
activity: currentActivity,
});
if (partialReply && pendingConsultation.current?.requestId === requestId) {
pendingConsultation.current = { ...pendingConsultation.current, partialReply };
}
},
});
try {
const response = await fetch("/api/consult", {
method: "POST",
@@ -721,24 +745,6 @@ export function useConsultationRun(params: ConsultationRunParams) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let answer = "";
const updateStreamingAnswer = (activity?: AgentActivityView) => {
const partialReply = parseAgentReply(answer).text;
latestPartialReply = partialReply;
thinkingSections = applyThinkingSectionProgress(thinkingSections, partialReply);
setStreamingReply((current) => ({
sessionId,
text: partialReply,
thinkingText: streamedThinking.trim() || undefined,
thinkingSections: thinkingSections.length ? thinkingSections : undefined,
timeline: timelineState.rows,
activity: activity
? nextActivityView(current?.sessionId === sessionId ? current.activity : undefined, activity)
: current?.sessionId === sessionId ? current.activity : undefined,
}));
if (partialReply && pendingConsultation.current?.requestId === requestId) {
pendingConsultation.current = { ...pendingConsultation.current, partialReply };
}
};
const updateActivity = (event: ConsultationAgentPublicEvent) => {
let activity: AgentActivityView | undefined;
if (event.type === "skill.started") {
@@ -766,23 +772,23 @@ export function useConsultationRun(params: ConsultationRunParams) {
} else if (event.type === "answer.delta") {
activity = { phase: "answer-composition", label: CONSULTATION_COMPOSING_LABEL };
}
if (activity) updateStreamingAnswer(activity);
if (activity) {
currentActivity = nextActivityView(currentActivity, activity);
frames.touch();
}
};
if ((response.headers.get("content-type") ?? "").includes("application/x-ndjson")) {
const parser = createNdjsonParser((event) => {
timelineState = reduceConsultationTimeline(timelineState, event);
if (event.type === "answer.delta") answer += event.text;
frames.touch();
if (event.type === "answer.delta") {
answer += event.text;
frames.setAnswer(answer);
}
if (event.type === "thinking.delta" && typeof event.text === "string") {
streamedThinking += event.text;
setStreamingReply((current) => ({
sessionId,
text: current?.sessionId === sessionId ? current.text : parseAgentReply(answer).text,
thinkingText: streamedThinking.trim() || undefined,
thinkingSections: current?.sessionId === sessionId ? current.thinkingSections : thinkingSections,
timeline: timelineState.rows,
activity: current?.sessionId === sessionId ? current.activity : undefined,
}));
frames.setThinking(streamedThinking);
}
if (event.type === "thinking.section") {
thinkingSections = applyThinkingSectionProgress(
@@ -794,14 +800,6 @@ export function useConsultationRun(params: ConsultationRunParams) {
}),
parseAgentReply(answer).text,
);
setStreamingReply((current) => ({
sessionId,
text: current?.sessionId === sessionId ? current.text : parseAgentReply(answer).text,
thinkingText: streamedThinking.trim() || undefined,
thinkingSections,
timeline: timelineState.rows,
activity: current?.sessionId === sessionId ? current.activity : undefined,
}));
}
if (event.type === "run.completed") {
runCompleted = true;
@@ -829,6 +827,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
parser.push(decoder.decode(value, { stream: true }));
}
parser.finish(decoder.decode());
frames.settle();
if (truncatedFailure) {
const reply = parseAgentReply(answer);
if (!reply.text) throw new ConsultationResponseError(502, truncatedFailure.message);
@@ -867,9 +866,11 @@ export function useConsultationRun(params: ConsultationRunParams) {
const { done, value } = await reader.read();
if (done) break;
answer += decoder.decode(value, { stream: true });
updateStreamingAnswer();
frames.setAnswer(answer);
}
answer += decoder.decode();
frames.setAnswer(answer);
frames.settle();
}
if (controller.signal.aborted) return Boolean(latestPartialReply);
const reply = parseAgentReply(answer);
@@ -915,6 +916,9 @@ export function useConsultationRun(params: ConsultationRunParams) {
void refreshAccount();
return true;
} catch (caught) {
// Whatever arrived before the failure is what gets kept, not just the
// part the pacing had released so far.
frames.settle();
const cancelled = controller.signal.aborted;
const ownsInterface = pendingConsultation.current?.requestId === requestId;
const partialReply = latestPartialReply;
@@ -992,6 +996,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
}
return Boolean(partialReply);
} finally {
frames.dispose();
cancellationRequests.current.delete(requestId);
const pending = pendingConsultation.current;
if (pending?.requestId !== requestId || pending.phase !== "recovering") {
+69
View File
@@ -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 };
}
+212
View File
@@ -0,0 +1,212 @@
/**
* Frame-coalesced release of streamed agent output.
*
* Every network chunk used to become its own React commit, and each commit
* re-parsed the whole partial answer. This buffer sits between the event
* parser and `setState`: events mutate an accumulator, and at most one flush
* happens per animation frame. Answer and thinking text are released at a
* steady per-frame pace so a burst of chunks reads as flowing text instead of
* a jump, while a large backlog (reconnect, slow tab) catches up in roughly a
* dozen frames.
*
* Pure release arithmetic lives in exported functions so the policy is
* testable without a DOM; scheduling is injectable for the same reason.
*/
export const STREAM_RELEASE_MIN_CHARS = 2;
export const STREAM_RELEASE_CATCHUP_DIVISOR = 12;
export const STREAM_HIDDEN_FLUSH_MS = 250;
/**
* Characters to reveal on one frame. `backlogChars` is how much was waiting
* when the newest text arrived: dividing that by twelve clears any burst in
* about twelve frames, while the two-character floor keeps a slow model from
* reading as stalled. Callers without a backlog figure pass the pending count.
*/
export function streamReleaseCount(pendingChars: number, backlogChars = pendingChars): number {
if (pendingChars <= 0) return 0;
return Math.min(
pendingChars,
Math.max(STREAM_RELEASE_MIN_CHARS, Math.ceil(backlogChars / STREAM_RELEASE_CATCHUP_DIVISOR)),
);
}
/** Advance a released prefix toward its target by one frame's worth of text. */
export function advanceStreamRelease(released: string, target: string, backlogChars?: number): string {
if (!target.startsWith(released)) {
// The target was replaced rather than extended: restart from its head.
return target.slice(0, streamReleaseCount(target.length, backlogChars ?? target.length));
}
const pending = target.length - released.length;
if (pending <= 0) return target;
return target.slice(0, released.length + streamReleaseCount(pending, backlogChars ?? pending));
}
export type StreamFrameSnapshot<Meta> = Readonly<{
answer: string;
thinking: string;
meta: Meta;
/** True when this flush released everything that had arrived. */
settled: boolean;
}>;
export type StreamFrameScheduler = Readonly<{
requestFrame: (callback: () => void) => number;
cancelFrame: (handle: number) => void;
requestTimeout: (callback: () => void, delayMs: number) => number;
cancelTimeout: (handle: number) => void;
hidden: () => boolean;
}>;
export type StreamFrameBufferOptions<Meta> = Readonly<{
initialMeta: Meta;
flush: (snapshot: StreamFrameSnapshot<Meta>) => void;
scheduler?: StreamFrameScheduler;
}>;
export type StreamFrameBuffer<Meta> = Readonly<{
setAnswer: (fullText: string) => void;
setThinking: (fullText: string) => void;
setMeta: (next: Meta | ((current: Meta) => Meta)) => void;
/** Publish meta-only changes (timeline rows, activity) on the next frame. */
touch: () => void;
/** Release everything received and flush synchronously. */
settle: () => void;
/** Drop everything, including scheduled work, without flushing. */
reset: (meta?: Meta) => void;
dispose: () => void;
/** Text released so far, for callers that persist partial output. */
released: () => Readonly<{ answer: string; thinking: string }>;
}>;
function pendingChars(released: string, target: string): number {
return target.startsWith(released) ? target.length - released.length : target.length;
}
function browserScheduler(): StreamFrameScheduler {
return {
requestFrame: (callback) => window.requestAnimationFrame(callback),
cancelFrame: (handle) => window.cancelAnimationFrame(handle),
requestTimeout: (callback, delayMs) => window.setTimeout(callback, delayMs),
cancelTimeout: (handle) => window.clearTimeout(handle),
hidden: () => typeof document !== "undefined" && document.hidden,
};
}
export function createStreamFrameBuffer<Meta>(
options: StreamFrameBufferOptions<Meta>,
): StreamFrameBuffer<Meta> {
const scheduler = options.scheduler ?? browserScheduler();
let targetAnswer = "";
let targetThinking = "";
let releasedAnswer = "";
let releasedThinking = "";
let answerBacklog = 0;
let thinkingBacklog = 0;
let meta = options.initialMeta;
let dirty = false;
let disposed = false;
let frameHandle: number | null = null;
let timeoutHandle: number | null = null;
const cancelScheduled = () => {
if (frameHandle !== null) {
scheduler.cancelFrame(frameHandle);
frameHandle = null;
}
if (timeoutHandle !== null) {
scheduler.cancelTimeout(timeoutHandle);
timeoutHandle = null;
}
};
const emit = (settled: boolean) => {
dirty = false;
options.flush({
answer: releasedAnswer,
thinking: releasedThinking,
meta,
settled,
});
};
const step = () => {
frameHandle = null;
timeoutHandle = null;
if (disposed) return;
if (scheduler.hidden()) {
releasedAnswer = targetAnswer;
releasedThinking = targetThinking;
} else {
releasedAnswer = advanceStreamRelease(releasedAnswer, targetAnswer, answerBacklog);
releasedThinking = advanceStreamRelease(releasedThinking, targetThinking, thinkingBacklog);
}
if (releasedAnswer === targetAnswer) answerBacklog = 0;
if (releasedThinking === targetThinking) thinkingBacklog = 0;
const caughtUp = releasedAnswer === targetAnswer && releasedThinking === targetThinking;
emit(caughtUp);
if (!caughtUp) schedule();
};
const schedule = () => {
if (disposed || frameHandle !== null || timeoutHandle !== null) return;
if (scheduler.hidden()) {
timeoutHandle = scheduler.requestTimeout(step, STREAM_HIDDEN_FLUSH_MS);
} else {
frameHandle = scheduler.requestFrame(step);
}
};
return {
setAnswer(fullText) {
if (disposed || fullText === targetAnswer) return;
targetAnswer = fullText;
answerBacklog = Math.max(answerBacklog, pendingChars(releasedAnswer, targetAnswer));
schedule();
},
setThinking(fullText) {
if (disposed || fullText === targetThinking) return;
targetThinking = fullText;
thinkingBacklog = Math.max(thinkingBacklog, pendingChars(releasedThinking, targetThinking));
schedule();
},
setMeta(next) {
if (disposed) return;
meta = typeof next === "function" ? (next as (current: Meta) => Meta)(meta) : next;
dirty = true;
schedule();
},
touch() {
if (disposed) return;
dirty = true;
schedule();
},
settle() {
if (disposed) return;
cancelScheduled();
releasedAnswer = targetAnswer;
releasedThinking = targetThinking;
answerBacklog = 0;
thinkingBacklog = 0;
emit(true);
},
reset(nextMeta) {
cancelScheduled();
targetAnswer = "";
targetThinking = "";
releasedAnswer = "";
releasedThinking = "";
answerBacklog = 0;
thinkingBacklog = 0;
dirty = false;
if (nextMeta !== undefined) meta = nextMeta;
},
dispose() {
disposed = true;
cancelScheduled();
},
released() {
return { answer: releasedAnswer, thinking: releasedThinking };
},
};
}
@@ -0,0 +1,74 @@
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"\}/);
});
@@ -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);
});
+210
View File
@@ -0,0 +1,210 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
STREAM_HIDDEN_FLUSH_MS,
STREAM_RELEASE_CATCHUP_DIVISOR,
STREAM_RELEASE_MIN_CHARS,
advanceStreamRelease,
createStreamFrameBuffer,
streamReleaseCount,
type StreamFrameScheduler,
type StreamFrameSnapshot,
} from "../src/lib/stream-frame-buffer.ts";
function fakeScheduler(hidden = () => false) {
const frames: Array<() => void> = [];
const timeouts: Array<{ callback: () => void; delayMs: number }> = [];
let handle = 0;
const scheduler: StreamFrameScheduler = {
requestFrame(callback) {
frames.push(callback);
handle += 1;
return handle;
},
cancelFrame() {
frames.length = 0;
},
requestTimeout(callback, delayMs) {
timeouts.push({ callback, delayMs });
handle += 1;
return handle;
},
cancelTimeout() {
timeouts.length = 0;
},
hidden,
};
return {
scheduler,
tick() {
const pending = frames.splice(0);
for (const callback of pending) callback();
return pending.length;
},
tickTimeouts() {
const pending = timeouts.splice(0);
for (const entry of pending) entry.callback();
return pending;
},
get scheduledFrames() {
return frames.length;
},
};
}
test("release count is at least two characters and catches up a backlog within about twelve frames", () => {
assert.equal(streamReleaseCount(0), 0);
assert.equal(streamReleaseCount(1), 1);
assert.equal(streamReleaseCount(2), STREAM_RELEASE_MIN_CHARS);
assert.equal(streamReleaseCount(5), STREAM_RELEASE_MIN_CHARS);
assert.equal(streamReleaseCount(24), STREAM_RELEASE_MIN_CHARS);
assert.equal(streamReleaseCount(25), 3);
assert.equal(streamReleaseCount(1200), 1200 / STREAM_RELEASE_CATCHUP_DIVISOR);
let released = "";
const target = "字".repeat(3_000);
let frames = 0;
while (released !== target && frames < 100) {
released = advanceStreamRelease(released, target, target.length);
frames += 1;
}
// A 3,000-character backlog that arrived at once clears in twelve frames (~200ms).
assert.equal(frames, STREAM_RELEASE_CATCHUP_DIVISOR);
assert.equal(released, target);
// Without the backlog figure the pace still floors at two characters per frame.
assert.equal(advanceStreamRelease("", "十二个字符十二个字符十二"), "十二");
});
test("a replaced target that no longer extends the released prefix jumps instead of stalling", () => {
assert.equal(advanceStreamRelease("旧的回答", "新"), "新");
// A replacement restarts at the paced rate from the new head rather than showing stale text.
assert.equal(advanceStreamRelease("abc", "abd"), "ab");
});
test("many events collapse into one flush per frame and settle releases everything synchronously", () => {
const fake = fakeScheduler();
const flushes: StreamFrameSnapshot<string[]>[] = [];
const buffer = createStreamFrameBuffer<string[]>({
initialMeta: [],
scheduler: fake.scheduler,
flush: (snapshot) => flushes.push(snapshot),
});
// 200 one-character tokens arrive four per frame across fifty frames, the way a
// model streams Chinese text; each frame is allowed one commit.
let answer = "";
let frameCount = 0;
for (let index = 0; index < 200; index += 1) {
answer += "字";
buffer.setAnswer(answer);
buffer.setMeta((rows) => [...rows, `row-${index}`]);
if (index % 4 === 3) frameCount += fake.tick();
}
assert.equal(frameCount, 50);
assert.equal(flushes.length, 50);
assert.ok(flushes.length * 3 <= 200, "at most one commit per frame, not per token");
for (let index = 1; index < flushes.length; index += 1) {
assert.ok(flushes[index]!.answer.length >= flushes[index - 1]!.answer.length);
assert.ok(flushes[index]!.answer.length - flushes[index - 1]!.answer.length >= STREAM_RELEASE_MIN_CHARS);
}
assert.equal(flushes.at(-1)!.meta.length, 200);
assert.ok(flushes.at(-1)!.answer.length < 200, "pacing is still behind the network");
buffer.settle();
assert.equal(flushes.at(-1)!.answer, answer);
assert.equal(flushes.at(-1)!.settled, true);
assert.equal(fake.scheduledFrames, 0);
assert.equal(buffer.released().answer, answer);
});
test("thinking text is paced separately from the answer and meta-only touches still flush", () => {
const fake = fakeScheduler();
const flushes: StreamFrameSnapshot<null>[] = [];
const buffer = createStreamFrameBuffer<null>({
initialMeta: null,
scheduler: fake.scheduler,
flush: (snapshot) => flushes.push(snapshot),
});
buffer.setThinking("先看事业宫,再看大运。");
fake.tick();
assert.equal(flushes.length, 1);
assert.equal(flushes[0]!.answer, "");
assert.ok(flushes[0]!.thinking.length >= STREAM_RELEASE_MIN_CHARS);
assert.equal(flushes[0]!.settled, false);
buffer.settle();
assert.equal(flushes.at(-1)!.thinking, "先看事业宫,再看大运。");
buffer.touch();
fake.tick();
assert.equal(flushes.length, 3);
assert.equal(flushes.at(-1)!.settled, true);
});
test("a burst that lands mid-stream is cleared within about twelve frames instead of trickling", () => {
const fake = fakeScheduler();
const flushes: StreamFrameSnapshot<null>[] = [];
const buffer = createStreamFrameBuffer<null>({
initialMeta: null,
scheduler: fake.scheduler,
flush: (snapshot) => flushes.push(snapshot),
});
buffer.setAnswer("字".repeat(20));
fake.tick();
buffer.setAnswer("字".repeat(2_420));
let frames = 0;
while (fake.scheduledFrames > 0 && frames < 100) {
fake.tick();
frames += 1;
}
assert.equal(flushes.at(-1)!.answer.length, 2_420);
assert.ok(frames <= STREAM_RELEASE_CATCHUP_DIVISOR + 1, `took ${frames} frames`);
});
test("a hidden document falls back to a timeout and releases everything at once", () => {
const fake = fakeScheduler(() => true);
const flushes: StreamFrameSnapshot<null>[] = [];
const buffer = createStreamFrameBuffer<null>({
initialMeta: null,
scheduler: fake.scheduler,
flush: (snapshot) => flushes.push(snapshot),
});
buffer.setAnswer("字".repeat(500));
assert.equal(fake.scheduledFrames, 0);
const fired = fake.tickTimeouts();
assert.equal(fired.length, 1);
assert.equal(fired[0]!.delayMs, STREAM_HIDDEN_FLUSH_MS);
assert.equal(flushes.length, 1);
assert.equal(flushes[0]!.answer.length, 500);
assert.equal(flushes[0]!.settled, true);
});
test("reset drops received and released text plus scheduled work, and dispose silences the buffer", () => {
const fake = fakeScheduler();
const flushes: StreamFrameSnapshot<number>[] = [];
const buffer = createStreamFrameBuffer<number>({
initialMeta: 1,
scheduler: fake.scheduler,
flush: (snapshot) => flushes.push(snapshot),
});
buffer.setAnswer("第一次尝试的正文");
fake.tick();
assert.equal(flushes.length, 1);
buffer.reset(2);
assert.equal(fake.scheduledFrames, 0);
assert.deepEqual(buffer.released(), { answer: "", thinking: "" });
buffer.touch();
fake.tick();
assert.equal(flushes.at(-1)!.answer, "");
assert.equal(flushes.at(-1)!.meta, 2);
buffer.dispose();
buffer.setAnswer("不再发布");
buffer.touch();
assert.equal(fake.tick(), 0);
buffer.settle();
assert.equal(flushes.at(-1)!.answer, "");
});