feat(ai): add sandboxed HTML preview for AI rich content

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-07 22:44:23 +08:00
1 parent 11e0db5acb
commit 39b2d2a6ac
22 files changed
+1199 -13

No files matched your search

@@ -260,6 +260,56 @@ describe("createAiMessageRenderer rich chart segments", () => {
});
});
describe("createAiMessageRenderer rich html segments", () => {
const markdown = (text: string) => `<p>${text}</p>`;
it("routes a closed html fence to an html segment carrying the safe document", () => {
const renderer = createAiMessageRenderer({ markdown });
const segments = renderer.render("```html\n<p>hi</p>\n```");
const seg = segments[0];
expect(seg.type).toBe("html");
if (seg.type !== "html") return;
expect(seg.content).toBe("<p>hi</p>");
expect(seg.document).toContain("Content-Security-Policy");
expect(seg.document).toContain("<p>hi</p>");
});
it("keeps an unfinished html fence as a plain code segment while streaming", () => {
const renderer = createAiMessageRenderer({ markdown });
const [seg] = renderer.render("```html\n<p>hi", { streaming: true });
expect(seg.type).toBe("code");
if (seg.type !== "code") return;
expect(seg.pending).toBe(true);
});
it("switches an open html fence to an html segment once the closing fence arrives", () => {
const renderer = createAiMessageRenderer({ markdown });
expect(renderer.render("```html\n<p>hi", { streaming: true })[0].type).toBe("code");
expect(renderer.render("```html\n<p>hi</p>\n```", { streaming: true })[0].type).toBe("html");
});
it("falls back to a code segment when the html body exceeds the preview budget", () => {
const renderer = createAiMessageRenderer({ markdown });
const oversize = "x".repeat(512 * 1024 + 1);
const [seg] = renderer.render(`\`\`\`html\n${oversize}\n\`\`\``);
expect(seg.type).toBe("code");
if (seg.type !== "code") return;
expect(seg.lang).toBe("HTML");
expect(seg.isSql).toBe(false);
expect(seg.pending).toBe(false);
expect(seg.content).toBe(oversize);
});
it("never routes html fences into the sql code path that offers run/apply buttons", () => {
const renderer = createAiMessageRenderer({ markdown });
const [seg] = renderer.render("```html\n<p>hi</p>\n```");
// The template only offers run/apply on `type === 'code'` segments with
// `isSql`; an html segment must never wear that shape.
expect(seg.type).not.toBe("code");
expect(seg.type).toBe("html");
});
});
describe("splitStreamingTextBlocks", () => {
const long = "内容".repeat(200);
+2 -2
View File
@@ -1,4 +1,4 @@
import { AI_RICH_BLOCK_HANDLERS, type AiMessageChartSegment, type AiRichBlockHandler } from "@/lib/ai/richContent/aiRichContent";
import { AI_RICH_BLOCK_HANDLERS, type AiMessageChartSegment, type AiMessageHtmlSegment, type AiRichBlockHandler } from "@/lib/ai/richContent/aiRichContent";
export interface AiMessageTextSegment {
type: "text";
@@ -16,7 +16,7 @@ export interface AiMessageCodeSegment {
pending: boolean;
}
export type AiMessageRenderSegment = AiMessageTextSegment | AiMessageCodeSegment | AiMessageChartSegment;
export type AiMessageRenderSegment = AiMessageTextSegment | AiMessageCodeSegment | AiMessageChartSegment | AiMessageHtmlSegment;
interface MessageSegment {
type: "text" | "code";
@@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AI_HTML_MAX_CONTENT_CHARS, AI_HTML_PREVIEW_CSP, buildSafeHtmlPreview, isAiHtmlPreviewEligible } from "@/lib/ai/richContent/aiHtmlPreview";
describe("AI_HTML_PREVIEW_CSP", () => {
it("declares the deny-by-default fetch directives", () => {
expect(AI_HTML_PREVIEW_CSP).toContain("default-src 'none'");
expect(AI_HTML_PREVIEW_CSP).toContain("img-src data: blob:");
expect(AI_HTML_PREVIEW_CSP).toContain("style-src 'unsafe-inline'");
expect(AI_HTML_PREVIEW_CSP).toContain("font-src data:");
});
it("explicitly declares the directives that do not inherit default-src", () => {
expect(AI_HTML_PREVIEW_CSP).toContain("base-uri 'none'");
expect(AI_HTML_PREVIEW_CSP).toContain("form-action 'none'");
});
it("grants no script execution whatsoever", () => {
// No script-src: scripts fall back to default-src 'none' and are blocked.
expect(AI_HTML_PREVIEW_CSP).not.toContain("script-src");
});
it("opens no network direction beyond inline data/blob payloads", () => {
expect(AI_HTML_PREVIEW_CSP).not.toContain("http");
expect(AI_HTML_PREVIEW_CSP).not.toContain("connect-src");
expect(AI_HTML_PREVIEW_CSP).not.toContain("frame-src");
expect(AI_HTML_PREVIEW_CSP).not.toContain("child-src");
});
});
describe("buildSafeHtmlPreview", () => {
it("wraps the raw content in a standalone document with an inline CSP", () => {
const wrapped = buildSafeHtmlPreview("<p>hi</p>");
expect(wrapped.startsWith("<!doctype html>")).toBe(true);
expect(wrapped).toContain('<meta charset="utf-8">');
expect(wrapped).toContain(`<meta http-equiv="Content-Security-Policy" content="${AI_HTML_PREVIEW_CSP}">`);
// The CSP meta must appear before the content: it is the outer shell's
// document-wide policy, not something the payload could place after.
expect(wrapped.indexOf("Content-Security-Policy")).toBeLessThan(wrapped.indexOf("<p>hi</p>"));
expect(wrapped.endsWith("</body>\n</html>")).toBe(true);
});
it("embeds the raw content verbatim (unescaped, sandboxing is the runtime guard)", () => {
const hostile = `<p onclick="x()">a</p><script>fetch('https://ex.example')</script><img src="https://ex.example/i.png" onerror="x()">`;
const wrapped = buildSafeHtmlPreview(hostile);
expect(wrapped).toContain(hostile);
});
it("defuses <meta http-equiv=refresh> in any case, quoting style, or attribute order", () => {
const vectors = [
'<meta http-equiv="refresh" content="0;url=https://attacker/">',
"<meta http-equiv='refresh' content='0;url=https://attacker/'>",
"<meta http-equiv=refresh content='0;url=https://attacker/'>",
'<meta content="0;url=https://attacker/" http-equiv="refresh">',
'<META HTTP-EQUIV="Refresh" CONTENT="0;url=https://attacker/">',
'<meta name="x" http-equiv="refresh" charset="utf-8" content="0;url=https://attacker/">',
'<meta http-equiv="refresh" content="0;url=https://attacker/"/>',
];
for (const vector of vectors) {
const wrapped = buildSafeHtmlPreview(vector);
expect(wrapped).toContain("<!-- meta refresh removed -->");
// No live refresh meta may survive in any spelling.
expect(wrapped).not.toMatch(/<meta\b[^>]*\bhttp-equiv\s*=\s*["']?\s*refresh\b/i);
}
});
it("preserves the body around a defused refresh meta and leaves other metas untouched", () => {
const body = '<p>before</p><meta http-equiv="refresh" content="0;url=https://attacker/"><p>after</p><meta charset="utf-8">';
const wrapped = buildSafeHtmlPreview(body);
expect(wrapped).toContain('<p>before</p><!-- meta refresh removed --><p>after</p><meta charset="utf-8">');
});
it("stays safe when the AI content is a complete HTML document", () => {
const completeDocument = "<!DOCTYPE html><html><head><title>p</title></head><body>own</body></html>";
const wrapped = buildSafeHtmlPreview(completeDocument);
// Error recovery: the outer doctype/CSP meta still precede everything.
expect(wrapped.indexOf("Content-Security-Policy")).toBeLessThan(wrapped.indexOf(completeDocument));
expect(wrapped.startsWith("<!doctype html>")).toBe(true);
});
it("is deterministic so preview and saved file are byte-identical", () => {
expect(buildSafeHtmlPreview("<b>x</b>")).toBe(buildSafeHtmlPreview("<b>x</b>"));
});
});
describe("isAiHtmlPreviewEligible", () => {
it("accepts bodies within the 512KB budget", () => {
expect(isAiHtmlPreviewEligible("x")).toBe(true);
expect(isAiHtmlPreviewEligible("x".repeat(AI_HTML_MAX_CONTENT_CHARS))).toBe(true);
});
it("rejects empty and over-budget bodies", () => {
expect(isAiHtmlPreviewEligible("")).toBe(false);
expect(isAiHtmlPreviewEligible("x".repeat(AI_HTML_MAX_CONTENT_CHARS + 1))).toBe(false);
});
});
describe("copy-risk session state", () => {
beforeEach(() => {
vi.resetModules();
});
it("requires a confirmation for the first copy of a session", async () => {
const mod = await import("@/lib/ai/richContent/aiHtmlPreview");
expect(mod.isAiHtmlCopyRiskAcknowledged()).toBe(false);
mod.acknowledgeAiHtmlCopyRisk();
expect(mod.isAiHtmlCopyRiskAcknowledged()).toBe(true);
});
it("resets when the app session restarts (fresh module graph)", async () => {
const first = await import("@/lib/ai/richContent/aiHtmlPreview");
first.acknowledgeAiHtmlCopyRisk();
vi.resetModules();
const second = await import("@/lib/ai/richContent/aiHtmlPreview");
expect(second.isAiHtmlCopyRiskAcknowledged()).toBe(false);
});
});
@@ -0,0 +1,127 @@
import { readFileSync, writeFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { AI_HTML_PREVIEW_CSP, buildSafeHtmlPreview } from "@/lib/ai/richContent/aiHtmlPreview";
// The canary payload tries every escape the PRD requires evidence for: inline
// script, remote image, fetch, form submit, and <base> rewriting. It must
// survive verbatim into the wrapped document (a canary that was stripped proves
// nothing) — the CSP shell, not content filtering, is what neutralizes it.
const canaryVectors = [
'<script>document.title = "SCRIPT-RAN"; document.body.style.background = "red"; fetch("https://canary.invalid/fetch");</script>',
'<img src="https://canary.invalid/remote.png" alt="remote">',
'<img src="https://canary.invalid/onerror.png" onerror="document.title = \'ONERROR-RAN\'" alt="onerror">',
'<form action="https://canary.invalid/submit"><input type="submit" value="submit"></form>',
'<base href="https://canary.invalid/">',
'<a href="relative.png">relative link</a>',
// The relative <img> fires an auto-load with no click required: if `base-uri
// 'none'` failed, <base href> would rewrite it to
// https://canary.invalid/relative.png and the netlog would expose the leak
// without manual interaction.
'<img src="relative.png" alt="relative">',
// Visible only when scripting is DISABLED (context A): a green PASS marker.
'<noscript><p style="color:green;margin:0;font:600 13px system-ui">scripts are blocked → PASS</p></noscript>',
].join("\n");
// Navigation canary — deliberately NOT in canaryVectors: every vector above
// must survive verbatim (a stripped canary proves nothing), but no CSP fetch
// directive governs navigation and `sandbox=""` only gates top-navigation and
// popups, so a sandboxed iframe can still navigate itself. The refresh meta is
// the one vector the wrapper itself must defuse before it reaches either
// context; a zero-click `<meta refresh>` is an automatic outbound beacon.
const metaRefreshCanary = '<meta http-equiv="refresh" content="0;url=https://canary.invalid/">';
const componentSource = readFileSync(new URL("../../../../components/ai/rich/AiHtmlPreview.vue", import.meta.url), "utf8");
describe("AiHtmlPreview sandbox hardening (static canary)", () => {
it("sandboxes every preview iframe without any escape hatch", () => {
// Inspect every actual iframe tag, not the whole source (comments mention the
// sandbox tokens by name when documenting why they are absent). The inline
// card and the expanded dialog iframes must carry identical boundaries.
const iframeTags = componentSource.match(/<iframe[^>]*>/g) ?? [];
expect(iframeTags.length).toBeGreaterThanOrEqual(2);
for (const iframeTag of iframeTags) {
expect(iframeTag).toContain('sandbox=""');
for (const forbidden of ["allow-scripts", "allow-same-origin", "allow-forms", "allow-popups", "allow-modals", "allow-top-navigation"]) {
expect(iframeTag).not.toContain(forbidden);
}
}
});
it("renders the wrapped shell, never the raw source, as the iframe payload", () => {
expect(componentSource).toContain(':srcdoc="document"');
// The component has no path to inject `content` into the DOM itself.
expect(componentSource).not.toContain('v-html="content"');
});
it("keeps the canary vectors present in the wrapped document while the CSP neutralizes each", () => {
const wrapped = buildSafeHtmlPreview(canaryVectors);
// Every vector is still in the payload — nothing was stripped or rewritten.
for (const vector of canaryVectors.split("\n")) {
expect(wrapped).toContain(vector);
}
const position = (needle: string) => wrapped.indexOf(needle);
// The CSP meta precedes every vector: the policy applies document-wide.
for (const vector of canaryVectors.split("\n")) {
expect(position("Content-Security-Policy")).toBeLessThan(position(vector));
}
// Vector → neutralizer mapping (per the PRD's canary matrix):
// inline/`onerror=` scripts and `fetch()` → no script-src grant, blocked by default-src 'none'
expect(AI_HTML_PREVIEW_CSP).toContain("default-src 'none'");
expect(AI_HTML_PREVIEW_CSP).not.toContain("script-src");
// remote img → img-src limited to data:/blob:
expect(AI_HTML_PREVIEW_CSP).toContain("img-src data: blob:");
// form submit target → form-action 'none' (does NOT inherit default-src)
expect(AI_HTML_PREVIEW_CSP).toContain("form-action 'none'");
// <base> rewrite → base-uri 'none' (does NOT inherit default-src)
expect(AI_HTML_PREVIEW_CSP).toContain("base-uri 'none'");
});
it("defuses the meta-refresh navigation canary (navigation is outside the CSP fetch model)", () => {
const wrapped = buildSafeHtmlPreview(`${canaryVectors}\n${metaRefreshCanary}`);
// The verbatim-survival canaries are untouched by the defusing pass...
for (const vector of canaryVectors.split("\n")) {
expect(wrapped).toContain(vector);
}
// ...while the refresh meta is swapped for an inert comment: the wrapped
// document holds no live http-equiv=refresh in any spelling.
expect(wrapped).not.toContain(metaRefreshCanary);
expect(wrapped).toContain("<!-- meta refresh removed -->");
expect(wrapped).not.toMatch(/<meta\b[^>]*\bhttp-equiv\s*=\s*["']?\s*refresh\b/i);
});
it("writes a manual WebView canary harness when AI_HTML_CANARY_OUT is set", () => {
const outPath = process.env.AI_HTML_CANARY_OUT;
if (!outPath) {
// Regenerate on demand: AI_HTML_CANARY_OUT=<path> pnpm vitest run <this file>
return;
}
const wrapped = buildSafeHtmlPreview(`${canaryVectors}\n${metaRefreshCanary}`);
// The visible PASS marker inside the sandbox: `noscript` renders only when
// scripting is disabled — i.e. exactly when the sandbox held.
const harness = [
"<!doctype html>",
'<html lang="en"><head><meta charset="utf-8"><title>AI HTML preview runtime canary</title>',
"<style>body{font-family:system-ui;max-width:60rem;margin:2rem auto;padding:0 1rem;line-height:1.5}",
"iframe{width:100%;height:16rem;border:2px solid #888;background:#fff}",
"li{margin:.25rem 0}code{background:#f2f2f2;padding:0 .3rem}</style></head><body>",
"<h1>AI HTML preview runtime canary</h1>",
"<p>This harness embeds the exact bytes <code>buildSafeHtmlPreview</code> produces (the same payload “Save Safe HTML” writes). Open it in the app WebView (dev build) and in a regular browser. All checks must show <b>PASS</b> and the network panel must stay empty.</p>",
"<h2>Context A — iframe preview (sandbox=&quot;&quot;)</h2>",
`<iframe sandbox="" srcdoc="${wrapped.replace(/"/g, "&quot;")}"></iframe>`,
"<h2>Context B — saved standalone file</h2>",
"<p>Click “Download wrapped file”, then open the downloaded copy directly in the WebView/browser: outside the sandbox, the CSP meta alone must hold.</p>",
// `<\/` keeps the canary's own `</script>` from terminating this harness script.
`<script>const blob = new Blob([${JSON.stringify(wrapped).replace(/<\//g, "<\\/")}], {type: "text/html"});const a = document.createElement("a");a.href = URL.createObjectURL(blob);a.download = "ai-html-canary-wrapped.html";a.textContent = "Download wrapped file";document.body.appendChild(a);</script>`,
"<h2>Checklist (both contexts)</h2><ul>",
"<li><b>Script:</b> the area stays WHITE — a red background or <code>SCRIPT-RAN</code> title means FAIL. Inside context A a green “scripts are blocked → PASS” note is visible (that is <code>&lt;noscript&gt;</code> rendering).</li>",
"<li><b>Remote image:</b> both canary <code>&lt;img&gt;</code> elements render as broken-image placeholders, never loaded (DevTools network: no request to <code>canary.invalid</code>).</li>",
"<li><b>fetch:</b> no network request to <code>canary.invalid</code>.</li>",
"<li><b>Form:</b> submitting the canary form does nothing — no navigation to <code>canary.invalid</code>.</li>",
"<li><b>Base rewrite:</b> the “relative link” does not resolve to <code>canary.invalid/relative.png</code>.</li>",
"<li><b>Meta refresh:</b> the document does not navigate away on its own — the canary refresh meta must appear only as the inert <code>&lt;!-- meta refresh removed --&gt;</code> comment.</li>",
"</ul></body></html>",
].join("\n");
writeFileSync(outPath, harness, "utf8");
expect(readFileSync(outPath, "utf8")).toContain('sandbox=""');
});
});
@@ -1,13 +1,15 @@
import { describe, expect, it } from "vitest";
import { AI_RICH_BLOCK_HANDLERS, isAiRichBlockLanguage } from "@/lib/ai/richContent/aiRichContent";
import { parseAiChartSpec } from "@/lib/ai/richContent/aiChartSpec";
import { AI_HTML_MAX_CONTENT_CHARS, AI_HTML_PREVIEW_CSP, buildSafeHtmlPreview } from "@/lib/ai/richContent/aiHtmlPreview";
const validBar = JSON.stringify({ version: 1, type: "bar", xAxis: { values: ["Jan", "Feb"] }, series: [{ name: "Revenue", data: [120, 200] }] });
const invalidBar = "{ not json";
const validHtml = "<p>Hello <strong>world</strong></p>";
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("registers exactly the chart-json and html handlers in V1", () => {
expect(Object.keys(AI_RICH_BLOCK_HANDLERS)).toEqual(["chart-json", "html"]);
});
it("returns null for an unfinished fence (closed=false) without parsing", () => {
@@ -32,14 +34,43 @@ describe("AI_RICH_BLOCK_HANDLERS", () => {
expect(handler.parse(JSON.stringify({ version: 1, type: "pie", data: [{ name: "A", value: -1 }] }), { closed: true })).toBeNull();
});
it("keeps an unfinished html fence unpreviewed", () => {
const handler = AI_RICH_BLOCK_HANDLERS.html;
expect(handler.language).toBe("html");
expect(handler.parse(validHtml, { closed: false })).toBeNull();
});
it("parses a closed html block into a segment carrying the safe document", () => {
const handler = AI_RICH_BLOCK_HANDLERS.html;
const segment = handler.parse(validHtml, { closed: true });
expect(segment?.type).toBe("html");
if (segment?.type !== "html") return;
expect(segment.content).toBe(validHtml);
// The document is the wrapped shell, never the raw source, and it is the
// exact bytes "Save Safe HTML" writes.
expect(segment.document).toBe(buildSafeHtmlPreview(validHtml));
expect(segment.document).toContain(AI_HTML_PREVIEW_CSP);
expect(segment.document).toContain(validHtml);
});
it("rejects an over-budget html body deterministically", () => {
const handler = AI_RICH_BLOCK_HANDLERS.html;
const oversize = "x".repeat(AI_HTML_MAX_CONTENT_CHARS + 1);
expect(handler.parse(oversize, { 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");
for (const lang of ["sql", "bash", "json", "SQL", "BASH"]) {
expect(isAiRichBlockLanguage(lang)).toBe(false);
}
expect(isAiRichBlockLanguage("chart-json ")).toBe(true);
expect(isAiRichBlockLanguage("html")).toBe(true);
});
it("matches the raw language tag case-insensitively", () => {
expect(isAiRichBlockLanguage("CHART-JSON")).toBe(true);
expect(isAiRichBlockLanguage(" Chart-Json ")).toBe(true);
expect(isAiRichBlockLanguage("HTML")).toBe(true);
expect(isAiRichBlockLanguage(" Html ")).toBe(true);
});
});
@@ -0,0 +1,82 @@
/**
* Safe HTML preview shell (V1).
*
* The AI may emit a fenced ```html block. It renders inside a fully sandboxed
* iframe (`sandbox=""` — no allow-scripts, no allow-same-origin) whose `srcdoc`
* is wrapped by {@link buildSafeHtmlPreview}. The wrapper inlines a CSP meta
* that is the single safety boundary in BOTH contexts the content can reach:
*
* - the iframe preview (`sandbox=""` already blocks script execution at the
* browser level; the CSP additionally blocks network fetches);
* - the standalone file written by "Save Safe HTML" — out of the sandbox, so
* the CSP meta alone must block scripts and network. The saved file is the
* wrapped document, never the raw source: once the content leaves the
* sandbox its protection is only as strong as this shell.
*
* `default-src 'none'` is the fallback for the fetch directives, but
* `form-action` and `base-uri` do NOT inherit from `default-src`, so both are
* declared explicitly: `base-uri 'none'` stops `<base>` from rewriting
* relative URLs, `form-action 'none'` stops `<form action=…>` from submitting
* to a remote target. There is deliberately no `script-src`: scripts fall back
* to `default-src 'none'` and are blocked outright.
*
* Navigation is the one direction no CSP fetch directive governs, and
* `sandbox=""` only gates top-navigation/popups — a sandboxed iframe may still
* navigate itself. So `<meta http-equiv="refresh">` (a zero-click outbound
* request beacon) is the single construct the wrapper defuses outright instead
* of passing it through to the CSP shell.
*/
/** Max raw ```html fence body length (characters) that may enter the iframe `srcdoc`. */
export const AI_HTML_MAX_CONTENT_CHARS = 512 * 1024;
export const AI_HTML_PREVIEW_CSP = "default-src 'none'; base-uri 'none'; form-action 'none'; img-src data: blob:; style-src 'unsafe-inline'; font-src data:";
/** True when the raw fence body may be previewed / saved. Oversize stays a plain code segment. */
export function isAiHtmlPreviewEligible(content: string): boolean {
return content.length > 0 && content.length <= AI_HTML_MAX_CONTENT_CHARS;
}
/**
* Matches any `<meta>` carrying `http-equiv=refresh`: case-insensitive on the
* tag name and the `http-equiv` key/value, tolerant of unquoted, single- or
* double-quoted values in any attribute order with extra attributes present.
*/
const AI_HTML_META_REFRESH_PATTERN = /<meta\b[^>]*?\bhttp-equiv\s*=\s*(?:"\s*refresh\s*"|'\s*refresh\s*'|refresh\b)[^>]*>/gi;
/** Inert replacement for a defused refresh meta: a comment navigates nowhere. */
const AI_HTML_META_REFRESH_REPLACEMENT = "<!-- meta refresh removed -->";
/** Strip every `http-equiv=refresh` meta; everything else passes through byte-identical. */
function defuseMetaRefresh(content: string): string {
return content.replace(AI_HTML_META_REFRESH_PATTERN, AI_HTML_META_REFRESH_REPLACEMENT);
}
/**
* Wrap raw AI HTML in the safe standalone document. The same bytes back BOTH
* the iframe preview and the "Save Safe HTML" payload, so what is previewed is
* exactly what gets saved. If the AI content is itself a complete document,
* the browser's error recovery nests it inside the body while the outer CSP
* meta still applies document-wide. The only rewrite is the refresh meta
* defused above — navigation sits outside the CSP fetch model.
*/
export function buildSafeHtmlPreview(content: string): string {
return ["<!doctype html>", "<html>", "<head>", '<meta charset="utf-8">', `<meta http-equiv="Content-Security-Policy" content="${AI_HTML_PREVIEW_CSP}">`, "</head>", "<body>", defuseMetaRefresh(content), "</body>", "</html>"].join("\n");
}
/**
* "Copy source" puts the raw AI HTML on the clipboard where it may later be
* opened outside DBX. The first copy of a session requires an explicit risk
* confirmation; the choice is remembered for the session only (it resets when
* the app restarts), and later copies degrade to a risk toast instead of
* repeating the confirmation.
*/
let htmlCopyRiskAcknowledged = false;
export function isAiHtmlCopyRiskAcknowledged(): boolean {
return htmlCopyRiskAcknowledged;
}
export function acknowledgeAiHtmlCopyRisk(): void {
htmlCopyRiskAcknowledged = true;
}
@@ -1,17 +1,18 @@
/**
* Rich Content Registry (V1 — charts only).
* Rich Content Registry (V1 — charts and safe HTML preview).
*
* 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.
* path. V1 registers `chart-json` and `html`; future artifact types (mermaid,
* tables, files) join as new handlers without touching the parser core.
*/
import { parseAiChartSpec, type AiChartSpec } from "@/lib/ai/richContent/aiChartSpec";
import { buildSafeHtmlPreview, isAiHtmlPreviewEligible } from "@/lib/ai/richContent/aiHtmlPreview";
/** Segment produced for a successfully parsed rich block (PR1: charts only). */
/** Segment produced for a successfully parsed chart-json block. */
export interface AiMessageChartSegment {
type: "chart";
/** The raw chart-json fence body (preserved for copy / export / debugging). */
@@ -20,8 +21,21 @@ export interface AiMessageChartSegment {
spec: AiChartSpec;
}
/** Union of rich segments; V1 has only the chart member. */
export type AiMessageRichSegment = AiMessageChartSegment;
/**
* Segment produced for an eligible ```html block. `document` is the
* CSP-wrapped standalone shell used for BOTH the iframe preview and the
* "Save Safe HTML" payload, so the preview and the saved file never drift.
*/
export interface AiMessageHtmlSegment {
type: "html";
/** The raw html fence body (what "copy source" puts on the clipboard). */
content: string;
/** `buildSafeHtmlPreview(content)` — rendered in the sandboxed iframe. */
document: string;
}
/** Union of rich segments. */
export type AiMessageRichSegment = AiMessageChartSegment | AiMessageHtmlSegment;
export interface AiRichBlockFlags {
/**
@@ -53,6 +67,19 @@ function chartHandler(): AiRichBlockHandler {
};
}
function htmlHandler(): AiRichBlockHandler {
return {
language: "html",
parse(content, flags) {
if (!flags.closed) return null;
// Over-budget (or empty) bodies stay a plain code segment: still fully
// visible and copyable, just never handed to the preview pipeline.
if (!isAiHtmlPreviewEligible(content)) return null;
return { type: "html", content, document: buildSafeHtmlPreview(content) };
},
};
}
/**
* Registry keyed by the raw fenced lang (lowercased, trimmed). The caller in
* `aiMessageRender.ts` performs the lookup BEFORE `normalizeAiCodeLanguage`, so
@@ -60,9 +87,10 @@ function chartHandler(): AiRichBlockHandler {
*/
export const AI_RICH_BLOCK_HANDLERS: Record<string, AiRichBlockHandler> = {
"chart-json": chartHandler(),
html: htmlHandler(),
};
/** Language tags the rich registry owns (V1: `chart-json`). */
/** Language tags the rich registry owns (V1: `chart-json`, `html`). */
export function isAiRichBlockLanguage(rawLang: string): boolean {
return Object.prototype.hasOwnProperty.call(AI_RICH_BLOCK_HANDLERS, rawLang.trim().toLowerCase());
}