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

+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);