diff --git a/studio/backend/main.py b/studio/backend/main.py index 004ae404cd..917c5acc86 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -312,6 +312,7 @@ from starlette.requests import Request as _StarletteRequest # noqa: E402 _CSP_SCRIPT_NONCE_HEADER = "x-internal-script-nonce" +_ARTIFACT_PREVIEW_FRAME_PATH = "/api/inference/artifact-preview-frame" def _build_csp(script_nonce: "str | None" = None) -> str: @@ -327,6 +328,7 @@ def _build_csp(script_nonce: "str | None" = None) -> str: "style-src 'self' 'unsafe-inline'; " f"{script_src}; " "font-src 'self' data:; " + "frame-src 'self'; " "frame-ancestors 'none'; " "form-action 'self'; " "base-uri 'self'" @@ -343,7 +345,8 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): if nonce is not None: del response.headers[_CSP_SCRIPT_NONCE_HEADER] response.headers.setdefault("Content-Security-Policy", _build_csp(nonce)) - response.headers.setdefault("X-Frame-Options", "DENY") + if request.url.path != _ARTIFACT_PREVIEW_FRAME_PATH: + response.headers.setdefault("X-Frame-Options", "DENY") response.headers.setdefault("X-Content-Type-Options", "nosniff") response.headers.setdefault("Referrer-Policy", "no-referrer") response.headers.setdefault( diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index efa9f0b6ad..e1d2c8008d 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -239,6 +239,58 @@ router = APIRouter() studio_router = APIRouter() +_ARTIFACT_PREVIEW_FRAME_CSP = ( + "default-src 'none'; " + "script-src 'unsafe-inline'; " + "style-src 'unsafe-inline'; " + "img-src data: blob:; " + "font-src data:; " + "media-src data: blob:; " + "connect-src 'none'; " + "object-src 'none'; " + "base-uri 'none'; " + "form-action 'none'; " + "sandbox allow-scripts" +) +_ARTIFACT_PREVIEW_FRAME_HTML = """ + + + + + +""" + + +@studio_router.get("/artifact-preview-frame", include_in_schema=False) +async def artifact_preview_frame(): + """Serve the opaque sandbox shell used for client-side HTML artifacts.""" + + return Response( + content=_ARTIFACT_PREVIEW_FRAME_HTML, + media_type="text/html; charset=utf-8", + headers={ + "Cache-Control": "no-store", + "Content-Security-Policy": _ARTIFACT_PREVIEW_FRAME_CSP, + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + }, + ) + + def _detect_safetensors_features(backend, chat_template: Optional[str]) -> dict: """Classify reasoning/tool capabilities via the GGUF classifier so flags match across backends. gpt-oss is overridden because Harmony @@ -419,15 +471,23 @@ async def _await_cancel_then_close(cancel_event, resp) -> None: return -# Appended to tool-use nudge to discourage plan-without-action +# Appended to tool-use nudge to discourage plan-without-action. +# Keep render_html guidance gated to turns where the artifact tool is actually +# present in the tool schema; otherwise small local models can hallucinate a +# missing tool call instead of following the fenced-HTML fallback prompt. _TOOL_ACTION_NUDGE = ( " IMPORTANT: Always call tools directly -- never write code yourself." " Never describe what you plan to do -- just call the tool immediately." - " For HTML, CSS, or JavaScript artifact requests, call render_html when it is available." " For non-artifact code requests, call the python tool when it is available." " For factual questions that require current information, call web_search when it is available." " Do NOT output raw code blocks when an enabled tool can satisfy the request." ) +_ARTIFACT_TOOL_ACTION_NUDGE = " For HTML, CSS, or JavaScript artifact requests, call render_html when it is available." + + +def _tool_action_nudge(has_artifact: bool) -> str: + return _TOOL_ACTION_NUDGE + (_ARTIFACT_TOOL_ACTION_NUDGE if has_artifact else "") + # Strip tool-call XML the speculative buffer in core/inference/llama_cpp.py # split across the visible/DRAIN boundary. Four leak shapes: @@ -2447,7 +2507,7 @@ async def openai_chat_completions( _nudge = "" if _nudge: - _nudge += _TOOL_ACTION_NUDGE + _nudge += _tool_action_nudge(_has_artifact) # Append nudge to system prompt (preserve user's prompt) if system_prompt: system_prompt = system_prompt.rstrip() + "\n\n" + _nudge @@ -2935,7 +2995,7 @@ async def openai_chat_completions( _sf_system_prompt = system_prompt if _sf_nudge: - _sf_nudge += _TOOL_ACTION_NUDGE + _sf_nudge += _tool_action_nudge(_sf_has_artifact) if _sf_system_prompt: _sf_system_prompt = _sf_system_prompt.rstrip() + "\n\n" + _sf_nudge else: @@ -4614,7 +4674,7 @@ async def anthropic_messages( _nudge = "" if _nudge: - _nudge += _TOOL_ACTION_NUDGE + _nudge += _tool_action_nudge(_has_artifact) # Inject into system prompt if openai_messages and openai_messages[0].get("role") == "system": openai_messages[0]["content"] = ( diff --git a/studio/frontend/src/components/assistant-ui/tool-ui-render-html.tsx b/studio/frontend/src/components/assistant-ui/tool-ui-render-html.tsx index 7e125183af..79b914d6de 100644 --- a/studio/frontend/src/components/assistant-ui/tool-ui-render-html.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-ui-render-html.tsx @@ -6,20 +6,16 @@ import { ArtifactCard } from "@/features/chat"; import { type ToolCallMessagePartComponent, - useAuiState, + useToolArgsStatus, } from "@assistant-ui/react"; -import { FileTextIcon, LoaderIcon } from "lucide-react"; -import { memo, useEffect, useState } from "react"; -import { - ToolFallbackContent, - ToolFallbackRoot, - ToolFallbackTrigger, -} from "./tool-fallback"; +import { memo } from "react"; -interface RenderHtmlArgs { +// Context7 assistant-ui docs: tool UIs can read streaming args via +// useToolArgsStatus, so render_html does not need to wait for tool completion. +type RenderHtmlArgs = Record & { code?: string; title?: string; -} +}; function formatToolResult(result: unknown): string { if (typeof result === "string") return result; @@ -37,6 +33,7 @@ const RenderHtmlToolUIImpl: ToolCallMessagePartComponent = ({ status, toolCallId, }) => { + const { propStatus } = useToolArgsStatus(); const parsedArgs = (args as RenderHtmlArgs) ?? {}; const code = typeof parsedArgs.code === "string" ? parsedArgs.code : ""; const hasCode = code.trim().length > 0; @@ -44,50 +41,44 @@ const RenderHtmlToolUIImpl: ToolCallMessagePartComponent = ({ typeof parsedArgs.title === "string" ? parsedArgs.title : "HTML artifact"; const resultText = formatToolResult(result); const isRunning = status?.type === "running"; - const hasText = useAuiState(({ message }) => - message.content.some( - (part) => - part.type === "text" && - "text" in part && - (part as { text: string }).text.length > 0, - ), - ); - const [open, setOpen] = useState(true); + const codeIsStreaming = propStatus.code === "streaming"; - useEffect(() => { - const nextOpen = isRunning ? true : !hasText; - const timeoutId = window.setTimeout(() => setOpen(nextOpen), 0); - return () => window.clearTimeout(timeoutId); - }, [hasText, isRunning]); + if (hasCode) { + return ( + + ); + } return ( - - - - {isRunning && !hasCode ? ( -
- - Waiting for artifact source… -
- ) : hasCode ? ( - - ) : ( -

- {resultText || "No HTML artifact source was provided."} +

+
+
+

+ Generating artifact…

- )} - - +

