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/app/api/chat/route.ts b/app/api/chat/route.ts index ccf9467..bbec19a 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -161,7 +161,11 @@ export async function POST(req: Request) { } catch (cause) { await mcp.close() return Response.json( - { error: sanitizeCheckpointReason(cause) }, + { + error: sanitizeCheckpointReason( + cause instanceof Error ? cause : String(cause) + ), + }, { status: 409 } ) } diff --git a/components/artifact-card.tsx b/components/artifact-card.tsx new file mode 100644 index 0000000..75091e8 --- /dev/null +++ b/components/artifact-card.tsx @@ -0,0 +1,55 @@ +"use client" + +import { Eye, FileCode2 } from "lucide-react" + +import { cn } from "@/lib/utils" + +export type ArtifactReference = { + artifactId: string + name: string + sizeBytes: number + sessionId: string + agentId: string +} + +export function ArtifactCard({ + artifact, + onOpen, + className, +}: { + artifact: ArtifactReference + onOpen: (artifact: ArtifactReference) => void + className?: string +}) { + return ( +
+
+
+
+ ) +} diff --git a/components/artifact-inspector.tsx b/components/artifact-inspector.tsx new file mode 100644 index 0000000..1fd05b6 --- /dev/null +++ b/components/artifact-inspector.tsx @@ -0,0 +1,179 @@ +"use client" + +import * as React from "react" +import { Check, Copy, Download, Loader2, PanelRightClose } from "lucide-react" + +import { + createIsolatedHtmlDocument, + isHtmlArtifact, +} from "@/lib/alluka/artifact-preview" +import type { ArtifactReference } from "@/components/artifact-card" + +type ArtifactContents = { + id: string + name: string + mediaType: string + content: string +} + +function artifactUrl(artifact: ArtifactReference) { + const query = new URLSearchParams({ + sessionId: artifact.sessionId, + agentId: artifact.agentId, + }) + return `/api/artifacts/${encodeURIComponent(artifact.artifactId)}?${query}` +} + +export function ArtifactInspector({ + artifact, + onClose, +}: { + artifact: ArtifactReference | null + onClose: () => void +}) { + const [cache, setCache] = React.useState>({}) + const [error, setError] = React.useState<{ + artifactId: string + message: string + } | null>(null) + const [copied, setCopied] = React.useState(false) + const contents = artifact ? cache[artifact.artifactId] : undefined + + React.useEffect(() => { + if (!artifact || contents) return + const controller = new AbortController() + void fetch(artifactUrl(artifact), { + cache: "no-store", + signal: controller.signal, + }) + .then(async (response) => { + const payload: { error?: string } & Partial = + await response.json().catch(() => ({})) + const { content, mediaType, name } = payload + if (!response.ok || !content || !mediaType || !name) { + throw new Error(payload.error ?? "Could not load this artifact.") + } + setCache((current) => ({ + ...current, + [artifact.artifactId]: { + id: payload.id ?? artifact.artifactId, + name, + mediaType, + content, + }, + })) + }) + .catch((cause) => { + if (!controller.signal.aborted) { + setError({ + artifactId: artifact.artifactId, + message: + cause instanceof Error + ? cause.message + : "Could not load this artifact.", + }) + } + }) + return () => controller.abort() + }, [artifact, contents]) + + if (!artifact) return null + + const previewIsHtml = contents + ? isHtmlArtifact(contents.name, contents.mediaType) + : false + const url = artifactUrl(artifact) + const currentError = + error?.artifactId === artifact.artifactId ? error.message : null + + async function copyArtifact() { + if (!contents) return + await navigator.clipboard.writeText(contents.content) + setCopied(true) + window.setTimeout(() => setCopied(false), 1600) + } + + return ( +