feat(chat): smooth streaming output

This commit is contained in:
2026-06-10 21:12:53 +08:00
parent 7d2ae87e39
commit 224d53a04d
9 changed files with 915 additions and 85 deletions
+167
View File
@@ -0,0 +1,167 @@
# Chat 流式生成动画改造经验
本文记录 `src/components/chat` 里本次文字生成、图表生成、滚动稳定性的改造经验。重点不是复盘代码行数,而是总结后续继续调整时应遵守的工程边界和交互原则。
## 目标
- 文本生成要有连续感,避免 token 直接到达导致忽快忽慢。
- 已生成内容必须稳定,不能反复淡入、重排或闪烁。
- 图表和工具调用插入时不能让“分析结果”边框剧烈抖动。
- 底部自动滚动要跟随,但不能每个 token 都强制贴底。
- 动画应辅助理解,不能比内容本身更抢眼。
## 文本流式生成
### 经验结论
不要把后端 token 到达节奏直接暴露给 UI。后端 token 通常不均匀,前端如果每个 token 都立即渲染,会出现文字跳动、滚动频繁、动画看不出来等问题。
更稳的做法是类似 Vercel AI SDK `smoothStream` 的思路:
- token 先进入缓冲区。
- 前端按固定节奏释放 chunk。
- chunk 尽量按词、短语、标点边界切分。
- 当缓冲积压较大时,自适应加快 drain,避免显示落后真实输出太多。
当前实现采用:
- `TOKEN_PLAYBACK_INTERVAL_MS = 16`
- 小缓冲按较短 chunk 输出。
- 大缓冲最多每帧释放 `160` 字符。
- 中文优先使用 `Intl.Segmenter("zh", { granularity: "word" })`
- 非 token 事件前强制 flush,保证工具调用、done、error 的顺序正确。
### 踩坑
- 只做 `setTimeout(120ms)` 批量 flush 不够。它只是减少更新次数,并不能形成稳定播放节奏。
- interval 太小,例如 `8ms`,浏览器调度不一定更稳定,反而可能增加 React 更新压力。
- 中文按 `Intl.Segmenter` 的单个词输出会显得慢,必须结合缓冲长度动态放大 chunk。
- `done``error``tool_call` 前如果不 flush,会造成文本和结构事件顺序错乱。
## Markdown 动画
### 经验结论
Markdown 是流式文本动画里最容易出问题的部分。原因是 `ReactMarkdown` 每次都会重新解析完整内容,原始 Markdown 字符索引和最终 DOM 文本节点索引不一致。
典型例子:
- `**加粗**` 的原始长度包含 `**`,但可见文本不包含。
- 列表符号、链接语法、代码块围栏都可能影响原始索引。
- 新增文本可能落在 `p``li``strong``code` 等不同节点里。
因此不要简单用原始 `text.length``fadeFrom` 去对应 Markdown 渲染后的 DOM 文本。
当前策略:
- Markdown 仍完整解析,保证格式正确。
- 在 rehype 阶段处理 AST。
- 从 AST 尾部反向找最后的可见 text node。
- 只给最后一段尾部文本加动画。
- 每次最多动画最后 `48` 个字符,避免大 chunk 整段闪烁。
### 踩坑
-`text.length` 作为 React key 会导致整段 Markdown remount,所有文本都会重新淡入。
- CSS animation 和 Web Animations 同时作用在同一个 span 上,会出现闪烁或动画重启。
- 在 render 阶段读写 ref 会触发 React hooks lint 规则,也容易产生不可控渲染。
- 反向遍历 AST 时拆分 text node 要注意顺序。使用 `unshift` 时应先插入动画尾巴,再插入稳定文本,最终 DOM 才是“稳定文本在前,动画尾巴在后”。
## 当前文字动画建议
推荐保留轻量动画:
- 使用 Web Animations,在 `useLayoutEffect` 中启动,避免先完整显示一帧再裁切。
- 使用 `clip-path` 做左到右 reveal。
- 叠加轻微 opacity:当前约 `0.46 -> 1`
- 时长控制在 `120ms - 260ms`
不要做:
- 外层整段 `motion.div` 淡入。
- 每次流式更新都改变 key。
- 对整个 Markdown AST 的新增范围大面积包 span。
- 在生成中对已有文本重复动画。
## 滚动和边框稳定
### 经验结论
滚动条在最底部时,内容增长会不断改变 `scrollTop`。如果每个 token 都执行 `scrollTop = scrollHeight``scrollIntoView`,最后一个 assistant turn 的边框会产生明显抖动。
当前策略:
- 生成中不再每个 token 精确贴底。
- 底部保留生成缓冲区,当前约 `180px`
- 只有缓冲被消耗到阈值后才恢复滚动。
- 用户离开底部附近后,不再强制自动跟随。
- 使用 `scrollbar-gutter: stable` 减少滚动条出现/消失造成的宽度变化。
### 踩坑
- “锁最大高度”不是正确方向。问题不是高度无限增长,而是底部锚定过于频繁。
- 每 token 自动滚动会把视口不断向下推,视觉上就是边框抖动。
- 滚动判断阈值要和底部缓冲一致,否则缓冲刚出现就被判断为“离开底部”。
## 图表生成
### 经验结论
图表不能等数据到达后突然插入。图表生成应先占位,再 crossfade,再让图表内部动画接管。
当前策略:
- 工具调用 pending 时使用固定尺寸 `ChartGenerationSkeleton`
- 图表真实数据到达后,继续短暂保留 skeleton overlay。
- ECharts 在 skeleton 下方淡入。
- 容器尺寸保持一致,避免边框高度突变。
- ECharts 内部使用 enter/update 动画,而不是外层布局动画。
图表类型动画建议:
- 折线图:平滑 enter,面积渐显。
- 柱状图:柱子从基线增长,并对数据点轻微 stagger。
- 饼图:使用 expansion/sweep 类进入动画。
- update 动画要短于 enter 动画。
### 踩坑
- 只给外层图表卡片 fade in 不够,插入瞬间仍可能造成内容跳变。
- skeleton 和最终图表尺寸不一致,会导致边框先长再缩。
- 图表更新时不要重建组件,尽量让 ECharts diff 数据并执行内部 transition。
## 状态提示
“正在生成”状态是有价值的,应该保留。它承担了部分动感和系统状态反馈,不需要让文本动画本身过于夸张。
推荐:
- 状态放在“分析结果”标题行右侧。
- 使用小尺寸、低干扰的 pulsing dots。
- 不使用末尾光标,避免和业务文本混在一起。
## 验证建议
每次调整流式动画后至少跑:
```bash
npx eslint src/components/chat/AgentMarkdownBlock.tsx src/components/chat/AgentTurn.tsx src/components/chat/ChatInlineChart.tsx src/components/chat/AgentWorkspace.tsx src/components/chat/GlobalChatbox.tsx src/components/chat/hooks/useAgentChatSession.ts
npx tsc --noEmit
npm test -- src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx src/components/chat/hooks/useAgentChatSession.actions.test.tsx src/components/chat/AgentWorkspace.test.tsx src/components/chat/ChatInlineChart.test.ts --runInBand
```
人工验证重点:
- 长中文回答是否明显落后后端真实速度。
- Markdown 加粗、列表、代码块是否乱序。
- 底部自动滚动时“分析结果”边框是否抖动。
- 工具调用 pending 到图表出现时是否有高度跳变。
- 用户手动上滚后是否停止强制跟随。
## 后续调整原则
1. 先调节 token playback,再调动画。
2. 动画只作用于新增内容,已有内容不能重播。
3. Markdown 动画优先保守,宁可弱一点,也不能破坏文本顺序。
4. 图表和工具调用先稳定布局,再考虑视觉效果。
5. 滚动跟随要有缓冲,不能逐 token 贴底。
+146 -5
View File
@@ -1,14 +1,141 @@
"use client"; "use client";
import React from "react"; import React from "react";
import ReactMarkdown from "react-markdown"; import type { Element, Root, RootContent, Text } from "hast";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import markdownStyles from "./GlobalChatboxMarkdown.module.css"; import markdownStyles from "./GlobalChatboxMarkdown.module.css";
export const normalizeClipboardText = (value: string) => value.replace(/\s+$/u, ""); export const normalizeClipboardText = (value: string) => value.replace(/\s+$/u, "");
export const MarkdownBlock = ({ children }: { children: string }) => { const isTextNode = (node: RootContent): node is Text => node.type === "text";
const isElementNode = (node: RootContent): node is Element => node.type === "element";
const createFadeSpan = (value: string, fadeKey: string): Element => ({
type: "element",
tagName: "span",
properties: {
className: [markdownStyles.streamFade],
dataStreamFadeKey: fadeKey,
dataStreamRevealLength: value.length,
},
children: [{ type: "text", value }],
});
const splitTextTail = (value: string, tailLength: number) => {
const codePoints = Array.from(value);
const stableText = codePoints.slice(0, -tailLength).join("");
const animatedText = codePoints.slice(-tailLength).join("");
return { stableText, animatedText };
};
const createStreamFadePlugin = (fadeLength: number, fadeKey: string) => {
return () => (tree: Root) => {
let remainingFadeLength = fadeLength;
const visitChildren = (parent: Element | Root) => {
const nextChildren: RootContent[] = [];
for (let index = parent.children.length - 1; index >= 0; index -= 1) {
const child = (parent.children as RootContent[])[index];
if (isTextNode(child)) {
if (!child.value.trim()) {
nextChildren.unshift(child);
continue;
}
if (remainingFadeLength <= 0) {
nextChildren.unshift(child);
continue;
}
const textLength = Array.from(child.value).length;
const tailLength = Math.min(textLength, remainingFadeLength);
const { stableText, animatedText } = splitTextTail(child.value, tailLength);
remainingFadeLength -= tailLength;
if (animatedText) {
nextChildren.unshift(createFadeSpan(animatedText, fadeKey));
}
if (stableText) {
nextChildren.unshift({ ...child, value: stableText });
}
continue;
}
if (isElementNode(child)) {
visitChildren(child);
}
nextChildren.unshift(child);
}
parent.children = nextChildren as typeof parent.children;
};
visitChildren(tree);
};
};
const StreamFadeSpan: Components["span"] = ({ node, children, ...props }) => {
const ref = React.useRef<HTMLSpanElement>(null);
const fadeKeyValue = node?.properties?.dataStreamFadeKey;
const fadeKey = typeof fadeKeyValue === "string" ? fadeKeyValue : undefined;
const revealLengthValue = node?.properties?.dataStreamRevealLength;
const revealLength =
typeof revealLengthValue === "number"
? revealLengthValue
: typeof revealLengthValue === "string"
? Number.parseInt(revealLengthValue, 10)
: 0;
React.useLayoutEffect(() => {
if (!fadeKey) return;
const element = ref.current;
if (!element) return;
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
const duration = Math.min(260, Math.max(120, revealLength * 14));
const animation = element.animate(
[
{ clipPath: "inset(0 100% 0 0)", opacity: 0.46 },
{ clipPath: "inset(0 0% 0 0)", opacity: 1 },
],
{
duration,
easing: "cubic-bezier(0.16, 1, 0.3, 1)",
fill: "both",
},
);
return () => {
animation.cancel();
};
}, [fadeKey, revealLength]);
return (
<span {...props} ref={ref}>
{children}
</span>
);
};
const markdownComponents: Components = {
span: StreamFadeSpan,
};
export const MarkdownBlock = ({
children,
streamFadeKey,
streamFadeLength,
}: {
children: string;
streamFadeKey?: string;
streamFadeLength?: number | null;
}) => {
const handleCopy = React.useCallback((event: React.ClipboardEvent<HTMLDivElement>) => { const handleCopy = React.useCallback((event: React.ClipboardEvent<HTMLDivElement>) => {
const selectedText = window.getSelection()?.toString(); const selectedText = window.getSelection()?.toString();
if (!selectedText) return; if (!selectedText) return;
@@ -16,12 +143,26 @@ export const MarkdownBlock = ({ children }: { children: string }) => {
event.preventDefault(); event.preventDefault();
event.clipboardData.setData("text/plain", normalizeClipboardText(selectedText)); event.clipboardData.setData("text/plain", normalizeClipboardText(selectedText));
}, []); }, []);
const rehypePlugins = React.useMemo(
() =>
typeof streamFadeLength === "number" && streamFadeLength > 0
? [createStreamFadePlugin(
streamFadeLength,
streamFadeKey ?? `stream-tail-${children.length}`,
)]
: [],
[children.length, streamFadeKey, streamFadeLength],
);
return ( return (
<div className={markdownStyles.markdown} onCopy={handleCopy}> <div className={markdownStyles.markdown} onCopy={handleCopy}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown> <ReactMarkdown
components={markdownComponents}
remarkPlugins={[remarkGfm]}
rehypePlugins={rehypePlugins}
>
{children}
</ReactMarkdown>
</div> </div>
); );
}; };
+122 -8
View File
@@ -25,7 +25,7 @@ import {
import type { Message, SpeechState } from "./GlobalChatbox.types"; import type { Message, SpeechState } from "./GlobalChatbox.types";
import { stripMarkdown } from "./GlobalChatbox.utils"; import { stripMarkdown } from "./GlobalChatbox.utils";
import { AgentProgressTimeline } from "./AgentProgressTimeline"; import { AgentProgressTimeline } from "./AgentProgressTimeline";
import { ChatInlineChart } from "./ChatInlineChart"; import { ChartGenerationSkeleton, ChatInlineChart } from "./ChatInlineChart";
import { ChatToolCallBlock } from "./ChatToolCallBlock"; import { ChatToolCallBlock } from "./ChatToolCallBlock";
import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock"; import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock";
import { PermissionRequestGroup } from "./AgentPermissionRequests"; import { PermissionRequestGroup } from "./AgentPermissionRequests";
@@ -51,6 +51,105 @@ type AgentTurnProps = {
onRejectQuestion: (requestId: string) => void; onRejectQuestion: (requestId: string) => void;
}; };
const StreamingStatus = () => {
const theme = useTheme();
return (
<Stack
direction="row"
spacing={0.75}
alignItems="center"
sx={{
px: 1,
py: 0.35,
borderRadius: 999,
bgcolor: alpha(theme.palette.primary.main, 0.07),
color: "text.secondary",
}}
>
<Stack direction="row" spacing={0.35} alignItems="center">
{[0, 1, 2].map((index) => (
<motion.span
key={index}
animate={{ opacity: [0.28, 0.86, 0.28] }}
transition={{
duration: 0.95,
repeat: Infinity,
delay: index * 0.14,
ease: "easeInOut",
}}
style={{
width: 4,
height: 4,
borderRadius: "50%",
background: theme.palette.primary.main,
display: "block",
}}
/>
))}
</Stack>
<Typography variant="caption" color="text.secondary" fontWeight={700}>
</Typography>
</Stack>
);
};
const StreamingMarkdownBlock = ({
text,
isStreaming,
segmentKey,
}: {
text: string;
isStreaming: boolean;
segmentKey: string;
}) => {
const [streamTextState, setStreamTextState] = React.useState<{
displayText: string;
animatedTailLength: number;
}>({
displayText: text,
animatedTailLength: 0,
});
React.useLayoutEffect(() => {
setStreamTextState((current) => {
if (current.displayText === text && current.animatedTailLength === 0) {
return current;
}
if (!isStreaming) {
return {
displayText: text,
animatedTailLength: 0,
};
}
if (current.displayText === text) {
return current;
}
return {
displayText: text,
animatedTailLength:
text.length > current.displayText.length &&
text.startsWith(current.displayText)
? Math.min(48, text.length - current.displayText.length)
: 0,
};
});
}, [isStreaming, text]);
return (
<MarkdownBlock
streamFadeKey={`${segmentKey}-${streamTextState.displayText.length}`}
streamFadeLength={streamTextState.animatedTailLength}
>
{streamTextState.displayText}
</MarkdownBlock>
);
};
export const AgentTurn = React.memo( export const AgentTurn = React.memo(
({ ({
message, message,
@@ -69,6 +168,7 @@ export const AgentTurn = React.memo(
const theme = useTheme(); const theme = useTheme();
const isUser = message.role === "user"; const isUser = message.role === "user";
const isErrorMessage = Boolean(message.isError); const isErrorMessage = Boolean(message.isError);
const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming;
const [isHovered, setIsHovered] = React.useState(false); const [isHovered, setIsHovered] = React.useState(false);
const isProgressComplete = message.progress?.some( const isProgressComplete = message.progress?.some(
(item) => item.phase === "complete" && item.status === "completed", (item) => item.phase === "complete" && item.status === "completed",
@@ -238,17 +338,28 @@ export const AgentTurn = React.memo(
borderRadius: 4, borderRadius: 4,
bgcolor: alpha("#fff", 0.4), bgcolor: alpha("#fff", 0.4),
border: `1px solid ${alpha("#fff", 0.6)}`, border: `1px solid ${alpha("#fff", 0.6)}`,
position: "relative",
}} }}
> >
<Stack spacing={1.2}> <Stack spacing={1.2}>
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}> <Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
</Typography>
</Typography>
{isStreamingAssistant ? <StreamingStatus /> : null}
</Stack>
{contentSegments.map((segment, segIdx) => { {contentSegments.map((segment, segIdx) => {
if (segment.type === "text") { if (segment.type === "text") {
const text = segment.content.trim(); const text = segment.content.trim();
if (!text && contentSegments.length > 1) return null; if (!text && contentSegments.length > 1) return null;
return <MarkdownBlock key={segIdx}>{text || "..."}</MarkdownBlock>; return (
<StreamingMarkdownBlock
key={segIdx}
text={text || "..."}
isStreaming={isStreamingAssistant}
segmentKey={`${message.id}-${segIdx}`}
/>
);
} }
if (segment.type === "tool_call") { if (segment.type === "tool_call") {
if ( if (
@@ -267,6 +378,7 @@ export const AgentTurn = React.memo(
series={p.series} series={p.series}
x_axis_name={(p.x_axis_name as string) ?? undefined} x_axis_name={(p.x_axis_name as string) ?? undefined}
y_axis_name={(p.y_axis_name as string) ?? undefined} y_axis_name={(p.y_axis_name as string) ?? undefined}
isStreaming={isStreamingAssistant}
/> />
); );
} }
@@ -279,9 +391,10 @@ export const AgentTurn = React.memo(
} }
if (segment.type === "tool_call_pending") { if (segment.type === "tool_call_pending") {
return ( return (
<Typography key="tool-pending" variant="caption" color="text.secondary"> <ChartGenerationSkeleton
... key="tool-pending"
</Typography> status={<StreamingStatus />}
/>
); );
} }
return null; return null;
@@ -306,6 +419,7 @@ export const AgentTurn = React.memo(
series={artifact.params.series} series={artifact.params.series}
x_axis_name={(artifact.params.x_axis_name as string) ?? undefined} x_axis_name={(artifact.params.x_axis_name as string) ?? undefined}
y_axis_name={(artifact.params.y_axis_name as string) ?? undefined} y_axis_name={(artifact.params.y_axis_name as string) ?? undefined}
isStreaming={isStreamingAssistant}
/> />
))} ))}
</Stack> </Stack>
+18 -2
View File
@@ -7,6 +7,7 @@ import { AgentWorkspace } from "./AgentWorkspace";
import type { Message } from "./GlobalChatbox.types"; import type { Message } from "./GlobalChatbox.types";
const renderCounts = new Map<string, number>(); const renderCounts = new Map<string, number>();
const streamingFlags = new Map<string, boolean>();
jest.mock("next/image", () => ({ jest.mock("next/image", () => ({
__esModule: true, __esModule: true,
@@ -16,7 +17,18 @@ jest.mock("next/image", () => ({
jest.mock("framer-motion", () => ({ jest.mock("framer-motion", () => ({
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>, AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
motion: { motion: {
div: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>, div: ({
children,
animate: _animate,
exit: _exit,
initial: _initial,
layout: _layout,
transition: _transition,
whileHover: _whileHover,
...props
}: React.HTMLAttributes<HTMLDivElement> & Record<string, unknown>) => (
<div {...props}>{children}</div>
),
}, },
})); }));
@@ -25,8 +37,9 @@ jest.mock("./GlobalChatbox.parts", () => ({
})); }));
jest.mock("./AgentTurn", () => ({ jest.mock("./AgentTurn", () => ({
AgentTurn: ({ message }: { message: Message }) => { AgentTurn: ({ message, isStreaming }: { message: Message; isStreaming: boolean }) => {
renderCounts.set(message.id, (renderCounts.get(message.id) ?? 0) + 1); renderCounts.set(message.id, (renderCounts.get(message.id) ?? 0) + 1);
streamingFlags.set(message.id, isStreaming);
return <div data-testid={`turn-${message.id}`}>{message.content}</div>; return <div data-testid={`turn-${message.id}`}>{message.content}</div>;
}, },
})); }));
@@ -49,6 +62,7 @@ describe("AgentWorkspace", () => {
beforeEach(() => { beforeEach(() => {
renderCounts.clear(); renderCounts.clear();
streamingFlags.clear();
}); });
it("shows a loading skeleton instead of the empty state while switching history sessions", () => { it("shows a loading skeleton instead of the empty state while switching history sessions", () => {
@@ -106,5 +120,7 @@ describe("AgentWorkspace", () => {
expect(renderCounts.get("user-1")).toBe(1); expect(renderCounts.get("user-1")).toBe(1);
expect(renderCounts.get("assistant-1")).toBe(1); expect(renderCounts.get("assistant-1")).toBe(1);
expect(renderCounts.get("assistant-2")).toBe(2); expect(renderCounts.get("assistant-2")).toBe(2);
expect(streamingFlags.get("assistant-1")).toBe(false);
expect(streamingFlags.get("assistant-2")).toBe(true);
}); });
}); });
+48 -17
View File
@@ -21,7 +21,9 @@ type AgentWorkspaceProps = {
messages: Message[]; messages: Message[];
isStreaming: boolean; isStreaming: boolean;
isLoadingSession?: boolean; isLoadingSession?: boolean;
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
bottomRef: React.RefObject<HTMLDivElement | null>; bottomRef: React.RefObject<HTMLDivElement | null>;
onScrollStateChange?: (isNearBottom: boolean) => void;
speakingMessageId: string | null; speakingMessageId: string | null;
speechState: SpeechState; speechState: SpeechState;
onSpeak: (messageId: string, text: string) => void; onSpeak: (messageId: string, text: string) => void;
@@ -51,6 +53,9 @@ type TurnListProps = {
onRejectQuestion: (requestId: string) => void; onRejectQuestion: (requestId: string) => void;
}; };
const STREAMING_BOTTOM_RESERVE_PX = 180;
const STREAMING_NEAR_BOTTOM_THRESHOLD_PX = STREAMING_BOTTOM_RESERVE_PX + 120;
const sameMessages = (left: Message[], right: Message[]) => const sameMessages = (left: Message[], right: Message[]) =>
left.length === right.length && left.length === right.length &&
left.every((message, index) => message === right[index]); left.every((message, index) => message === right[index]);
@@ -293,7 +298,9 @@ export const AgentWorkspace = ({
messages, messages,
isStreaming, isStreaming,
isLoadingSession = false, isLoadingSession = false,
scrollContainerRef,
bottomRef, bottomRef,
onScrollStateChange,
speakingMessageId, speakingMessageId,
speechState, speechState,
onSpeak, onSpeak,
@@ -321,9 +328,24 @@ export const AgentWorkspace = ({
: undefined; : undefined;
const historyMessages = const historyMessages =
streamingMessage !== undefined ? messages.slice(0, -1) : messages; streamingMessage !== undefined ? messages.slice(0, -1) : messages;
const handleScroll = React.useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
if (!onScrollStateChange) return;
const target = event.currentTarget;
const distanceToBottom =
target.scrollHeight - target.scrollTop - target.clientHeight;
onScrollStateChange(
distanceToBottom <
(isStreaming ? STREAMING_NEAR_BOTTOM_THRESHOLD_PX : 96),
);
},
[isStreaming, onScrollStateChange],
);
return ( return (
<Box <Box
ref={scrollContainerRef}
onScroll={handleScroll}
sx={{ sx={{
flex: 1, flex: 1,
overflowY: "auto", overflowY: "auto",
@@ -331,6 +353,7 @@ export const AgentWorkspace = ({
py: 2, py: 2,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
scrollbarGutter: "stable",
zIndex: 5, zIndex: 5,
}} }}
> >
@@ -346,7 +369,7 @@ export const AgentWorkspace = ({
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<TurnList <TurnList
messages={historyMessages} messages={historyMessages}
isStreaming={isStreaming} isStreaming={false}
speakingMessageId={speakingMessageId} speakingMessageId={speakingMessageId}
speechState={speechState} speechState={speechState}
onSpeak={onSpeak} onSpeak={onSpeak}
@@ -361,21 +384,23 @@ export const AgentWorkspace = ({
/> />
{streamingMessage ? ( {streamingMessage ? (
<TurnList <Box sx={{ width: "100%" }}>
messages={[streamingMessage]} <TurnList
isStreaming={isStreaming} messages={[streamingMessage]}
speakingMessageId={speakingMessageId} isStreaming={isStreaming}
speechState={speechState} speakingMessageId={speakingMessageId}
onSpeak={onSpeak} speechState={speechState}
onPauseSpeech={onPauseSpeech} onSpeak={onSpeak}
onResumeSpeech={onResumeSpeech} onPauseSpeech={onPauseSpeech}
onStopSpeech={onStopSpeech} onResumeSpeech={onResumeSpeech}
isTtsSupported={isTtsSupported} onStopSpeech={onStopSpeech}
onCreateBranch={onCreateBranch} isTtsSupported={isTtsSupported}
onReplyPermission={onReplyPermission} onCreateBranch={onCreateBranch}
onReplyQuestion={onReplyQuestion} onReplyPermission={onReplyPermission}
onRejectQuestion={onRejectQuestion} onReplyQuestion={onReplyQuestion}
/> onRejectQuestion={onRejectQuestion}
/>
</Box>
) : null} ) : null}
</Box> </Box>
) : null} ) : null}
@@ -403,7 +428,13 @@ export const AgentWorkspace = ({
</motion.div> </motion.div>
) : null} ) : null}
<div ref={bottomRef} style={{ height: 1 }} /> <div
ref={bottomRef}
style={{
flexShrink: 0,
height: isStreaming ? STREAMING_BOTTOM_RESERVE_PX : 1,
}}
/>
</Box> </Box>
); );
}; };
+166 -34
View File
@@ -3,7 +3,8 @@
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import ReactECharts from "echarts-for-react"; import ReactECharts from "echarts-for-react";
import * as echarts from "echarts"; import * as echarts from "echarts";
import { Box, Paper, Typography, alpha, useTheme } from "@mui/material"; import { AnimatePresence, motion } from "framer-motion";
import { Box, Paper, Skeleton, Stack, Typography, alpha, useTheme } from "@mui/material";
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Inline chart rendered inside a chat message bubble. */ /* Inline chart rendered inside a chat message bubble. */
@@ -47,8 +48,12 @@ export interface ChatInlineChartProps {
series?: unknown; series?: unknown;
y_axis_name?: string; y_axis_name?: string;
x_axis_name?: string; x_axis_name?: string;
isStreaming?: boolean;
} }
export const CHART_HEIGHT = 240;
export const CHART_MIN_HEIGHT = 286;
const COLORS = [ const COLORS = [
"#5470c6", "#5470c6",
"#91cc75", "#91cc75",
@@ -61,6 +66,49 @@ const COLORS = [
"#ea7ccc", "#ea7ccc",
]; ];
const ChartSkeletonContent = ({ status }: { status?: React.ReactNode }) => (
<Stack spacing={1.25} sx={{ p: 1.5 }}>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Skeleton variant="text" width="34%" height={20} />
{status}
</Stack>
<Skeleton variant="rounded" height={208} sx={{ borderRadius: 2 }} />
<Stack direction="row" spacing={1}>
<Skeleton variant="text" width="24%" height={16} />
<Skeleton variant="text" width="18%" height={16} />
<Skeleton variant="text" width="20%" height={16} />
</Stack>
</Stack>
);
export const ChartGenerationSkeleton = ({ status }: { status?: React.ReactNode }) => {
const theme = useTheme();
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.18 }}
style={{ width: "100%" }}
>
<Paper
elevation={0}
sx={{
mt: 1.5,
mb: 1,
minHeight: CHART_MIN_HEIGHT,
borderRadius: 3,
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
bgcolor: alpha("#fff", 0.78),
overflow: "hidden",
}}
>
<ChartSkeletonContent status={status} />
</Paper>
</motion.div>
);
};
const toFiniteNumber = (value: unknown): number | null => { const toFiniteNumber = (value: unknown): number | null => {
if (typeof value === "number") { if (typeof value === "number") {
return Number.isFinite(value) ? value : null; return Number.isFinite(value) ? value : null;
@@ -189,13 +237,23 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
series, series,
y_axis_name: yAxisName, y_axis_name: yAxisName,
x_axis_name: xAxisName, x_axis_name: xAxisName,
isStreaming = false,
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const [showIntroSkeleton, setShowIntroSkeleton] = React.useState(true);
const { xData, series: chartSeries } = useMemo( const { xData, series: chartSeries } = useMemo(
() => normalizeChartData(x_data, series), () => normalizeChartData(x_data, series),
[x_data, series], [x_data, series],
); );
React.useEffect(() => {
const timer = window.setTimeout(() => {
setShowIntroSkeleton(false);
}, isStreaming ? 360 : 260);
return () => window.clearTimeout(timer);
}, [isStreaming]);
const option = useMemo(() => { const option = useMemo(() => {
if (!chartSeries.length) return null; if (!chartSeries.length) return null;
@@ -208,6 +266,11 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
})) ?? []; })) ?? [];
return { return {
animation: true,
animationDuration: isStreaming ? 560 : 420,
animationDurationUpdate: 240,
animationEasing: "cubicOut",
animationEasingUpdate: "cubicOut",
tooltip: { trigger: "item" }, tooltip: { trigger: "item" },
legend: { top: "bottom", textStyle: { fontSize: 11 } }, legend: { top: "bottom", textStyle: { fontSize: 11 } },
series: [ series: [
@@ -223,6 +286,10 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
}, },
}, },
label: { fontSize: 11 }, label: { fontSize: 11 },
animationType: "expansion",
animationDuration: isStreaming ? 560 : 420,
animationDelay: (idx: number) => idx * 40,
animationDurationUpdate: 240,
}, },
], ],
color: COLORS, color: COLORS,
@@ -231,6 +298,11 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
/* ---------- Line / Bar chart ---------- */ /* ---------- Line / Bar chart ---------- */
return { return {
animation: true,
animationDuration: isStreaming ? 560 : 420,
animationDurationUpdate: 240,
animationEasing: "cubicOut",
animationEasingUpdate: "cubicOut",
tooltip: { trigger: "axis", confine: true }, tooltip: { trigger: "axis", confine: true },
legend: { top: "top", textStyle: { fontSize: 11 } }, legend: { top: "top", textStyle: { fontSize: 11 } },
grid: { grid: {
@@ -262,14 +334,22 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
: undefined, : undefined,
series: chartSeries.map((s, i) => { series: chartSeries.map((s, i) => {
const color = COLORS[i % COLORS.length]; const color = COLORS[i % COLORS.length];
const isLineSeries = chartType === "line";
return { return {
name: s.name, name: s.name,
type: (s.type ?? chartType) as string, type: (s.type ?? chartType) as string,
data: s.data, data: s.data,
symbol: chartType === "line" ? "none" : undefined, symbol: isLineSeries ? "none" : undefined,
smooth: chartType === "line", smooth: isLineSeries,
itemStyle: { color }, itemStyle: { color },
...(chartType === "line" animationDuration: isStreaming ? 560 : 420,
animationDurationUpdate: 240,
animationDelay:
chartType === "bar"
? (idx: number) => i * 80 + idx * 18
: i * 80,
animationDelayUpdate: 0,
...(isLineSeries
? { ? {
areaStyle: { areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
@@ -284,44 +364,96 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
}), }),
color: COLORS, color: COLORS,
}; };
}, [chartType, xData, chartSeries, title, yAxisName, xAxisName]); }, [chartType, xData, chartSeries, title, yAxisName, xAxisName, isStreaming]);
if (!option) { if (!option) {
return ( return (
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}> <Paper
elevation={0}
</Typography> sx={{
mt: 1.5,
mb: 1,
minHeight: 72,
display: "flex",
alignItems: "center",
px: 2,
borderRadius: 3,
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
bgcolor: alpha("#fff", 0.72),
}}
>
<Typography variant="caption" color="text.secondary">
</Typography>
</Paper>
); );
} }
return ( return (
<Paper <motion.div
elevation={0} initial={{ opacity: 0 }}
sx={{ animate={{ opacity: 1 }}
mt: 1.5, transition={{ duration: 0.22, ease: "easeOut" }}
mb: 1, style={{ width: "100%" }}
borderRadius: 3,
border: `1px solid ${alpha(theme.palette.divider, 0.15)}`,
bgcolor: alpha("#fff", 0.92),
overflow: "hidden",
}}
> >
{title && ( <Paper
<Typography elevation={0}
variant="subtitle2" sx={{
sx={{ px: 2, pt: 1.5, fontWeight: 600, color: "text.primary" }} mt: 1.5,
mb: 1,
minHeight: CHART_MIN_HEIGHT,
borderRadius: 3,
border: `1px solid ${alpha(theme.palette.divider, 0.15)}`,
bgcolor: alpha("#fff", 0.92),
overflow: "hidden",
position: "relative",
}}
>
<AnimatePresence initial={false}>
{showIntroSkeleton ? (
<Box
key="chart-intro-skeleton"
component={motion.div}
aria-hidden
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.22, ease: "easeOut" }}
sx={{
position: "absolute",
inset: 0,
zIndex: 2,
bgcolor: alpha("#fff", 0.92),
pointerEvents: "none",
}}
>
<ChartSkeletonContent />
</Box>
) : null}
</AnimatePresence>
{title && (
<Typography
variant="subtitle2"
sx={{ px: 2, pt: 1.5, fontWeight: 600, color: "text.primary" }}
>
{title}
</Typography>
)}
<Box
component={motion.div}
initial={{ opacity: 0 }}
animate={{ opacity: showIntroSkeleton ? 0.35 : 1 }}
transition={{ duration: 0.24, ease: "easeOut" }}
sx={{ px: 1, pb: 1, minHeight: CHART_HEIGHT }}
> >
{title} <ReactECharts
</Typography> option={option}
)} style={{ height: CHART_HEIGHT, width: "100%" }}
<Box sx={{ px: 1, pb: 1 }}> notMerge
<ReactECharts lazyUpdate
option={option} />
style={{ height: 240, width: "100%" }} </Box>
notMerge </Paper>
lazyUpdate </motion.div>
/>
</Box>
</Paper>
); );
}; };
+60 -4
View File
@@ -24,6 +24,9 @@ import { useSpeechRecognition, useSpeechSynthesis } from "./GlobalChatbox.voice"
import { useAgentChatSession } from "./hooks/useAgentChatSession"; import { useAgentChatSession } from "./hooks/useAgentChatSession";
import { useAgentToolActions } from "./hooks/useAgentToolActions"; import { useAgentToolActions } from "./hooks/useAgentToolActions";
const STREAMING_BOTTOM_RESERVE_PX = 180;
const STREAMING_SCROLL_RESTORE_AT_PX = STREAMING_BOTTOM_RESERVE_PX - 36;
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const [width, setWidth] = useState(520); const [width, setWidth] = useState(520);
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
@@ -35,6 +38,9 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
useState<AgentApprovalMode>("request"); useState<AgentApprovalMode>("request");
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const workspaceScrollRef = useRef<HTMLDivElement>(null);
const isNearBottomRef = useRef(true);
const streamingScrollFrameRef = useRef<number | null>(null);
const composerRef = useRef<AgentComposerHandle | null>(null); const composerRef = useRef<AgentComposerHandle | null>(null);
const hasResetForOpenRef = useRef(false); const hasResetForOpenRef = useRef(false);
const theme = useTheme(); const theme = useTheme();
@@ -123,9 +129,53 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
bottomRef.current?.scrollIntoView({ behavior }); bottomRef.current?.scrollIntoView({ behavior });
}, []); }, []);
const cancelStreamingScroll = useCallback(() => {
if (streamingScrollFrameRef.current === null) return;
window.cancelAnimationFrame(streamingScrollFrameRef.current);
streamingScrollFrameRef.current = null;
}, []);
const scheduleStreamingScrollToBottom = useCallback(() => {
if (streamingScrollFrameRef.current !== null) return;
streamingScrollFrameRef.current = window.requestAnimationFrame(() => {
streamingScrollFrameRef.current = null;
const container = workspaceScrollRef.current;
if (!container || !isNearBottomRef.current) return;
const distanceToBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
if (distanceToBottom < STREAMING_SCROLL_RESTORE_AT_PX) return;
container.scrollTop = container.scrollHeight - container.clientHeight;
});
}, []);
const handleWorkspaceScrollStateChange = useCallback((isNearBottom: boolean) => {
isNearBottomRef.current = isNearBottom;
}, []);
useEffect(() => { useEffect(() => {
scrollToBottom(isStreaming ? "auto" : "smooth"); if (isStreaming) {
}, [isStreaming, messages, scrollToBottom]); if (!isNearBottomRef.current) return;
scheduleStreamingScrollToBottom();
return;
}
cancelStreamingScroll();
scrollToBottom("smooth");
}, [
cancelStreamingScroll,
isStreaming,
messages,
scheduleStreamingScrollToBottom,
scrollToBottom,
]);
useEffect(
() => () => {
cancelStreamingScroll();
},
[cancelStreamingScroll],
);
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
@@ -140,10 +190,12 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
composerRef.current?.clear(); composerRef.current?.clear();
setIsHistoryOpen(false); setIsHistoryOpen(false);
composerRef.current?.focus(); composerRef.current?.focus();
isNearBottomRef.current = true;
cancelStreamingScroll();
scrollToBottom("auto"); scrollToBottom("auto");
}, 0); }, 0);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [createSession, isHydrating, open, scrollToBottom]); }, [cancelStreamingScroll, createSession, isHydrating, open, scrollToBottom]);
const handleSend = useCallback(async (prompt: string) => { const handleSend = useCallback(async (prompt: string) => {
if (isStreaming || isCheckingAuth) return; if (isStreaming || isCheckingAuth) return;
@@ -181,9 +233,11 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
composerRef.current?.clear(); composerRef.current?.clear();
window.setTimeout(() => { window.setTimeout(() => {
composerRef.current?.focus(); composerRef.current?.focus();
isNearBottomRef.current = true;
cancelStreamingScroll();
scrollToBottom("auto"); scrollToBottom("auto");
}, 0); }, 0);
}, [createSession, handleStopSpeech, scrollToBottom, stopListening]); }, [cancelStreamingScroll, createSession, handleStopSpeech, scrollToBottom, stopListening]);
const handleHistoryToggle = useCallback(() => { const handleHistoryToggle = useCallback(() => {
setIsHistoryOpen((prev) => !prev); setIsHistoryOpen((prev) => !prev);
@@ -377,7 +431,9 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
messages={messages} messages={messages}
isStreaming={isStreaming} isStreaming={isStreaming}
isLoadingSession={Boolean(loadingSessionId)} isLoadingSession={Boolean(loadingSessionId)}
scrollContainerRef={workspaceScrollRef}
bottomRef={bottomRef} bottomRef={bottomRef}
onScrollStateChange={handleWorkspaceScrollStateChange}
speakingMessageId={speakingMessageId} speakingMessageId={speakingMessageId}
speechState={speechState} speechState={speechState}
onSpeak={handleSpeak} onSpeak={handleSpeak}
@@ -115,3 +115,8 @@
color: var(--chat-md-quote-text); color: var(--chat-md-quote-text);
border-radius: 6px; border-radius: 6px;
} }
.streamFade {
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
+183 -15
View File
@@ -10,6 +10,69 @@ import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSess
import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState"; import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState";
import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types"; import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types";
const TOKEN_PLAYBACK_INTERVAL_MS = 16;
const TOKEN_PLAYBACK_BASE_CHARS = 28;
const TOKEN_PLAYBACK_MAX_CHARS = 160;
const sliceCodePoints = (value: string, count: number) =>
Array.from(value).slice(0, count).join("");
let cachedSegmenter: Intl.Segmenter | null | undefined;
const getSegmenter = () => {
if (cachedSegmenter !== undefined) return cachedSegmenter;
cachedSegmenter =
typeof Intl !== "undefined" && "Segmenter" in Intl
? new Intl.Segmenter("zh", { granularity: "word" })
: null;
return cachedSegmenter;
};
const getPlaybackChunkSize = (bufferLength: number) => {
if (bufferLength >= 600) return TOKEN_PLAYBACK_MAX_CHARS;
if (bufferLength >= 300) return 112;
if (bufferLength >= 140) return 72;
if (bufferLength >= 64) return 44;
return TOKEN_PLAYBACK_BASE_CHARS;
};
const takeNextTokenPlaybackChunk = (content: string, maxChars: number) => {
if (content.length <= maxChars) return content;
const targetChars = Math.max(12, Math.floor(maxChars * 0.68));
const segmenter = getSegmenter();
if (segmenter) {
let chunk = "";
for (const segment of segmenter.segment(content)) {
chunk += segment.segment;
if (
chunk.length >= maxChars ||
(chunk.length >= targetChars &&
/[\s,.!?;:]/u.test(segment.segment))
) {
return chunk;
}
}
}
const phrase = content.match(/^.{1,12}?[\s,.!?;:]+/u)?.[0];
if (phrase) return phrase;
const cjkChunk = content.match(
/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+/u,
)?.[0];
if (cjkChunk) return sliceCodePoints(cjkChunk, Math.min(maxChars, 18));
const wordChunk = content.match(/^\S+\s*/u)?.[0];
if (wordChunk) {
return wordChunk.length <= maxChars
? wordChunk
: sliceCodePoints(wordChunk, maxChars);
}
return sliceCodePoints(content, Math.min(maxChars, 12));
};
export const useAgentChatSession = ({ export const useAgentChatSession = ({
projectId, projectId,
onToolCall, onToolCall,
@@ -34,6 +97,11 @@ export const useAgentChatSession = ({
const isSessionTitleManuallyEditedRef = useRef(false); const isSessionTitleManuallyEditedRef = useRef(false);
const cancelPromiseRef = useRef<Promise<void> | null>(null); const cancelPromiseRef = useRef<Promise<void> | null>(null);
const titleUpdateNonceRef = useRef(0); const titleUpdateNonceRef = useRef(0);
const pendingTokenRef = useRef<{
assistantMessageId: string;
content: string;
} | null>(null);
const tokenPlaybackIntervalRef = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
sessionIdRef.current = sessionId; sessionIdRef.current = sessionId;
@@ -43,6 +111,99 @@ export const useAgentChatSession = ({
messagesRef.current = messages; messagesRef.current = messages;
}, [messages]); }, [messages]);
const applyTokenContent = useCallback((assistantMessageId: string, content: string) => {
if (!content) return;
setMessages((prev) => {
const next = prev.map((message) =>
message.id === assistantMessageId
? {
...message,
content: message.content + content,
isError: false,
}
: message,
);
messagesRef.current = next;
return next;
});
}, []);
const cancelTokenPlayback = useCallback(() => {
const intervalId = tokenPlaybackIntervalRef.current;
if (intervalId === null) return;
window.clearInterval(intervalId);
tokenPlaybackIntervalRef.current = null;
}, []);
const flushPendingTokens = useCallback(() => {
const pending = pendingTokenRef.current;
pendingTokenRef.current = null;
cancelTokenPlayback();
if (!pending) return;
applyTokenContent(pending.assistantMessageId, pending.content);
}, [applyTokenContent, cancelTokenPlayback]);
const scheduleTokenPlayback = useCallback(() => {
if (tokenPlaybackIntervalRef.current !== null) return;
const id = window.setInterval(() => {
const pending = pendingTokenRef.current;
if (!pending) {
window.clearInterval(id);
tokenPlaybackIntervalRef.current = null;
return;
}
const chunk = takeNextTokenPlaybackChunk(
pending.content,
getPlaybackChunkSize(pending.content.length),
);
if (!chunk) {
window.clearInterval(id);
tokenPlaybackIntervalRef.current = null;
pendingTokenRef.current = null;
return;
}
const remaining = pending.content.slice(chunk.length);
pendingTokenRef.current = remaining
? { assistantMessageId: pending.assistantMessageId, content: remaining }
: null;
applyTokenContent(pending.assistantMessageId, chunk);
if (!remaining) {
window.clearInterval(id);
tokenPlaybackIntervalRef.current = null;
}
}, TOKEN_PLAYBACK_INTERVAL_MS);
tokenPlaybackIntervalRef.current = id;
}, [applyTokenContent]);
const queueTokenContent = useCallback(
(assistantMessageId: string, content: string) => {
const pending = pendingTokenRef.current;
if (pending && pending.assistantMessageId !== assistantMessageId) {
flushPendingTokens();
}
pendingTokenRef.current = {
assistantMessageId,
content:
pending?.assistantMessageId === assistantMessageId
? pending.content + content
: content,
};
scheduleTokenPlayback();
},
[flushPendingTokens, scheduleTokenPlayback],
);
useEffect(
() => () => {
pendingTokenRef.current = null;
cancelTokenPlayback();
},
[cancelTokenPlayback],
);
useEffect(() => { useEffect(() => {
isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited; isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited;
@@ -135,6 +296,10 @@ export const useAgentChatSession = ({
assistantMessageId?: string; assistantMessageId?: string;
}, },
) => { ) => {
if (event.type !== "token") {
flushPendingTokens();
}
if ( if (
event.type !== "session_title" && event.type !== "session_title" &&
"sessionId" in event && "sessionId" in event &&
@@ -187,17 +352,7 @@ export const useAgentChatSession = ({
} }
if (event.type === "token") { if (event.type === "token") {
setMessages((prev) => queueTokenContent(assistantMessageId, event.content);
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
content: message.content + event.content,
isError: false,
}
: message,
),
);
} else if (event.type === "progress") { } else if (event.type === "progress") {
setMessages((prev) => setMessages((prev) =>
prev.map((message) => prev.map((message) =>
@@ -303,7 +458,13 @@ export const useAgentChatSession = ({
setIsStreaming(false); setIsStreaming(false);
} }
}, },
[appendArtifact, getLastAssistantMessageId, onToolCall], [
appendArtifact,
flushPendingTokens,
getLastAssistantMessageId,
onToolCall,
queueTokenContent,
],
); );
const resumeStreamingSession = useCallback( const resumeStreamingSession = useCallback(
@@ -319,18 +480,20 @@ export const useAgentChatSession = ({
onEvent: (event) => applyStreamEvent(event), onEvent: (event) => applyStreamEvent(event),
}) })
.catch((error) => { .catch((error) => {
flushPendingTokens();
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
console.error("[GlobalChatbox] Failed to resume chat stream:", error); console.error("[GlobalChatbox] Failed to resume chat stream:", error);
setIsStreaming(false); setIsStreaming(false);
} }
}) })
.finally(() => { .finally(() => {
flushPendingTokens();
if (abortRef.current === controller) { if (abortRef.current === controller) {
abortRef.current = null; abortRef.current = null;
} }
}); });
}, },
[applyStreamEvent], [applyStreamEvent, flushPendingTokens],
); );
resumeStreamingSessionRef.current = resumeStreamingSession; resumeStreamingSessionRef.current = resumeStreamingSession;
@@ -379,6 +542,7 @@ export const useAgentChatSession = ({
}), }),
}); });
} catch (error) { } catch (error) {
flushPendingTokens();
if (controller.signal.aborted) { if (controller.signal.aborted) {
setMessages((prev) => setMessages((prev) =>
prev prev
@@ -415,12 +579,14 @@ export const useAgentChatSession = ({
); );
setIsStreaming(false); setIsStreaming(false);
} finally { } finally {
flushPendingTokens();
abortRef.current = null; abortRef.current = null;
setIsStreaming(false); setIsStreaming(false);
} }
}, },
[ [
applyStreamEvent, applyStreamEvent,
flushPendingTokens,
getApprovalMode, getApprovalMode,
getModel, getModel,
isHydrating, isHydrating,
@@ -433,6 +599,7 @@ export const useAgentChatSession = ({
const abort = useCallback(() => { const abort = useCallback(() => {
const controller = abortRef.current; const controller = abortRef.current;
controller?.abort(); controller?.abort();
flushPendingTokens();
setIsStreaming(false); setIsStreaming(false);
const assistantMessageId = getLastAssistantMessageId(); const assistantMessageId = getLastAssistantMessageId();
@@ -455,7 +622,7 @@ export const useAgentChatSession = ({
} }
}); });
cancelPromiseRef.current = trackedCancelPromise; cancelPromiseRef.current = trackedCancelPromise;
}, [getLastAssistantMessageId]); }, [flushPendingTokens, getLastAssistantMessageId]);
const replyPermission = useCallback( const replyPermission = useCallback(
async (requestId: string, reply: PermissionReply) => { async (requestId: string, reply: PermissionReply) => {
@@ -668,6 +835,7 @@ export const useAgentChatSession = ({
const createSession = useCallback(() => { const createSession = useCallback(() => {
if (isHydrating || isStreaming) return; if (isHydrating || isStreaming) return;
flushPendingTokens();
const controller = abortRef.current; const controller = abortRef.current;
controller?.abort(); controller?.abort();
hydrationNonceRef.current += 1; hydrationNonceRef.current += 1;
@@ -678,7 +846,7 @@ export const useAgentChatSession = ({
setIsSessionTitleManuallyEdited(false); setIsSessionTitleManuallyEdited(false);
setSessionId(undefined); setSessionId(undefined);
setIsStreaming(false); setIsStreaming(false);
}, [isHydrating, isStreaming]); }, [flushPendingTokens, isHydrating, isStreaming]);
const switchSession = useCallback( const switchSession = useCallback(
async (nextSessionId: string, optimisticTitle?: string) => { async (nextSessionId: string, optimisticTitle?: string) => {