Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"use client";

import * as React from "react";

import type { HTMLVisualThemeSnapshot } from "@/shared/lib/html-visual-theme";

type ChatArtifactSVGPreviewProps = {
complete: boolean;
invalidMessage: string;
source: string;
theme: HTMLVisualThemeSnapshot;
title: string;
};

type SVGPreview = {
source: string;
theme: HTMLVisualThemeSnapshot;
} & (
| { status: "invalid" }
| {
status: "ready";
url: string;
}
);

const SVG_NAMESPACE = "http://www.w3.org/2000/svg";

function applyPreviewTheme(source: string, theme: HTMLVisualThemeSnapshot): string | null {
const parser = new DOMParser();
const xmlDocument = parser.parseFromString(source, "image/svg+xml");
let root: Element = xmlDocument.documentElement;
if (root.localName !== "svg") return null;

if (root.namespaceURI === null) {
const htmlDocument = parser.parseFromString(source, "text/html");
const [htmlRoot] = Array.from(htmlDocument.body.children);
if (htmlDocument.body.children.length !== 1 || htmlRoot?.localName !== "svg") return null;
root = htmlRoot;
} else if (root.namespaceURI !== SVG_NAMESPACE) {
return null;
}

const style = root.ownerDocument.createElementNS(SVG_NAMESPACE, "style");
const variables = theme.variables.map(([name, value]) => `${name}:${value}`).join(";");
style.setAttribute("data-deeix-artifact-theme", "");
style.textContent = `:root{color-scheme:${theme.colorScheme};${variables}}`;
root.prepend(style);

return new XMLSerializer().serializeToString(root);
}

