460 lines
13 KiB
TypeScript
460 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import React, { useMemo } from "react";
|
|
import ReactECharts from "echarts-for-react";
|
|
import * as echarts from "echarts";
|
|
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. */
|
|
/* Accepts structured data produced by the AI tool_call. */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export interface ChatChartSeries {
|
|
name: string;
|
|
data: number[];
|
|
type?: "line" | "bar";
|
|
}
|
|
|
|
type RawChartPoint =
|
|
| number
|
|
| string
|
|
| [unknown, unknown]
|
|
| RawChartPointObject;
|
|
|
|
type RawChartPointObject = {
|
|
x?: unknown;
|
|
y?: unknown;
|
|
time?: unknown;
|
|
timestamp?: unknown;
|
|
label?: unknown;
|
|
name?: unknown;
|
|
value?: unknown;
|
|
};
|
|
|
|
type RawChartSeries = {
|
|
name?: unknown;
|
|
data?: unknown;
|
|
points?: unknown;
|
|
values?: unknown;
|
|
type?: unknown;
|
|
};
|
|
|
|
export interface ChatInlineChartProps {
|
|
title?: string;
|
|
chart_type?: "line" | "bar" | "pie";
|
|
x_data?: unknown;
|
|
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",
|
|
"#fac858",
|
|
"#ee6666",
|
|
"#73c0de",
|
|
"#3ba272",
|
|
"#fc8452",
|
|
"#9a60b4",
|
|
"#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;
|
|
}
|
|
if (typeof value === "string" && value.trim()) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
export const pointToLabelValue = (
|
|
point: RawChartPoint,
|
|
fallbackLabel: string,
|
|
): { label: string; value: number } | null => {
|
|
const directValue = toFiniteNumber(point);
|
|
if (directValue !== null) {
|
|
return { label: fallbackLabel, value: directValue };
|
|
}
|
|
|
|
if (Array.isArray(point)) {
|
|
const value = toFiniteNumber(point[1]);
|
|
if (value === null) return null;
|
|
return { label: String(point[0] ?? fallbackLabel), value };
|
|
}
|
|
|
|
if (point && typeof point === "object") {
|
|
const value = toFiniteNumber(point.value ?? point.y);
|
|
if (value === null) return null;
|
|
const label =
|
|
point.x ?? point.time ?? point.timestamp ?? point.label ?? point.name ?? fallbackLabel;
|
|
return { label: String(label), value };
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const normalizeXData = (rawXData: unknown): string[] =>
|
|
Array.isArray(rawXData)
|
|
? rawXData.map((item) => String(item ?? "")).filter((item) => item.length > 0)
|
|
: [];
|
|
|
|
const normalizeSeriesType = (type: unknown): "line" | "bar" | undefined =>
|
|
type === "line" || type === "bar" ? type : undefined;
|
|
|
|
const isRawChartPoint = (item: unknown): boolean => {
|
|
if (toFiniteNumber(item) !== null) return true;
|
|
if (Array.isArray(item)) return item.length >= 2 && toFiniteNumber(item[1]) !== null;
|
|
if (item && typeof item === "object") {
|
|
const rawItem = item as RawChartSeries & RawChartPointObject;
|
|
return (
|
|
rawItem.data === undefined &&
|
|
rawItem.points === undefined &&
|
|
rawItem.values === undefined &&
|
|
toFiniteNumber(rawItem.value ?? rawItem.y) !== null
|
|
);
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const normalizeRawSeriesItems = (rawSeries: unknown): unknown[] => {
|
|
if (!Array.isArray(rawSeries)) {
|
|
return rawSeries && typeof rawSeries === "object" ? [rawSeries] : [];
|
|
}
|
|
|
|
return rawSeries.length > 0 && rawSeries.every(isRawChartPoint)
|
|
? [{ name: "数据", data: rawSeries }]
|
|
: rawSeries;
|
|
};
|
|
|
|
export const normalizeChartData = (
|
|
rawXData: unknown,
|
|
rawSeries: unknown,
|
|
): { xData: string[]; series: ChatChartSeries[] } => {
|
|
const xData = normalizeXData(rawXData);
|
|
const rawSeriesItems = normalizeRawSeriesItems(rawSeries);
|
|
if (!rawSeriesItems.length) {
|
|
return { xData, series: [] };
|
|
}
|
|
|
|
const normalizedSeries = rawSeriesItems
|
|
.map((rawItem, seriesIndex): ChatChartSeries | null => {
|
|
const item =
|
|
rawItem && typeof rawItem === "object" && !Array.isArray(rawItem)
|
|
? (rawItem as RawChartSeries)
|
|
: ({ data: rawItem } satisfies RawChartSeries);
|
|
const rawData = item.data ?? item.points ?? item.values;
|
|
if (!Array.isArray(rawData)) return null;
|
|
|
|
const labelsFromPoints: string[] = [];
|
|
const data = rawData
|
|
.map((point, index) => {
|
|
const parsed = pointToLabelValue(
|
|
point as RawChartPoint,
|
|
xData[index] ?? `${index + 1}`,
|
|
);
|
|
if (!parsed) return null;
|
|
labelsFromPoints[index] = parsed.label;
|
|
return parsed.value;
|
|
})
|
|
.filter((value): value is number => value !== null);
|
|
|
|
if (!data.length) return null;
|
|
if (!xData.length && labelsFromPoints.length) {
|
|
xData.push(...labelsFromPoints);
|
|
}
|
|
|
|
return {
|
|
name:
|
|
typeof item.name === "string" && item.name.trim()
|
|
? item.name
|
|
: `系列 ${seriesIndex + 1}`,
|
|
data,
|
|
type: normalizeSeriesType(item.type),
|
|
};
|
|
})
|
|
.filter((item): item is ChatChartSeries => Boolean(item));
|
|
|
|
return { xData, series: normalizedSeries };
|
|
};
|
|
|
|
export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
|
title,
|
|
chart_type: chartType = "line",
|
|
x_data,
|
|
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;
|
|
|
|
/* ---------- Pie chart ---------- */
|
|
if (chartType === "pie") {
|
|
const pieData =
|
|
chartSeries[0]?.data.map((value, i) => ({
|
|
name: xData?.[i] ?? `${i}`,
|
|
value,
|
|
})) ?? [];
|
|
|
|
return {
|
|
animation: true,
|
|
animationDuration: isStreaming ? 560 : 420,
|
|
animationDurationUpdate: 240,
|
|
animationEasing: "cubicOut",
|
|
animationEasingUpdate: "cubicOut",
|
|
tooltip: { trigger: "item" },
|
|
legend: { top: "bottom", textStyle: { fontSize: 11 } },
|
|
series: [
|
|
{
|
|
type: "pie",
|
|
radius: ["30%", "60%"],
|
|
data: pieData,
|
|
emphasis: {
|
|
itemStyle: {
|
|
shadowBlur: 10,
|
|
shadowOffsetX: 0,
|
|
shadowColor: "rgba(0, 0, 0, 0.5)",
|
|
},
|
|
},
|
|
label: { fontSize: 11 },
|
|
animationType: "expansion",
|
|
animationDuration: isStreaming ? 560 : 420,
|
|
animationDelay: (idx: number) => idx * 40,
|
|
animationDurationUpdate: 240,
|
|
},
|
|
],
|
|
color: COLORS,
|
|
};
|
|
}
|
|
|
|
/* ---------- 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: {
|
|
left: "5%",
|
|
right: "5%",
|
|
bottom: "12%",
|
|
top: title ? "18%" : "14%",
|
|
containLabel: true,
|
|
},
|
|
xAxis: {
|
|
type: "category" as const,
|
|
boundaryGap: chartType === "bar",
|
|
data: xData ?? [],
|
|
axisLabel: {
|
|
fontSize: 10,
|
|
rotate: xData && xData.length > 10 ? 30 : 0,
|
|
},
|
|
name: xAxisName,
|
|
},
|
|
yAxis: {
|
|
type: "value" as const,
|
|
scale: true,
|
|
axisLabel: { fontSize: 10 },
|
|
name: yAxisName,
|
|
},
|
|
dataZoom:
|
|
xData && xData.length > 20
|
|
? [{ type: "inside", start: 0, end: 100 }]
|
|
: 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: isLineSeries ? "none" : undefined,
|
|
smooth: isLineSeries,
|
|
itemStyle: { color },
|
|
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, [
|
|
{ offset: 0, color: alpha(color, 0.3) },
|
|
{ offset: 1, color: alpha(color, 0.05) },
|
|
]),
|
|
opacity: 0.3,
|
|
},
|
|
}
|
|
: {}),
|
|
};
|
|
}),
|
|
color: COLORS,
|
|
};
|
|
}, [chartType, xData, chartSeries, title, yAxisName, xAxisName, isStreaming]);
|
|
|
|
if (!option) {
|
|
return (
|
|
<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 (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ duration: 0.22, ease: "easeOut" }}
|
|
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.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 }}
|
|
>
|
|
<ReactECharts
|
|
option={option}
|
|
style={{ height: CHART_HEIGHT, width: "100%" }}
|
|
notMerge
|
|
lazyUpdate
|
|
/>
|
|
</Box>
|
|
</Paper>
|
|
</motion.div>
|
|
);
|
|
};
|