Spaces:
Sleeping
Sleeping
| import type { Message } from "$lib/types/Message"; | |
| /** | |
| * Artifacts: substantial, self-contained pieces of content (apps, documents, | |
| * components, diagrams) that the model emits inside <artifact> tags. They are | |
| * rendered in a dedicated side panel with live preview, next to the chat. | |
| * | |
| * Two operations exist: | |
| * - create/rewrite: `<artifact identifier="id" type="html" title="...">full content</artifact>` | |
| * - update: `<artifact identifier="id" type="update">` containing | |
| * `<old_str>…</old_str><new_str>…</new_str>` 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<string, ArtifactKind> = { | |
| 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 | |
| // (`<<artifact …>`, `<<old_str>`), so every opening matcher tolerates a run of | |
| // `<` and consumes it whole — otherwise the extra bracket leaks into the prose | |
| // and, worse, `<old_str>…<new_str>` 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<string, string> { | |
| const attrs: Record<string, string> = {}; | |
| 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. "<artifa" for | |
| * "<artifact"), return the index where that partial starts, otherwise -1. | |
| * Used to hide tags that are still streaming in token by token. | |
| */ | |
| function partialTagStart(text: string, tag: string): number { | |
| let lastOpen = text.lastIndexOf("<"); | |
| if (lastOpen === -1) return -1; | |
| const tail = text.slice(lastOpen); | |
| if (tail.length >= 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 `</artifact` fragment from content. */ | |
| function trimPartialCloseTag(content: string): string { | |
| const idx = partialTagStart(content, "</artifact>"); | |
| 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<string, string>, 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 | |
| // ("<<svg ..."), not just on the protocol tags — collapse it so the stray | |
| // "<" doesn't render as text in the preview or pollute the code view | |
| content = content.replace(/^(\s*)<{2,}(?=[!/a-zA-Z])/, "$1<"); | |
| return { | |
| kind: "create" as const, | |
| identifier, | |
| type: normalizeArtifactKind(rawType), | |
| title: attrs.title || identifier, | |
| language: attrs.language || undefined, | |
| content, | |
| closed, | |
| }; | |
| } | |
| /** | |
| * Split raw message text into plain-text and artifact segments. | |
| * Streaming-safe: an unterminated artifact yields a `closed: false` op, and a | |
| * partially-received opening tag at the very end of the text is hidden. | |
| */ | |
| export function splitArtifactSegments(text: string): ArtifactSegment[] { | |
| const segments: ArtifactSegment[] = []; | |
| let cursor = 0; | |
| const pushText = (content: string) => { | |
| 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. "<artifact iden…") | |
| const partialAttr = remaining.search(/<+artifact\b[^>]*$/i); | |
| if (partialAttr !== -1) { | |
| pushText(remaining.slice(0, partialAttr)); | |
| } else { | |
| const partial = partialTagStart(remaining, "<artifact"); | |
| pushText(partial === -1 ? remaining : remaining.slice(0, partial)); | |
| } | |
| break; | |
| } | |
| pushText(remaining.slice(0, openMatch.index)); | |
| const attrs = parseAttributes(openMatch[1]); | |
| const contentStart = openMatch.index + openMatch[0].length; | |
| const closeMatch = CLOSE_TAG_REGEX.exec(remaining.slice(contentStart)); | |
| if (!closeMatch) { | |
| // Still streaming: everything after the opening tag belongs to the artifact | |
| const inner = remaining.slice(contentStart); | |
| segments.push({ type: "artifact", op: buildOperation(attrs, inner, false) }); | |
| break; | |
| } | |
| const inner = remaining.slice(contentStart, contentStart + closeMatch.index); | |
| segments.push({ type: "artifact", op: buildOperation(attrs, inner, true) }); | |
| cursor += contentStart + closeMatch.index + closeMatch[0].length; | |
| } | |
| return segments; | |
| } | |
| export interface ApplyUpdateResult { | |
| content: string; | |
| applied: number; | |
| failed: number; | |
| } | |
| function escapeRegExp(s: string): string { | |
| return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | |
| } | |
| /** | |
| * Locate `needle` in `content`. Exact match first; if that fails, retry with | |
| * every whitespace run collapsed to \s+ — models routinely mis-copy | |
| * indentation in old_str, and a strict-only match would turn the whole edit | |
| * into a silent no-op (observed live with Kimi-K2.6 counting spaces). | |
| * Bracket runs are matched loosely the same way ("<<svg" ≈ "<svg"), since the | |
| * stored content is normalized but models quote their own raw output. | |
| */ | |
| function findMatch(content: string, needle: string): { start: number; end: number } | null { | |
| const exact = content.indexOf(needle); | |
| if (exact !== -1) return { start: exact, end: exact + needle.length }; | |
| const trimmed = needle.trim(); | |
| if (!trimmed) return null; | |
| try { | |
| const pattern = new RegExp( | |
| escapeRegExp(trimmed.replace(/<{2,}/g, "<")).replace(/</g, "<+").replace(/\s+/g, "\\s+") | |
| ); | |
| const match = pattern.exec(content); | |
| if (match) return { start: match.index, end: match.index + match[0].length }; | |
| } catch { | |
| // needle too large/odd for a regex — treat as not found | |
| } | |
| return null; | |
| } | |
| /** | |
| * Apply find/replace pairs to a base version. Only the first occurrence is | |
| * replaced (whitespace-tolerant fallback, see findMatch). Failed pairs are | |
| * skipped so one bad match doesn't void the rest of the edit. | |
| */ | |
| export function applyArtifactUpdate(base: string, pairs: ArtifactUpdatePair[]): ApplyUpdateResult { | |
| let content = base; | |
| let applied = 0; | |
| let failed = 0; | |
| for (const pair of pairs) { | |
| if (pair.old.length === 0) { | |
| failed += 1; | |
| continue; | |
| } | |
| const match = findMatch(content, pair.old); | |
| if (!match) { | |
| failed += 1; | |
| continue; | |
| } | |
| content = content.slice(0, match.start) + pair.new + content.slice(match.end); | |
| applied += 1; | |
| } | |
| return { content, applied, failed }; | |
| } | |
| export type ArtifactOpLabel = "create" | "rewrite" | "update"; | |
| export interface ArtifactVersion { | |
| identifier: string; | |
| type: ArtifactKind; | |
| title: string; | |
| language?: string; | |
| content: string; | |
| complete: boolean; | |
| /** Generation ended (abort/error) before the closing tag arrived */ | |
| interrupted?: boolean; | |
| op: ArtifactOpLabel; | |
| /** 1-based version number within the artifact */ | |
| version: number; | |
| messageId: Message["id"]; | |
| /** For updates: number of find/replace pairs that did not match */ | |
| failedPairs?: number; | |
| } | |
| export interface Artifact { | |
| identifier: string; | |
| versions: ArtifactVersion[]; | |
| } | |
| export interface ArtifactCardRef { | |
| identifier: string; | |
| /** 1-based version this card points at; -1 when the op couldn't be applied */ | |
| version: number; | |
| } | |
| export interface ArtifactRegistry { | |
| artifacts: Map<string, Artifact>; | |
| /** Lookup for inline cards, keyed by `${messageId}:${opIndexWithinMessage}` */ | |
| byMessageOp: Map<string, ArtifactCardRef>; | |
| /** 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<Pick<Message, "id" | "from" | "content">>, | |
| liveMessageId?: Message["id"] | |
| ): ArtifactRegistry { | |
| const artifacts = new Map<string, Artifact>(); | |
| const byMessageOp = new Map<string, ArtifactCardRef>(); | |
| let streaming: ArtifactCardRef | undefined; | |
| for (const message of messages) { | |
| if (message.from !== "assistant" || !message.content.includes("<artifact")) continue; | |
| // Drop <think> 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(/<think>[\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("<artifact")) return text; | |
| return splitArtifactSegments(text) | |
| .map((segment) => (segment.type === "text" ? segment.content : "")) | |
| .join("") | |
| .replace(/\n{3,}/g, "\n\n") | |
| .trim(); | |
| } | |
| const CODE_LANGUAGE_EXTENSIONS: Record<string, string> = { | |
| 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)}`; | |
| } | |