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
+166 -34
View File
@@ -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>
);
};