+
+
+
+
+
diff --git a/apps/desktop/src/components/ai/rich/__tests__/AiHtmlPreview.spec.ts b/apps/desktop/src/components/ai/rich/__tests__/AiHtmlPreview.spec.ts
new file mode 100644
index 0000000000..d50c7109dd
--- /dev/null
+++ b/apps/desktop/src/components/ai/rich/__tests__/AiHtmlPreview.spec.ts
@@ -0,0 +1,218 @@
+// @vitest-environment happy-dom
+
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ copyToClipboard: vi.fn().mockResolvedValue(undefined),
+ saveTextFile: vi.fn().mockResolvedValue(undefined),
+ toast: vi.fn(),
+}));
+
+vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
+vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
+vi.mock("@/lib/common/clipboard", () => ({ copyToClipboard: mocks.copyToClipboard }));
+vi.mock("@/lib/export/saveTextFile", () => ({ saveTextFile: mocks.saveTextFile }));
+
+// The real Dialog portals its content only while open; we stub it as a
+// transparent DOM passthrough that mirrors that gating (slot only rendered when
+// `open` is true) and that closes through Vue's emit mechanism when clicked.
+// This mirrors the CodeSnapshotDialog.spec.ts stub approach.
+vi.mock("@/components/ui/dialog", async () => {
+ const { defineComponent, h } = await import("vue");
+ const passthrough = defineComponent({
+ setup(_props, { slots }) {
+ return () => h("div", slots.default?.());
+ },
+ });
+ return {
+ Dialog: defineComponent({
+ props: { open: Boolean },
+ emits: ["update:open"],
+ setup(props, { emit, slots }) {
+ return () => h("div", { "data-open": String(props.open), onClick: () => emit("update:open", false) }, props.open ? slots.default?.() : undefined);
+ },
+ }),
+ DialogContent: passthrough,
+ DialogHeader: passthrough,
+ DialogTitle: defineComponent({
+ setup(_props, { slots }) {
+ return () => h("div", { "data-slot": "dialog-title" }, slots.default?.());
+ },
+ }),
+ DialogDescription: passthrough,
+ };
+});
+
+import { buildSafeHtmlPreview } from "@/lib/ai/richContent/aiHtmlPreview";
+import type { App } from "vue";
+import { createApp, nextTick } from "vue";
+
+const content = "
Hello world
";
+const documentHtml = buildSafeHtmlPreview(content);
+
+let app: App | null = null;
+let root: HTMLDivElement | null = null;
+
+async function mountPreview() {
+ // Dynamic import after resetModules so each test starts with a fresh copy-risk session state.
+ const { default: AiHtmlPreview } = await import("@/components/ai/rich/AiHtmlPreview.vue");
+ app = createApp(AiHtmlPreview, { content, document: documentHtml });
+ root = window.document.createElement("div");
+ window.document.body.appendChild(root);
+ app.mount(root);
+ await nextTick();
+ await nextTick();
+}
+
+function unmount() {
+ app?.unmount();
+ app = null;
+ root?.remove();
+ root = null;
+}
+
+function button(title: string): HTMLButtonElement | null {
+ return root?.querySelector(`button[aria-label="${title}"]`) ?? null;
+}
+
+function copyButton() {
+ return button("ai.htmlCopySource");
+}
+
+function confirmStrip(): Element | null {
+ return root?.querySelector('[role="alertdialog"]') ?? null;
+}
+
+async function click(element: HTMLElement | null) {
+ element?.dispatchEvent(new window.Event("click", { bubbles: true }));
+ await nextTick();
+ await nextTick();
+}
+
+beforeEach(() => {
+ vi.resetModules();
+ mocks.copyToClipboard.mockClear();
+ mocks.saveTextFile.mockClear();
+ mocks.toast.mockClear();
+ unmount();
+});
+
+describe("AiHtmlPreview", () => {
+ it("renders a fully sandboxed iframe over the CSP-wrapped document", async () => {
+ await mountPreview();
+ const iframe = root?.querySelector("iframe");
+ expect(iframe).not.toBeNull();
+ // `sandbox=""` without allow-scripts / allow-same-origin: nothing may execute.
+ expect(iframe?.getAttribute("sandbox")).toBe("");
+ const srcdoc = iframe?.getAttribute("srcdoc") ?? "";
+ expect(srcdoc).toContain("Content-Security-Policy");
+ expect(srcdoc).toContain("default-src 'none'");
+ expect(srcdoc).toContain(content);
+ });
+
+ it("asks before the first source copy and cancel keeps the clipboard untouched", async () => {
+ await mountPreview();
+ await click(copyButton());
+ expect(confirmStrip()).not.toBeNull();
+ expect(mocks.copyToClipboard).not.toHaveBeenCalled();
+ // Cancel = the first (non-destructive) button in the strip.
+ const cancel = [...(confirmStrip()?.querySelectorAll("button") ?? [])][0];
+ await click(cancel);
+ expect(confirmStrip()).toBeNull();
+ expect(mocks.copyToClipboard).not.toHaveBeenCalled();
+ });
+
+ it("copies after explicit confirmation and remembers the choice for the session", async () => {
+ await mountPreview();
+ await click(copyButton());
+ const strip = confirmStrip();
+ expect(strip).not.toBeNull();
+ const checkbox = strip?.querySelector('input[type="checkbox"]');
+ if (checkbox) {
+ checkbox.checked = true;
+ // v-model needs the change event, not just the property write.
+ checkbox.dispatchEvent(new window.Event("change"));
+ await nextTick();
+ }
+ const buttons = [...(strip?.querySelectorAll("button") ?? [])];
+ await click(buttons[buttons.length - 1]);
+ expect(mocks.copyToClipboard).toHaveBeenCalledWith(content);
+ expect(confirmStrip()).toBeNull();
+
+ // A later copy in the same session — even from a freshly mounted preview —
+ // skips the confirmation and shows only the risk toast.
+ mocks.copyToClipboard.mockClear();
+ unmount();
+ await mountPreview();
+ await click(copyButton());
+ expect(confirmStrip()).toBeNull();
+ expect(mocks.copyToClipboard).toHaveBeenCalledWith(content);
+ expect(mocks.toast).toHaveBeenCalledWith("ai.htmlCopyRiskToast");
+ });
+
+ it("opens the same sandboxed document in an expanded dialog and closes it", async () => {
+ await mountPreview();
+ expect(root?.querySelectorAll("iframe").length).toBe(1);
+
+ await click(button("ai.htmlExpandPreview"));
+ // The stub Dialog renders inline; the second iframe carries the same
+ // sandbox boundary and the same CSP-wrapped srcdoc as the inline card.
+ const iframes = [...(root?.querySelectorAll("iframe") ?? [])];
+ expect(iframes.length).toBe(2);
+ const dialogIframe = iframes[1];
+ expect(dialogIframe.getAttribute("sandbox")).toBe("");
+ const srcdoc = dialogIframe.getAttribute("srcdoc") ?? "";
+ expect(srcdoc).toContain("Content-Security-Policy");
+ expect(srcdoc).toContain("default-src 'none'");
+ expect(srcdoc).toContain(content);
+ // The wide preview must override DialogContent's max-w-sm at every
+ // breakpoint, including narrow desktop windows and mobile-sized WebViews.
+ expect(root?.innerHTML).toContain("max-w-[min(1100px,calc(100vw-32px))]");
+ // The dialog title is the same preview label used for the inline card.
+ expect(root?.querySelector('[data-slot="dialog-title"]')?.textContent).toBe("ai.htmlPreviewLabel");
+
+ // Closing (Esc / overlay / programmatic update:open) removes the iframe.
+ const dialog = root?.querySelector('[data-open="true"]');
+ await click(dialog);
+ expect(root?.querySelectorAll("iframe").length).toBe(1);
+ });
+
+ it("writes the wrapped safe document, never the raw source", async () => {
+ await mountPreview();
+ await click(button("ai.htmlSaveSafe"));
+ expect(mocks.saveTextFile).toHaveBeenCalledWith(documentHtml, "dbx-ai-html-preview.html", "HTML", "html");
+ expect(mocks.saveTextFile.mock.calls[0][0]).not.toBe(content);
+ });
+
+ it("suppresses the risk toast when the clipboard write fails", async () => {
+ await mountPreview();
+ // Arm the remembered session acknowledgment, mirroring the existing flow.
+ await click(copyButton());
+ const strip = confirmStrip();
+ expect(strip).not.toBeNull();
+ const checkbox = strip?.querySelector('input[type="checkbox"]');
+ if (checkbox) {
+ checkbox.checked = true;
+ checkbox.dispatchEvent(new window.Event("change"));
+ await nextTick();
+ }
+ const buttons = [...(strip?.querySelectorAll("button") ?? [])];
+ await click(buttons[buttons.length - 1]);
+ expect(mocks.copyToClipboard).toHaveBeenCalledWith(content);
+
+ // A later copy in the same session skips the confirmation — but this time
+ // the clipboard write fails, so the risk toast must not claim success.
+ // (Regression for #I01: the previous code toasted unconditionally.)
+ mocks.copyToClipboard.mockRejectedValueOnce(new Error("denied"));
+ mocks.toast.mockClear();
+ unmount();
+ await mountPreview();
+ await click(copyButton());
+ expect(confirmStrip()).toBeNull();
+ expect(mocks.copyToClipboard).toHaveBeenCalledWith(content);
+ expect(mocks.toast).not.toHaveBeenCalled();
+ // The failure is still silent (doCopy swallows), so the "copied" checkmark
+ // must not appear either — no false success signal at all.
+ expect(root?.querySelector(".text-green-500")).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue
index fc0304ee25..11701a5062 100644
--- a/apps/desktop/src/components/editor/AiAssistant.vue
+++ b/apps/desktop/src/components/editor/AiAssistant.vue
@@ -188,6 +188,11 @@ const AiChartRenderer = defineAsyncComponent({
h("span", t("common.loading")),
]),
});
+const AiHtmlPreview = defineAsyncComponent({
+ // HTML previews are rare (the prompt only allows them on explicit request),
+ // so lazy-load the component just like the chart renderer.
+ loader: () => import("@/components/ai/rich/AiHtmlPreview.vue"),
+});
const settings = useSettingsStore();
const connectionStore = useConnectionStore();
const savedSqlStore = useSavedSqlStore();
@@ -4714,6 +4719,7 @@ async function openExternalUrl(url: string) {
+
diff --git a/apps/desktop/src/i18n/__tests__/aiRichContentParity.spec.ts b/apps/desktop/src/i18n/__tests__/aiRichContentParity.spec.ts
new file mode 100644
index 0000000000..1cb524ffc6
--- /dev/null
+++ b/apps/desktop/src/i18n/__tests__/aiRichContentParity.spec.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from "vitest";
+import en from "../locales/en";
+import es from "../locales/es";
+import it_ from "../locales/it";
+import ja from "../locales/ja";
+import ko from "../locales/ko";
+import ptBR from "../locales/pt-BR";
+import tr from "../locales/tr";
+import zhCN from "../locales/zh-CN";
+import zhTW from "../locales/zh-TW";
+
+const locales: Array<[string, Record]> = [
+ ["en", en],
+ ["es", es],
+ ["it", it_],
+ ["ja", ja],
+ ["ko", ko],
+ ["pt-BR", ptBR],
+ ["tr", tr],
+ ["zh-CN", zhCN],
+ ["zh-TW", zhTW],
+];
+
+// Keys the safe-HTML preview (PR2 of #6467) adds to the `ai` namespace. Every
+// locale must expose all of them — the preview UI renders in whatever locale
+// the user runs, and a missing key would leak raw keys into the toolbar.
+const AI_HTML_PREVIEW_KEYS = ["htmlPreviewLabel", "htmlExpandPreview", "htmlExpandPreviewHint", "htmlSaveSafe", "htmlSaveFailed", "htmlCopySource", "htmlCopyRiskBody", "htmlCopyRiskAccept", "htmlCopyRiskRemember", "htmlCopyRiskToast"] as const;
+
+describe("AI rich content locale parity", () => {
+ it.each(locales)("%s exposes the full ai.html* key set with non-empty copy", (_name, locale) => {
+ const ai = (locale as { ai: Record }).ai;
+ for (const key of AI_HTML_PREVIEW_KEYS) {
+ expect(ai[key], `${_name}: ai.${key}`).toBeTypeOf("string");
+ expect(ai[key] as string, `${_name}: ai.${key}`).not.toHaveLength(0);
+ }
+ });
+});
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index c1a33a02fb..e4cbac10a4 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -3037,6 +3037,16 @@ export default {
chartDownloadPng: "Download PNG",
chartDownloadFailed: "Failed to download chart image",
chartData: "Chart data",
+ htmlPreviewLabel: "AI-generated HTML preview",
+ htmlExpandPreview: "Open larger preview",
+ htmlExpandPreviewHint: "This preview runs fully sandboxed — scripts and network access are blocked.",
+ htmlSaveSafe: "Save Safe HTML",
+ htmlSaveFailed: "Failed to save HTML file",
+ htmlCopySource: "Copy source",
+ htmlCopyRiskBody: "This HTML was AI-generated. Opening it outside DBX may execute scripts and access the network.",
+ htmlCopyRiskAccept: "Copy anyway",
+ htmlCopyRiskRemember: "Don't ask again this session",
+ htmlCopyRiskToast: "Copied. This AI-generated HTML may execute scripts or access the network when opened outside DBX.",
},
contextMenu: {
openConnection: "Open Connection",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index bc4ed27c07..015f4b8ce4 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -2824,6 +2824,16 @@ export default withEnglishFallback({
chartDownloadPng: "Descargar PNG",
chartDownloadFailed: "No se pudo descargar la imagen del gráfico",
chartData: "Datos del gráfico",
+ htmlPreviewLabel: "Vista previa de HTML generado por IA",
+ htmlExpandPreview: "Abrir vista previa ampliada",
+ htmlExpandPreviewHint: "Esta vista previa se ejecuta en un sandbox completo: los scripts y el acceso a la red están bloqueados.",
+ htmlSaveSafe: "Guardar HTML seguro",
+ htmlSaveFailed: "No se pudo guardar el archivo HTML",
+ htmlCopySource: "Copiar código fuente",
+ htmlCopyRiskBody: "Este HTML fue generado por IA. Abrirlo fuera de DBX puede ejecutar scripts y acceder a la red.",
+ htmlCopyRiskAccept: "Copiar de todos modos",
+ htmlCopyRiskRemember: "No volver a preguntar en esta sesión",
+ htmlCopyRiskToast: "Copiado. Este HTML generado por IA puede ejecutar scripts o acceder a la red si se abre fuera de DBX.",
agentSteps: {
generated: "SQL generado",
noSql: "No se encontró SQL",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index c4ec1f2bfa..15437d36c5 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -2718,6 +2718,16 @@ export default withEnglishFallback({
chartDownloadPng: "Scarica PNG",
chartDownloadFailed: "Impossibile scaricare l'immagine del grafico",
chartData: "Dati del grafico",
+ htmlPreviewLabel: "Anteprima HTML generata dall'IA",
+ htmlExpandPreview: "Apri anteprima ingrandita",
+ htmlExpandPreviewHint: "Questa anteprima viene eseguita in una sandbox completa: script e accesso alla rete sono bloccati.",
+ htmlSaveSafe: "Salva HTML sicuro",
+ htmlSaveFailed: "Salvataggio del file HTML non riuscito",
+ htmlCopySource: "Copia sorgente",
+ htmlCopyRiskBody: "Questo HTML è stato generato dall'IA. Aprirlo fuori da DBX può eseguire script e accedere alla rete.",
+ htmlCopyRiskAccept: "Copia comunque",
+ htmlCopyRiskRemember: "Non chiedere più in questa sessione",
+ htmlCopyRiskToast: "Copiato. Questo HTML generato dall'IA può eseguire script o accedere alla rete se aperto fuori da DBX.",
agentSteps: {
generated: "SQL generato",
noSql: "Nessun SQL trovato",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index 70803f4aef..c82a64b03c 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -2860,6 +2860,16 @@ export default withEnglishFallback({
chartDownloadPng: "PNGをダウンロード",
chartDownloadFailed: "グラフ画像をダウンロードできませんでした",
chartData: "グラフのデータ",
+ htmlPreviewLabel: "AI生成HTMLのプレビュー",
+ htmlExpandPreview: "拡大プレビューを開く",
+ htmlExpandPreviewHint: "このプレビューは完全なサンドボックスで実行され、スクリプトとネットワークアクセスはブロックされます。",
+ htmlSaveSafe: "安全なHTMLを保存",
+ htmlSaveFailed: "HTMLファイルの保存に失敗しました",
+ htmlCopySource: "ソースをコピー",
+ htmlCopyRiskBody: "このHTMLはAIによって生成されました。DBXの外部で開くとスクリプトが実行され、ネットワークへアクセスする可能性があります。",
+ htmlCopyRiskAccept: "それでもコピー",
+ htmlCopyRiskRemember: "このセッションでは今後確認しない",
+ htmlCopyRiskToast: "コピーしました。このAI生成HTMLは、DBXの外部で開くとスクリプトを実行したりネットワークへアクセスしたりする可能性があります。",
agentSteps: {
generated: "SQL生成完了",
noSql: "SQLが見つかりません",
diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts
index 8a336c2004..8258704f72 100644
--- a/apps/desktop/src/i18n/locales/ko.ts
+++ b/apps/desktop/src/i18n/locales/ko.ts
@@ -2830,6 +2830,16 @@ export default withEnglishFallback({
chartDownloadPng: "PNG 다운로드",
chartDownloadFailed: "차트 이미지를 다운로드하지 못했습니다",
chartData: "차트 데이터",
+ htmlPreviewLabel: "AI 생성 HTML 미리보기",
+ htmlExpandPreview: "큰 화면으로 미리보기",
+ htmlExpandPreviewHint: "이 미리보기는 완전한 샌드박스에서 실행되며 스크립트와 네트워크 액세스가 차단됩니다.",
+ htmlSaveSafe: "안전한 HTML 저장",
+ htmlSaveFailed: "HTML 파일 저장에 실패했습니다",
+ htmlCopySource: "소스 복사",
+ htmlCopyRiskBody: "이 HTML은 AI가 생성했습니다. DBX 외부에서 열면 스크립트가 실행되고 네트워크에 접근할 수 있습니다.",
+ htmlCopyRiskAccept: "그래도 복사",
+ htmlCopyRiskRemember: "이 세션에서 다시 묻지 않기",
+ htmlCopyRiskToast: "복사되었습니다. 이 AI 생성 HTML은 DBX 외부에서 열면 스크립트를 실행하거나 네트워크에 접근할 수 있습니다.",
},
contextMenu: {
openConnection: "연결 열기",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index 469b706f98..a0f366649d 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -2826,6 +2826,16 @@ export default withEnglishFallback({
chartDownloadPng: "Baixar PNG",
chartDownloadFailed: "Falha ao baixar a imagem do gráfico",
chartData: "Dados do gráfico",
+ htmlPreviewLabel: "Pré-visualização de HTML gerado por IA",
+ htmlExpandPreview: "Abrir visualização ampliada",
+ htmlExpandPreviewHint: "Esta visualização é executada em sandbox completo — scripts e acesso à rede são bloqueados.",
+ htmlSaveSafe: "Salvar HTML seguro",
+ htmlSaveFailed: "Falha ao salvar o arquivo HTML",
+ htmlCopySource: "Copiar código-fonte",
+ htmlCopyRiskBody: "Este HTML foi gerado por IA. Abri-lo fora do DBX pode executar scripts e acessar a rede.",
+ htmlCopyRiskAccept: "Copiar mesmo assim",
+ htmlCopyRiskRemember: "Não perguntar novamente nesta sessão",
+ htmlCopyRiskToast: "Copiado. Este HTML gerado por IA pode executar scripts ou acessar a rede quando aberto fora do DBX.",
agentSteps: {
generated: "SQL gerado",
noSql: "Nenhum SQL encontrado",
diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts
index b129e2788f..6da8987739 100644
--- a/apps/desktop/src/i18n/locales/tr.ts
+++ b/apps/desktop/src/i18n/locales/tr.ts
@@ -3011,6 +3011,16 @@ export default withEnglishFallback({
chartDownloadPng: "PNG İndir",
chartDownloadFailed: "Grafik görüntüsü indirilemedi",
chartData: "Grafik verileri",
+ htmlPreviewLabel: "AI tarafından oluşturulan HTML önizlemesi",
+ htmlExpandPreview: "Büyük önizleme aç",
+ htmlExpandPreviewHint: "Bu önizleme tamamen korumalı alanda çalışır; betikler ve ağ erişimi engellenir.",
+ htmlSaveSafe: "Güvenli HTML'i kaydet",
+ htmlSaveFailed: "HTML dosyası kaydedilemedi",
+ htmlCopySource: "Kaynağı kopyala",
+ htmlCopyRiskBody: "Bu HTML yapay zeka tarafından oluşturuldu. DBX dışında açmak betik çalıştırabilir ve ağa erişebilir.",
+ htmlCopyRiskAccept: "Yine de kopyala",
+ htmlCopyRiskRemember: "Bu oturumda tekrar sorma",
+ htmlCopyRiskToast: "Kopyalandı. Bu AI tarafından oluşturulan HTML, DBX dışında açıldığında betik çalıştırabilir veya ağa erişebilir.",
},
contextMenu: {
openConnection: "Bağlantıyı Aç",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index 06f7c7c231..be6eb88a97 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -2960,6 +2960,16 @@ export default withEnglishFallback({
chartDownloadPng: "下载 PNG",
chartDownloadFailed: "图表图片下载失败",
chartData: "图表数据",
+ htmlPreviewLabel: "AI 生成的 HTML 预览",
+ htmlExpandPreview: "在大窗口预览",
+ htmlExpandPreviewHint: "预览在完全沙箱中运行,脚本与网络访问均被阻止。",
+ htmlSaveSafe: "保存安全 HTML",
+ htmlSaveFailed: "保存 HTML 文件失败",
+ htmlCopySource: "复制源码",
+ htmlCopyRiskBody: "此 HTML 由 AI 生成,在 DBX 外打开可能执行脚本并访问网络。",
+ htmlCopyRiskAccept: "仍要复制",
+ htmlCopyRiskRemember: "本会话不再询问",
+ htmlCopyRiskToast: "已复制。此 AI 生成的 HTML 在 DBX 外打开可能执行脚本或访问网络。",
},
contextMenu: {
openConnection: "打开连接",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index d3faa1037f..1d8f480f27 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -2938,6 +2938,16 @@ export default withEnglishFallback({
chartDownloadPng: "下載 PNG",
chartDownloadFailed: "圖表圖片下載失敗",
chartData: "圖表資料",
+ htmlPreviewLabel: "AI 產生的 HTML 預覽",
+ htmlExpandPreview: "在大視窗預覽",
+ htmlExpandPreviewHint: "預覽在完全沙箱中執行,指令碼與網路存取均被封鎖。",
+ htmlSaveSafe: "儲存安全 HTML",
+ htmlSaveFailed: "儲存 HTML 檔案失敗",
+ htmlCopySource: "複製原始碼",
+ htmlCopyRiskBody: "此 HTML 由 AI 產生,在 DBX 外開啟可能執行指令碼並存取網路。",
+ htmlCopyRiskAccept: "仍要複製",
+ htmlCopyRiskRemember: "本次工作階段不再詢問",
+ htmlCopyRiskToast: "已複製。此 AI 產生的 HTML 在 DBX 外開啟可能執行指令碼或存取網路。",
},
contextMenu: {
openConnection: "開啟連線",
diff --git a/apps/desktop/src/lib/__tests__/ai/aiMessageRender.spec.ts b/apps/desktop/src/lib/__tests__/ai/aiMessageRender.spec.ts
index 3ac21b08c6..d30ea50d4a 100644
--- a/apps/desktop/src/lib/__tests__/ai/aiMessageRender.spec.ts
+++ b/apps/desktop/src/lib/__tests__/ai/aiMessageRender.spec.ts
@@ -260,6 +260,56 @@ describe("createAiMessageRenderer rich chart segments", () => {
});
});
+describe("createAiMessageRenderer rich html segments", () => {
+ const markdown = (text: string) => `
${text}
`;
+
+ it("routes a closed html fence to an html segment carrying the safe document", () => {
+ const renderer = createAiMessageRenderer({ markdown });
+ const segments = renderer.render("```html\n
hi
\n```");
+ const seg = segments[0];
+ expect(seg.type).toBe("html");
+ if (seg.type !== "html") return;
+ expect(seg.content).toBe("
");
+ });
+
+ it("keeps an unfinished html fence as a plain code segment while streaming", () => {
+ const renderer = createAiMessageRenderer({ markdown });
+ const [seg] = renderer.render("```html\n
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
\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
hi
\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);
diff --git a/apps/desktop/src/lib/ai/aiMessageRender.ts b/apps/desktop/src/lib/ai/aiMessageRender.ts
index 1a0d0bb5ae..56d7ca76b8 100644
--- a/apps/desktop/src/lib/ai/aiMessageRender.ts
+++ b/apps/desktop/src/lib/ai/aiMessageRender.ts
@@ -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";
diff --git a/apps/desktop/src/lib/ai/richContent/__tests__/aiHtmlPreview.spec.ts b/apps/desktop/src/lib/ai/richContent/__tests__/aiHtmlPreview.spec.ts
new file mode 100644
index 0000000000..cfb02cb787
--- /dev/null
+++ b/apps/desktop/src/lib/ai/richContent/__tests__/aiHtmlPreview.spec.ts
@@ -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("
hi
");
+ expect(wrapped.startsWith("")).toBe(true);
+ expect(wrapped).toContain('');
+ expect(wrapped).toContain(``);
+ // 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("