import type { Message } from "$lib/types/Message"; /** * Artifacts: substantial, self-contained pieces of content (apps, documents, * components, diagrams) that the model emits inside tags. They are * rendered in a dedicated side panel with live preview, next to the chat. * * Two operations exist: * - create/rewrite: `full content` * - update: `` containing * `` pairs applied as exact, * first-occurrence string replacements on the latest version. This lets the * model make targeted edits without re-emitting the whole artifact. * * Every operation produces a new version, so the panel can navigate history. * Parsing is streaming-safe: unterminated tags yield `closed: false` blocks * whose content grows as tokens arrive, and partially-received tags are kept * out of the rendered markdown. */ export type ArtifactKind = "html" | "svg" | "code" | "markdown" | "react" | "mermaid"; const KIND_ALIASES: Record = { html: "html", "text/html": "html", svg: "svg", "image/svg+xml": "svg", code: "code", "application/vnd.ant.code": "code", markdown: "markdown", md: "markdown", "text/markdown": "markdown", react: "react", jsx: "react", tsx: "react", "application/vnd.ant.react": "react", mermaid: "mermaid", "application/vnd.ant.mermaid": "mermaid", }; export function normalizeArtifactKind(type: string | undefined): ArtifactKind { if (!type) return "code"; return KIND_ALIASES[type.trim().toLowerCase()] ?? "code"; } export function isPreviewableKind(kind: ArtifactKind): boolean { return kind !== "code"; } export interface ArtifactUpdatePair { old: string; new: string; } export interface ArtifactCreateOp { kind: "create"; identifier: string; type: ArtifactKind; title: string; language?: string; content: string; closed: boolean; } export interface ArtifactUpdateOp { kind: "update"; identifier: string; title?: string; pairs: ArtifactUpdatePair[]; closed: boolean; } export type ArtifactOperation = ArtifactCreateOp | ArtifactUpdateOp; export type ArtifactSegment = | { type: "text"; content: string } | { type: "artifact"; op: ArtifactOperation }; // Some models double the opening bracket of tags they were taught // (`<`, `<`), so every opening matcher tolerates a run of // `<` and consumes it whole — otherwise the extra bracket leaks into the prose // and, worse, `` adjacency silently stops matching. const OPEN_TAG_REGEX = /<+artifact\b([^>]*)>/i; const CLOSE_TAG_REGEX = /<+\/artifact>/i; const UPDATE_PAIR_REGEX = /<+old_str>([\s\S]*?)<+\/old_str>\s*<+new_str>([\s\S]*?)<+\/new_str>/g; function parseAttributes(raw: string): Record { const attrs: Record = {}; const attrRegex = /([\w-]+)\s*=\s*"([^"]*)"/g; let match: RegExpExecArray | null; while ((match = attrRegex.exec(raw)) !== null) { attrs[match[1].toLowerCase()] = match[2]; } return attrs; } /** * If `text` ends with a partial occurrence of `tag` (e.g. "= tag.length) return -1; if (!tag.toLowerCase().startsWith(tail.toLowerCase())) return -1; // Swallow any doubled brackets right before the partial tag while (lastOpen > 0 && text[lastOpen - 1] === "<") lastOpen -= 1; return lastOpen; } /** Strip a trailing, partially-streamed `"); return idx === -1 ? content : content.slice(0, idx); } function parseUpdatePairs(inner: string): ArtifactUpdatePair[] { const pairs: ArtifactUpdatePair[] = []; UPDATE_PAIR_REGEX.lastIndex = 0; let match: RegExpExecArray | null; while ((match = UPDATE_PAIR_REGEX.exec(inner)) !== null) { pairs.push({ old: match[1], new: match[2] }); } return pairs; } function buildOperation(attrs: Record, inner: string, closed: boolean) { const identifier = attrs.identifier || attrs.id || "untitled-artifact"; const rawType = (attrs.type ?? "").trim().toLowerCase(); if (rawType === "update") { return { kind: "update" as const, identifier, title: attrs.title || undefined, pairs: parseUpdatePairs(inner), closed, }; } // Tolerate a single leading/trailing newline so models can format tags on their own lines let content = (closed ? inner : trimPartialCloseTag(inner)).replace(/^\r?\n/, ""); if (closed) content = content.replace(/\r?\n[ \t]*$/, ""); // Some models (Kimi-K2.6) double brackets on the content's leading tag too // ("< { if (content.length > 0) segments.push({ type: "text", content }); }; while (cursor < text.length) { const remaining = text.slice(cursor); const openMatch = OPEN_TAG_REGEX.exec(remaining); if (!openMatch) { // Hide a trailing partially-streamed opening tag (e.g. "]*$/i); if (partialAttr !== -1) { pushText(remaining.slice(0, partialAttr)); } else { const partial = partialTagStart(remaining, "; /** Lookup for inline cards, keyed by `${messageId}:${opIndexWithinMessage}` */ byMessageOp: Map; /** The artifact version currently being streamed, if any */ streaming?: ArtifactCardRef; } export function artifactOpKey(messageId: Message["id"], opIndex: number): string { return `${messageId}:${opIndex}`; } /** * Walk the active conversation path and fold artifact operations into * versioned artifacts. Updates apply onto the latest version of the same * identifier; re-emitting a known identifier counts as a rewrite. * * `liveMessageId` is the message currently receiving tokens, if any. An * unclosed tag only means "still streaming" there; anywhere else nothing can * append to the content anymore (aborted or errored generation), so the * version is final and flagged `interrupted` instead of spinning forever. */ export function collectArtifacts( messages: Array>, liveMessageId?: Message["id"] ): ArtifactRegistry { const artifacts = new Map(); const byMessageOp = new Map(); let streaming: ArtifactCardRef | undefined; for (const message of messages) { if (message.from !== "assistant" || !message.content.includes(" reasoning before parsing: models rehearse artifact tags in // there, which must not become phantom versions. Mirrors ChatMessage, // which splits think blocks out before expanding artifact cards, keeping // opIndex numbering consistent between the two. const visibleContent = message.content.replace(/[\s\S]*?(?:<\/think>|$)/gi, ""); let opIndex = 0; for (const segment of splitArtifactSegments(visibleContent)) { if (segment.type !== "artifact") continue; const op = segment.op; const key = artifactOpKey(message.id, opIndex); opIndex += 1; let artifact = artifacts.get(op.identifier); const isLive = !op.closed && message.id === liveMessageId; const interrupted = !op.closed && !isLive; if (op.kind === "create") { if (!artifact) { artifact = { identifier: op.identifier, versions: [] }; artifacts.set(op.identifier, artifact); } const version: ArtifactVersion = { identifier: op.identifier, type: op.type, title: op.title, language: op.language, content: op.content, complete: !isLive, interrupted, op: artifact.versions.length > 0 ? "rewrite" : "create", version: artifact.versions.length + 1, messageId: message.id, }; artifact.versions.push(version); byMessageOp.set(key, { identifier: op.identifier, version: version.version }); if (isLive) { streaming = { identifier: op.identifier, version: version.version }; } continue; } // update op const base = artifact?.versions.at(-1); if (!artifact || !base) { // Update referencing an unknown artifact: surface a disabled card byMessageOp.set(key, { identifier: op.identifier, version: -1 }); continue; } const result = applyArtifactUpdate(base.content, op.pairs); const version: ArtifactVersion = { identifier: op.identifier, type: base.type, title: op.title || base.title, language: base.language, content: result.content, complete: !isLive, interrupted, op: "update", version: artifact.versions.length + 1, messageId: message.id, // A finished update with zero parsed pairs is a no-op the model // didn't intend — surface it as a failed edit instead of silently // showing "Edited" with unchanged content. failedPairs: Math.max(result.failed, !isLive && op.pairs.length === 0 ? 1 : 0), }; artifact.versions.push(version); byMessageOp.set(key, { identifier: op.identifier, version: version.version }); if (isLive) { streaming = { identifier: op.identifier, version: version.version }; } } } return { artifacts, byMessageOp, streaming }; } /** Remove artifact blocks from text (used for clipboard copies of messages). */ export function stripArtifacts(text: string): string { if (!text.includes(" (segment.type === "text" ? segment.content : "")) .join("") .replace(/\n{3,}/g, "\n\n") .trim(); } const CODE_LANGUAGE_EXTENSIONS: Record = { python: "py", javascript: "js", typescript: "ts", java: "java", c: "c", cpp: "cpp", "c++": "cpp", csharp: "cs", go: "go", rust: "rs", ruby: "rb", php: "php", swift: "swift", kotlin: "kt", bash: "sh", shell: "sh", sql: "sql", json: "json", yaml: "yaml", css: "css", scss: "scss", html: "html", }; function fileExtension(version: ArtifactVersion): string { switch (version.type) { case "html": return "html"; case "svg": return "svg"; case "markdown": return "md"; case "mermaid": return "mmd"; case "react": return "jsx"; case "code": return CODE_LANGUAGE_EXTENSIONS[version.language?.toLowerCase() ?? ""] ?? "txt"; } } export function artifactFileName(version: ArtifactVersion): string { return `${version.identifier}.${fileExtension(version)}`; }