feat(ai): add sandboxed HTML preview for AI rich content
This commit is contained in:
1 parent
11e0db5acb
commit
39b2d2a6ac
22 files changed
+1199
-13
No files matched your search
@@ -0,0 +1,141 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { AlertTriangle, Check, Copy, Download, Maximize2 } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { saveTextFile } from "@/lib/export/saveTextFile";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { acknowledgeAiHtmlCopyRisk, isAiHtmlCopyRiskAcknowledged } from "@/lib/ai/richContent/aiHtmlPreview";
|
||||
|
||||
const props = defineProps<{
|
||||
/** Raw AI html fence body — what "copy source" puts on the clipboard. */
|
||||
content: string;
|
||||
/** `buildSafeHtmlPreview(content)` — shown in the iframe and written by "Save Safe HTML". */
|
||||
document: string;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const copied = ref(false);
|
||||
const previewExpanded = ref(false);
|
||||
// Inline confirmation instead of a modal: the first copy attempt arms the risk
|
||||
// strip; confirming copies (optionally remembering the choice for the
|
||||
// session), canceling keeps the clipboard untouched.
|
||||
const showCopyConfirm = ref(false);
|
||||
const rememberChoice = ref(false);
|
||||
|
||||
function copySource() {
|
||||
if (!isAiHtmlCopyRiskAcknowledged()) {
|
||||
showCopyConfirm.value = true;
|
||||
return;
|
||||
}
|
||||
void warnAfterCopy();
|
||||
}
|
||||
|
||||
// Later copies skip the confirmation but keep a visible risk reminder — only
|
||||
// when the copy actually succeeded.
|
||||
async function warnAfterCopy() {
|
||||
if (await doCopy()) toast(t("ai.htmlCopyRiskToast"));
|
||||
}
|
||||
|
||||
function confirmCopy() {
|
||||
if (rememberChoice.value) acknowledgeAiHtmlCopyRisk();
|
||||
showCopyConfirm.value = false;
|
||||
void doCopy();
|
||||
}
|
||||
|
||||
function cancelCopy() {
|
||||
showCopyConfirm.value = false;
|
||||
rememberChoice.value = false;
|
||||
}
|
||||
|
||||
async function doCopy(): Promise<boolean> {
|
||||
try {
|
||||
await copyToClipboard(props.content);
|
||||
copied.value = true;
|
||||
window.setTimeout(() => (copied.value = false), 1600);
|
||||
return true;
|
||||
} catch {
|
||||
// The raw AI HTML is the sensitive payload: do not cover up a clipboard
|
||||
// failure with a success-looking toast, and keep the preview usable if
|
||||
// the host denies clipboard access.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Saves the WRAPPED safe document (inlined CSP), never the raw source: even
|
||||
// once the file is opened outside the iframe sandbox the CSP still blocks
|
||||
// scripts and network access.
|
||||
async function saveSafeHtml() {
|
||||
try {
|
||||
await saveTextFile(props.document, "dbx-ai-html-preview.html", "HTML", "html");
|
||||
} catch {
|
||||
toast(t("ai.htmlSaveFailed"));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- `sandbox=""` is the strongest sandbox: no allow-scripts (nothing executes),
|
||||
no allow-same-origin (no origin/cookie/storage access), no forms or
|
||||
popups. The `srcdoc` is the CSP-wrapped standalone document — identical
|
||||
bytes to the "Save Safe HTML" payload, so what is previewed is exactly
|
||||
what gets saved. -->
|
||||
<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="t('ai.htmlPreviewLabel')">
|
||||
<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="mr-auto 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.htmlExpandPreview')" :aria-label="t('ai.htmlExpandPreview')" @click="previewExpanded = true">
|
||||
<Maximize2 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="copied ? t('ai.copied') : t('ai.htmlCopySource')"
|
||||
:aria-label="copied ? t('ai.copied') : t('ai.htmlCopySource')"
|
||||
@click="copySource"
|
||||
>
|
||||
<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.htmlSaveSafe')" :aria-label="t('ai.htmlSaveSafe')" @click="saveSafeHtml">
|
||||
<Download class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="showCopyConfirm" class="flex items-start gap-2 border-b border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-200" role="alertdialog" :aria-label="t('ai.htmlCopySource')">
|
||||
<AlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="leading-snug">{{ t("ai.htmlCopyRiskBody") }}</p>
|
||||
<div class="mt-1.5 flex flex-wrap items-center gap-2">
|
||||
<label class="flex items-center gap-1 select-none">
|
||||
<input v-model="rememberChoice" type="checkbox" class="h-3 w-3 accent-amber-600" />
|
||||
<span>{{ t("ai.htmlCopyRiskRemember") }}</span>
|
||||
</label>
|
||||
<span class="flex-1" />
|
||||
<button type="button" class="rounded border border-amber-400 px-2 py-0.5 hover:bg-amber-100 dark:border-amber-500/50 dark:hover:bg-amber-500/20" @click="cancelCopy">{{ t("common.cancel") }}</button>
|
||||
<button type="button" class="rounded bg-amber-600 px-2 py-0.5 font-medium text-white hover:bg-amber-700" @click="confirmCopy">{{ t("ai.htmlCopyRiskAccept") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1">
|
||||
<iframe sandbox="" :srcdoc="document" class="h-full w-full bg-white" :title="t('ai.htmlPreviewLabel')" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- The expanded dialog renders the SAME sandboxed document as the inline
|
||||
card — identical `sandbox=""` and the identical CSP-wrapped `srcdoc`
|
||||
bytes. This is pure UX magnification: the zero-trust boundary does not
|
||||
change, there is no path here that hands raw HTML to the OS browser. -->
|
||||
<Dialog :open="previewExpanded" @update:open="previewExpanded = $event">
|
||||
<!-- Match other large dialogs: override DialogContent's 384px default at
|
||||
every breakpoint while keeping 16px viewport gutters. -->
|
||||
<DialogContent class="max-w-[min(1100px,calc(100vw-32px))] flex h-[85vh] flex-col">
|
||||
<DialogHeader class="shrink-0">
|
||||
<DialogTitle>{{ t("ai.htmlPreviewLabel") }}</DialogTitle>
|
||||
<DialogDescription>{{ t("ai.htmlExpandPreviewHint") }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="min-h-0 flex-1">
|
||||
<iframe sandbox="" :srcdoc="document" class="h-full w-full bg-white" :title="t('ai.htmlPreviewLabel')" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -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 = "<p>Hello <strong>world</strong></p>";
|
||||
const documentHtml = buildSafeHtmlPreview(content);
|
||||
|
||||
let app: App<Element> | 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<HTMLInputElement>('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<HTMLInputElement>('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();
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
<div v-html="seg.html" />
|
||||
</div>
|
||||
<AiChartRenderer v-else-if="seg.type === 'chart'" :spec="seg.spec" :content="seg.content" />
|
||||
<AiHtmlPreview v-else-if="seg.type === 'html'" :content="seg.content" :document="seg.document" />
|
||||
<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" />
|
||||
|
||||
Reference in new issue
Block a user