From 224d53a04d31a70f7b4d8253f47a4b065d36e6a8 Mon Sep 17 00:00:00 2001 From: Huarch Date: Wed, 10 Jun 2026 21:12:53 +0800 Subject: [PATCH] feat(chat): smooth streaming output --- docs/chat-streaming-animation-notes.md | 167 +++++++++++++++ src/components/chat/AgentMarkdownBlock.tsx | 151 ++++++++++++- src/components/chat/AgentTurn.tsx | 130 +++++++++++- src/components/chat/AgentWorkspace.test.tsx | 20 +- src/components/chat/AgentWorkspace.tsx | 65 ++++-- src/components/chat/ChatInlineChart.tsx | 200 +++++++++++++++--- src/components/chat/GlobalChatbox.tsx | 64 +++++- .../chat/GlobalChatboxMarkdown.module.css | 5 + .../chat/hooks/useAgentChatSession.ts | 198 +++++++++++++++-- 9 files changed, 915 insertions(+), 85 deletions(-) create mode 100644 docs/chat-streaming-animation-notes.md diff --git a/docs/chat-streaming-animation-notes.md b/docs/chat-streaming-animation-notes.md new file mode 100644 index 0000000..f23d4d3 --- /dev/null +++ b/docs/chat-streaming-animation-notes.md @@ -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 贴底。 diff --git a/src/components/chat/AgentMarkdownBlock.tsx b/src/components/chat/AgentMarkdownBlock.tsx index b95c4fe..2acef39 100644 --- a/src/components/chat/AgentMarkdownBlock.tsx +++ b/src/components/chat/AgentMarkdownBlock.tsx @@ -1,14 +1,141 @@ "use client"; 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 markdownStyles from "./GlobalChatboxMarkdown.module.css"; 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(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 ( + + {children} + + ); +}; + +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) => { const selectedText = window.getSelection()?.toString(); if (!selectedText) return; @@ -16,12 +143,26 @@ export const MarkdownBlock = ({ children }: { children: string }) => { event.preventDefault(); 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 (
- {children} + + {children} +
); }; - - diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index 2d69838..7c9fa7b 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -25,7 +25,7 @@ import { import type { Message, SpeechState } from "./GlobalChatbox.types"; import { stripMarkdown } from "./GlobalChatbox.utils"; import { AgentProgressTimeline } from "./AgentProgressTimeline"; -import { ChatInlineChart } from "./ChatInlineChart"; +import { ChartGenerationSkeleton, ChatInlineChart } from "./ChatInlineChart"; import { ChatToolCallBlock } from "./ChatToolCallBlock"; import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock"; import { PermissionRequestGroup } from "./AgentPermissionRequests"; @@ -51,6 +51,105 @@ type AgentTurnProps = { onRejectQuestion: (requestId: string) => void; }; +const StreamingStatus = () => { + const theme = useTheme(); + + return ( + + + {[0, 1, 2].map((index) => ( + + ))} + + + 正在生成 + + + ); +}; + +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 ( + + {streamTextState.displayText} + + ); +}; + export const AgentTurn = React.memo( ({ message, @@ -69,6 +168,7 @@ export const AgentTurn = React.memo( const theme = useTheme(); const isUser = message.role === "user"; const isErrorMessage = Boolean(message.isError); + const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming; const [isHovered, setIsHovered] = React.useState(false); const isProgressComplete = message.progress?.some( (item) => item.phase === "complete" && item.status === "completed", @@ -238,17 +338,28 @@ export const AgentTurn = React.memo( borderRadius: 4, bgcolor: alpha("#fff", 0.4), border: `1px solid ${alpha("#fff", 0.6)}`, + position: "relative", }} > - - 分析结果 - + + + 分析结果 + + {isStreamingAssistant ? : null} + {contentSegments.map((segment, segIdx) => { if (segment.type === "text") { const text = segment.content.trim(); if (!text && contentSegments.length > 1) return null; - return {text || "..."}; + return ( + + ); } if (segment.type === "tool_call") { if ( @@ -267,6 +378,7 @@ export const AgentTurn = React.memo( series={p.series} x_axis_name={(p.x_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") { return ( - - 正在准备工具调用... - + } + /> ); } return null; @@ -306,6 +419,7 @@ export const AgentTurn = React.memo( series={artifact.params.series} x_axis_name={(artifact.params.x_axis_name as string) ?? undefined} y_axis_name={(artifact.params.y_axis_name as string) ?? undefined} + isStreaming={isStreamingAssistant} /> ))} diff --git a/src/components/chat/AgentWorkspace.test.tsx b/src/components/chat/AgentWorkspace.test.tsx index 237d903..a28aae5 100644 --- a/src/components/chat/AgentWorkspace.test.tsx +++ b/src/components/chat/AgentWorkspace.test.tsx @@ -7,6 +7,7 @@ import { AgentWorkspace } from "./AgentWorkspace"; import type { Message } from "./GlobalChatbox.types"; const renderCounts = new Map(); +const streamingFlags = new Map(); jest.mock("next/image", () => ({ __esModule: true, @@ -16,7 +17,18 @@ jest.mock("next/image", () => ({ jest.mock("framer-motion", () => ({ AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, motion: { - div: ({ children, ...props }: React.HTMLAttributes) =>
{children}
, + div: ({ + children, + animate: _animate, + exit: _exit, + initial: _initial, + layout: _layout, + transition: _transition, + whileHover: _whileHover, + ...props + }: React.HTMLAttributes & Record) => ( +
{children}
+ ), }, })); @@ -25,8 +37,9 @@ jest.mock("./GlobalChatbox.parts", () => ({ })); jest.mock("./AgentTurn", () => ({ - AgentTurn: ({ message }: { message: Message }) => { + AgentTurn: ({ message, isStreaming }: { message: Message; isStreaming: boolean }) => { renderCounts.set(message.id, (renderCounts.get(message.id) ?? 0) + 1); + streamingFlags.set(message.id, isStreaming); return
{message.content}
; }, })); @@ -49,6 +62,7 @@ describe("AgentWorkspace", () => { beforeEach(() => { renderCounts.clear(); + streamingFlags.clear(); }); 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("assistant-1")).toBe(1); expect(renderCounts.get("assistant-2")).toBe(2); + expect(streamingFlags.get("assistant-1")).toBe(false); + expect(streamingFlags.get("assistant-2")).toBe(true); }); }); diff --git a/src/components/chat/AgentWorkspace.tsx b/src/components/chat/AgentWorkspace.tsx index e1ef132..0b0dab9 100644 --- a/src/components/chat/AgentWorkspace.tsx +++ b/src/components/chat/AgentWorkspace.tsx @@ -21,7 +21,9 @@ type AgentWorkspaceProps = { messages: Message[]; isStreaming: boolean; isLoadingSession?: boolean; + scrollContainerRef?: React.RefObject; bottomRef: React.RefObject; + onScrollStateChange?: (isNearBottom: boolean) => void; speakingMessageId: string | null; speechState: SpeechState; onSpeak: (messageId: string, text: string) => void; @@ -51,6 +53,9 @@ type TurnListProps = { 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[]) => left.length === right.length && left.every((message, index) => message === right[index]); @@ -293,7 +298,9 @@ export const AgentWorkspace = ({ messages, isStreaming, isLoadingSession = false, + scrollContainerRef, bottomRef, + onScrollStateChange, speakingMessageId, speechState, onSpeak, @@ -321,9 +328,24 @@ export const AgentWorkspace = ({ : undefined; const historyMessages = streamingMessage !== undefined ? messages.slice(0, -1) : messages; + const handleScroll = React.useCallback( + (event: React.UIEvent) => { + 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 ( @@ -346,7 +369,7 @@ export const AgentWorkspace = ({ {streamingMessage ? ( - + + + ) : null} ) : null} @@ -403,7 +428,13 @@ export const AgentWorkspace = ({ ) : null} -
+
); }; diff --git a/src/components/chat/ChatInlineChart.tsx b/src/components/chat/ChatInlineChart.tsx index 9715b14..65ef5c8 100644 --- a/src/components/chat/ChatInlineChart.tsx +++ b/src/components/chat/ChatInlineChart.tsx @@ -3,7 +3,8 @@ import React, { useMemo } from "react"; import ReactECharts from "echarts-for-react"; 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. */ @@ -47,8 +48,12 @@ export interface ChatInlineChartProps { series?: unknown; y_axis_name?: string; x_axis_name?: string; + isStreaming?: boolean; } +export const CHART_HEIGHT = 240; +export const CHART_MIN_HEIGHT = 286; + const COLORS = [ "#5470c6", "#91cc75", @@ -61,6 +66,49 @@ const COLORS = [ "#ea7ccc", ]; +const ChartSkeletonContent = ({ status }: { status?: React.ReactNode }) => ( + + + + {status} + + + + + + + + +); + +export const ChartGenerationSkeleton = ({ status }: { status?: React.ReactNode }) => { + const theme = useTheme(); + + return ( + + + + + + ); +}; + const toFiniteNumber = (value: unknown): number | null => { if (typeof value === "number") { return Number.isFinite(value) ? value : null; @@ -189,13 +237,23 @@ export const ChatInlineChart: React.FC = ({ series, y_axis_name: yAxisName, x_axis_name: xAxisName, + isStreaming = false, }) => { const theme = useTheme(); + const [showIntroSkeleton, setShowIntroSkeleton] = React.useState(true); const { xData, series: chartSeries } = useMemo( () => normalizeChartData(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(() => { if (!chartSeries.length) return null; @@ -208,6 +266,11 @@ export const ChatInlineChart: React.FC = ({ })) ?? []; return { + animation: true, + animationDuration: isStreaming ? 560 : 420, + animationDurationUpdate: 240, + animationEasing: "cubicOut", + animationEasingUpdate: "cubicOut", tooltip: { trigger: "item" }, legend: { top: "bottom", textStyle: { fontSize: 11 } }, series: [ @@ -223,6 +286,10 @@ export const ChatInlineChart: React.FC = ({ }, }, label: { fontSize: 11 }, + animationType: "expansion", + animationDuration: isStreaming ? 560 : 420, + animationDelay: (idx: number) => idx * 40, + animationDurationUpdate: 240, }, ], color: COLORS, @@ -231,6 +298,11 @@ export const ChatInlineChart: React.FC = ({ /* ---------- Line / Bar chart ---------- */ return { + animation: true, + animationDuration: isStreaming ? 560 : 420, + animationDurationUpdate: 240, + animationEasing: "cubicOut", + animationEasingUpdate: "cubicOut", tooltip: { trigger: "axis", confine: true }, legend: { top: "top", textStyle: { fontSize: 11 } }, grid: { @@ -262,14 +334,22 @@ export const ChatInlineChart: React.FC = ({ : undefined, series: chartSeries.map((s, i) => { const color = COLORS[i % COLORS.length]; + const isLineSeries = chartType === "line"; return { name: s.name, type: (s.type ?? chartType) as string, data: s.data, - symbol: chartType === "line" ? "none" : undefined, - smooth: chartType === "line", + symbol: isLineSeries ? "none" : undefined, + smooth: isLineSeries, 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: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ @@ -284,44 +364,96 @@ export const ChatInlineChart: React.FC = ({ }), color: COLORS, }; - }, [chartType, xData, chartSeries, title, yAxisName, xAxisName]); + }, [chartType, xData, chartSeries, title, yAxisName, xAxisName, isStreaming]); if (!option) { return ( - - 图表数据为空 - + + + 图表数据为空 + + ); } return ( - - {title && ( - + + {showIntroSkeleton ? ( + + + + ) : null} + + {title && ( + + {title} + + )} + - {title} - - )} - - - - + + + + ); }; diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index e0bfd11..ce53b76 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -24,6 +24,9 @@ import { useSpeechRecognition, useSpeechSynthesis } from "./GlobalChatbox.voice" import { useAgentChatSession } from "./hooks/useAgentChatSession"; 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 = ({ open, onClose }) => { const [width, setWidth] = useState(520); const [isResizing, setIsResizing] = useState(false); @@ -35,6 +38,9 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { useState("request"); const bottomRef = useRef(null); + const workspaceScrollRef = useRef(null); + const isNearBottomRef = useRef(true); + const streamingScrollFrameRef = useRef(null); const composerRef = useRef(null); const hasResetForOpenRef = useRef(false); const theme = useTheme(); @@ -123,9 +129,53 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { 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(() => { - scrollToBottom(isStreaming ? "auto" : "smooth"); - }, [isStreaming, messages, scrollToBottom]); + if (isStreaming) { + if (!isNearBottomRef.current) return; + scheduleStreamingScrollToBottom(); + return; + } + cancelStreamingScroll(); + scrollToBottom("smooth"); + }, [ + cancelStreamingScroll, + isStreaming, + messages, + scheduleStreamingScrollToBottom, + scrollToBottom, + ]); + + useEffect( + () => () => { + cancelStreamingScroll(); + }, + [cancelStreamingScroll], + ); useEffect(() => { if (!open) { @@ -140,10 +190,12 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { composerRef.current?.clear(); setIsHistoryOpen(false); composerRef.current?.focus(); + isNearBottomRef.current = true; + cancelStreamingScroll(); scrollToBottom("auto"); }, 0); return () => window.clearTimeout(timer); - }, [createSession, isHydrating, open, scrollToBottom]); + }, [cancelStreamingScroll, createSession, isHydrating, open, scrollToBottom]); const handleSend = useCallback(async (prompt: string) => { if (isStreaming || isCheckingAuth) return; @@ -181,9 +233,11 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { composerRef.current?.clear(); window.setTimeout(() => { composerRef.current?.focus(); + isNearBottomRef.current = true; + cancelStreamingScroll(); scrollToBottom("auto"); }, 0); - }, [createSession, handleStopSpeech, scrollToBottom, stopListening]); + }, [cancelStreamingScroll, createSession, handleStopSpeech, scrollToBottom, stopListening]); const handleHistoryToggle = useCallback(() => { setIsHistoryOpen((prev) => !prev); @@ -377,7 +431,9 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { messages={messages} isStreaming={isStreaming} isLoadingSession={Boolean(loadingSessionId)} + scrollContainerRef={workspaceScrollRef} bottomRef={bottomRef} + onScrollStateChange={handleWorkspaceScrollStateChange} speakingMessageId={speakingMessageId} speechState={speechState} onSpeak={handleSpeak} diff --git a/src/components/chat/GlobalChatboxMarkdown.module.css b/src/components/chat/GlobalChatboxMarkdown.module.css index 3ffcc21..aa51fc7 100644 --- a/src/components/chat/GlobalChatboxMarkdown.module.css +++ b/src/components/chat/GlobalChatboxMarkdown.module.css @@ -115,3 +115,8 @@ color: var(--chat-md-quote-text); border-radius: 6px; } + +.streamFade { + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index 0c5b9d9..de92b9d 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -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 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 = ({ projectId, onToolCall, @@ -34,6 +97,11 @@ export const useAgentChatSession = ({ const isSessionTitleManuallyEditedRef = useRef(false); const cancelPromiseRef = useRef | null>(null); const titleUpdateNonceRef = useRef(0); + const pendingTokenRef = useRef<{ + assistantMessageId: string; + content: string; + } | null>(null); + const tokenPlaybackIntervalRef = useRef(null); useEffect(() => { sessionIdRef.current = sessionId; @@ -43,6 +111,99 @@ export const useAgentChatSession = ({ messagesRef.current = 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(() => { isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited; @@ -135,6 +296,10 @@ export const useAgentChatSession = ({ assistantMessageId?: string; }, ) => { + if (event.type !== "token") { + flushPendingTokens(); + } + if ( event.type !== "session_title" && "sessionId" in event && @@ -187,17 +352,7 @@ export const useAgentChatSession = ({ } if (event.type === "token") { - setMessages((prev) => - prev.map((message) => - message.id === assistantMessageId - ? { - ...message, - content: message.content + event.content, - isError: false, - } - : message, - ), - ); + queueTokenContent(assistantMessageId, event.content); } else if (event.type === "progress") { setMessages((prev) => prev.map((message) => @@ -303,7 +458,13 @@ export const useAgentChatSession = ({ setIsStreaming(false); } }, - [appendArtifact, getLastAssistantMessageId, onToolCall], + [ + appendArtifact, + flushPendingTokens, + getLastAssistantMessageId, + onToolCall, + queueTokenContent, + ], ); const resumeStreamingSession = useCallback( @@ -319,18 +480,20 @@ export const useAgentChatSession = ({ onEvent: (event) => applyStreamEvent(event), }) .catch((error) => { + flushPendingTokens(); if (!controller.signal.aborted) { console.error("[GlobalChatbox] Failed to resume chat stream:", error); setIsStreaming(false); } }) .finally(() => { + flushPendingTokens(); if (abortRef.current === controller) { abortRef.current = null; } }); }, - [applyStreamEvent], + [applyStreamEvent, flushPendingTokens], ); resumeStreamingSessionRef.current = resumeStreamingSession; @@ -379,6 +542,7 @@ export const useAgentChatSession = ({ }), }); } catch (error) { + flushPendingTokens(); if (controller.signal.aborted) { setMessages((prev) => prev @@ -415,12 +579,14 @@ export const useAgentChatSession = ({ ); setIsStreaming(false); } finally { + flushPendingTokens(); abortRef.current = null; setIsStreaming(false); } }, [ applyStreamEvent, + flushPendingTokens, getApprovalMode, getModel, isHydrating, @@ -433,6 +599,7 @@ export const useAgentChatSession = ({ const abort = useCallback(() => { const controller = abortRef.current; controller?.abort(); + flushPendingTokens(); setIsStreaming(false); const assistantMessageId = getLastAssistantMessageId(); @@ -455,7 +622,7 @@ export const useAgentChatSession = ({ } }); cancelPromiseRef.current = trackedCancelPromise; - }, [getLastAssistantMessageId]); + }, [flushPendingTokens, getLastAssistantMessageId]); const replyPermission = useCallback( async (requestId: string, reply: PermissionReply) => { @@ -668,6 +835,7 @@ export const useAgentChatSession = ({ const createSession = useCallback(() => { if (isHydrating || isStreaming) return; + flushPendingTokens(); const controller = abortRef.current; controller?.abort(); hydrationNonceRef.current += 1; @@ -678,7 +846,7 @@ export const useAgentChatSession = ({ setIsSessionTitleManuallyEdited(false); setSessionId(undefined); setIsStreaming(false); - }, [isHydrating, isStreaming]); + }, [flushPendingTokens, isHydrating, isStreaming]); const switchSession = useCallback( async (nextSessionId: string, optimisticTitle?: string) => {