feat: render OpenClaw chart cards
This commit is contained in:
parent
fa10115043
commit
1af8a29b58
539
components/elements/openclaw-chart-card.tsx
Normal file
539
components/elements/openclaw-chart-card.tsx
Normal file
@ -0,0 +1,539 @@
|
|||||||
|
"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 }];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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}
|
||||||
|
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="overflow-x-auto px-3 py-3">
|
||||||
|
<svg
|
||||||
|
className="min-w-[620px] max-w-full"
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,6 +5,10 @@ import type { ComponentProps, ReactNode } from "react";
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { Streamdown } from "streamdown";
|
import { Streamdown } from "streamdown";
|
||||||
import { ImagePreview } from "@/components/image-preview";
|
import { ImagePreview } from "@/components/image-preview";
|
||||||
|
import {
|
||||||
|
OpenClawChartCard,
|
||||||
|
splitOpenClawChartBlocks,
|
||||||
|
} from "@/components/elements/openclaw-chart-card";
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
@ -402,7 +406,7 @@ export const Response = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderTextWithImages = (text: string, keyPrefix: string): ReactNode => {
|
const renderTextSegment = (text: string, keyPrefix: string): ReactNode => {
|
||||||
if (!text.trim()) return null;
|
if (!text.trim()) return null;
|
||||||
|
|
||||||
if (text.includes(") {
|
if (text.includes(") {
|
||||||
@ -447,6 +451,29 @@ export const Response = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderTextWithImages = (text: string, keyPrefix: string): ReactNode => {
|
||||||
|
if (!text.trim()) return null;
|
||||||
|
|
||||||
|
const chartSegments = splitOpenClawChartBlocks(text);
|
||||||
|
const hasChartSegments = chartSegments.some((segment) => segment.type === "chart");
|
||||||
|
if (!hasChartSegments) {
|
||||||
|
return renderTextSegment(text, keyPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
return chartSegments.map((segment, index) => {
|
||||||
|
if (segment.type === "chart") {
|
||||||
|
return (
|
||||||
|
<OpenClawChartCard
|
||||||
|
chart={segment.value}
|
||||||
|
key={`${keyPrefix}-chart-${index}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderTextSegment(segment.value, `${keyPrefix}-text-${index}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const rawText =
|
const rawText =
|
||||||
typeof props.children === "string"
|
typeof props.children === "string"
|
||||||
? normalizeMarkdownContent(props.children)
|
? normalizeMarkdownContent(props.children)
|
||||||
|
|||||||
159
docs/openclaw-chart-response-spec.md
Normal file
159
docs/openclaw-chart-response-spec.md
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
# 大龙虾 OpenClaw 返回 Markdown 图表规范
|
||||||
|
|
||||||
|
本文档用于规范大龙虾 / OpenClaw 助手在第三方聊天窗口中返回可视化图表数据。第三方前端会继续渲染普通 Markdown 文本和 Markdown 表格;如果回答中包含标准 `openclaw-chart` 代码块,前端会自动渲染为图表卡片。
|
||||||
|
|
||||||
|
## 1. 返回总原则
|
||||||
|
|
||||||
|
- `answer` 字段仍然返回一段 Markdown 字符串。
|
||||||
|
- 普通解释、结论、列表、标题继续使用 GitHub Flavored Markdown。
|
||||||
|
- 表格继续使用标准 Markdown 表格。
|
||||||
|
- 需要展示趋势、增长率、销售额、占比、排名等指标时,在 Markdown 中追加一个或多个 `openclaw-chart` 代码块。
|
||||||
|
- 图表数据必须使用合法 JSON,不要包含注释、尾逗号、中文引号或非 JSON 内容。
|
||||||
|
- 图表代码块只放结构化数据,不要把说明文案写进 JSON 外的代码块内。
|
||||||
|
|
||||||
|
## 2. 图表代码块格式
|
||||||
|
|
||||||
|
请使用下面格式:
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
```openclaw-chart
|
||||||
|
{
|
||||||
|
"openclaw_chart": true,
|
||||||
|
"type": "combo",
|
||||||
|
"title": "月销售额与环比走势",
|
||||||
|
"subtitle": "2025-05 至 2026-05",
|
||||||
|
"xKey": "month",
|
||||||
|
"yAxisLabel": "销售额(亿)",
|
||||||
|
"rightYAxisLabel": "环比(%)",
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"key": "sales",
|
||||||
|
"name": "销售额",
|
||||||
|
"type": "bar",
|
||||||
|
"unit": "亿",
|
||||||
|
"yAxis": "left"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "mom",
|
||||||
|
"name": "环比",
|
||||||
|
"type": "line",
|
||||||
|
"unit": "%",
|
||||||
|
"yAxis": "right"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"data": [
|
||||||
|
{ "month": "2025-05", "sales": 242.48, "mom": null },
|
||||||
|
{ "month": "2025-06", "sales": 228.00, "mom": -6.0 },
|
||||||
|
{ "month": "2025-07", "sales": 208.39, "mom": -8.6 }
|
||||||
|
],
|
||||||
|
"note": "sales 为销售额,mom 为月环比。"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
## 3. 字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `openclaw_chart` | boolean | 建议 | 固定写 `true`,方便识别 |
|
||||||
|
| `type` | string | 是 | 图表类型:`line`、`bar`、`combo` |
|
||||||
|
| `title` | string | 是 | 图表标题 |
|
||||||
|
| `subtitle` | string | 否 | 副标题,例如时间范围或口径 |
|
||||||
|
| `xKey` | string | 是 | X 轴字段名,必须对应 `data` 中的字段 |
|
||||||
|
| `yAxisLabel` | string | 否 | 左侧 Y 轴说明 |
|
||||||
|
| `rightYAxisLabel` | string | 否 | 右侧 Y 轴说明 |
|
||||||
|
| `series` | array | 是 | 指标系列配置,最多建议 6 个 |
|
||||||
|
| `series[].key` | string | 是 | 指标字段名,必须对应 `data` 中的字段 |
|
||||||
|
| `series[].name` | string | 是 | 前端展示名称 |
|
||||||
|
| `series[].type` | string | 否 | 单个指标展示类型:`line` 或 `bar` |
|
||||||
|
| `series[].unit` | string | 否 | 单位,例如 `亿`、`%`、`件` |
|
||||||
|
| `series[].yAxis` | string | 否 | `left` 或 `right`,不同量纲建议拆左右轴 |
|
||||||
|
| `series[].color` | string | 否 | 可选十六进制颜色,例如 `#4f8cff` |
|
||||||
|
| `data` | array | 是 | 图表数据,最多建议 80 行 |
|
||||||
|
| `note` | string | 否 | 图表底部备注 |
|
||||||
|
|
||||||
|
## 4. 数值要求
|
||||||
|
|
||||||
|
- `data` 中用于画图的指标值必须尽量返回数字,不要返回 `"242.48亿"` 或 `"6.0%"`。
|
||||||
|
- 单位放在 `series[].unit` 中。
|
||||||
|
- 没有值时使用 `null`,不要使用 `-`、`无`、`N/A`。
|
||||||
|
- 百分比按展示数值返回,例如 `-6.0` 表示 `-6.0%`,不要返回 `-0.06`。
|
||||||
|
- 金额、销量等字段不要加千分位逗号。
|
||||||
|
|
||||||
|
推荐:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "month": "2025-06", "sales": 228.0, "mom": -6.0 }
|
||||||
|
```
|
||||||
|
|
||||||
|
不推荐:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "month": "2025-06", "sales": "228.0亿", "mom": "-6.0%" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 推荐图表类型
|
||||||
|
|
||||||
|
| 场景 | 推荐 type | series 配置 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 单个指标趋势 | `line` | 指标 `type: "line"` |
|
||||||
|
| 多个月销售额/销量对比 | `bar` | 指标 `type: "bar"` |
|
||||||
|
| 销售额 + 环比/同比 | `combo` | 销售额 `bar` 左轴,环比/同比 `line` 右轴 |
|
||||||
|
| 关键词排名/品类排名 | `bar` | 排名值或销售额 `bar` |
|
||||||
|
| 多指标同量纲趋势 | `line` | 多条 `line` |
|
||||||
|
|
||||||
|
## 6. 完整回答示例
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
2025-05 至 2026-05 期间,整体销售额先下滑后回升,10 月出现明显反弹。
|
||||||
|
|
||||||
|
## 关键数据
|
||||||
|
|
||||||
|
| 月份 | 销售额 | 环比 |
|
||||||
|
| --- | ---: | ---: |
|
||||||
|
| 2025-05 | 242.48亿 | - |
|
||||||
|
| 2025-06 | 228.00亿 | -6.0% |
|
||||||
|
| 2025-07 | 208.39亿 | -8.6% |
|
||||||
|
|
||||||
|
```openclaw-chart
|
||||||
|
{
|
||||||
|
"openclaw_chart": true,
|
||||||
|
"type": "combo",
|
||||||
|
"title": "月销售额与环比走势",
|
||||||
|
"subtitle": "单位:销售额=亿,环比=%",
|
||||||
|
"xKey": "month",
|
||||||
|
"yAxisLabel": "销售额(亿)",
|
||||||
|
"rightYAxisLabel": "环比(%)",
|
||||||
|
"series": [
|
||||||
|
{ "key": "sales", "name": "销售额", "type": "bar", "unit": "亿", "yAxis": "left" },
|
||||||
|
{ "key": "mom", "name": "环比", "type": "line", "unit": "%", "yAxis": "right" }
|
||||||
|
],
|
||||||
|
"data": [
|
||||||
|
{ "month": "2025-05", "sales": 242.48, "mom": null },
|
||||||
|
{ "month": "2025-06", "sales": 228.00, "mom": -6.0 },
|
||||||
|
{ "month": "2025-07", "sales": 208.39, "mom": -8.6 },
|
||||||
|
{ "month": "2025-08", "sales": 207.82, "mom": -0.3 },
|
||||||
|
{ "month": "2025-09", "sales": 209.88, "mom": 1.0 },
|
||||||
|
{ "month": "2025-10", "sales": 249.90, "mom": 19.1 }
|
||||||
|
],
|
||||||
|
"note": "环比为空的月份用 null。"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 建议
|
||||||
|
|
||||||
|
重点关注 10 月反弹原因,并拆解品类、关键词和渠道贡献。
|
||||||
|
````
|
||||||
|
|
||||||
|
## 7. 多图表返回
|
||||||
|
|
||||||
|
同一个 `answer` 中可以返回多个 `openclaw-chart` 代码块。建议每个图表前先用 Markdown 写一句说明,避免用户不知道图表含义。
|
||||||
|
|
||||||
|
## 8. 兼容说明
|
||||||
|
|
||||||
|
- 如果前端识别不到合法图表 JSON,会按普通代码块显示。
|
||||||
|
- 图表卡片支持下载图表数据 CSV。
|
||||||
|
- Markdown 表格仍支持横向滚动和下载表格 CSV。
|
||||||
|
- 前端不支持 HTML、自定义 CSS 或脚本,请不要返回这类内容。
|
||||||
Loading…
x
Reference in New Issue
Block a user