feat(chat): smooth streaming output
This commit is contained in:
@@ -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<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 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 (
|
||||
<div className={markdownStyles.markdown} onCopy={handleCopy}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
|
||||
<ReactMarkdown
|
||||
components={markdownComponents}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={rehypePlugins}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<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(
|
||||
({
|
||||
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",
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1.2}>
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
|
||||
分析结果
|
||||
</Typography>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
|
||||
分析结果
|
||||
</Typography>
|
||||
{isStreamingAssistant ? <StreamingStatus /> : null}
|
||||
</Stack>
|
||||
{contentSegments.map((segment, segIdx) => {
|
||||
if (segment.type === "text") {
|
||||
const text = segment.content.trim();
|
||||
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 (
|
||||
@@ -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 (
|
||||
<Typography key="tool-pending" variant="caption" color="text.secondary">
|
||||
正在准备工具调用...
|
||||
</Typography>
|
||||
<ChartGenerationSkeleton
|
||||
key="tool-pending"
|
||||
status={<StreamingStatus />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AgentWorkspace } from "./AgentWorkspace";
|
||||
import type { Message } from "./GlobalChatbox.types";
|
||||
|
||||
const renderCounts = new Map<string, number>();
|
||||
const streamingFlags = new Map<string, boolean>();
|
||||
|
||||
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<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", () => ({
|
||||
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 <div data-testid={`turn-${message.id}`}>{message.content}</div>;
|
||||
},
|
||||
}));
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,9 @@ type AgentWorkspaceProps = {
|
||||
messages: Message[];
|
||||
isStreaming: boolean;
|
||||
isLoadingSession?: boolean;
|
||||
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
|
||||
bottomRef: React.RefObject<HTMLDivElement | null>;
|
||||
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<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 (
|
||||
<Box
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
@@ -331,6 +353,7 @@ export const AgentWorkspace = ({
|
||||
py: 2,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
scrollbarGutter: "stable",
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
@@ -346,7 +369,7 @@ export const AgentWorkspace = ({
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<TurnList
|
||||
messages={historyMessages}
|
||||
isStreaming={isStreaming}
|
||||
isStreaming={false}
|
||||
speakingMessageId={speakingMessageId}
|
||||
speechState={speechState}
|
||||
onSpeak={onSpeak}
|
||||
@@ -361,21 +384,23 @@ export const AgentWorkspace = ({
|
||||
/>
|
||||
|
||||
{streamingMessage ? (
|
||||
<TurnList
|
||||
messages={[streamingMessage]}
|
||||
isStreaming={isStreaming}
|
||||
speakingMessageId={speakingMessageId}
|
||||
speechState={speechState}
|
||||
onSpeak={onSpeak}
|
||||
onPauseSpeech={onPauseSpeech}
|
||||
onResumeSpeech={onResumeSpeech}
|
||||
onStopSpeech={onStopSpeech}
|
||||
isTtsSupported={isTtsSupported}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onReplyPermission={onReplyPermission}
|
||||
onReplyQuestion={onReplyQuestion}
|
||||
onRejectQuestion={onRejectQuestion}
|
||||
/>
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<TurnList
|
||||
messages={[streamingMessage]}
|
||||
isStreaming={isStreaming}
|
||||
speakingMessageId={speakingMessageId}
|
||||
speechState={speechState}
|
||||
onSpeak={onSpeak}
|
||||
onPauseSpeech={onPauseSpeech}
|
||||
onResumeSpeech={onResumeSpeech}
|
||||
onStopSpeech={onStopSpeech}
|
||||
isTtsSupported={isTtsSupported}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onReplyPermission={onReplyPermission}
|
||||
onReplyQuestion={onReplyQuestion}
|
||||
onRejectQuestion={onRejectQuestion}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
) : null}
|
||||
@@ -403,7 +428,13 @@ export const AgentWorkspace = ({
|
||||
</motion.div>
|
||||
) : null}
|
||||
|
||||
<div ref={bottomRef} style={{ height: 1 }} />
|
||||
<div
|
||||
ref={bottomRef}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
height: isStreaming ? STREAMING_BOTTOM_RESERVE_PX : 1,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 }) => (
|
||||
<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 => {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
@@ -189,13 +237,23 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
||||
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<ChatInlineChartProps> = ({
|
||||
})) ?? [];
|
||||
|
||||
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<ChatInlineChartProps> = ({
|
||||
},
|
||||
},
|
||||
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<ChatInlineChartProps> = ({
|
||||
|
||||
/* ---------- 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<ChatInlineChartProps> = ({
|
||||
: 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<ChatInlineChartProps> = ({
|
||||
}),
|
||||
color: COLORS,
|
||||
};
|
||||
}, [chartType, xData, chartSeries, title, yAxisName, xAxisName]);
|
||||
}, [chartType, xData, chartSeries, title, yAxisName, xAxisName, isStreaming]);
|
||||
|
||||
if (!option) {
|
||||
return (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
|
||||
图表数据为空
|
||||
</Typography>
|
||||
<Paper
|
||||
elevation={0}
|
||||
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 (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
mt: 1.5,
|
||||
mb: 1,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${alpha(theme.palette.divider, 0.15)}`,
|
||||
bgcolor: alpha("#fff", 0.92),
|
||||
overflow: "hidden",
|
||||
}}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.22, ease: "easeOut" }}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{title && (
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ px: 2, pt: 1.5, fontWeight: 600, color: "text.primary" }}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
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}
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ px: 1, pb: 1 }}>
|
||||
<ReactECharts
|
||||
option={option}
|
||||
style={{ height: 240, width: "100%" }}
|
||||
notMerge
|
||||
lazyUpdate
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
<ReactECharts
|
||||
option={option}
|
||||
style={{ height: CHART_HEIGHT, width: "100%" }}
|
||||
notMerge
|
||||
lazyUpdate
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<Props> = ({ open, onClose }) => {
|
||||
const [width, setWidth] = useState(520);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
@@ -35,6 +38,9 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
useState<AgentApprovalMode>("request");
|
||||
|
||||
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 hasResetForOpenRef = useRef(false);
|
||||
const theme = useTheme();
|
||||
@@ -123,9 +129,53 @@ export const GlobalChatbox: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ open, onClose }) => {
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
isLoadingSession={Boolean(loadingSessionId)}
|
||||
scrollContainerRef={workspaceScrollRef}
|
||||
bottomRef={bottomRef}
|
||||
onScrollStateChange={handleWorkspaceScrollStateChange}
|
||||
speakingMessageId={speakingMessageId}
|
||||
speechState={speechState}
|
||||
onSpeak={handleSpeak}
|
||||
|
||||
@@ -115,3 +115,8 @@
|
||||
color: var(--chat-md-quote-text);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.streamFade {
|
||||
box-decoration-break: clone;
|
||||
-webkit-box-decoration-break: clone;
|
||||
}
|
||||
|
||||
@@ -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<Promise<void> | null>(null);
|
||||
const titleUpdateNonceRef = useRef(0);
|
||||
const pendingTokenRef = useRef<{
|
||||
assistantMessageId: string;
|
||||
content: string;
|
||||
} | null>(null);
|
||||
const tokenPlaybackIntervalRef = useRef<number | null>(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) => {
|
||||
|
||||
Reference in New Issue
Block a user