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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user