"use client"; import { BarChart3Icon, DownloadIcon } from "lucide-react"; type OpenClawChartType = "bar" | "line" | "combo"; type OpenClawChartSeries = { key: string; name?: string; type?: "bar" | "line"; unit?: string; color?: string; yAxis?: "left" | "right"; }; export type OpenClawChartSpec = { openclaw_chart?: boolean; type?: OpenClawChartType; title?: string; subtitle?: string; xKey: string; yAxisLabel?: string; rightYAxisLabel?: string; series: OpenClawChartSeries[]; data: Array>; note?: string; }; export type OpenClawResponseSegment = | { type: "text"; value: string } | { type: "chart"; value: OpenClawChartSpec }; const CHART_COLORS = [ "#4f8cff", "#14b8a6", "#f59e0b", "#ef4444", "#8b5cf6", "#06b6d4", ]; function normalizeChartKey(value: unknown) { return String(value || "").trim(); } function escapeCsvCell(value: string) { const normalized = value.replace(/\s+/g, " ").trim(); const escaped = normalized.replace(/"/g, '""'); return /[",\r\n]/.test(escaped) ? `"${escaped}"` : escaped; } function downloadTextFile(filename: string, content: string, type: string) { const blob = new Blob([content], { type }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename; document.body.appendChild(anchor); anchor.click(); anchor.remove(); URL.revokeObjectURL(url); } function parseChartNumber(value: unknown): number | null { if (typeof value === "number") { return Number.isFinite(value) ? value : null; } if (typeof value !== "string") { return null; } const normalized = value .replace(/,/g, "") .replace(/%/g, "") .replace(/[^\d.+-]/g, "") .trim(); if (!normalized) return null; const parsed = Number(normalized); return Number.isFinite(parsed) ? parsed : null; } function formatChartValue(value: number, unit?: string) { const abs = Math.abs(value); const formatted = abs >= 100 ? value.toFixed(0) : abs >= 10 ? value.toFixed(1) : value.toFixed(2); return `${formatted.replace(/\.0+$/, "").replace(/(\.\d*?)0+$/, "$1")}${unit || ""}`; } function truncateChartLabel(value: unknown, maxLength = 8) { const label = String(value ?? ""); if (label.length <= maxLength) return label; return `${label.slice(0, maxLength - 1)}…`; } function safeJsonParse(value: string): unknown { try { return JSON.parse(value); } catch { return null; } } function normalizeOpenClawChartSpec(value: unknown): OpenClawChartSpec | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; } const raw = value as Record; const xKey = normalizeChartKey(raw.xKey || raw.x_key); const rawSeries = Array.isArray(raw.series) ? raw.series : []; const data = Array.isArray(raw.data) ? raw.data : []; if (!xKey || rawSeries.length === 0 || data.length === 0) { return null; } const series = rawSeries .map((item, index): OpenClawChartSeries | null => { if (!item || typeof item !== "object" || Array.isArray(item)) { return null; } const rawSeriesItem = item as Record; const key = normalizeChartKey(rawSeriesItem.key); if (!key) return null; const rawType = String(rawSeriesItem.type || "").trim(); const rawYAxis = String(rawSeriesItem.yAxis || rawSeriesItem.y_axis || "").trim(); return { key, name: normalizeChartKey(rawSeriesItem.name) || key, type: rawType === "bar" || rawType === "line" ? rawType : undefined, unit: normalizeChartKey(rawSeriesItem.unit) || undefined, color: normalizeChartKey(rawSeriesItem.color) || CHART_COLORS[index % CHART_COLORS.length], yAxis: rawYAxis === "right" ? "right" : "left", }; }) .filter((item): item is OpenClawChartSeries => Boolean(item)) .slice(0, 6); if (series.length === 0) return null; const rawType = String(raw.type || "").trim(); const type: OpenClawChartType = rawType === "bar" || rawType === "line" || rawType === "combo" ? rawType : "combo"; const normalizedData = data .filter((item): item is Record => Boolean(item && typeof item === "object" && !Array.isArray(item)) ) .slice(0, 80); if (normalizedData.length === 0) return null; return { openclaw_chart: true, type, title: normalizeChartKey(raw.title) || "数据图表", subtitle: normalizeChartKey(raw.subtitle) || undefined, xKey, yAxisLabel: normalizeChartKey(raw.yAxisLabel || raw.y_axis_label) || undefined, rightYAxisLabel: normalizeChartKey(raw.rightYAxisLabel || raw.right_y_axis_label) || undefined, series, data: normalizedData, note: normalizeChartKey(raw.note) || undefined, }; } export function splitOpenClawChartBlocks(content: string): OpenClawResponseSegment[] { const chartBlockPattern = /```(?:openclaw-chart|openclaw_chart|claw-chart|claw_chart|chart)\s*\n([\s\S]*?)```/gi; const segments: OpenClawResponseSegment[] = []; let cursor = 0; let match: RegExpExecArray | null; while ((match = chartBlockPattern.exec(content)) !== null) { const parsed = normalizeOpenClawChartSpec(safeJsonParse(match[1] || "")); if (!parsed) continue; if (match.index > cursor) { segments.push({ type: "text", value: content.slice(cursor, match.index) }); } segments.push({ type: "chart", value: parsed }); cursor = chartBlockPattern.lastIndex; } if (cursor < content.length) { segments.push({ type: "text", value: content.slice(cursor) }); } return segments.length > 0 ? segments : [{ type: "text", value: content }]; } export function chartDataToCsv(chart: OpenClawChartSpec) { const headers = [chart.xKey, ...chart.series.map((series) => series.name || series.key)]; const rows = chart.data.map((row) => [ row[chart.xKey], ...chart.series.map((series) => row[series.key]), ] .map((cell) => escapeCsvCell(String(cell ?? ""))) .join(",") ); return [headers.map(escapeCsvCell).join(","), ...rows].join("\r\n"); } export function OpenClawChartCard({ chart }: { chart: OpenClawChartSpec }) { const width = 720; const height = 320; const margin = { top: 28, right: 58, bottom: 54, left: 58 }; const plotWidth = width - margin.left - margin.right; const plotHeight = height - margin.top - margin.bottom; const data = chart.data; const getSeriesType = (series: OpenClawChartSeries) => series.type || (chart.type === "bar" ? "bar" : "line"); const barSeries = chart.series.filter((series) => getSeriesType(series) === "bar"); const lineSeries = chart.series.filter((series) => getSeriesType(series) !== "bar"); const hasRightAxis = chart.series.some((series) => series.yAxis === "right"); const getSeriesValues = (axis: "left" | "right") => chart.series .filter((series) => (series.yAxis || "left") === axis) .flatMap((series) => data .map((row) => parseChartNumber(row[series.key])) .filter((value): value is number => value !== null) ); const buildDomain = (axis: "left" | "right") => { const values = getSeriesValues(axis); if (values.length === 0) return { min: 0, max: 1 }; let min = Math.min(...values); let max = Math.max(...values); const hasBarOnAxis = chart.series.some( (series) => (series.yAxis || "left") === axis && getSeriesType(series) === "bar" ); if (hasBarOnAxis) { min = Math.min(0, min); max = Math.max(0, max); } if (min === max) { const pad = Math.abs(max || 1) * 0.2; min -= pad; max += pad; } const padding = (max - min) * 0.12; return { min: min - padding, max: max + padding }; }; const leftDomain = buildDomain("left"); const rightDomain = buildDomain("right"); const scaleX = (index: number) => data.length <= 1 ? margin.left + plotWidth / 2 : margin.left + (index / (data.length - 1)) * plotWidth; const scaleY = (value: number, axis: "left" | "right" = "left") => { const domain = axis === "right" ? rightDomain : leftDomain; const ratio = (value - domain.min) / (domain.max - domain.min || 1); return margin.top + plotHeight - ratio * plotHeight; }; const ticks = Array.from({ length: 5 }, (_, index) => index / 4); const xLabelEvery = Math.max(1, Math.ceil(data.length / 6)); const barGap = 4; const barWidth = Math.max( 6, Math.min( 26, (plotWidth / Math.max(1, data.length) - barGap * 2) / Math.max(1, barSeries.length) ) ); const handleDownload = () => { downloadTextFile( `chart-data-${Date.now()}.csv`, `\uFEFF${chartDataToCsv(chart)}`, "text/csv;charset=utf-8" ); }; return (
{chart.title || "数据图表"}
{chart.subtitle ? (
{chart.subtitle}
) : null}
{chart.title || "数据图表"} {ticks.map((tick) => { const y = margin.top + tick * plotHeight; const leftValue = leftDomain.max - tick * (leftDomain.max - leftDomain.min); const rightValue = rightDomain.max - tick * (rightDomain.max - rightDomain.min); return ( {formatChartValue(leftValue)} {hasRightAxis ? ( {formatChartValue(rightValue)} ) : null} ); })} {hasRightAxis ? ( ) : null} {barSeries.map((series, seriesIndex) => { const axis = series.yAxis || "left"; return data.map((row, index) => { const value = parseChartNumber(row[series.key]); if (value === null) return null; const xCenter = scaleX(index); const groupWidth = barSeries.length * barWidth + (barSeries.length - 1) * barGap; const x = xCenter - groupWidth / 2 + seriesIndex * (barWidth + barGap); const zeroY = scaleY(0, axis); const valueY = scaleY(value, axis); const y = Math.min(zeroY, valueY); const barHeight = Math.max(1, Math.abs(zeroY - valueY)); return ( {`${row[chart.xKey] ?? ""} ${series.name || series.key}: ${formatChartValue(value, series.unit)}`} ); }); })} {lineSeries.map((series, seriesIndex) => { const axis = series.yAxis || "left"; const points = data .map((row, index) => { const value = parseChartNumber(row[series.key]); if (value === null) return null; return { x: scaleX(index), y: scaleY(value, axis), value, label: row[chart.xKey], }; }) .filter((item): item is { x: number; y: number; value: number; label: unknown } => Boolean(item) ); if (points.length === 0) return null; const path = points .map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`) .join(" "); const color = series.color || CHART_COLORS[(barSeries.length + seriesIndex) % CHART_COLORS.length]; return ( {points.map((point, index) => ( {`${point.label ?? ""} ${series.name || series.key}: ${formatChartValue(point.value, series.unit)}`} ))} ); })} {data.map((row, index) => index % xLabelEvery === 0 || index === data.length - 1 ? ( {truncateChartLabel(row[chart.xKey])} ) : null )} {chart.yAxisLabel ? ( {chart.yAxisLabel} ) : null} {hasRightAxis && chart.rightYAxisLabel ? ( {chart.rightYAxisLabel} ) : null}
{chart.series.map((series, index) => (
{series.name || series.key} {series.unit ? ({series.unit}) : null}
))}
{chart.note ? (
{chart.note}
) : null}
); }