feat: render OpenClaw chart cards

This commit is contained in:
134 2026-06-24 10:06:12 +08:00
parent fa10115043
commit 1af8a29b58
3 changed files with 726 additions and 1 deletions

View 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>
);
}

View File

@ -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("![") && text.includes("](")) { if (text.includes("![") && 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)

View 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 或脚本,请不要返回这类内容。