541 lines
17 KiB
TypeScript
541 lines
17 KiB
TypeScript
"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<Record<string, unknown>>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown> =>
|
|
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 (
|
|
<div className="my-3 w-full min-w-0 max-w-full overflow-hidden rounded-xl border border-border bg-background/70 shadow-sm whitespace-normal">
|
|
<div className="flex items-start justify-between gap-3 border-b border-border/70 bg-muted/30 px-4 py-3">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
|
<BarChart3Icon className="h-4 w-4 text-muted-foreground" />
|
|
<span className="truncate">{chart.title || "数据图表"}</span>
|
|
</div>
|
|
{chart.subtitle ? (
|
|
<div className="mt-1 text-xs leading-5 text-muted-foreground">
|
|
{chart.subtitle}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleDownload}
|
|
data-pdf-exclude="true"
|
|
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-background hover:text-foreground"
|
|
title="下载图表数据 CSV"
|
|
>
|
|
<DownloadIcon className="h-3.5 w-3.5" />
|
|
<span>下载数据</span>
|
|
</button>
|
|
</div>
|
|
<div className="chat-card-x-scroll w-full min-w-0 overflow-x-auto px-3 pb-4 pt-3">
|
|
<svg
|
|
className="min-w-[720px] max-w-none"
|
|
role="img"
|
|
viewBox={`0 0 ${width} ${height}`}
|
|
>
|
|
<title>{chart.title || "数据图表"}</title>
|
|
<rect
|
|
fill="transparent"
|
|
height={plotHeight}
|
|
width={plotWidth}
|
|
x={margin.left}
|
|
y={margin.top}
|
|
/>
|
|
{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 (
|
|
<g key={`tick-${tick}`}>
|
|
<line
|
|
stroke="currentColor"
|
|
strokeOpacity="0.08"
|
|
x1={margin.left}
|
|
x2={margin.left + plotWidth}
|
|
y1={y}
|
|
y2={y}
|
|
/>
|
|
<text
|
|
fill="currentColor"
|
|
fontSize="11"
|
|
opacity="0.55"
|
|
textAnchor="end"
|
|
x={margin.left - 8}
|
|
y={y + 4}
|
|
>
|
|
{formatChartValue(leftValue)}
|
|
</text>
|
|
{hasRightAxis ? (
|
|
<text
|
|
fill="currentColor"
|
|
fontSize="11"
|
|
opacity="0.55"
|
|
textAnchor="start"
|
|
x={margin.left + plotWidth + 8}
|
|
y={y + 4}
|
|
>
|
|
{formatChartValue(rightValue)}
|
|
</text>
|
|
) : null}
|
|
</g>
|
|
);
|
|
})}
|
|
<line
|
|
stroke="currentColor"
|
|
strokeOpacity="0.18"
|
|
x1={margin.left}
|
|
x2={margin.left + plotWidth}
|
|
y1={margin.top + plotHeight}
|
|
y2={margin.top + plotHeight}
|
|
/>
|
|
<line
|
|
stroke="currentColor"
|
|
strokeOpacity="0.18"
|
|
x1={margin.left}
|
|
x2={margin.left}
|
|
y1={margin.top}
|
|
y2={margin.top + plotHeight}
|
|
/>
|
|
{hasRightAxis ? (
|
|
<line
|
|
stroke="currentColor"
|
|
strokeOpacity="0.18"
|
|
x1={margin.left + plotWidth}
|
|
x2={margin.left + plotWidth}
|
|
y1={margin.top}
|
|
y2={margin.top + plotHeight}
|
|
/>
|
|
) : 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 (
|
|
<rect
|
|
fill={series.color || CHART_COLORS[seriesIndex % CHART_COLORS.length]}
|
|
height={barHeight}
|
|
key={`${series.key}-${index}`}
|
|
opacity="0.78"
|
|
rx="3"
|
|
width={barWidth}
|
|
x={x}
|
|
y={y}
|
|
>
|
|
<title>
|
|
{`${row[chart.xKey] ?? ""} ${series.name || series.key}: ${formatChartValue(value, series.unit)}`}
|
|
</title>
|
|
</rect>
|
|
);
|
|
});
|
|
})}
|
|
|
|
{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 (
|
|
<g key={series.key}>
|
|
<path
|
|
d={path}
|
|
fill="none"
|
|
stroke={color}
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth="3"
|
|
/>
|
|
{points.map((point, index) => (
|
|
<circle
|
|
cx={point.x}
|
|
cy={point.y}
|
|
fill={color}
|
|
key={`${series.key}-${index}`}
|
|
r="4"
|
|
>
|
|
<title>
|
|
{`${point.label ?? ""} ${series.name || series.key}: ${formatChartValue(point.value, series.unit)}`}
|
|
</title>
|
|
</circle>
|
|
))}
|
|
</g>
|
|
);
|
|
})}
|
|
|
|
{data.map((row, index) =>
|
|
index % xLabelEvery === 0 || index === data.length - 1 ? (
|
|
<text
|
|
fill="currentColor"
|
|
fontSize="11"
|
|
key={`x-${index}`}
|
|
opacity="0.62"
|
|
textAnchor="middle"
|
|
x={scaleX(index)}
|
|
y={height - 22}
|
|
>
|
|
{truncateChartLabel(row[chart.xKey])}
|
|
</text>
|
|
) : null
|
|
)}
|
|
{chart.yAxisLabel ? (
|
|
<text fill="currentColor" fontSize="11" opacity="0.58" x={margin.left} y={16}>
|
|
{chart.yAxisLabel}
|
|
</text>
|
|
) : null}
|
|
{hasRightAxis && chart.rightYAxisLabel ? (
|
|
<text
|
|
fill="currentColor"
|
|
fontSize="11"
|
|
opacity="0.58"
|
|
textAnchor="end"
|
|
x={margin.left + plotWidth}
|
|
y={16}
|
|
>
|
|
{chart.rightYAxisLabel}
|
|
</text>
|
|
) : null}
|
|
</svg>
|
|
</div>
|
|
<div className="flex flex-wrap gap-x-4 gap-y-2 border-t border-border/60 px-4 py-3">
|
|
{chart.series.map((series, index) => (
|
|
<div
|
|
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground"
|
|
key={series.key}
|
|
>
|
|
<span
|
|
className="h-2.5 w-2.5 rounded-full"
|
|
style={{
|
|
backgroundColor: series.color || CHART_COLORS[index % CHART_COLORS.length],
|
|
}}
|
|
/>
|
|
<span>{series.name || series.key}</span>
|
|
{series.unit ? <span>({series.unit})</span> : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
{chart.note ? (
|
|
<div className="border-t border-border/60 px-4 py-2 text-xs leading-5 text-muted-foreground">
|
|
{chart.note}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|