Mike0021's picture
Use upstream chat-ui frontend
016ed07 verified
Raw
History Blame Contribute Delete
15.1 kB
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)}`;
}