diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md
index 13f0ec14..64b141c6 100644
--- a/docs/BUG_HISTORY.md
+++ b/docs/BUG_HISTORY.md
@@ -7266,3 +7266,19 @@
- 相关记录:BUG-453、BUG-463
- 复发自:BUG-463(idle 不再追问,拒答路径与 occupation blocking 仍挡住采用卡)
- 修复版本:待发布
+
+## BUG-473 | 流式回答每个网络事件都全量重渲染,长回答越写越卡
+
+- 状态:resolved
+- 首次发现:2026-09-01
+- 最近更新:2026-09-01
+- 影响面:`use-consultation-run.ts` NDJSON 回调、`rectification-agentic-chat.tsx` `send` 循环、`chat-message-content.tsx`
+- 用户现象:回答越长越卡,文字一坨一坨蹦出来而不是流出来;思考步骤更新时整条消息跟着抖。
+- 触发条件:任何一次流式咨询或校正回合,回答超过一两千字后明显。
+- 根因:服务端每个模型 text-delta 立即发一条 `answer.delta`,客户端每条事件各自 `setStreamingReply` / `setMessages`;每次提交都对整段部分回答跑 `parseAgentReply`、`applyThinkingSectionProgress`、`splitSpokenAnswerAndTechniqueAudit` 和 `react-markdown` 全量 parse,随回答长度呈平方级增长。
+- 修复:新增 `lib/stream-frame-buffer.ts`,所有流式事件先落到累加器,`requestAnimationFrame` 合并成每帧最多一次提交;正文与思考文本按 `max(2, ceil(积压/12))` 每帧匀速释放,突发积压约十二帧追平,页面隐藏时退化为 250ms 定时并一次放完,`run.completed`/失败/中止时同步冲干净。`chat-message-content.tsx` 在流式时把已完成段落(最后一个空行之前,不切进代码围栏、列表项之间或表格)交给按内容记忆的前缀组件,只有尾块每帧重 parse;结算后仍整篇一次 parse。
+- 验证:`tests/stream-frame-buffer.test.ts`(释放公式、200 token/50 帧只提交 50 次、突发 12 帧追平、隐藏退化、reset/dispose)、`tests/chat-markdown-split.test.ts`(切分规则、前缀稳定、前缀与尾块分开 parse)、`tests/home-streaming-render-split.test.ts` 新增按帧驱动的渲染次数上限。
+- 防复发:流式状态必须经 `stream-frame-buffer` 提交,不得在事件回调里直接 `setState`;流式期间的 Markdown 渲染必须走前缀/尾块切分。
+- 相关记录:BUG-474
+- 复发自:无
+- 修复版本:待发布
diff --git a/frontend/src/components/chat-message-content.tsx b/frontend/src/components/chat-message-content.tsx
index bec2ea9d..24cafa41 100644
--- a/frontend/src/components/chat-message-content.tsx
+++ b/frontend/src/components/chat-message-content.tsx
@@ -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) => (
+
{paragraph}
+ ));
+}
+
+/**
+ * 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 ? : 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({
{spoken ? (
- {renderMarkdown
- ? renderMarkdown(spoken)
- : (plainParagraphs(spoken) ?? []).map((paragraph, index) => (
-
{paragraph}
- ))}
+ {streaming
+ ?
+ : renderMarkdown
+ ? renderMarkdown(spoken)
+ : (plainParagraphs(spoken) ?? []).map((paragraph, index) => (
+
{paragraph}
+ ))}
) : null}
{vargaSentence ?
{vargaSentence}
: null}
diff --git a/frontend/src/components/chat-message-row.tsx b/frontend/src/components/chat-message-row.tsx
index 03d0a1c2..401cc4cd 100644
--- a/frontend/src/components/chat-message-row.tsx
+++ b/frontend/src/components/chat-message-row.tsx
@@ -116,6 +116,7 @@ export function ChatMessageRow({
text={message.text}
auditRows={message.agentExecutionReceipt?.techniqueAuditTable}
vargaSentence={showThinkingPanel ? null : vargaSentence}
+ streaming={message.state !== "settled"}
/>
)
: null;
diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx
index 1872245f..563b70f0 100644
--- a/frontend/src/components/rectification-agentic-chat.tsx
+++ b/frontend/src/components/rectification-agentic-chat.tsx
@@ -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
({
+ 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);
}
diff --git a/frontend/src/hooks/use-consultation-run.ts b/frontend/src/hooks/use-consultation-run.ts
index 160fb1fa..b766adb2 100644
--- a/frontend/src/hooks/use-consultation-run.ts
+++ b/frontend/src/hooks/use-consultation-run.ts
@@ -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({
+ 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") {
diff --git a/frontend/src/lib/chat-markdown-split.ts b/frontend/src/lib/chat-markdown-split.ts
new file mode 100644
index 00000000..7e73ed86
--- /dev/null
+++ b/frontend/src/lib/chat-markdown-split.ts
@@ -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 `` 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 };
+}
diff --git a/frontend/src/lib/stream-frame-buffer.ts b/frontend/src/lib/stream-frame-buffer.ts
new file mode 100644
index 00000000..c96b2c0b
--- /dev/null
+++ b/frontend/src/lib/stream-frame-buffer.ts
@@ -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 = 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 = Readonly<{
+ initialMeta: Meta;
+ flush: (snapshot: StreamFrameSnapshot) => void;
+ scheduler?: StreamFrameScheduler;
+}>;
+
+export type StreamFrameBuffer = 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(
+ options: StreamFrameBufferOptions,
+): StreamFrameBuffer {
+ 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 };
+ },
+ };
+}
diff --git a/frontend/tests/chat-markdown-split.test.ts b/frontend/tests/chat-markdown-split.test.ts
new file mode 100644
index 00000000..fd8586e9
--- /dev/null
+++ b/frontend/tests/chat-markdown-split.test.ts
@@ -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*= 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({
+ 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);
+});
diff --git a/frontend/tests/stream-frame-buffer.test.ts b/frontend/tests/stream-frame-buffer.test.ts
new file mode 100644
index 00000000..81fbf787
--- /dev/null
+++ b/frontend/tests/stream-frame-buffer.test.ts
@@ -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[] = [];
+ const buffer = createStreamFrameBuffer({
+ 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[] = [];
+ const buffer = createStreamFrameBuffer({
+ 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[] = [];
+ const buffer = createStreamFrameBuffer({
+ 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[] = [];
+ const buffer = createStreamFrameBuffer({
+ 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[] = [];
+ const buffer = createStreamFrameBuffer({
+ 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, "");
+});