From e1eaf6e202e5b6b5d7abbb762065a3325b8ee155 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Tue, 16 Jun 2026 17:38:42 -0700 Subject: [PATCH] Studio: HTML canvas cards in chat with auto-render, a Code view, and visible diffusion code (#6374) * Studio: auto-render fenced HTML in chat replies as canvas cards After an assistant reply finishes, append a clickable canvas card for each fenced html block in its text, with no render_html tool call and no extra message. Fragments and non-collapsed documents are covered; full documents already collapsed in place and blocks rendered by the render_html tool are skipped so nothing shows twice. Hoists the fence helpers out of markdown-text.tsx into a shared module (html-fences.ts) and adds a line-based multi-fence scanner so several html blocks in one reply are all found. * Studio: add HTML Code button to canvas cards and keep diffusion code visible When Canvas mode is on or a diffusion model is loaded, the canvas card shows a Preview and an HTML Code button side by side; Code opens the panel source view. The requested view is threaded through openArtifact so the surface opens to preview or code. Diffusion no longer collapses its full HTML answer, so the raw code stays in the message and the trailing canvas card is appended next to it. * Studio: drop the Code button on diffusion cards since their code is already inline * Studio: address review feedback on HTML canvas auto-cards - Build the fence-body indent regex once per fence instead of per line. - Only skip full-doc fences the in-place collapse can render (plain unindented triple-backtick), so 4-backtick or indented docs still get a card. - Scan each text part on its own so a fence cannot stitch across a tool, source, or reasoning part. - Exclude diffusion replies from the collapse/skip gates and the card Code button, since diffusion keeps its HTML inline. --------- Co-authored-by: Daniel Han --- .../components/assistant-ui/markdown-text.tsx | 85 ++-------- .../assistant-ui/message-html-artifacts.tsx | Bin 0 -> 2767 bytes .../src/components/assistant-ui/thread.tsx | 2 + .../features/chat/artifacts/artifact-card.tsx | 106 ++++++++----- .../chat/artifacts/artifact-surface.tsx | 7 + .../features/chat/artifacts/html-fences.ts | 145 ++++++++++++++++++ .../src/features/chat/artifacts/store.ts | 7 +- 7 files changed, 238 insertions(+), 114 deletions(-) create mode 100644 studio/frontend/src/components/assistant-ui/message-html-artifacts.tsx create mode 100644 studio/frontend/src/features/chat/artifacts/html-fences.ts 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 0000000000000000000000000000000000000000..7555287211b21d411d80c88f6b49c092eac99ce0 GIT binary patch literal 2767 zcmb7GT~8x76zy|<#bs2j5;d7@snmyM({0K}7YSHIXjN&oTFi`xadGUC?ExY-|Gnqh zGa-OlsXSx``}%&IbMEy_r?faf_-k^a3TeEY9GB7rRVnG-(fs%GlgUr{+le)LHJDCm z-!?0!Zt6h0Z+Cy5G){V0Q>Z2VVZ64Xrui`)nL*i35{88L$_IgfOXf9hRCMUTa(2k}{-%N1_iQr!$KLO)1@1Hlkpyh6k<3 zWGunLi9Tpf2eOh52Q3%Q{!lmKG$Pb-Roq!sQrR|I6(UIgj{L1^umza<%JvWr0myeB}HgK*tCERSm>P$C}B~Ak3i{&f%Ri7 zDt{5d6W3a%H{QPli08tE@!(%PPq7(u@>52A5}EQ_@i@(Ss}=z;KBc$O!K77FU{!3V zNMY4TI@*sVp30@&oa-$a<2j)+2S!9-y%-zF7Dek=6kKyuMRZXz&$m)GaS0?lfbJ!p zoRhaC*k8;XFp!aVkb(~?6g8+5g|ps+Xk*QUl@=Y)a7$m_wyuz4&@H?*%G`|dL1B## zbUweh{JJ>AV}AMRf@YL`#~mRbBBROXG<5>Q-4hJG9qq$t4Cg6v2q|1MBH1f^88sgaOg; zu|GlUm`JDm93325{~?b&%@9B96JRBc_BQTo(W$D}(u*)JBgC+Ml*m)=?NSc^Tb!d! zRG{>y@(L2(nlu~EJZ6ht(GcTc!_+v=2~3~FAzrK_>6~>wx`oGE*@WkE>9hhER-W!v zs3AF?44iR%0hA8H2`$FY3TL^Ed&oJR-?Y8{KTv?IfKd_QA<8SIgLK0o$BV@9%N83P zBBM8NC`)fhFPiq}m!jD^9Nhpf3>Y@Ys0$8a0_QpZKk85YZIu~57xXjakGiRbUg^f4 zukF2|>s7?$)r8x_AS!gr8m$0&sj8~=${J=9utVuZ9oyE&Az#>1qPMle{h**-4A>`7 z`>E+Ui33~sgzn4v*y)<}D;c=1v|(Jx0b&|uhlZzMN#DK!3z+7;`)55m0>Yhxon)3e z?${q}mx6YHRcg019aC;R*oIFHjtN{53%%<{#D3L1qc{qO2neJ(By>raTOabwuTl^ zVOKvdypqFHzie(k_LTm1Qj}cv1jnnBB_3U3{hR~Dgn6|mtT#{1%)a7uT94`T2I}!^ zpfMFX5XY}weMyWmU8$a2OLK$oCZ2?EdyYCX4D`qxtyu)!T2kTrQr+Pr2{+-HSjt(p z43k}|w0wv^6I4JqqM7Vwy-FXuXt8_a` { /> + 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