diff --git a/studio/frontend/src/components/assistant-ui/markdown-text.tsx b/studio/frontend/src/components/assistant-ui/markdown-text.tsx index efdef37e72..40fc8b8da6 100644 --- a/studio/frontend/src/components/assistant-ui/markdown-text.tsx +++ b/studio/frontend/src/components/assistant-ui/markdown-text.tsx @@ -4,6 +4,13 @@ "use client"; import { ArtifactCard, useChatRuntimeStore } from "@/features/chat"; +import { + getCodeFence, + isFullHtmlDocument, + isHtmlFence, + isRenderableRenderHtmlToolPart, + isSvgFence, +} from "@/features/chat/artifacts/html-fences"; import { copyToClipboard } from "@/lib/copy-to-clipboard"; import { preprocessLaTeX } from "@/lib/latex"; import { openLink } from "@/lib/open-link"; @@ -45,62 +52,16 @@ const STREAMDOWN_COMPONENTS = { }; const COPY_RESET_MS = 2000; const MERMAID_SOURCE_RE = /```mermaid\s*([\s\S]*?)```/i; -const CODE_FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*?)\r?\n?```$/; const ACTION_PANEL_CLASS = "pointer-events-auto flex shrink-0 items-center gap-1"; const ACTION_BUTTON_CLASS = "flex size-8 cursor-pointer items-center justify-center rounded-[10px] text-chat-icon-fg transition-all hover:bg-chat-icon-bg-hover hover:text-chat-icon-fg-hover disabled:cursor-not-allowed disabled:opacity-50"; -type CodeFence = { - language: string | null; - source: string; -}; - -type ToolCallPartLike = { - type?: string; - toolName?: string; - args?: unknown; - result?: unknown; -}; - -function isRenderableRenderHtmlToolPart(part: unknown): boolean { - const toolPart = part as ToolCallPartLike; - if (toolPart.type !== "tool-call" || toolPart.toolName !== "render_html") { - return false; - } - if ( - typeof toolPart.result === "string" && - toolPart.result.startsWith("Error:") - ) { - return false; - } - if ( - typeof toolPart.result === "string" && - toolPart.result.startsWith("Rendered HTML canvas") - ) { - return true; - } - const args = toolPart.args as { code?: unknown } | undefined; - return typeof args?.code === "string" && args.code.trim().length > 0; -} - function getMermaidSource(blockContent: string): string | null { const source = blockContent.match(MERMAID_SOURCE_RE)?.[1]?.trim(); return source && source.length > 0 ? source : null; } -function getCodeFence(blockContent: string): CodeFence | null { - const match = blockContent.trimEnd().match(CODE_FENCE_RE); - if (!match) { - return null; - } - - return { - language: match[1]?.trim() || null, - source: match[2], - }; -} - function getCodeFilename(language: string | null) { const extByLanguage: Record = { bash: "sh", @@ -131,28 +92,6 @@ function getCodeFilename(language: string | null) { return `snippet.${ext}`; } -function isSvgFence(codeFence: CodeFence): boolean { - const lang = codeFence.language?.toLowerCase() ?? ""; - if (lang === "svg") return true; - if (lang === "xml" || lang === "html") { - const trimmed = codeFence.source.trimStart(); - // Match followed by ]/i.test(trimmed); -} - const UNSAFE_SVG_RE = /]|on\w+\s*=|javascript:|]|]|]|]/i; @@ -285,15 +224,13 @@ function CodeBlockActions({ ); } -// DiffusionGemma renders its denoising live in the bubble (see DiffusionCanvas in -// thread.tsx) and has the HTML canvas feature on by default, so a full-HTML answer -// (e.g. a playable game) renders as an interactive card without the global toggle. +// Collapse a full-HTML answer in place into an artifact card. Diffusion keeps the +// raw code visible instead (the trailing MessageHtmlArtifacts appends its card). function StreamdownBlock(props: BlockProps) { const shouldCollapseHtmlArtifacts = useChatRuntimeStore( (state) => - state.artifactsEnabled || - state.collapseHtmlArtifacts || - state.loadedIsDiffusion, + (state.artifactsEnabled || state.collapseHtmlArtifacts) && + !state.loadedIsDiffusion, ); const messageHasRenderableRenderHtmlTool = useAuiState(({ message }) => message.parts.some(isRenderableRenderHtmlToolPart), diff --git a/studio/frontend/src/components/assistant-ui/message-html-artifacts.tsx b/studio/frontend/src/components/assistant-ui/message-html-artifacts.tsx new file mode 100644 index 0000000000..7555287211 Binary files /dev/null and b/studio/frontend/src/components/assistant-ui/message-html-artifacts.tsx differ diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index ee3754bdf6..c9dfb348d9 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -11,6 +11,7 @@ import { } from "@/components/assistant-ui/generated-image-overlay-context"; import { downloadImagePart } from "@/components/assistant-ui/image"; import { MarkdownText } from "@/components/assistant-ui/markdown-text"; +import { MessageHtmlArtifacts } from "@/components/assistant-ui/message-html-artifacts"; import { MessageTiming } from "@/components/assistant-ui/message-timing"; import { Reasoning, ReasoningGroup } from "@/components/assistant-ui/reasoning"; import { RagSourcesGroup } from "@/components/assistant-ui/rag-sources"; @@ -3227,6 +3228,7 @@ const AssistantMessage: FC = () => { /> + diff --git a/studio/frontend/src/features/chat/artifacts/artifact-card.tsx b/studio/frontend/src/features/chat/artifacts/artifact-card.tsx index 561cb04041..a1dabe8ecd 100644 --- a/studio/frontend/src/features/chat/artifacts/artifact-card.tsx +++ b/studio/frontend/src/features/chat/artifacts/artifact-card.tsx @@ -3,12 +3,14 @@ "use client"; +import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon"; import { cn } from "@/lib/utils"; import { useAuiState } from "@assistant-ui/react"; import { LayoutTwoColumnIcon as Layout2ColumnIcon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { useLayoutEffect, useMemo } from "react"; import { useChatRuntimeStore } from "../stores/chat-runtime-store"; +import type { ArtifactViewMode } from "./html-frame"; import { hasAutoOpenedArtifact, rememberAutoOpenedArtifact, @@ -20,6 +22,9 @@ import { createChatArtifact, } from "./types"; +const CARD_BASE = + "group/artifact-card relative flex min-h-[52px] cursor-pointer items-center overflow-hidden rounded-lg border border-border/70 bg-muted/15 px-3 py-2 text-left transition-colors hover:bg-muted/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 dark:bg-muted/10 dark:hover:bg-muted/20"; + export function ArtifactCard({ code, title, @@ -40,6 +45,11 @@ export function ArtifactCard({ isStreaming?: boolean; }) { const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId); + // Canvas mode collapses the raw code in place, so offer a Code button too. + // Diffusion keeps its code inline, so it needs no Code button. + const showCodeButton = useChatRuntimeStore( + (state) => state.artifactsEnabled && !state.loadedIsDiffusion, + ); const messageIdFromContext = useAuiState(({ message }) => message.id); const threadIdFromContext = useAuiState( ({ threads }) => threads.mainThreadId, @@ -87,7 +97,7 @@ export function ArtifactCard({ } rememberAutoOpenedArtifact(artifact.id); - openArtifact(artifact, { surface }); + openArtifact(artifact, { surface, view: "preview" }); }, [ artifact, autoOpen, @@ -97,45 +107,63 @@ export function ArtifactCard({ updateArtifact, ]); - return ( - - ) : null} -
- - - - {artifact.title} - - - HTML canvas - - + const renderButton = (view: ArtifactViewMode) => { + const isCode = view === "source"; + return ( +
- +
+ {isCode ? ( + + ) : ( + + )} + + + {isCode ? "HTML Code" : artifact.title} + + + HTML canvas + + + {isStreaming && !isCode ? ( + + Generating + + ) : null} +
+ + ); + }; + + if (!showCodeButton) { + return
{renderButton("preview")}
; + } + + return ( +
+ {renderButton("preview")} + {renderButton("source")} +
); } diff --git a/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx b/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx index 2d5cbabe8d..b27acdf6ee 100644 --- a/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx +++ b/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx @@ -31,6 +31,7 @@ import { } from "react"; import { Streamdown } from "streamdown"; import { ArtifactHtmlFrame, type ArtifactViewMode } from "./html-frame"; +import { useChatArtifactsStore } from "./store"; import type { ChatArtifact } from "./types"; import { getArtifactFilename } from "./types"; @@ -119,6 +120,8 @@ export function ArtifactSurface({ onOpenFullscreen?: () => void; }) { const [viewMode, setViewMode] = useState("preview"); + // Follow the view the opener asked for (Preview vs Code button), per artifact. + const requestedView = useChatArtifactsStore((state) => state.requestedView); const [copied, setCopied] = useState(false); const copyResetRef = useRef | null>(null); const surfaceRef = useRef(null); @@ -138,6 +141,10 @@ export function ArtifactSurface({ }; }, []); + useEffect(() => { + setViewMode(requestedView); + }, [artifact.id, requestedView]); + useEffect(() => { if (variant !== "overlay") return; previousFocusRef.current = document.activeElement; diff --git a/studio/frontend/src/features/chat/artifacts/html-fences.ts b/studio/frontend/src/features/chat/artifacts/html-fences.ts new file mode 100644 index 0000000000..5398cf1add --- /dev/null +++ b/studio/frontend/src/features/chat/artifacts/html-fences.ts @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +// Shared fenced-code helpers for the HTML-artifact render paths, hoisted from +// markdown-text.tsx so the in-place collapse and the post-message auto-render +// agree on what counts as a renderable HTML fence. + +export type CodeFence = { + language: string | null; + source: string; +}; + +// Matches one fenced block spanning the whole string (one pre-split block). +export const CODE_FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*?)\r?\n?```$/; + +export type ToolCallPartLike = { + type?: string; + toolName?: string; + args?: unknown; + result?: unknown; +}; + +// True when a part is a render_html tool call with usable code or a non-error result. +export function isRenderableRenderHtmlToolPart(part: unknown): boolean { + const toolPart = part as ToolCallPartLike; + if (toolPart.type !== "tool-call" || toolPart.toolName !== "render_html") { + return false; + } + if ( + typeof toolPart.result === "string" && + toolPart.result.startsWith("Error:") + ) { + return false; + } + if ( + typeof toolPart.result === "string" && + toolPart.result.startsWith("Rendered HTML canvas") + ) { + return true; + } + const args = toolPart.args as { code?: unknown } | undefined; + return typeof args?.code === "string" && args.code.trim().length > 0; +} + +export function getCodeFence(blockContent: string): CodeFence | null { + const match = blockContent.trimEnd().match(CODE_FENCE_RE); + if (!match) { + return null; + } + + return { + language: match[1]?.trim() || null, + source: match[2], + }; +} + +export function isSvgFence(codeFence: CodeFence): boolean { + const lang = codeFence.language?.toLowerCase() ?? ""; + if (lang === "svg") return true; + if (lang === "xml" || lang === "html") { + const trimmed = codeFence.source.trimStart(); + // then ]/i.test(trimmed); +} + +export interface HtmlFence { + source: string; + isFullDocument: boolean; + // Plain 3-backtick unindented fence: the only form the in-place collapser + // (CODE_FENCE_RE) recognizes, so only these may be skipped as already shown. + isPlainFence: boolean; + index: number; +} + +// Opening fence: up to 3 leading spaces, >=3 backticks, then a backtick-free info string. +const FENCE_OPEN_RE = /^( {0,3})(`{3,})([^`\r\n]*)$/; + +// Scan a full message for every closed ```html fence. Line-based so multiple +// fences are found and backticks inside a