From e0d3562c86da87425f50c199860347b028b40632 Mon Sep 17 00:00:00 2001 From: wasimysaid Date: Mon, 25 May 2026 18:04:21 +0200 Subject: [PATCH 01/15] Studio: add chat HTML artifact primitives --- .../features/chat/artifacts/artifact-card.tsx | 171 ++++++++++++++++++ .../features/chat/artifacts/html-frame.tsx | 68 +++++++ .../src/features/chat/artifacts/store.ts | 64 +++++++ .../src/features/chat/artifacts/types.ts | 77 ++++++++ studio/frontend/src/features/chat/index.ts | 1 + 5 files changed, 381 insertions(+) create mode 100644 studio/frontend/src/features/chat/artifacts/artifact-card.tsx create mode 100644 studio/frontend/src/features/chat/artifacts/html-frame.tsx create mode 100644 studio/frontend/src/features/chat/artifacts/store.ts create mode 100644 studio/frontend/src/features/chat/artifacts/types.ts diff --git a/studio/frontend/src/features/chat/artifacts/artifact-card.tsx b/studio/frontend/src/features/chat/artifacts/artifact-card.tsx new file mode 100644 index 0000000000..3de7da1a2e --- /dev/null +++ b/studio/frontend/src/features/chat/artifacts/artifact-card.tsx @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"use client"; + +import { copyToClipboard } from "@/lib/copy-to-clipboard"; +import { cn } from "@/lib/utils"; +import { useChatRuntimeStore } from "../stores/chat-runtime-store"; +import { useAuiState } from "@assistant-ui/react"; +import { + CheckIcon, + CopyIcon, + DownloadIcon, + ExternalLinkIcon, + FileTextIcon, +} from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { ArtifactHtmlFrame } from "./html-frame"; +import { useChatArtifactsStore } from "./store"; +import { + type ChatArtifact, + type ChatArtifactSource, + createChatArtifact, + getArtifactFilename, +} from "./types"; + +const COPY_RESET_MS = 2000; + +function downloadTextFile(filename: string, text: string): void { + const blob = new Blob([text], { type: "text/html;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + window.setTimeout(() => URL.revokeObjectURL(url), 0); +} + +function useCopiedState() { + const [copied, setCopied] = useState(false); + const resetTimeoutRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current); + }; + }, []); + + const showCopied = () => { + setCopied(true); + if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current); + resetTimeoutRef.current = setTimeout(() => { + setCopied(false); + resetTimeoutRef.current = null; + }, COPY_RESET_MS); + }; + + return { copied, showCopied }; +} + +export function ArtifactCard({ + code, + title, + source, + sourceToolCallId, + sourceMessageId, + className, + preview = true, +}: { + code: string; + title?: string | null; + source: ChatArtifactSource; + sourceToolCallId?: string | null; + sourceMessageId?: string | null; + className?: string; + preview?: boolean; +}) { + const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId); + const messageIdFromContext = useAuiState(({ message }) => message.id); + const threadIdFromContext = useAuiState( + ({ threads }) => threads.mainThreadId, + ); + const artifactThreadId = threadIdFromContext ?? activeThreadId ?? null; + const openArtifact = useChatArtifactsStore((state) => state.openArtifact); + const { copied, showCopied } = useCopiedState(); + const artifact = useMemo( + () => + createChatArtifact({ + code, + title, + source, + sourceMessageId: sourceMessageId ?? messageIdFromContext ?? null, + sourceToolCallId: sourceToolCallId ?? null, + threadId: artifactThreadId, + }), + [ + artifactThreadId, + code, + messageIdFromContext, + source, + sourceMessageId, + sourceToolCallId, + title, + ], + ); + const filename = getArtifactFilename(artifact); + const surface = artifactThreadId ? "panel" : "overlay"; + + return ( +
+
+
+ +
+
+

+ {artifact.title} +

+

+ HTML artifact ·{" "} + {source === "tool" ? "tool call" : "fenced fallback"} +

+
+
+ + + +
+
+ {preview ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/studio/frontend/src/features/chat/artifacts/html-frame.tsx b/studio/frontend/src/features/chat/artifacts/html-frame.tsx new file mode 100644 index 0000000000..26a9924467 --- /dev/null +++ b/studio/frontend/src/features/chat/artifacts/html-frame.tsx @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"use client"; + +import { cn } from "@/lib/utils"; +import { useEffect, useMemo, useRef, useState } from "react"; + +const HTML_FRAME_DEFAULT_HEIGHT = 400; +const HTML_FRAME_MAX_HEIGHT = 900; + +export type ArtifactViewMode = "preview" | "source"; +export const ARTIFACT_VIEW_MODES: readonly ArtifactViewMode[] = [ + "preview", + "source", +]; + +export function isArtifactViewMode(value: string): value is ArtifactViewMode { + return (ARTIFACT_VIEW_MODES as readonly string[]).includes(value); +} + +export function buildArtifactSrcDoc(code: string): string { + const resizeScript = ``; + return `${code}\n${resizeScript}`; +} + +export function ArtifactHtmlFrame({ + code, + title = "HTML artifact preview", + className, + fill = false, +}: { + code: string; + title?: string; + className?: string; + fill?: boolean; +}) { + const iframeRef = useRef(null); + const [height, setHeight] = useState(HTML_FRAME_DEFAULT_HEIGHT); + const srcDoc = useMemo(() => buildArtifactSrcDoc(code), [code]); + + useEffect(() => { + const handler = (event: MessageEvent) => { + if (event.source !== iframeRef.current?.contentWindow) return; + if (typeof event.data?.chatArtifactHeight !== "number") return; + setHeight( + Math.min( + Math.max(event.data.chatArtifactHeight, 160), + HTML_FRAME_MAX_HEIGHT, + ), + ); + }; + window.addEventListener("message", handler); + return () => window.removeEventListener("message", handler); + }, []); + + return ( +