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