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
@@ -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);
}