export function ChatArtifactSVGPreview({
complete,
invalidMessage,
source,
theme,
title,
}: ChatArtifactSVGPreviewProps) {
const [preview, setPreview] = React.useState<SVGPreview | null>(null);
const [failedURL, setFailedURL] = React.useState<string | null>(null);

React.useEffect(() => {
setFailedURL(null);
if (!complete) {
setPreview(null);
return;
}

const previewSource = applyPreviewTheme(source, theme);
if (!previewSource) {
setPreview({ source, status: "invalid", theme });
return;
}

// SVG image documents do not expose scripts, event handlers, or links as active page DOM.
const url = URL.createObjectURL(
new Blob([previewSource], { type: "image/svg+xml;charset=utf-8" }),
);
setPreview({ source, status: "ready", theme, url });

return () => URL.revokeObjectURL(url);
}, [complete, source, theme]);

const currentPreview =
complete && preview?.source === source && preview.theme === theme ? preview : null;
const previewURL = currentPreview?.status === "ready" ? currentPreview.url : null;
const failed =
currentPreview?.status === "invalid" || Boolean(previewURL && failedURL === previewURL);

return (
<div className="flex h-full min-h-[320px] w-full items-center justify-center overflow-auto bg-background p-3">
{previewURL && !failed ? (
<img
src={previewURL}
alt={title}
className="block max-h-full max-w-full select-none object-contain"
decoding="async"
draggable={false}
referrerPolicy="no-referrer"
onError={() => setFailedURL(previewURL)}
/>
) : complete && failed ? (
<p className="px-6 text-center text-sm text-muted-foreground">{invalidMessage}</p>
) : null}
</div>
);
}
57 changes: 46 additions & 11 deletions frontend/features/chat/components/sections/chat-artifact.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ import {
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ChatArtifactSVGPreview } from "@/features/chat/components/sections/chat-artifact-svg-preview";
import {
buildArtifactPreviewDocument,
type ChatArtifact,
downloadArtifactHTML,
resolveArtifactDownloadName,
} from "@/features/chat/model/chat-artifacts";
import {
Expand All @@ -29,6 +29,7 @@ import { useFontSizePreference } from "@/features/settings/utils/font-size";
import { cn } from "@/lib/utils";
import { CopyActionButton } from "@/shared/components/copy-action";
import { useTheme } from "@/shared/components/theme-provider";
import { downloadBlob } from "@/shared/lib/export-download";
import {
captureHTMLVisualThemeSnapshot,
type HTMLVisualThemeSnapshot,
Expand Down Expand Up @@ -165,8 +166,18 @@ function ChatArtifactPanel({
setPreviewTheme(captureHTMLVisualThemeSnapshot(resolvedTheme));
}, [chatFont, chatFontWeight, fontSize, preset, resolvedTheme]);

const previewHTML = React.useMemo(
() => buildArtifactPreviewDocument(artifact.kind, artifact.code, previewTheme),
const artifactPreview = React.useMemo(
() =>
artifact.kind === "svg"
? ({ mode: "svg" } as const)
: ({
documentHTML: buildArtifactPreviewDocument(
artifact.kind,
artifact.code,
previewTheme,
),
mode: "frame",
} as const),
[artifact.code, artifact.kind, previewTheme],
);
const canPreview = artifact.code.trim().length > 0;
Expand All @@ -177,8 +188,18 @@ function ChatArtifactPanel({

const handleDownload = React.useCallback(() => {
if (!canPreview) return;
downloadArtifactHTML(resolveArtifactDownloadName(artifact.kind), previewHTML);
}, [artifact.kind, canPreview, previewHTML]);
if (artifactPreview.mode === "svg") {
downloadBlob(
new Blob([artifact.code], { type: "image/svg+xml;charset=utf-8" }),
resolveArtifactDownloadName(artifact.kind),
);
return;
}
downloadBlob(
new Blob([artifactPreview.documentHTML], { type: "text/html;charset=utf-8" }),
resolveArtifactDownloadName(artifact.kind),
);
}, [artifact.kind, artifact.code, artifactPreview, canPreview]);

return (
<aside
Expand Down Expand Up @@ -232,7 +253,11 @@ function ChatArtifactPanel({
</TooltipTrigger>
<TooltipContent side="bottom">{t("copySource")}</TooltipContent>
</Tooltip>
<ArtifactActionButton label={t("downloadHtml")} disabled={!canPreview} onClick={handleDownload}>
<ArtifactActionButton
label={artifact.kind === "svg" ? t("downloadSvg") : t("downloadHtml")}
disabled={!canPreview}
onClick={handleDownload}
>
<Download className="size-3" />
</ArtifactActionButton>
<ArtifactActionButton label={t("close")} onClick={onClose}>
Expand All @@ -243,11 +268,21 @@ function ChatArtifactPanel({

<TabsContent value="preview" className="mt-0 min-h-0 flex-1 overflow-hidden">
{canPreview ? (
<ArtifactPreviewFrame
key={artifact.id}
documentHTML={previewHTML}
title={t("previewTitle")}
/>
artifactPreview.mode === "svg" ? (
<ChatArtifactSVGPreview
complete={artifact.complete}
invalidMessage={t("invalidSvg")}
source={artifact.code}
theme={previewTheme}
title={t("previewTitle")}
/>
) : (
<ArtifactPreviewFrame
key={artifact.id}
documentHTML={artifactPreview.documentHTML}
title={t("previewTitle")}
/>
)
) : (
<div className="flex h-full min-h-[320px] items-center justify-center bg-muted/15 px-6 text-center text-sm text-muted-foreground">
{t("empty")}
Expand Down
20 changes: 4 additions & 16 deletions frontend/features/chat/model/chat-artifacts.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { ChatAreaMessage } from "@/features/chat/types/messages";
import { getBrandingSnapshot } from "@/shared/config/branding";
import {
resolveArtifactPreviewKind,
type ArtifactPreviewKind,
resolveArtifactPreviewKind,
} from "@/shared/lib/artifact-preview";
import { getBrandingSnapshot } from "@/shared/config/branding";
import type { HTMLVisualThemeSnapshot } from "@/shared/lib/html-visual-theme";

export type { ArtifactPreviewKind } from "@/shared/lib/artifact-preview";
Expand Down Expand Up @@ -47,7 +47,6 @@ const ARTIFACT_CSP = [
"connect-src 'none'",
"manifest-src 'none'",
"prefetch-src 'none'",
"navigate-to 'none'",
"img-src data: blob:",
"media-src data: blob:",
"font-src data:",
Expand Down Expand Up @@ -234,7 +233,7 @@ body { margin: 0; font: 14px/1.5 var(--font-sans); color: var(--foreground); bac
}

export function buildArtifactPreviewDocument(
kind: ArtifactPreviewKind,
kind: Exclude<ArtifactPreviewKind, "svg">,
code: string,
theme: HTMLVisualThemeSnapshot,
): string {
Expand All @@ -246,21 +245,10 @@ export function buildArtifactPreviewDocument(
export function resolveArtifactDownloadName(kind: ArtifactPreviewKind): string {
if (kind === "css") return "artifact-css-preview.html";
if (kind === "javascript") return "artifact-js-preview.html";
if (kind === "svg") return "artifact.svg";
return "artifact-preview.html";
}

export function downloadArtifactHTML(fileName: string, value: string): void {
const blob = new Blob([value], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}

export function extractArtifactsFromContent(
message: Pick<ChatAreaMessage, "content" | "isStreaming" | "key" | "publicID" | "runID" | "updatedAt">,
): ChatArtifact[] {
Expand Down
2 changes: 2 additions & 0 deletions frontend/i18n/messages/en-US/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@
"sourceCopied": "Source copied",
"copyFailed": "Could not copy source",
"downloadHtml": "Download HTML",
"downloadSvg": "Download SVG",
"invalidSvg": "This SVG source is invalid and cannot be previewed.",
"close": "Close",
"resize": "Resize Artifact",
"empty": "This artifact is empty and cannot be previewed yet.",
Expand Down
2 changes: 2 additions & 0 deletions frontend/i18n/messages/zh-CN/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@
"sourceCopied": "源码已复制",
"copyFailed": "源码复制失败",
"downloadHtml": "下载 HTML",
"downloadSvg": "下载 SVG",
"invalidSvg": "SVG 源码无效,无法预览。",
"close": "关闭",
"resize": "调整 Artifact 宽度",
"empty": "当前 Artifact 为空,暂无法预览。",
Expand Down
89 changes: 87 additions & 2 deletions frontend/shared/lib/artifact-preview.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,101 @@
export type ArtifactPreviewKind = "html" | "css" | "javascript";
export type ArtifactPreviewKind = "html" | "css" | "javascript" | "svg";

const HTML_LIKE_RE = /^\s*(?:<!doctype\s+html|<html\b|<head\b|<body\b|<(?:article|canvas|div|main|section|style|script|svg)\b)/i;
const HTML_LIKE_RE = /^\s*(?:<!doctype\s+html|<html\b|<head\b|<body\b|<(?:article|canvas|div|main|section|style|script)\b)/i;
const SVG_DOCTYPE_RE = /^<!doctype\s+svg(?:\s|\[|>)/i;
const SVG_ROOT_RE = /^<(?:[a-z_][\w.-]*:)?svg(?:\s|\/?>)/i;

function normalizeLanguage(language: string): string {
return language.trim().toLowerCase();
}

function skipDocumentWhitespace(source: string, start: number): number {
let index = start;
while (index < source.length) {
const character = source.charCodeAt(index);
if (
character !== 0x09 &&
character !== 0x0a &&
character !== 0x0d &&
character !== 0x20 &&
character !== 0xfeff
) {
break;
}
index += 1;
}
return index;
}

function hasSVGDocumentRoot(code: string): boolean {
let cursor = skipDocumentWhitespace(code, 0);

while (cursor < code.length) {
if (code.startsWith("<!--", cursor)) {
const end = code.indexOf("-->", cursor + 4);
if (end < 0) return false;
cursor = skipDocumentWhitespace(code, end + 3);
continue;
}

if (code.startsWith("<?", cursor)) {
const end = code.indexOf("?>", cursor + 2);
if (end < 0) return false;
cursor = skipDocumentWhitespace(code, end + 2);
continue;
}

if (SVG_DOCTYPE_RE.test(code.slice(cursor))) {
let quote = "";
let subsetDepth = 0;
let end = -1;

for (let index = cursor; index < code.length; index += 1) {
const character = code[index];
if (quote) {
if (character === quote) quote = "";
continue;
}
if (character === '"' || character === "'") {
quote = character;
continue;
}
if (character === "[") {
subsetDepth += 1;
continue;
}
if (character === "]" && subsetDepth > 0) {
subsetDepth -= 1;
continue;
}
if (character === ">" && subsetDepth === 0) {
end = index;
break;
}
}

if (end < 0) return false;
cursor = skipDocumentWhitespace(code, end + 1);
continue;
}

break;
}

return SVG_ROOT_RE.test(code.slice(cursor));
}

export function resolveArtifactPreviewKind(language: string, code: string): ArtifactPreviewKind | null {
const normalized = normalizeLanguage(language);
if (["html", "htm", "xhtml"].includes(normalized)) return "html";
if (["css", "scss", "sass", "less"].includes(normalized)) return "css";
if (["js", "javascript", "mjs", "cjs"].includes(normalized)) return "javascript";
if (["svg", "svg+xml", "image/svg+xml"].includes(normalized)) return "svg";
if (
["", "markdown", "xml", "text/xml", "application/xml"].includes(normalized) &&
hasSVGDocumentRoot(code)
) {
return "svg";
}
if ((!normalized || normalized === "markdown") && HTML_LIKE_RE.test(code)) return "html";
return null;
}
Loading