feat(chat): smooth streaming output
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user