mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
feat(ai): render chart-json blocks as interactive charts
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2856,6 +2856,9 @@ export default withEnglishFallback({
|
||||
templateDefaultBadgeTitle: "{type} のデフォルトテンプレート",
|
||||
exportMarkdown: "Markdown としてエクスポート",
|
||||
analysis: "AI 分析",
|
||||
chartDownloadPng: "PNGをダウンロード",
|
||||
chartDownloadFailed: "グラフ画像をダウンロードできませんでした",
|
||||
chartData: "グラフのデータ",
|
||||
agentSteps: {
|
||||
generated: "SQL生成完了",
|
||||
noSql: "SQLが見つかりません",
|
||||
|
||||
@@ -2826,6 +2826,9 @@ export default withEnglishFallback({
|
||||
templateDefaultBadgeTitle: "{type}의 기본 템플릿",
|
||||
exportMarkdown: "Markdown으로 내보내기",
|
||||
analysis: "AI 분석",
|
||||
chartDownloadPng: "PNG 다운로드",
|
||||
chartDownloadFailed: "차트 이미지를 다운로드하지 못했습니다",
|
||||
chartData: "차트 데이터",
|
||||
},
|
||||
contextMenu: {
|
||||
openConnection: "연결 열기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ç",
|
||||
|
||||
@@ -2939,6 +2939,9 @@ export default withEnglishFallback({
|
||||
templateDefaultBadgeTitle: "{type} 的默认模板",
|
||||
exportMarkdown: "导出为 Markdown",
|
||||
analysis: "AI 分析",
|
||||
chartDownloadPng: "下载 PNG",
|
||||
chartDownloadFailed: "图表图片下载失败",
|
||||
chartData: "图表数据",
|
||||
},
|
||||
contextMenu: {
|
||||
openConnection: "打开连接",
|
||||
|
||||
@@ -2934,6 +2934,9 @@ export default withEnglishFallback({
|
||||
editResend: "重新傳送",
|
||||
editCancel: "取消",
|
||||
cannotDeleteActiveConfig: "無法刪除當前活躍的配置,請先切換到其他配置",
|
||||
chartDownloadPng: "下載 PNG",
|
||||
chartDownloadFailed: "圖表圖片下載失敗",
|
||||
chartData: "圖表資料",
|
||||
},
|
||||
contextMenu: {
|
||||
openConnection: "開啟連線",
|
||||
|
||||
+39
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user