"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 }) => ( {status} ); export const ChartGenerationSkeleton = ({ status }: { status?: React.ReactNode }) => { const theme = useTheme(); return ( ); }; 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 = ({ 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 ( 图表数据为空 ); } return ( {showIntroSkeleton ? ( ) : null} {title && ( {title} )} ); };