From 477797b87848fdcc5d3c3ac32e902d70db9d7967 Mon Sep 17 00:00:00 2001 From: Khushal Bhardwaj Date: Sun, 16 Aug 2026 14:40:37 +0530 Subject: [PATCH 1/3] feat(artifacts): add isolated HTML previews --- app/api/artifacts/[artifactId]/route.ts | 54 ++++++ components/artifact-card.tsx | 234 ++++++++++++++++++++++++ components/chat-message.tsx | 57 +++++- components/chat.tsx | 33 +++- components/parts/artifact-part.tsx | 27 +++ lib/alluka/artifact-preview.test.ts | 25 +++ lib/alluka/artifact-preview.ts | 65 +++++++ lib/alluka/repository.ts | 22 +++ package.json | 1 + tsconfig.json | 1 + 10 files changed, 517 insertions(+), 2 deletions(-) create mode 100644 app/api/artifacts/[artifactId]/route.ts create mode 100644 components/artifact-card.tsx create mode 100644 lib/alluka/artifact-preview.test.ts create mode 100644 lib/alluka/artifact-preview.ts diff --git a/app/api/artifacts/[artifactId]/route.ts b/app/api/artifacts/[artifactId]/route.ts new file mode 100644 index 0000000..a589d46 --- /dev/null +++ b/app/api/artifacts/[artifactId]/route.ts @@ -0,0 +1,54 @@ +import { Effect } from "effect" + +import { AllukaRepository, runRepository } from "@/lib/alluka/repository" + +export async function GET( + request: Request, + { params }: { params: Promise<{ artifactId: string }> } +) { + const { artifactId } = await params + const url = new URL(request.url) + const sessionId = url.searchParams.get("sessionId") + const agentId = url.searchParams.get("agentId") + if (!sessionId || !agentId) { + return Response.json( + { error: "Artifact context is required." }, + { status: 400 } + ) + } + + const result = await runRepository( + Effect.gen(function* () { + const repository = yield* AllukaRepository + return yield* repository.readArtifact(artifactId, sessionId, agentId) + }) + ) + if (!result) { + return Response.json({ error: "Artifact not found." }, { status: 404 }) + } + + if (url.searchParams.get("download") === "1") { + return new Response(result.content, { + headers: { + "content-disposition": `attachment; filename="${result.artifact.name.replaceAll('"', "")}"`, + "content-type": "text/plain; charset=utf-8", + "x-content-type-options": "nosniff", + }, + }) + } + + return Response.json( + { + id: result.artifact.id, + name: result.artifact.name, + mediaType: result.artifact.mediaType, + content: result.content, + }, + { + headers: { + "cache-control": "no-store", + "x-content-type-options": "nosniff", + }, + } + ) +} diff --git a/components/artifact-card.tsx b/components/artifact-card.tsx new file mode 100644 index 0000000..5280d31 --- /dev/null +++ b/components/artifact-card.tsx @@ -0,0 +1,234 @@ +"use client" + +import * as React from "react" +import { + Check, + Copy, + Download, + Eye, + EyeOff, + FileCode2, + Loader2, +} from "lucide-react" + +import { + createIsolatedHtmlDocument, + isHtmlArtifact, +} from "@/lib/alluka/artifact-preview" +import { cn } from "@/lib/utils" + +type ArtifactContents = { + id: string + name: string + mediaType: string + content: string +} + +function artifactUrl(artifactId: string, sessionId: string, agentId: string) { + const query = new URLSearchParams({ sessionId, agentId }) + return `/api/artifacts/${encodeURIComponent(artifactId)}?${query}` +} + +export function ArtifactCard({ + artifactId, + name, + sizeBytes, + sessionId, + agentId, + className, +}: { + artifactId: string + name: string + sizeBytes: number + sessionId: string + agentId: string + className?: string +}) { + const [open, setOpen] = React.useState(false) + const [contents, setContents] = React.useState(null) + const [error, setError] = React.useState(null) + const [copyState, setCopyState] = React.useState<"idle" | "copied">("idle") + const [height, setHeight] = React.useState(420) + + async function loadContents() { + if (contents) return contents + const response = await fetch(artifactUrl(artifactId, sessionId, agentId), { + cache: "no-store", + }) + const payload: { error?: string } & Partial = + await response.json().catch(() => ({})) + if ( + !response.ok || + !payload.content || + !payload.mediaType || + !payload.name + ) { + throw new Error(payload.error ?? "Could not load this artifact.") + } + const artifact = { + id: payload.id ?? artifactId, + name: payload.name, + mediaType: payload.mediaType, + content: payload.content, + } + setContents(artifact) + return artifact + } + + async function togglePreview() { + if (open) { + setOpen(false) + setContents(null) + setError(null) + return + } + setError(null) + setOpen(true) + try { + await loadContents() + } catch (cause) { + setOpen(false) + setError( + cause instanceof Error ? cause.message : "Could not load this artifact." + ) + } + } + + async function copyArtifact() { + setError(null) + try { + const artifact = await loadContents() + await navigator.clipboard.writeText(artifact.content) + setCopyState("copied") + window.setTimeout(() => setCopyState("idle"), 1600) + } catch (cause) { + setError( + cause instanceof Error ? cause.message : "Could not copy this artifact." + ) + } + } + + const url = artifactUrl(artifactId, sessionId, agentId) + const previewIsHtml = isHtmlArtifact(name, contents?.mediaType ?? "") + + return ( +
+
+
+ + {error ? ( +

+ {error} +

+ ) : null} + + {open ? ( +
+
+

+ {previewIsHtml + ? "Isolated HTML preview. Scripts, navigation, and network access are blocked." + : "Artifact contents"} +

+ +
+
+ {!contents ? ( +
+ {" "} + Loading artifact +
+ ) : previewIsHtml ? ( +