+ {resultText || "Waiting for HTML source"} +

+
+ + Generating + +
+
+
+
+
); }; diff --git a/studio/frontend/src/features/chat/artifacts/artifact-card.tsx b/studio/frontend/src/features/chat/artifacts/artifact-card.tsx index 1a6a088931..cece2a43ed 100644 --- a/studio/frontend/src/features/chat/artifacts/artifact-card.tsx +++ b/studio/frontend/src/features/chat/artifacts/artifact-card.tsx @@ -6,16 +6,9 @@ import { copyToClipboard } from "@/lib/copy-to-clipboard"; import { cn } from "@/lib/utils"; import { useAuiState } from "@assistant-ui/react"; -import { - CheckIcon, - CopyIcon, - DownloadIcon, - ExternalLinkIcon, - FileTextIcon, -} from "lucide-react"; +import { CheckIcon, CopyIcon, DownloadIcon } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { useChatRuntimeStore } from "../stores/chat-runtime-store"; -import { ArtifactHtmlFrame } from "./html-frame"; import { useChatArtifactsStore } from "./store"; import { type ChatArtifact, @@ -25,6 +18,7 @@ import { } from "./types"; const COPY_RESET_MS = 2000; +const autoOpenedArtifactIds = new Set(); function downloadTextFile(filename: string, text: string): void { const blob = new Blob([text], { type: "text/html;charset=utf-8" }); @@ -60,6 +54,10 @@ function useCopiedState() { return { copied, showCopied }; } +function artifactDisplayTitle(title: string): string { + return /artifact/i.test(title) ? title : `${title} Artifact`; +} + export function ArtifactCard({ code, title, @@ -67,7 +65,8 @@ export function ArtifactCard({ sourceToolCallId, sourceMessageId, className, - preview = true, + autoOpen = false, + isStreaming = false, }: { code: string; title?: string | null; @@ -76,6 +75,8 @@ export function ArtifactCard({ sourceMessageId?: string | null; className?: string; preview?: boolean; + autoOpen?: boolean; + isStreaming?: boolean; }) { const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId); const messageIdFromContext = useAuiState(({ message }) => message.id); @@ -84,6 +85,10 @@ export function ArtifactCard({ ); const artifactThreadId = threadIdFromContext ?? activeThreadId ?? null; const openArtifact = useChatArtifactsStore((state) => state.openArtifact); + const updateArtifact = useChatArtifactsStore((state) => state.updateArtifact); + const selectedArtifactId = useChatArtifactsStore( + (state) => state.selectedArtifactId, + ); const { copied, showCopied } = useCopiedState(); const artifact = useMemo( () => @@ -94,10 +99,12 @@ export function ArtifactCard({ sourceMessageId: sourceMessageId ?? messageIdFromContext ?? null, sourceToolCallId: sourceToolCallId ?? null, threadId: artifactThreadId, + isStreaming, }), [ artifactThreadId, code, + isStreaming, messageIdFromContext, source, sourceMessageId, @@ -107,43 +114,71 @@ export function ArtifactCard({ ); const filename = getArtifactFilename(artifact); const surface = artifactThreadId ? "panel" : "overlay"; + const lineCount = artifact.code.split("\n").length; + const displayTitle = artifactDisplayTitle(artifact.title); + + useEffect(() => { + if (!autoOpen) return; + if (!autoOpenedArtifactIds.has(artifact.id)) { + autoOpenedArtifactIds.add(artifact.id); + openArtifact(artifact, { surface }); + return; + } + if (selectedArtifactId === artifact.id) { + updateArtifact(artifact); + } + }, [ + artifact, + autoOpen, + openArtifact, + selectedArtifactId, + surface, + updateArtifact, + ]); return (
openArtifact(artifact, { surface })} > -
-
- -
-
-

- {artifact.title} -

-

+

+
+
-
- {preview ? ( -
- -
- ) : null}
); } diff --git a/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx b/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx index f5fce60c08..d62a5ca0e2 100644 --- a/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx +++ b/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx @@ -3,6 +3,11 @@ "use client"; +import { createCodePlugin } from "@/components/assistant-ui/code-plugin"; +import { + unslothDarkTheme, + unslothLightTheme, +} from "@/components/assistant-ui/code-themes"; import { Button } from "@/components/ui/button"; import { copyToClipboard } from "@/lib/copy-to-clipboard"; import { cn } from "@/lib/utils"; @@ -14,12 +19,35 @@ import { Maximize2Icon, XIcon, } from "lucide-react"; -import { type KeyboardEvent, useEffect, useRef, useState } from "react"; +import { + type KeyboardEvent, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Streamdown } from "streamdown"; import { ArtifactHtmlFrame, type ArtifactViewMode } from "./html-frame"; import type { ChatArtifact } from "./types"; import { getArtifactFilename } from "./types"; const COPY_RESET_MS = 2000; +const artifactSourceCodePlugin = createCodePlugin({ + themes: [unslothLightTheme, unslothDarkTheme], +}); + +function buildHtmlFence(source: string): string { + const longestBacktickRun = Math.max( + 2, + ...(source.match(/`+/g) ?? []).map((match) => match.length), + ); + const fence = "`".repeat(longestBacktickRun + 1); + return `${fence}html\n${source}\n${fence}`; +} +// Sandboxed artifact iframes are intentionally excluded from the overlay focus +// trap. Granting same-origin sandbox privileges would weaken isolation, so +// keyboard users can reach Studio controls here while fully interactive artifact +// content remains a known sandbox limitation. const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; @@ -57,12 +85,20 @@ export function ArtifactSurface({ onClose: () => void; onOpenFullscreen?: () => void; }) { - const [viewMode, setViewMode] = useState("preview"); + const [viewMode, setViewMode] = useState( + artifact.isStreaming ? "source" : "preview", + ); const [copied, setCopied] = useState(false); const copyResetRef = useRef | null>(null); const surfaceRef = useRef(null); const previousFocusRef = useRef(null); const filename = getArtifactFilename(artifact); + const sourceMarkdown = useMemo( + () => buildHtmlFence(artifact.code), + [artifact.code], + ); + const effectiveViewMode = + artifact.isStreaming && viewMode === "preview" ? "source" : viewMode; useEffect(() => { return () => { @@ -135,7 +171,7 @@ export function ArtifactSurface({ className={cn( "flex min-h-0 flex-col overflow-hidden border border-border bg-background shadow-xl", variant === "panel" - ? "h-full w-full rounded-none border-y-0 border-r-0" + ? "mt-[48px] h-[calc(100%_-_48px)] w-full rounded-none border-y-0 border-r-0" : "h-[min(92vh,900px)] w-[min(96vw,1200px)] rounded-2xl", )} aria-label={`${artifact.title} artifact`} @@ -151,6 +187,7 @@ export function ArtifactSurface({

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

@@ -208,14 +245,18 @@ export function ArtifactSurface({ @@ -223,7 +264,7 @@ export function ArtifactSurface({
- {viewMode === "preview" ? ( + {effectiveViewMode === "preview" ? ( ) : ( -
-            {artifact.code}
-          
+
+ + {sourceMarkdown} + +
)}
diff --git a/studio/frontend/src/features/chat/artifacts/html-frame.tsx b/studio/frontend/src/features/chat/artifacts/html-frame.tsx index f3814e7b4e..1ac4dc3acd 100644 --- a/studio/frontend/src/features/chat/artifacts/html-frame.tsx +++ b/studio/frontend/src/features/chat/artifacts/html-frame.tsx @@ -3,8 +3,10 @@ "use client"; +import { apiUrl } from "@/lib/api-base"; import { cn } from "@/lib/utils"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { hashArtifactCode } from "./types"; const HTML_FRAME_DEFAULT_HEIGHT = 400; const HTML_FRAME_MAX_HEIGHT = 900; @@ -40,11 +42,27 @@ export function ArtifactHtmlFrame({ }) { const iframeRef = useRef(null); const [height, setHeight] = useState(HTML_FRAME_DEFAULT_HEIGHT); - const srcDoc = useMemo(() => buildArtifactSrcDoc(code), [code]); + const artifactHtml = useMemo(() => buildArtifactSrcDoc(code), [code]); + const src = useMemo( + () => + apiUrl( + `/api/inference/artifact-preview-frame?v=${encodeURIComponent(hashArtifactCode(code))}`, + ), + [code], + ); + const postArtifactHtml = useCallback(() => { + iframeRef.current?.contentWindow?.postMessage( + { type: "unsloth:artifact-html", html: artifactHtml }, + "*", + ); + }, [artifactHtml]); useEffect(() => { const handler = (event: MessageEvent) => { if (event.source !== iframeRef.current?.contentWindow) return; + if (event.data?.chatArtifactReady === true) { + postArtifactHtml(); + } if (typeof event.data?.chatArtifactHeight !== "number") return; setHeight( Math.min( @@ -55,14 +73,15 @@ export function ArtifactHtmlFrame({ }; window.addEventListener("message", handler); return () => window.removeEventListener("message", handler); - }, []); + }, [postArtifactHtml]); return (