diff --git a/components/elements/openclaw-chart-card.tsx b/components/elements/openclaw-chart-card.tsx new file mode 100644 index 000000000..4081c9831 --- /dev/null +++ b/components/elements/openclaw-chart-card.tsx @@ -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>; + 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 }]; +} + +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} +
+ ); +} diff --git a/components/elements/response.tsx b/components/elements/response.tsx index 636891fa4..fe863d6fa 100644 --- a/components/elements/response.tsx +++ b/components/elements/response.tsx @@ -5,6 +5,10 @@ import type { ComponentProps, ReactNode } from "react"; import { useRef, useState } from "react"; import { Streamdown } from "streamdown"; import { ImagePreview } from "@/components/image-preview"; +import { + OpenClawChartCard, + splitOpenClawChartBlocks, +} from "@/components/elements/openclaw-chart-card"; import { Collapsible, 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.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 ( + + ); + } + + return renderTextSegment(segment.value, `${keyPrefix}-text-${index}`); + }); + }; + const rawText = typeof props.children === "string" ? normalizeMarkdownContent(props.children) diff --git a/docs/openclaw-chart-response-spec.md b/docs/openclaw-chart-response-spec.md new file mode 100644 index 000000000..b6cc14ab7 --- /dev/null +++ b/docs/openclaw-chart-response-spec.md @@ -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 或脚本,请不要返回这类内容。