feat(ai): render chart-json blocks as interactive charts

This commit is contained in:
Abeautifulsnow
2026-09-07 14:03:57 +08:00
committed by GitHub
parent 852ad1c808
commit 29c6d1fbed
22 changed files with 1214 additions and 9 deletions
@@ -0,0 +1,143 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { Check, Copy, Download } from "@lucide/vue";
import { use } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { LineChart, BarChart, PieChart } from "echarts/charts";
import { AriaComponent, DataZoomComponent, GridComponent, TooltipComponent, LegendComponent, TitleComponent } from "echarts/components";
import VChart from "vue-echarts";
import { useTheme } from "@/composables/useTheme";
import { useToast } from "@/composables/useToast";
import { copyToClipboard } from "@/lib/common/clipboard";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { useI18n } from "vue-i18n";
import type { AiChartSpec } from "@/lib/ai/richContent/aiChartSpec";
import { buildAiChartOption } from "@/lib/ai/richContent/aiChartOption";
// Module-level `use([...])` is idempotent, so re-importing this component
// alongside QueryChart.vue is safe.
use([CanvasRenderer, LineChart, BarChart, PieChart, AriaComponent, DataZoomComponent, GridComponent, TooltipComponent, LegendComponent, TitleComponent]);
const props = defineProps<{
spec: AiChartSpec;
/** Preserve the model's original JSON for copy and inspect-data affordances. */
content: string;
}>();
const { isDark } = useTheme();
const { t } = useI18n();
const { toast } = useToast();
const chartRef = ref<{ $el?: HTMLElement } | null>(null);
const copied = ref(false);
const formattedData = computed(() => {
try {
return JSON.stringify(JSON.parse(props.content), null, 2);
} catch {
return props.content;
}
});
async function copyChartJson() {
try {
await copyToClipboard(props.content);
copied.value = true;
window.setTimeout(() => (copied.value = false), 1600);
} catch {
// Keep the chart usable if the host denies clipboard access.
}
}
// Match the card chrome around the chart (light zinc-50 / dark zinc-900) so a
// dark-theme PNG with light text stays readable on white viewers.
const pngBackground = computed(() => (isDark.value ? "#18181b" : "#fafafa"));
// `canvas.toBlob` encodes the transparent ECharts canvas as-is, which yields an
// unreadable PNG in dark mode. Compose the export onto an opaque theme-matching
// background first: paint a filled rect on an offscreen canvas, then draw the
// chart canvas over it.
function canvasToOpaquePng(canvas: HTMLCanvasElement): Promise<Blob | null> {
return new Promise((resolve) => {
const width = Math.max(1, Math.ceil(canvas.width));
const height = Math.max(1, Math.ceil(canvas.height));
const offscreen = document.createElement("canvas");
offscreen.width = width;
offscreen.height = height;
const ctx = offscreen.getContext("2d");
if (!ctx) {
resolve(null);
return;
}
ctx.fillStyle = pngBackground.value;
ctx.fillRect(0, 0, width, height);
ctx.drawImage(canvas, 0, 0);
offscreen.toBlob(resolve, "image/png");
});
}
async function downloadPng() {
const canvas = chartRef.value?.$el?.querySelector("canvas");
if (!canvas) return;
let blob: Blob | null;
try {
blob = await canvasToOpaquePng(canvas);
} catch {
toast(t("ai.chartDownloadFailed"));
return;
}
if (!blob) {
toast(t("ai.chartDownloadFailed"));
return;
}
try {
if (isTauriRuntime()) {
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({
defaultPath: "dbx-ai-chart.png",
filters: [{ name: "PNG", extensions: ["png"] }],
});
if (!path) return;
const { writeFile } = await import("@tauri-apps/plugin-fs");
await writeFile(path, new Uint8Array(await blob.arrayBuffer()));
return;
}
// Browser fallback: object URLs avoid the size and lifecycle limitations of
// canvas data URLs while preserving the same PNG output.
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "dbx-ai-chart.png";
anchor.click();
URL.revokeObjectURL(url);
} catch {
toast(t("ai.chartDownloadFailed"));
}
}
</script>
<template>
<!-- Responsive non-zero height: `min-h-60` (240px) floors the chart inside the
auto-height message stream, `clamp(15rem,35vw,20rem)` scales it with the
panel up to 320px, and `autoresize` tracks panel resizes. vue-echarts owns
`.echarts { height: 100% }`, so its parent—not the VChart element—must
establish this viewport or the canvas resolves to 0px high. -->
<section class="my-2 flex min-h-60 h-[clamp(15rem,35vw,20rem)] flex-col overflow-hidden rounded-md border border-zinc-200 bg-zinc-50 dark:border-zinc-700/50 dark:bg-zinc-900" aria-label="AI-generated chart">
<div class="flex h-8 items-center justify-end gap-1 border-b border-zinc-200 px-2 dark:border-zinc-700/50">
<button type="button" class="rounded p-1 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-100" :title="copied ? t('ai.copied') : t('ai.copyCode')" :aria-label="copied ? t('ai.copied') : t('ai.copyCode')" @click="copyChartJson">
<Check v-if="copied" class="h-3.5 w-3.5 text-green-500" />
<Copy v-else class="h-3.5 w-3.5" />
</button>
<button type="button" class="rounded p-1 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-100" :title="t('ai.chartDownloadPng')" :aria-label="t('ai.chartDownloadPng')" @click="downloadPng">
<Download class="h-3.5 w-3.5" />
</button>
</div>
<div class="min-h-0 flex-1">
<VChart ref="chartRef" :option="buildAiChartOption(props.spec, { isDark })" autoresize class="h-full w-full p-2" />
</div>
</section>
<details class="mb-2 rounded-md border border-zinc-200 text-xs dark:border-zinc-700/50">
<summary class="cursor-pointer px-3 py-1.5 text-zinc-600 dark:text-zinc-300">{{ t("ai.chartData") }}</summary>
<pre class="max-h-48 overflow-auto border-t border-zinc-200 p-3 text-[11px] dark:border-zinc-700/50">{{ formattedData }}</pre>
</details>
</template>
@@ -0,0 +1,29 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const rendererSource = readFileSync(new URL("../AiChartRenderer.vue", import.meta.url), "utf8");
describe("AiChartRenderer layout", () => {
it("establishes the chart height on the parent that vue-echarts fills", () => {
expect(rendererSource).toContain("min-h-60 h-[clamp(15rem,35vw,20rem)]");
expect(rendererSource).toContain('class="min-h-0 flex-1"');
expect(rendererSource).toContain('class="h-full w-full p-2"');
expect(rendererSource).not.toContain('class="h-80 w-full p-2"');
});
it("uses the native save dialog and binary writer for PNGs in Tauri", () => {
expect(rendererSource).toContain('await import("@tauri-apps/plugin-dialog")');
expect(rendererSource).toContain('await import("@tauri-apps/plugin-fs")');
expect(rendererSource).toContain("await writeFile(path, new Uint8Array(await blob.arrayBuffer()))");
});
it("surfaces download failures through a localized toast instead of a hardcoded English tooltip", () => {
expect(rendererSource).toContain("t('ai.chartDownloadPng')");
expect(rendererSource).toContain('toast(t("ai.chartDownloadFailed"))');
const savePath = rendererSource.slice(rendererSource.indexOf("await writeFile(path, new Uint8Array(await blob.arrayBuffer()))"));
expect(savePath).toContain('} catch {\n toast(t("ai.chartDownloadFailed"));\n }');
expect(rendererSource).not.toContain('title="Download PNG"');
expect(rendererSource).not.toContain('aria-label="Download PNG"');
expect(rendererSource).toContain('t("ai.chartData")');
});
});
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, toRaw, watch, type Component } from "vue";
import { computed, defineAsyncComponent, h, nextTick, onMounted, onUnmounted, reactive, ref, toRaw, watch, type Component } from "vue";
import { uuid } from "@/lib/common/utils";
import { useI18n } from "vue-i18n";
import { translateBackendError } from "@/i18n/backend-errors";
@@ -176,6 +176,18 @@ import AiAttachmentCard from "@/components/editor/AiAttachmentCard.vue";
import { resolveAiMessageCopyText } from "@/lib/ai/aiMessageCopy";
const { t } = useI18n();
const AiChartRenderer = defineAsyncComponent({
// Lazy-load the chart renderer (and with it the whole echarts bundle): echarts
// is only pulled in when a message actually renders a chart-json segment.
loader: () => import("@/components/ai/rich/AiChartRenderer.vue"),
// A visible, fixed-height status prevents an async chunk from looking like a
// failed chart and reserves the same viewport before the canvas is ready.
loadingComponent: () =>
h("div", { class: "my-2 flex min-h-60 h-[clamp(15rem,35vw,20rem)] w-full items-center justify-center gap-2 rounded-md border border-zinc-200 bg-zinc-50 text-xs text-zinc-500 dark:border-zinc-700/50 dark:bg-zinc-900 dark:text-zinc-400", role: "status", "aria-live": "polite" }, [
h(Loader2, { class: "h-4 w-4 animate-spin", "aria-hidden": "true" }),
h("span", t("common.loading")),
]),
});
const settings = useSettingsStore();
const connectionStore = useConnectionStore();
const savedSqlStore = useSavedSqlStore();
@@ -4701,6 +4713,7 @@ async function openExternalUrl(url: string) {
<div v-if="seg.type === 'text'" class="ai-markdown whitespace-normal" @click.capture="onMarkdownClick">
<div v-html="seg.html" />
</div>
<AiChartRenderer v-else-if="seg.type === 'chart'" :spec="seg.spec" :content="seg.content" />
<div v-else class="my-2 overflow-hidden rounded-md border border-zinc-200 bg-zinc-50 dark:border-zinc-700/50 dark:bg-zinc-900">
<div class="flex items-center border-b border-zinc-200 px-3 py-1.5 text-[10px] font-medium text-zinc-600 dark:border-zinc-700/50 dark:text-zinc-400">
<component :is="seg.isSql ? Database : Terminal" class="h-3 w-3 mr-1.5" />
+3
View File
@@ -3016,6 +3016,9 @@ export default {
templateDefaultBadgeTitle: "Default template for {type}",
exportMarkdown: "Export as Markdown",
analysis: "AI Analysis",
chartDownloadPng: "Download PNG",
chartDownloadFailed: "Failed to download chart image",
chartData: "Chart data",
},
contextMenu: {
openConnection: "Open Connection",
+3
View File
@@ -2820,6 +2820,9 @@ export default withEnglishFallback({
templateDefaultBadgeTitle: "Plantilla predeterminada para {type}",
exportMarkdown: "Exportar como Markdown",
analysis: "AI Analysis",
chartDownloadPng: "Descargar PNG",
chartDownloadFailed: "No se pudo descargar la imagen del gráfico",
chartData: "Datos del gráfico",
agentSteps: {
generated: "SQL generado",
noSql: "No se encontró SQL",
+3
View File
@@ -2714,6 +2714,9 @@ export default withEnglishFallback({
templateDefaultBadgeTitle: "Modello predefinito per {type}",
exportMarkdown: "Esporta come Markdown",
analysis: "AI Analysis",
chartDownloadPng: "Scarica PNG",
chartDownloadFailed: "Impossibile scaricare l'immagine del grafico",
chartData: "Dati del grafico",
agentSteps: {
generated: "SQL generato",
noSql: "Nessun SQL trovato",
+3
View File
@@ -2856,6 +2856,9 @@ export default withEnglishFallback({
templateDefaultBadgeTitle: "{type} のデフォルトテンプレート",
exportMarkdown: "Markdown としてエクスポート",
analysis: "AI 分析",
chartDownloadPng: "PNGをダウンロード",
chartDownloadFailed: "グラフ画像をダウンロードできませんでした",
chartData: "グラフのデータ",
agentSteps: {
generated: "SQL生成完了",
noSql: "SQLが見つかりません",
+3
View File
@@ -2826,6 +2826,9 @@ export default withEnglishFallback({
templateDefaultBadgeTitle: "{type}의 기본 템플릿",
exportMarkdown: "Markdown으로 내보내기",
analysis: "AI 분석",
chartDownloadPng: "PNG 다운로드",
chartDownloadFailed: "차트 이미지를 다운로드하지 못했습니다",
chartData: "차트 데이터",
},
contextMenu: {
openConnection: "연결 열기",
+3
View File
@@ -2822,6 +2822,9 @@ export default withEnglishFallback({
templateDefaultBadgeTitle: "Modelo padrão para {type}",
exportMarkdown: "Exportar como Markdown",
analysis: "AI Analysis",
chartDownloadPng: "Baixar PNG",
chartDownloadFailed: "Falha ao baixar a imagem do gráfico",
chartData: "Dados do gráfico",
agentSteps: {
generated: "SQL gerado",
noSql: "Nenhum SQL encontrado",
+3
View File
@@ -3007,6 +3007,9 @@ export default withEnglishFallback({
templateDefaultBadgeTitle: "{type} için varsayılan şablon",
exportMarkdown: "Markdown Olarak Dışa Aktar",
analysis: "Yapay Zekâ Analizi",
chartDownloadPng: "PNG İndir",
chartDownloadFailed: "Grafik görüntüsü indirilemedi",
chartData: "Grafik verileri",
},
contextMenu: {
openConnection: "Bağlantıyı Aç",
+3
View File
@@ -2939,6 +2939,9 @@ export default withEnglishFallback({
templateDefaultBadgeTitle: "{type} 的默认模板",
exportMarkdown: "导出为 Markdown",
analysis: "AI 分析",
chartDownloadPng: "下载 PNG",
chartDownloadFailed: "图表图片下载失败",
chartData: "图表数据",
},
contextMenu: {
openConnection: "打开连接",
+3
View File
@@ -2934,6 +2934,9 @@ export default withEnglishFallback({
editResend: "重新傳送",
editCancel: "取消",
cannotDeleteActiveConfig: "無法刪除當前活躍的配置,請先切換到其他配置",
chartDownloadPng: "下載 PNG",
chartDownloadFailed: "圖表圖片下載失敗",
chartData: "圖表資料",
},
contextMenu: {
openConnection: "開啟連線",
+39
View File
@@ -204,4 +204,43 @@ describe("AI SQL dialect prompt", () => {
expect(prompt).toContain("db.collection.findOne({})");
expect(prompt).not.toContain("get_sample_data");
});
it("injects the rich-content chart protocol into the normal database system prompt", () => {
const prompt = buildSystemPrompt("generate", context(), "ask");
expect(prompt).toContain("chart-json");
expect(prompt).toContain("at most one chart per reply");
expect(prompt).toContain('"version":1,"type":"line"');
expect(prompt).toContain('"version":1,"type":"pie"');
expect(prompt).toContain('"xAxis":{"values":["Jan","Feb","Mar"]}');
expect(prompt).toContain("grounded in actual available data");
expect(prompt).toContain("Do not emit ```html code blocks unless the user explicitly asks for them.");
});
it("injects the rich-content chart protocol into the vector database system prompt", () => {
const prompt = buildSystemPrompt("general", context({ databaseType: "qdrant", connectionName: "Qdrant", database: "vec" }), "ask");
expect(prompt).toContain("chart-json");
expect(prompt).toContain("at most one chart per reply");
expect(prompt).toContain('"version":1,"type":"pie"');
expect(prompt).toContain("Do not emit ```html code blocks unless the user explicitly asks for them.");
});
it("injects the zh rich-content protocol into both normal and vector prompts", async () => {
await setLocale("zh-CN");
try {
const normal = buildSystemPrompt("generate", context(), "ask");
expect(normal).toContain("chart-json");
expect(normal).toContain("一条回复最多一个");
expect(normal).toContain('"version":1,"type":"line"');
expect(normal).toContain("不得编造");
expect(normal).toContain("不要输出 ```html 代码块,除非用户明确要求");
const vector = buildSystemPrompt("general", context({ databaseType: "milvus", connectionName: "Milvus", database: "vec" }), "ask");
expect(vector).toContain("chart-json");
expect(vector).toContain("一条回复最多一个");
expect(vector).toContain('"version":1,"type":"pie"');
expect(vector).toContain("不要输出 ```html 代码块,除非用户明确要求");
} finally {
await setLocale("en");
}
});
});
+56 -1
View File
@@ -1,7 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { createAiMessageRenderer, splitStreamingTextBlocks } from "@/lib/ai/aiMessageRender";
import { formatAiInlineMarkdown } from "@/lib/ai/aiMarkdown";
describe("createAiMessageRenderer", () => {
it("caches completed short messages", () => {
const markdown = vi.fn((text: string) => `<p>${text}</p>`);
@@ -205,6 +204,62 @@ describe("createAiMessageRenderer", () => {
});
});
describe("createAiMessageRenderer rich chart segments", () => {
const markdown = (text: string) => `<p>${text}</p>`;
const chartJson = JSON.stringify({ version: 1, type: "bar", xAxis: { values: ["Jan", "Feb"] }, series: [{ name: "Revenue", data: [120, 200] }] });
it("routes a closed chart-json fence to a chart segment", () => {
const renderer = createAiMessageRenderer({ markdown });
const segments = renderer.render("```chart-json\n" + chartJson + "\n```");
const seg = segments[0];
expect(seg.type).toBe("chart");
if (seg.type !== "chart") return;
expect(seg.content).toBe(chartJson);
expect(seg.spec.type).toBe("bar");
});
it("keeps an unfinished chart-json fence as a plain code segment while streaming", () => {
const renderer = createAiMessageRenderer({ markdown });
const [seg] = renderer.render(`\`\`\`chart-json\n${chartJson.slice(0, 20)}`, { streaming: true });
expect(seg.type).toBe("code");
if (seg.type !== "code") return;
expect(seg.pending).toBe(true);
});
it("switches an open chart-json fence to a chart segment once the closing fence arrives", () => {
const renderer = createAiMessageRenderer({ markdown });
const open = `\`\`\`chart-json\n${chartJson.slice(0, 20)}`;
const closed = `\`\`\`chart-json\n${chartJson}\n\`\`\``;
expect(renderer.render(open, { streaming: true })[0].type).toBe("code");
expect(renderer.render(closed, { streaming: true })[0].type).toBe("chart");
});
it("falls back to a code segment when a closed chart-json fence fails validation", () => {
const renderer = createAiMessageRenderer({ markdown });
const [seg] = renderer.render("```chart-json\n{ not json\n```");
expect(seg.type).toBe("code");
if (seg.type !== "code") return;
expect(seg.lang).toBe("CHART-JSON");
expect(seg.isSql).toBe(false);
expect(seg.pending).toBe(false);
});
it("keeps sql/bash/json fences on the plain code path", () => {
const renderer = createAiMessageRenderer({ markdown });
const [sql] = renderer.render("```sql\nSELECT 1\n```");
expect(sql.type).toBe("code");
if (sql.type !== "code") return;
expect(sql.lang).toBe("SQL");
expect(sql.isSql).toBe(true);
const [bash] = renderer.render("```bash\necho hi\n```");
expect(bash.type).toBe("code");
if (bash.type !== "code") return;
expect(bash.lang).toBe("BASH");
expect(bash.isSql).toBe(false);
});
});
describe("splitStreamingTextBlocks", () => {
const long = "内容".repeat(200);
+45 -1
View File
@@ -295,7 +295,7 @@ export function buildSystemPrompt(action: AiAction, context: AiContext, mode: Ai
const isZh = isChineseLocale(currentLocale());
const lines: string[] = [...buildBasePromptLines(isZh), ...buildModePromptLines(mode, isZh, context.databaseType), ...buildActionPromptLines(action, isZh), ...buildCustomInstructionLines(custom, isZh)];
const lines: string[] = [...buildBasePromptLines(isZh), ...buildModePromptLines(mode, isZh, context.databaseType), ...buildActionPromptLines(action, isZh), ...buildRichContentPromptLines(isZh), ...buildCustomInstructionLines(custom, isZh)];
lines.push(attachmentSafetyInstruction(isZh));
@@ -368,6 +368,7 @@ function buildVectorSystemPrompt(context: AiContext, mode: AiAssistantMode, cust
isZh ? `你是 DBX 内置的向量数据库助手。当前连接的是 ${dbLabel(context.databaseType)} 数据库。用中文回复。` : `You are DBX's vector database assistant. Connected to ${dbLabel(context.databaseType)}. Reply in English.`,
isZh ? "数据存储在集合(collections)中,每条记录包含唯一标识及可选的元数据负载(payload/metadata)。" : "Data is stored in collections. Each record has a unique identifier and optional metadata payload.",
...buildVectorModePromptLines(context, mode, isZh),
...buildRichContentPromptLines(isZh),
...buildCustomInstructionLines(custom, isZh),
attachmentSafetyInstruction(isZh),
"",
@@ -408,6 +409,49 @@ function buildVectorModePromptLines(context: AiContext, mode: AiAssistantMode, i
];
}
/**
* Rich Content protocol rules (V1, charts only). Injected into BOTH the normal
* `buildSystemPrompt` and `buildVectorSystemPrompt` paths — the vector branch
* early-returns inside `buildSystemPrompt`, so touching only the normal branch
* would silently leave vector DBs without the protocol hints.
*
* Keep this compact (≤200 tokens): the chart-json schema is expressed as
* minimal JSON examples, not a TypeScript schema. Rules are asymmetric:
* - charts are allowed but restrained (only when a visual comparison/trend/
* distribution/share materially improves the answer, at most one per reply);
* - HTML is only produced when the user explicitly asks for it (rendering
* lands in PR2).
*/
function buildRichContentPromptLines(isZh: boolean): string[] {
return isZh
? [
[
"你可以输出 ```chart-json 代码块来渲染图表(V1 支持 line/bar/pie)。仅在图表能实质改善回答时使用,例如视觉对比、趋势、分布或占比;一条回复最多一个。",
"示例(line/bar):```chart-json",
`{"version":1,"type":"line","xAxis":{"values":["Jan","Feb","Mar"]},"series":[{"name":"收入","data":[120,200,150]}]}`,
"```",
"示例(pie):```chart-json",
`{"version":1,"type":"pie","data":[{"name":"A","value":40},{"name":"B","value":60}]}`,
"```",
"图表数据必须来自当前可验证的数据上下文(查询结果、附件、用户提供的数据等),不得编造;数据应完整、不加截断符。",
"不要输出 ```html 代码块,除非用户明确要求。",
].join("\n"),
]
: [
[
"You may emit a ```chart-json code block to render a chart (V1 supports line/bar/pie). Use it only when a visual comparison, trend, distribution, or share materially improves the answer; at most one chart per reply.",
"Example (line/bar): ```chart-json",
`{"version":1,"type":"line","xAxis":{"values":["Jan","Feb","Mar"]},"series":[{"name":"Revenue","data":[120,200,150]}]}`,
"```",
"Example (pie): ```chart-json",
`{"version":1,"type":"pie","data":[{"name":"A","value":40},{"name":"B","value":60}]}`,
"```",
"Chart data must be grounded in actual available data (query results, attachments, provided values) and never invented; keep it complete, no truncation markers.",
"Do not emit ```html code blocks unless the user explicitly asks for them.",
].join("\n"),
];
}
function buildModePromptLines(mode: AiAssistantMode, isZh: boolean, databaseType: DatabaseType): string[] {
const currentTimeGuidance = currentTimeToolGuidance();
if (databaseType === "mongodb") {
+29 -6
View File
@@ -1,3 +1,5 @@
import { AI_RICH_BLOCK_HANDLERS, type AiMessageChartSegment, type AiRichBlockHandler } from "@/lib/ai/richContent/aiRichContent";
export interface AiMessageTextSegment {
type: "text";
content: string;
@@ -14,7 +16,7 @@ export interface AiMessageCodeSegment {
pending: boolean;
}
export type AiMessageRenderSegment = AiMessageTextSegment | AiMessageCodeSegment;
export type AiMessageRenderSegment = AiMessageTextSegment | AiMessageCodeSegment | AiMessageChartSegment;
interface MessageSegment {
type: "text" | "code";
@@ -36,6 +38,8 @@ export interface AiMessageRendererOptions {
maxCacheChars?: number;
markdown: (text: string) => string;
highlightCode?: (content: string, lang: string) => string;
/** Resolves a fenced raw lang to a rich handler (chart-json, ...). */
richHandlers?: Record<string, AiRichBlockHandler>;
}
const DEFAULT_MAX_ENTRIES = 100;
@@ -77,6 +81,8 @@ interface SegmentRenderFlags {
pending: boolean;
/** The segment is the growing tail of a streaming message. */
live: boolean;
/** True once the closing fence has arrived (rich handlers require it). */
closed?: boolean;
}
export function createAiMessageRenderer(options: AiMessageRendererOptions) {
@@ -90,11 +96,23 @@ export function createAiMessageRenderer(options: AiMessageRendererOptions) {
// Bounded by the one answer being streamed, and dropped as soon as another answer starts.
const streamBlocks = new Map<string, AiMessageRenderSegment>();
let streamContent = "";
// Rich blocks are dispatched by their raw fenced language tag (lowercased,
// trimmed) BEFORE normalizeAiCodeLanguage's SQL/SHELL label map, so the
// shared code path for SQL/SHELL buckets is untouched.
const richHandlers = options.richHandlers ?? AI_RICH_BLOCK_HANDLERS;
function renderSegment(segment: MessageSegment, flags: SegmentRenderFlags): AiMessageRenderSegment {
if (segment.type === "text") {
return { type: "text", content: segment.content, html: options.markdown(segment.content) };
}
const rawLang = (segment.lang || "").trim().toLowerCase();
const handler = richHandlers[rawLang];
if (handler) {
// The `closed` flag gates the handler call: an unfinished fence
// (streaming) always routes back to the plain code segment.
const rich = flags.closed ? handler.parse(segment.content, { closed: true }) : null;
if (rich) return rich;
}
const lang = normalizeAiCodeLanguage(segment.lang);
// Highlighting a block that is still streaming is wasted work: it is re-highlighted once the fence closes.
const highlighted = flags.live && flags.pending ? undefined : safeHighlightCode(segment.content, lang);
@@ -121,12 +139,16 @@ export function createAiMessageRenderer(options: AiMessageRendererOptions) {
if (segment.content.length > maxCacheableChars) return renderSegment(segment, flags);
// Length-prefixed so no field separator can be forged by segment content.
const key = `${segment.type}|${segment.lang ?? ""}|${flags.pending ? 1 : 0}|${segment.content.length}|${segment.content}`;
// `closed` also participates in the key so an open chart-json fence caches
// as plain code and the closed fence caches as its rich chart segment.
const key = `${segment.type}|${segment.lang ?? ""}|${flags.pending ? 1 : 0}|${flags.closed ? 1 : 0}|${segment.content.length}|${segment.content}`;
const cached = segmentCache.get(key);
if (cached) return cached;
const rendered = renderSegment(segment, flags);
segmentCache.set(key, rendered, segment.content.length + rendered.html.length);
// Rich chart segments carry their parsed spec instead of an html string.
const size = segment.content.length + ("html" in rendered ? rendered.html.length : 0);
segmentCache.set(key, rendered, size);
return rendered;
}
@@ -152,7 +174,7 @@ export function createAiMessageRenderer(options: AiMessageRendererOptions) {
const live = blocks[blocks.length - 1];
return [...blocks.slice(0, -1).map(renderStreamingBlock), renderSegment({ type: "text", content: live }, { pending: false, live: true })];
}
return [renderSegment(segment, { pending, live: true })];
return [renderSegment(segment, { pending, live: true, closed: segment.closed === true })];
}
function render(content: string, renderOptions: AiMessageRenderOptions = {}): AiMessageRenderSegment[] {
@@ -171,11 +193,12 @@ export function createAiMessageRenderer(options: AiMessageRendererOptions) {
const rendered = segments.flatMap((segment, index): AiMessageRenderSegment[] => {
if (streaming && index === lastIndex) return renderTail(segment);
const pending = segment.type === "code" && segment.closed !== true;
return [renderCachedSegment(segment, { pending, live: false })];
return [renderCachedSegment(segment, { pending, live: false, closed: segment.closed === true })];
});
// Charge for the HTML the entry pins, not just for the source text.
if (cacheable) cache.set(content, rendered, content.length + rendered.reduce((sum, segment) => sum + segment.html.length, 0));
// Chart segments carry a parsed spec, not html — fall back to content length.
if (cacheable) cache.set(content, rendered, content.length + rendered.reduce((sum, segment) => sum + ("html" in segment ? segment.html.length : 0), 0));
return rendered;
}
@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import { buildAiChartOption, type AiChartTheme } from "@/lib/ai/richContent/aiChartOption";
import { parseAiChartSpec } from "@/lib/ai/richContent/aiChartSpec";
import type { AiChartSpec } from "@/lib/ai/richContent/aiChartSpec";
const light: AiChartTheme = { isDark: false };
const dark: AiChartTheme = { isDark: true };
function specOf(json: string): AiChartSpec {
const result = parseAiChartSpec(json);
if (!result.ok) throw new Error(`unexpected parse failure: ${result.reason}`);
return result.spec;
}
describe("buildAiChartOption", () => {
it("builds a bar option with a category xAxis and value yAxis", () => {
const option = buildAiChartOption(specOf(JSON.stringify({ version: 1, type: "bar", xAxis: { values: ["Jan", "Feb"] }, series: [{ name: "Revenue", data: [1, 2] }] })), light);
expect(option.xAxis).toMatchObject({ type: "category", data: ["Jan", "Feb"] });
expect(option.yAxis).toMatchObject({ type: "value" });
if (Array.isArray(option.series)) {
expect(option.series).toHaveLength(1);
expect(option.series[0]).toMatchObject({ type: "bar", data: [1, 2], name: "Revenue" });
} else {
expect(option.series).toBeDefined();
}
});
it("uses smooth:true for line charts only", () => {
const line = buildAiChartOption(specOf(JSON.stringify({ version: 1, type: "line", xAxis: { values: ["a", "b"] }, series: [{ name: "s", data: [1, 2] }] })), light);
if (Array.isArray(line.series)) expect(line.series[0]).toMatchObject({ type: "line", smooth: true });
const bar = buildAiChartOption(specOf(JSON.stringify({ version: 1, type: "bar", xAxis: { values: ["a", "b"] }, series: [{ name: "s", data: [1, 2] }] })), light);
if (Array.isArray(bar.series)) expect(bar.series[0]).toMatchObject({ type: "bar", smooth: false });
});
it("maps pie data entries to name/value pairs with a donut radius", () => {
const option = buildAiChartOption(
specOf(
JSON.stringify({
version: 1,
type: "pie",
data: [
{ name: "A", value: 40 },
{ name: "B", value: 60 },
],
}),
),
light,
);
expect(option.tooltip).toMatchObject({ trigger: "item" });
if (Array.isArray(option.series)) {
expect(option.series[0]).toMatchObject({
type: "pie",
radius: ["30%", "60%"],
data: [
{ name: "A", value: 40 },
{ name: "B", value: 60 },
],
});
}
});
it("applies axis label colors from the theme (dark vs light)", () => {
const lightOption = buildAiChartOption(specOf(JSON.stringify({ version: 1, type: "bar", xAxis: { values: ["a"] }, series: [{ name: "s", data: [1] }] })), light);
const darkOption = buildAiChartOption(specOf(JSON.stringify({ version: 1, type: "bar", xAxis: { values: ["a"] }, series: [{ name: "s", data: [1] }] })), dark);
expect(lightOption.xAxis).toMatchObject({ axisLabel: { color: "#666" } });
expect(darkOption.xAxis).toMatchObject({ axisLabel: { color: "#aaa" } });
});
it("applies legend text colors from the theme", () => {
const lightOption = buildAiChartOption(specOf(JSON.stringify({ version: 1, type: "pie", data: [{ name: "A", value: 1 }] })), light);
const darkOption = buildAiChartOption(specOf(JSON.stringify({ version: 1, type: "pie", data: [{ name: "A", value: 1 }] })), dark);
expect(lightOption.legend).toMatchObject({ textStyle: { color: "#333" } });
expect(darkOption.legend).toMatchObject({ textStyle: { color: "#ccc" } });
});
it("sets a title when the spec carries one", () => {
const option = buildAiChartOption(specOf(JSON.stringify({ version: 1, type: "bar", title: "Sales", xAxis: { values: ["a"] }, series: [{ name: "s", data: [1] }] })), light);
expect(option.title).toMatchObject({ text: "Sales" });
});
it("omits the title when the spec has none", () => {
const option = buildAiChartOption(specOf(JSON.stringify({ version: 1, type: "bar", xAxis: { values: ["a"] }, series: [{ name: "s", data: [1] }] })), light);
expect(option.title).toBeUndefined();
});
it("uses axis names from xAxis/yAxis labels", () => {
const option = buildAiChartOption(specOf(JSON.stringify({ version: 1, type: "bar", xAxis: { label: "Month", values: ["a"] }, yAxis: { label: "Amount" }, series: [{ name: "s", data: [1] }] })), light);
expect(option.xAxis).toMatchObject({ name: "Month" });
expect(option.yAxis).toMatchObject({ name: "Amount" });
});
it("enables accessible output and zoom-safe labels for dense category axes", () => {
const option = buildAiChartOption(specOf(JSON.stringify({ version: 1, type: "line", xAxis: { values: Array.from({ length: 13 }, (_, i) => `day-${i}`) }, series: [{ name: "s", data: Array.from({ length: 13 }, (_, i) => i) }] })), light);
expect(option.aria).toMatchObject({ enabled: true });
expect(option.dataZoom).toHaveLength(2);
expect(option.xAxis).toMatchObject({ axisLabel: { rotate: 35, overflow: "truncate" } });
});
it("keeps the dense-axis slider at the canvas bottom edge and lifts the legend above it", () => {
const dense = buildAiChartOption(specOf(JSON.stringify({ version: 1, type: "line", xAxis: { values: Array.from({ length: 13 }, (_, i) => `day-${i}`) }, series: [{ name: "s", data: Array.from({ length: 13 }, (_, i) => i) }] })), light);
if (Array.isArray(dense.dataZoom)) {
const slider = dense.dataZoom.find((zoom) => typeof zoom === "object" && zoom !== null && "type" in zoom && (zoom as { type?: string }).type === "slider") as { bottom?: number; height?: number } | undefined;
expect(slider?.bottom).toBe(0);
expect(slider?.height).toBe(16);
}
expect(dense.legend).toMatchObject({ bottom: 32 });
expect(dense.grid).toMatchObject({ bottom: 76 });
const sparse = buildAiChartOption(specOf(JSON.stringify({ version: 1, type: "line", xAxis: { values: ["a", "b"] }, series: [{ name: "s", data: [1, 2] }] })), light);
expect(sparse.dataZoom).toBeUndefined();
expect(sparse.legend).toMatchObject({ bottom: 0 });
});
});
@@ -0,0 +1,248 @@
import { describe, expect, it } from "vitest";
import { AI_CHART_VERSION, parseAiChartSpec, AI_CHART_MAX_JSON_CHARS, AI_CHART_MAX_SERIES, AI_CHART_MAX_POINTS_PER_ARRAY, AI_CHART_MAX_TOTAL_POINTS, AI_CHART_MAX_CATEGORY_STRING_LENGTH } from "@/lib/ai/richContent/aiChartSpec";
const barSpec = (overrides: Record<string, unknown> = {}) => ({
version: 1,
type: "bar",
xAxis: { values: ["Jan", "Feb", "Mar"] },
series: [{ name: "Revenue", data: [120, 200, 150] }],
...overrides,
});
describe("parseAiChartSpec", () => {
it("accepts a valid bar chart and normalizes data to numbers", () => {
const result = parseAiChartSpec(JSON.stringify(barSpec()));
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.spec.type).toBe("bar");
if (result.spec.type !== "bar") return;
expect(result.spec.version).toBe(1);
expect(result.spec.xAxis.values).toEqual(["Jan", "Feb", "Mar"]);
expect(result.spec.series[0].data).toEqual([120, 200, 150]);
});
it("accepts a valid line chart", () => {
const result = parseAiChartSpec(JSON.stringify({ version: 1, type: "line", xAxis: { values: [1, 2, 3] }, series: [{ name: "s", data: [1.5, -2, 0] }] }));
expect(result.ok).toBe(true);
});
it("accepts negative data values in line/bar", () => {
const result = parseAiChartSpec(JSON.stringify(barSpec({ series: [{ name: "s", data: [-5, 0, 3] }] })));
expect(result.ok).toBe(true);
});
it("accepts a valid pie chart with a single zero slice", () => {
const result = parseAiChartSpec(
JSON.stringify({
version: 1,
type: "pie",
data: [
{ name: "A", value: 40 },
{ name: "B", value: 0 },
],
}),
);
expect(result.ok).toBe(true);
if (!result.ok) return;
if (result.spec.type !== "pie") return;
expect(result.spec.data).toEqual([
{ name: "A", value: 40 },
{ name: "B", value: 0 },
]);
});
it("rejects invalid JSON", () => {
const result = parseAiChartSpec("{ not json");
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe("invalid JSON");
});
it("rejects a missing version (required protocol field)", () => {
const { version: _omitted, ...missing } = barSpec();
const result = parseAiChartSpec(JSON.stringify(missing));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe("chart version is required");
});
it("accepts an explicit version 1", () => {
const result = parseAiChartSpec(JSON.stringify(barSpec({ version: 1 })));
expect(result.ok).toBe(true);
});
it("rejects an unsupported version with a reason containing 'unsupported chart version'", () => {
const result = parseAiChartSpec(JSON.stringify(barSpec({ version: 2 })));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toContain("unsupported chart version");
});
it("rejects an unsupported chart type", () => {
const result = parseAiChartSpec(JSON.stringify(barSpec({ type: "scatter" })));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toContain("unsupported chart type");
});
it("keeps category axis values as-is (no numeric coercion): ['Jan','Feb'] and ['华东','华南']", () => {
for (const values of [
["Jan", "Feb"],
["华东", "华南"],
["产品A", "产品B"],
]) {
const result = parseAiChartSpec(JSON.stringify(barSpec({ xAxis: { values }, series: [{ name: "Revenue", data: [120, 200] }] })));
expect(result.ok).toBe(true);
if (result.ok && result.spec.type === "bar") expect(result.spec.xAxis.values).toEqual(values);
}
});
it("rejects non-finite numeric values in series data ('NaN', 'Infinity', non-numeric)", () => {
for (const data of [
["NaN", 2, 3],
["Infinity", 2, 3],
["abc", 2, 3],
[" ", 2, 3],
]) {
const result = parseAiChartSpec(JSON.stringify(barSpec({ series: [{ name: "s", data }] })));
expect(result.ok).toBe(false);
}
});
it("rejects non-finite pie values ('NaN', 'Infinity', non-numeric)", () => {
for (const data of [[{ name: "A", value: "NaN" }], [{ name: "A", value: "Infinity" }], [{ name: "A", value: "n/a" }]]) {
const result = parseAiChartSpec(JSON.stringify({ version: 1, type: "pie", data }));
expect(result.ok).toBe(false);
}
});
it("coerces numeric strings in series data and pie values", () => {
const bar = parseAiChartSpec(JSON.stringify(barSpec({ series: [{ name: "s", data: ["12", "3.5", 0] }] })));
expect(bar.ok).toBe(true);
if (bar.ok && bar.spec.type === "bar") expect(bar.spec.series[0].data).toEqual([12, 3.5, 0]);
const pie = parseAiChartSpec(JSON.stringify({ version: 1, type: "pie", data: [{ name: "A", value: "42" }] }));
expect(pie.ok).toBe(true);
if (pie.ok && pie.spec.type === "pie") expect(pie.spec.data[0].value).toBe(42);
});
it("rejects a missing or empty xAxis.values", () => {
expect(parseAiChartSpec(JSON.stringify(barSpec({ xAxis: {} }))).ok).toBe(false);
expect(parseAiChartSpec(JSON.stringify(barSpec({ xAxis: { values: [] } }))).ok).toBe(false);
});
it("rejects a missing or empty series array", () => {
expect(parseAiChartSpec(JSON.stringify(barSpec({ series: [] }))).ok).toBe(false);
expect(parseAiChartSpec(JSON.stringify(barSpec({ series: undefined }))).ok).toBe(false);
});
it("rejects length mismatch between a series data and xAxis.values", () => {
const result = parseAiChartSpec(JSON.stringify(barSpec({ series: [{ name: "s", data: [1, 2] }] })));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toContain("length does not match");
});
it("rejects a pie with no data or all-zero data", () => {
expect(parseAiChartSpec(JSON.stringify({ version: 1, type: "pie", data: [] })).ok).toBe(false);
expect(
parseAiChartSpec(
JSON.stringify({
version: 1,
type: "pie",
data: [
{ name: "A", value: 0 },
{ name: "B", value: 0 },
],
}),
).ok,
).toBe(false);
});
it("rejects negative pie values", () => {
const result = parseAiChartSpec(
JSON.stringify({
version: 1,
type: "pie",
data: [
{ name: "A", value: -1 },
{ name: "B", value: 2 },
],
}),
);
expect(result.ok).toBe(false);
});
it("rejects a non-object chart spec", () => {
expect(parseAiChartSpec("[]").ok).toBe(false);
expect(parseAiChartSpec('"text"').ok).toBe(false);
expect(parseAiChartSpec("42").ok).toBe(false);
});
it("rejects an empty spec", () => {
expect(parseAiChartSpec("").ok).toBe(false);
expect(parseAiChartSpec(" \n ").ok).toBe(false);
});
it("rejects a JSON body over 256KB", () => {
// The raw-length gate fires before JSON.parse, so an oversized body is
// rejected regardless of whether it would parse.
const big = "x".repeat(AI_CHART_MAX_JSON_CHARS + 1);
const result = parseAiChartSpec(big);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toContain("256KB");
});
it("rejects more than 8 series", () => {
const series = Array.from({ length: AI_CHART_MAX_SERIES + 1 }, (_, i) => ({ name: `s${i}`, data: [1, 2, 3] }));
const result = parseAiChartSpec(JSON.stringify(barSpec({ series })));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toContain("8 series");
});
it("rejects a data/values array with more than 5000 points", () => {
const values = Array.from({ length: AI_CHART_MAX_POINTS_PER_ARRAY + 1 }, (_, i) => i);
const result = parseAiChartSpec(JSON.stringify(barSpec({ xAxis: { values }, series: [{ name: "s", data: values.map(() => 1) }] })));
expect(result.ok).toBe(false);
});
it("rejects line/bar total points over 10000", () => {
// 3 series x 4000 points = 12000 > 10000, each under the 5000 cap.
const values = Array.from({ length: 4000 }, (_, i) => i);
const series = Array.from({ length: 3 }, (_, i) => ({ name: `s${i}`, data: values.map(() => i) }));
const result = parseAiChartSpec(JSON.stringify(barSpec({ xAxis: { values }, series })));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toContain(String(AI_CHART_MAX_TOTAL_POINTS));
});
it("accepts a line/bar total exactly at the 10000 cap", () => {
const values = Array.from({ length: 5000 }, (_, i) => i);
const series = Array.from({ length: 2 }, (_, i) => ({ name: `s${i}`, data: values.map(() => i) }));
const result = parseAiChartSpec(JSON.stringify(barSpec({ xAxis: { values }, series })));
expect(result.ok).toBe(true);
});
it("rejects an over-long category axis string (>256 chars)", () => {
const result = parseAiChartSpec(JSON.stringify(barSpec({ xAxis: { values: ["x".repeat(AI_CHART_MAX_CATEGORY_STRING_LENGTH + 1)] } })));
expect(result.ok).toBe(false);
});
it("keeps optional title / axis labels when present", () => {
const result = parseAiChartSpec(JSON.stringify(barSpec({ title: "Sales", xAxis: { label: "Month", values: ["Jan", "Feb"] }, yAxis: { label: "Amount" }, series: [{ name: "s", data: [1, 2] }] })));
expect(result.ok).toBe(true);
if (!result.ok) return;
if (result.spec.type !== "bar") return;
expect(result.spec.title).toBe("Sales");
expect(result.spec.xAxis.label).toBe("Month");
expect(result.spec.yAxis?.label).toBe("Amount");
});
it("returns a deterministic reason on failure and preserves raw", () => {
const raw = "{ broken";
const result = parseAiChartSpec(raw);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("invalid JSON");
expect(result.raw).toBe(raw);
}
});
it("exposes the chart version constant", () => {
expect(AI_CHART_VERSION).toBe(1);
});
});
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { AI_RICH_BLOCK_HANDLERS, isAiRichBlockLanguage } from "@/lib/ai/richContent/aiRichContent";
import { parseAiChartSpec } from "@/lib/ai/richContent/aiChartSpec";
const validBar = JSON.stringify({ version: 1, type: "bar", xAxis: { values: ["Jan", "Feb"] }, series: [{ name: "Revenue", data: [120, 200] }] });
const invalidBar = "{ not json";
describe("AI_RICH_BLOCK_HANDLERS", () => {
it("registers exactly the chart-json handler in V1", () => {
expect(Object.keys(AI_RICH_BLOCK_HANDLERS)).toEqual(["chart-json"]);
});
it("returns null for an unfinished fence (closed=false) without parsing", () => {
const handler = AI_RICH_BLOCK_HANDLERS["chart-json"];
expect(handler.language).toBe("chart-json");
// Even a malformed body must not be parsed while the fence is still open.
expect(handler.parse(invalidBar, { closed: false })).toBeNull();
});
it("parses a valid closed chart-json block into a chart segment", () => {
const handler = AI_RICH_BLOCK_HANDLERS["chart-json"];
const segment = handler.parse(validBar, { closed: true });
expect(segment).not.toBeNull();
expect(segment?.type).toBe("chart");
expect(segment?.content).toBe(validBar);
expect(parseAiChartSpec(segment!.content).ok).toBe(true);
});
it("returns null when a closed chart-json block fails validation", () => {
const handler = AI_RICH_BLOCK_HANDLERS["chart-json"];
expect(handler.parse(invalidBar, { closed: true })).toBeNull();
expect(handler.parse(JSON.stringify({ version: 1, type: "pie", data: [{ name: "A", value: -1 }] }), { closed: true })).toBeNull();
});
it("does not treat sql/bash/json as rich block languages", () => {
for (const lang of ["sql", "bash", "json", "SQL", "BASH", "chart-json "]) {
expect(isAiRichBlockLanguage(lang)).toBe(lang.trim().toLowerCase() === "chart-json");
}
});
it("matches the raw language tag case-insensitively", () => {
expect(isAiRichBlockLanguage("CHART-JSON")).toBe(true);
expect(isAiRichBlockLanguage(" Chart-Json ")).toBe(true);
});
});
@@ -0,0 +1,73 @@
/**
* UI adapter from a normalized {@link AiChartSpec} to an ECharts option.
* Decoupled from the wire protocol: only `buildAiChartOption` knows how a spec
* maps onto ECharts, so the protocol layer never needs to reason about
* rendering and the renderer never sees the raw chart-json.
*/
import type { EChartsOption } from "echarts";
import type { AiChartSpec } from "@/lib/ai/richContent/aiChartSpec";
export interface AiChartTheme {
isDark: boolean;
}
function axisLabelColor(theme: AiChartTheme): string {
return theme.isDark ? "#aaa" : "#666";
}
function legendTextStyle(theme: AiChartTheme): { color: string } {
return { color: theme.isDark ? "#ccc" : "#333" };
}
export function buildAiChartOption(spec: AiChartSpec, theme: AiChartTheme): EChartsOption {
// ECharts 6 uses `enabled`; older ECharts 5 examples call this `show`.
const aria = { enabled: true };
if (spec.type === "pie") {
return {
aria,
tooltip: { trigger: "item" },
legend: { bottom: 0, textStyle: legendTextStyle(theme) },
title: spec.title ? { text: spec.title, left: "center", textStyle: { color: theme.isDark ? "#ccc" : "#333", fontSize: 13 } } : undefined,
series: [
{
type: "pie",
radius: ["30%", "60%"],
data: spec.data.map((entry) => ({ name: entry.name, value: entry.value })),
},
],
};
}
const denseCategories = spec.xAxis.values.length > 12;
// The slider dataZoom band owns the canvas bottom edge (bottom: 0). The legend
// must sit ABOVE that band — an unplaced legend (bottom: 0) would overlap the
// slider and make both unreadable on dense axes.
return {
aria,
tooltip: { trigger: "axis" },
legend: denseCategories ? { bottom: 32, textStyle: legendTextStyle(theme) } : { bottom: 0, textStyle: legendTextStyle(theme) },
title: spec.title ? { text: spec.title, left: "center", textStyle: { color: theme.isDark ? "#ccc" : "#333", fontSize: 13 } } : undefined,
grid: { left: 60, right: 20, top: spec.title ? 40 : 20, bottom: denseCategories ? 76 : 40 },
dataZoom: denseCategories ? [{ type: "inside" }, { type: "slider", bottom: 0, height: 16 }] : undefined,
xAxis: {
type: "category",
name: spec.xAxis.label ?? undefined,
nameTextStyle: { color: axisLabelColor(theme) },
data: spec.xAxis.values,
axisLabel: denseCategories ? { color: axisLabelColor(theme), rotate: 35, overflow: "truncate", width: 88 } : { color: axisLabelColor(theme) },
},
yAxis: {
type: "value",
name: spec.yAxis?.label ?? undefined,
nameTextStyle: { color: axisLabelColor(theme) },
axisLabel: { color: axisLabelColor(theme) },
},
series: spec.series.map((series) => ({
name: series.name,
type: spec.type,
data: series.data,
smooth: spec.type === "line",
})),
};
}
@@ -0,0 +1,284 @@
/**
* Controlled chart-json protocol (V1).
*
* The AI emits a fenced ```chart-json block and the frontend parses it with a
* deliberately narrow, discriminable schema instead of forwarding a raw
* ECharts option. Unknown shapes are rejected deterministically and fall back
* to the plain code segment so the original data stays visible.
*
* The types below describe the NORMALIZED shape: after a successful parse,
* `series[].data` and pie `data[].value` are always finite `number`s. The
* raw JSON input may carry `number` or a coercible numeric string for those
* fields; the `string` alternatives in the input rules (documented inline)
* refer to that allowed raw input only.
*/
/** Version gate: V1 only understands version 1; the protocol requires an explicit `version` field. */
export const AI_CHART_VERSION = 1;
/** Max raw JSON length (characters) — protects JSON.parse and ECharts from giant inputs. */
export const AI_CHART_MAX_JSON_CHARS = 256 * 1024;
/** Max series in a line/bar chart. */
export const AI_CHART_MAX_SERIES = 8;
/** Max points in one series `data` array or in `xAxis.values`. */
export const AI_CHART_MAX_POINTS_PER_ARRAY = 5000;
/** Max total data points across all line/bar series. */
export const AI_CHART_MAX_TOTAL_POINTS = 10_000;
/** Max length of a single category axis value string. */
export const AI_CHART_MAX_CATEGORY_STRING_LENGTH = 256;
interface LineBarAiChartSpec {
version: 1;
type: "line" | "bar";
title?: string;
/** Required, non-empty. Category axis values are kept as-is (never coerced to numbers). */
xAxis: {
label?: string;
values: (string | number)[];
};
yAxis?: {
label?: string;
};
/** At least one series; each `data` length must equal `xAxis.values` length. */
series: {
name: string;
data: number[];
}[];
}
interface PieAiChartSpec {
version: 1;
type: "pie";
title?: string;
/** Non-empty. No `series` field on a pie. */
data: {
name: string;
value: number;
}[];
}
/** Normalized chart spec accepted by {@link buildAiChartOption}. */
export type AiChartSpec = LineBarAiChartSpec | PieAiChartSpec;
export type AiChartSpecResult = { ok: true; spec: AiChartSpec } | { ok: false; reason: string; raw: string };
/**
* Parse and validate a ```chart-json fence body.
*
* Rules (PRD "chart-json 受控 schema"):
* - JSON.parse failure → deterministic `{ ok: false, reason: "invalid JSON" }`.
* - `version` is a REQUIRED protocol field; V1 only supports 1. A missing
* `version` is rejected with `"chart version is required"`; an explicit value
* !== 1 is rejected with `"unsupported chart version: X"`.
* - `xAxis.values` (category axis) keeps `string | number` values as-is — never
* numeric coercion — only requiring non-empty, within bounds, and each string
* ≤ 256 chars.
* - `series[].data` / pie `data[].value` (numeric columns) accept `number` or a
* coercible numeric string; `"NaN"`, `"Infinity"`, and non-coercible strings
* reject the whole block. Normalized output always uses `number`.
* - line/bar: `xAxis.values` required & non-empty; `series` ≥ 1; each series
* `data.length === xAxis.values.length`; data may be negative.
* - pie: `data` ≥ 1; a single `value === 0` is allowed, but a negative value or
* an all-zero dataset is rejected (no proportional meaning).
* - Input limits: raw JSON ≤ 256KB; line/bar series ≤ 8; each data/values array
* ≤ 5000 points; total line/bar points ≤ 10000. Oversize is a deterministic
* rejection — never downsample or mutate the AI's original data.
*/
export function parseAiChartSpec(content: string): AiChartSpecResult {
const raw = content.trim();
if (!raw) return { ok: false, reason: "empty chart spec", raw };
if (raw.length > AI_CHART_MAX_JSON_CHARS) return { ok: false, reason: "chart spec exceeds 256KB limit", raw };
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { ok: false, reason: "invalid JSON", raw };
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return { ok: false, reason: "chart spec must be a JSON object", raw };
}
const spec = parsed as Record<string, unknown>;
// Version is a required protocol field: a missing version is a malformed
// block, and only version 1 is supported in V1.
if (spec.version === undefined) {
return { ok: false, reason: "chart version is required", raw };
}
if (spec.version !== AI_CHART_VERSION) {
return { ok: false, reason: `unsupported chart version: ${String(spec.version)}`, raw };
}
const type = spec.type;
if (type === "pie") {
return parsePieSpec(spec, raw);
}
if (type === "line" || type === "bar") {
return parseLineBarSpec(spec, raw);
}
return { ok: false, reason: `unsupported chart type: ${String(type)}`, raw };
}
/** Numeric columns: number or coercible numeric string; NaN/Infinity/non-numeric → null. */
function coerceNumericColumn(value: unknown): number | null {
if (typeof value === "number") return Number.isFinite(value) ? value : null;
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) return null;
const number = Number(trimmed);
return Number.isFinite(number) ? number : null;
}
return null;
}
/** Optional string field validator. */
function optionalString(value: unknown, field: string): { ok: true; value?: string } | { ok: false; reason: string } {
if (value === undefined) return { ok: true };
if (typeof value === "string") return { ok: true, value };
return { ok: false, reason: `${field} must be a string` };
}
function parseLineBarSpec(spec: Record<string, unknown>, raw: string): AiChartSpecResult {
const titleResult = optionalString(spec.title, "title");
if (!titleResult.ok) return { ok: false, reason: titleResult.reason, raw };
const title = titleResult.value;
const xAxis = spec.xAxis;
if (typeof xAxis !== "object" || xAxis === null || Array.isArray(xAxis)) {
return { ok: false, reason: "line/bar chart requires an xAxis object", raw };
}
const xAxisRecord = xAxis as Record<string, unknown>;
const values = xAxisRecord.values;
if (!Array.isArray(values) || values.length === 0) {
return { ok: false, reason: "xAxis.values must be a non-empty array", raw };
}
if (values.length > AI_CHART_MAX_POINTS_PER_ARRAY) {
return { ok: false, reason: "xAxis.values exceeds 5000 points", raw };
}
for (const value of values) {
if (typeof value === "number") {
if (!Number.isFinite(value)) return { ok: false, reason: "xAxis.values contains a non-finite number", raw };
continue;
}
if (typeof value === "string") {
if (value.length > AI_CHART_MAX_CATEGORY_STRING_LENGTH) {
return { ok: false, reason: "xAxis.values contains an over-long category string", raw };
}
continue;
}
return { ok: false, reason: "xAxis.values must contain only strings or numbers", raw };
}
// Category values stay as-is (no numeric coercion) — the PRD's "分类轴不做数值化".
const xAxisLabelResult = optionalString(xAxisRecord.label, "xAxis.label");
if (!xAxisLabelResult.ok) return { ok: false, reason: xAxisLabelResult.reason, raw };
let normalizedYAxis: LineBarAiChartSpec["yAxis"] | undefined;
const yAxis = spec.yAxis;
if (yAxis !== undefined) {
if (typeof yAxis !== "object" || yAxis === null || Array.isArray(yAxis)) {
return { ok: false, reason: "yAxis must be an object", raw };
}
const yAxisRecord = yAxis as Record<string, unknown>;
const yAxisLabelResult = optionalString(yAxisRecord.label, "yAxis.label");
if (!yAxisLabelResult.ok) return { ok: false, reason: yAxisLabelResult.reason, raw };
normalizedYAxis = { label: yAxisLabelResult.value };
}
const series = spec.series;
if (!Array.isArray(series) || series.length === 0) {
return { ok: false, reason: "line/bar chart requires a non-empty series array", raw };
}
if (series.length > AI_CHART_MAX_SERIES) {
return { ok: false, reason: "line/bar chart exceeds 8 series", raw };
}
const normalizedSeries: LineBarAiChartSpec["series"] = [];
let totalPoints = 0;
for (let i = 0; i < series.length; i++) {
const entry = series[i];
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
return { ok: false, reason: `series[${i}] must be an object`, raw };
}
const seriesRecord = entry as Record<string, unknown>;
const nameResult = optionalString(seriesRecord.name, `series[${i}].name`);
if (!nameResult.ok) return { ok: false, reason: nameResult.reason, raw };
const data = seriesRecord.data;
if (!Array.isArray(data)) {
return { ok: false, reason: `series[${i}].data must be an array`, raw };
}
if (data.length !== values.length) {
return { ok: false, reason: `series[${i}].data length does not match xAxis.values`, raw };
}
const normalizedData: number[] = [];
for (const point of data) {
const numeric = coerceNumericColumn(point);
if (numeric === null) {
return { ok: false, reason: `series[${i}].data contains a non-numeric value`, raw };
}
normalizedData.push(numeric);
}
totalPoints += normalizedData.length;
normalizedSeries.push({ name: nameResult.value ?? "", data: normalizedData });
}
if (totalPoints > AI_CHART_MAX_TOTAL_POINTS) {
return { ok: false, reason: "chart exceeds 10000 total data points", raw };
}
const result: LineBarAiChartSpec = {
version: 1,
type: spec.type as "line" | "bar",
xAxis: {
...(xAxisLabelResult.value !== undefined ? { label: xAxisLabelResult.value } : {}),
values: values as (string | number)[],
},
series: normalizedSeries,
};
if (title !== undefined) result.title = title;
if (normalizedYAxis !== undefined) result.yAxis = normalizedYAxis;
return { ok: true, spec: result };
}
function parsePieSpec(spec: Record<string, unknown>, raw: string): AiChartSpecResult {
const titleResult = optionalString(spec.title, "title");
if (!titleResult.ok) return { ok: false, reason: titleResult.reason, raw };
const title = titleResult.value;
const data = spec.data;
if (!Array.isArray(data) || data.length === 0) {
return { ok: false, reason: "pie chart requires a non-empty data array", raw };
}
if (data.length > AI_CHART_MAX_POINTS_PER_ARRAY) {
return { ok: false, reason: "pie data exceeds 5000 entries", raw };
}
const normalizedData: PieAiChartSpec["data"] = [];
let sum = 0;
for (let i = 0; i < data.length; i++) {
const entry = data[i];
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
return { ok: false, reason: `pie data[${i}] must be an object`, raw };
}
const entryRecord = entry as Record<string, unknown>;
const nameResult = optionalString(entryRecord.name, `pie data[${i}].name`);
if (!nameResult.ok) return { ok: false, reason: nameResult.reason, raw };
const value = coerceNumericColumn(entryRecord.value);
if (value === null) {
return { ok: false, reason: `pie data[${i}].value must be a finite number`, raw };
}
if (value < 0) {
return { ok: false, reason: "pie data values must not be negative", raw };
}
sum += value;
normalizedData.push({ name: nameResult.value ?? "", value });
}
if (sum === 0) {
return { ok: false, reason: "pie chart must not have all-zero values", raw };
}
const result: PieAiChartSpec = { version: 1, type: "pie", data: normalizedData };
if (title !== undefined) result.title = title;
return { ok: true, spec: result };
}
@@ -0,0 +1,68 @@
/**
* Rich Content Registry (V1 — charts only).
*
* A thin lookup that routes a fenced code block to a typed renderer by its RAW
* fenced language tag. The registry key must match before
* `normalizeAiCodeLanguage`'s SQL/SHELL label map runs: SQL/SHELL families
* never enter the registry and keep the existing proposal/confirm/execute code
* path. V1 registers only `chart-json`; later PRs add `html` (safe preview) and
* future artifact types as new handlers without touching the parser core.
*/
import { parseAiChartSpec, type AiChartSpec } from "@/lib/ai/richContent/aiChartSpec";
/** Segment produced for a successfully parsed rich block (PR1: charts only). */
export interface AiMessageChartSegment {
type: "chart";
/** The raw chart-json fence body (preserved for copy / export / debugging). */
content: string;
/** Normalized chart spec consumed by {@link buildAiChartOption}. */
spec: AiChartSpec;
}
/** Union of rich segments; V1 has only the chart member. */
export type AiMessageRichSegment = AiMessageChartSegment;
export interface AiRichBlockFlags {
/**
* True only when the closing fence has arrived. Handlers must not be invoked
* for an unfinished fence: a half-open block stays a plain code segment
* (streaming). The renderer normally enforces this gate before calling
* `parse`, but a handler may also defensively reject `closed: false` — the
* double check is cheap and keeps handlers safe if a future caller skips the
* renderer gate.
*/
closed: boolean;
}
export interface AiRichBlockHandler<T = AiMessageRichSegment> {
/** The raw fenced language tag this handler owns (lowercase). */
language: string;
parse: (content: string, flags: AiRichBlockFlags) => T | null;
}
function chartHandler(): AiRichBlockHandler {
return {
language: "chart-json",
parse(content, flags) {
if (!flags.closed) return null;
const result = parseAiChartSpec(content);
if (!result.ok) return null;
return { type: "chart", content, spec: result.spec };
},
};
}
/**
* Registry keyed by the raw fenced lang (lowercased, trimmed). The caller in
* `aiMessageRender.ts` performs the lookup BEFORE `normalizeAiCodeLanguage`, so
* a SQL/SHELL tag never collides with a rich tag.
*/
export const AI_RICH_BLOCK_HANDLERS: Record<string, AiRichBlockHandler> = {
"chart-json": chartHandler(),
};
/** Language tags the rich registry owns (V1: `chart-json`). */
export function isAiRichBlockLanguage(rawLang: string): boolean {
return Object.prototype.hasOwnProperty.call(AI_RICH_BLOCK_HANDLERS, rawLang.trim().toLowerCase());
}