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

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-07 14:03:57 +08:00
1 parent 852ad1c808
commit 29c6d1fbed
22 files changed
+1214 -9

No files matched your search

@@ -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" />