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- {resultText || "No HTML artifact source was provided."} +
+ Generating artifact…
- )} - - ++ {resultText || "Waiting for HTML source"} +
+- {artifact.title} -
-+
HTML artifact ·{" "} {artifact.source === "tool" ? "tool call" : "fenced fallback"} + {artifact.isStreaming ? " · streaming" : ""}
- {artifact.code}
-
+