From 0501afaced7d842248ceb8d6ec5f28078b72d530 Mon Sep 17 00:00:00 2001 From: Huarch Date: Wed, 10 Jun 2026 16:19:39 +0800 Subject: [PATCH] fix(chat): normalize chart tool data --- src/components/chat/AgentArtifactPanel.tsx | 10 +- src/components/chat/AgentTurn.tsx | 5 +- src/components/chat/ChatInlineChart.test.ts | 49 +++++++ src/components/chat/ChatInlineChart.tsx | 155 +++++++++++++++++++- 4 files changed, 205 insertions(+), 14 deletions(-) create mode 100644 src/components/chat/ChatInlineChart.test.ts diff --git a/src/components/chat/AgentArtifactPanel.tsx b/src/components/chat/AgentArtifactPanel.tsx index ab20934..820ba01 100644 --- a/src/components/chat/AgentArtifactPanel.tsx +++ b/src/components/chat/AgentArtifactPanel.tsx @@ -17,7 +17,6 @@ import SensorsRounded from "@mui/icons-material/SensorsRounded"; import BuildCircleRounded from "@mui/icons-material/BuildCircleRounded"; import { ChatInlineChart } from "./ChatInlineChart"; -import type { ChatChartSeries } from "./ChatInlineChart"; import type { AgentArtifact } from "./GlobalChatbox.types"; const artifactIcon = (kind: AgentArtifact["kind"]) => { @@ -61,8 +60,13 @@ export const AgentArtifactPanel = ({ artifacts }: { artifacts: AgentArtifact[] } chart_type={ (artifact.params.chart_type as "line" | "bar" | "pie") ?? "line" } - x_data={(artifact.params.x_data as string[]) ?? []} - series={(artifact.params.series as ChatChartSeries[]) ?? []} + x_data={ + artifact.params.x_data ?? + artifact.params.xData ?? + artifact.params.labels ?? + artifact.params.categories + } + 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} /> diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index 07da8fb..7ed8f89 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -26,7 +26,6 @@ import type { Message, SpeechState } from "./GlobalChatbox.types"; import { stripMarkdown } from "./GlobalChatbox.utils"; import { AgentProgressTimeline } from "./AgentProgressTimeline"; import { ChatInlineChart } from "./ChatInlineChart"; -import type { ChatChartSeries } from "./ChatInlineChart"; import { ChatToolCallBlock } from "./ChatToolCallBlock"; import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock"; import { PermissionRequestGroup } from "./AgentPermissionRequests"; @@ -251,8 +250,8 @@ export const AgentTurn = React.memo( chart_type={ (p.chart_type as "line" | "bar" | "pie") ?? "line" } - x_data={(p.x_data as string[]) ?? []} - series={(p.series as ChatChartSeries[]) ?? []} + x_data={p.x_data ?? p.xData ?? p.labels ?? p.categories} + series={p.series} x_axis_name={(p.x_axis_name as string) ?? undefined} y_axis_name={(p.y_axis_name as string) ?? undefined} /> diff --git a/src/components/chat/ChatInlineChart.test.ts b/src/components/chat/ChatInlineChart.test.ts new file mode 100644 index 0000000..083e7bc --- /dev/null +++ b/src/components/chat/ChatInlineChart.test.ts @@ -0,0 +1,49 @@ +import { normalizeChartData } from "./ChatInlineChart"; + +describe("normalizeChartData", () => { + it("keeps standard bar chart series data", () => { + const result = normalizeChartData(["A", "B"], [ + { name: "数量", data: [3, 5], type: "bar" }, + ]); + + expect(result).toEqual({ + xData: ["A", "B"], + series: [{ name: "数量", data: [3, 5], type: "bar" }], + }); + }); + + it("normalizes line chart point arrays into x labels and y values", () => { + const result = normalizeChartData(undefined, [ + { name: "压力", data: [["10:00", 12.5], ["11:00", 13.1]] }, + ]); + + expect(result).toEqual({ + xData: ["10:00", "11:00"], + series: [{ name: "压力", data: [12.5, 13.1], type: undefined }], + }); + }); + + it("normalizes pie chart point objects into a single series", () => { + const result = normalizeChartData(undefined, [ + { name: "低风险", value: 8 }, + { name: "高风险", value: 2 }, + ]); + + expect(result).toEqual({ + xData: ["低风险", "高风险"], + series: [{ name: "数据", data: [8, 2], type: undefined }], + }); + }); + + it("accepts a single series object", () => { + const result = normalizeChartData(["A", "B"], { + name: "流量", + values: ["1.2", "2.4"], + }); + + expect(result).toEqual({ + xData: ["A", "B"], + series: [{ name: "流量", data: [1.2, 2.4], type: undefined }], + }); + }); +}); diff --git a/src/components/chat/ChatInlineChart.tsx b/src/components/chat/ChatInlineChart.tsx index 1f7f9e4..80721d7 100644 --- a/src/components/chat/ChatInlineChart.tsx +++ b/src/components/chat/ChatInlineChart.tsx @@ -16,11 +16,25 @@ export interface ChatChartSeries { type?: "line" | "bar"; } +type RawChartPoint = + | number + | string + | [unknown, unknown] + | { 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?: string[]; - series?: ChatChartSeries[]; + x_data?: unknown; + series?: unknown; y_axis_name?: string; x_axis_name?: string; } @@ -37,23 +51,148 @@ const COLORS = [ "#ea7ccc", ]; +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 & RawChartPoint; + 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: xData, - series = [], + x_data, + series, y_axis_name: yAxisName, x_axis_name: xAxisName, }) => { const theme = useTheme(); + const { xData, series: chartSeries } = useMemo( + () => normalizeChartData(x_data, series), + [x_data, series], + ); const option = useMemo(() => { - if (!series.length) return null; + if (!chartSeries.length) return null; /* ---------- Pie chart ---------- */ if (chartType === "pie") { const pieData = - series[0]?.data.map((value, i) => ({ + chartSeries[0]?.data.map((value, i) => ({ name: xData?.[i] ?? `${i}`, value, })) ?? []; @@ -111,7 +250,7 @@ export const ChatInlineChart: React.FC = ({ xData && xData.length > 20 ? [{ type: "inside", start: 0, end: 100 }] : undefined, - series: series.map((s, i) => { + series: chartSeries.map((s, i) => { const color = COLORS[i % COLORS.length]; return { name: s.name, @@ -135,7 +274,7 @@ export const ChatInlineChart: React.FC = ({ }), color: COLORS, }; - }, [chartType, xData, series, title, yAxisName, xAxisName]); + }, [chartType, xData, chartSeries, title, yAxisName, xAxisName]); if (!option) { return (