/** * @file artifactView.ts * @description What makes a file an artifact, Claude Desktop's way, and how * one is shown: its kind from its name (a page, an image, a diagram, a React * component, a document, a table, code), the sandboxed document an HTML, * SVG, Mermaid or React artifact runs in (an iframe `srcdoc` with no * same-origin access, its libraries from a pinned CDN build, as Claude's own * artifacts load theirs), a CSV's rows, and the versions a conversation's * Write/Edit calls left of one file — rebuilt from their own inputs, so no * server keeps copies. * @author Nguyễn Ngọc Trí Vĩ */ import type { TranscriptMessage } from "./types"; export type ArtifactKind = "html" | "svg" | "markdown" | "mermaid" | "react" | "csv" | "image" | "code"; const EXT = (p: string) => (/\.([A-Za-z0-9]+)$/.exec(p.split(/[\\/]/).pop() || "")?.[1] || "").toLowerCase(); /** The kind a file shows as. */ export function artifactKind(path: string): ArtifactKind { const e = EXT(path); if (e === "html" || e === "htm") return "html"; if (e === "svg") return "svg"; if (e === "md" || e === "markdown" || e === "mdx") return "markdown"; if (e === "mmd" || e === "mermaid") return "mermaid"; if (e === "jsx" || e === "tsx") return "react"; if (e === "csv" || e === "tsv") return "csv"; if (["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico"].includes(e)) return "image"; return "code"; } /** Kinds with a rendered Preview (the rest show their code only). */ export const RENDERED: ReadonlySet = new Set([ "html", "svg", "markdown", "mermaid", "react", "csv", "image", ]); /** An edit worth a card in the conversation: a page, a document, a diagram, a * table — and a React component the model wrote whole (a `.tsx` it merely * edited is project code, not an artifact). */ export function isArtifact(path: string, created: boolean): boolean { const k = artifactKind(path); if (k === "react") return created; return k === "html" || k === "svg" || k === "markdown" || k === "mermaid" || k === "csv"; } /** A code fence language for the Code tab. */ export function artifactLang(path: string): string { const e = EXT(path); const map: Record = { htm: "html", mmd: "text", mermaid: "text", md: "markdown", mdx: "markdown", jsx: "jsx", tsx: "tsx", js: "javascript", mjs: "javascript", cjs: "javascript", ts: "typescript", py: "python", sh: "bash", yml: "yaml", }; return map[e] || e; } // Pinned builds (all on npm for well over two weeks), as Claude's own // artifacts load React, Tailwind and Mermaid from a CDN. const CDN = { react: "https://cdn.jsdelivr.net/npm/react@18.3.1/umd/react.production.min.js", reactDom: "https://cdn.jsdelivr.net/npm/react-dom@18.3.1/umd/react-dom.production.min.js", babel: "https://cdn.jsdelivr.net/npm/@babel/standalone@7.26.4/babel.min.js", tailwind: "https://cdn.tailwindcss.com/3.4.16", mermaid: "https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs", }; /** Text safe inside a ``; /** The document a rendered artifact runs in (null: it is not a frame kind). */ export function artifactDocument(kind: ArtifactKind, source: string, dark: boolean): string | null { switch (kind) { case "html": return /]|${source}`; case "svg": return `${source}`; case "mermaid": return `${ERROR_BOX}
${htmlEscape(source)}
`; case "react": return `
${ERROR_BOX} `; default: return null; } } /** The sandbox a frame kind gets: scripts but never the dashboard's origin * (no `allow-same-origin`), and none at all for an SVG. */ export const frameSandbox = (kind: ArtifactKind) => kind === "svg" ? "" : "allow-scripts allow-forms allow-modals allow-popups allow-downloads"; /** A CSV/TSV's rows (quoted fields, doubled quotes, newlines inside quotes). */ export function parseCsv(text: string, sep = ","): string[][] { const rows: string[][] = []; let row: string[] = []; let field = ""; let quoted = false; for (let i = 0; i < text.length; i++) { const c = text[i]!; if (quoted) { if (c === '"' && text[i + 1] === '"') { field += '"'; i++; } else if (c === '"') quoted = false; else field += c; } else if (c === '"' && field === "") quoted = true; else if (c === sep) { row.push(field); field = ""; } else if (c === "\n" || c === "\r") { if (c === "\r" && text[i + 1] === "\n") i++; row.push(field); rows.push(row); row = []; field = ""; } else field += c; } if (field !== "" || row.length) { row.push(field); rows.push(row); } return rows; } /** One version of a file, as a Write/Edit call left it. */ export interface ArtifactVersion { /** The call that made it. */ toolUseId: string | null; timestamp: string | null; /** null: the call is known, its result text is not (an edit to a file * whose earlier text the conversation never showed). */ content: string | null; } const samePath = (a: unknown, b: string) => typeof a === "string" && a.replace(/\\/g, "/") === b.replace(/\\/g, "/"); /** Applies one Edit (old → new, once or everywhere) to known text. */ function applyEdit(text: string, oldS: unknown, newS: unknown, all: unknown): string | null { if (typeof oldS !== "string" || typeof newS !== "string") return null; if (!text.includes(oldS)) return null; return all ? text.split(oldS).join(newS) : text.replace(oldS, () => newS); } /** * The versions of `path` in a conversation, oldest first: each successful * Write gives its whole text, and each Edit/MultiEdit after a known text gives * that text with its edits applied. A call whose input the transcript cut, or * an edit to text never shown, is a version of unknown content (the current * file on disk stands in for the newest one). */ export function artifactVersions(messages: TranscriptMessage[], path: string): ArtifactVersion[] { const failed = new Set(); for (const m of messages) for (const c of m.content) if (c.type === "tool_result" && c.is_error && c.id) failed.add(c.id); const out: ArtifactVersion[] = []; let text: string | null = null; for (const m of messages) { if (m.type !== "assistant") continue; for (const c of m.content) { if (c.type !== "tool_use" || (c.id && failed.has(c.id))) continue; const input = (c.input || {}) as Record; if (!samePath(input.file_path, path)) continue; if (c.name === "Write") { text = typeof input.content === "string" ? input.content : null; } else if (c.name === "Edit") { text = text === null ? null : applyEdit(text, input.old_string, input.new_string, input.replace_all); } else if (c.name === "MultiEdit") { const edits = Array.isArray(input.edits) ? (input.edits as Record[]) : []; for (const e of edits) { if (text === null) break; text = applyEdit(text, e.old_string, e.new_string, e.replace_all); } } else continue; out.push({ toolUseId: c.id ?? null, timestamp: m.timestamp, content: text }); } } return out; }