Studio: fix chat artifact panel and sandbox previews
This commit is contained in:
parent
1aef0eca51
commit
b6faf7ffcd
11 changed files with 342 additions and 129 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 = """<!doctype html>
|
||||
<html>
|
||||
<head><meta charset=\"utf-8\" /></head>
|
||||
<body>
|
||||
<script>
|
||||
(() => {
|
||||
const render = (html) => {
|
||||
document.open();
|
||||
document.write(html);
|
||||
document.close();
|
||||
};
|
||||
window.addEventListener("message", (event) => {
|
||||
const data = event.data;
|
||||
if (!data || data.type !== "unsloth:artifact-html" || typeof data.html !== "string") return;
|
||||
render(data.html);
|
||||
});
|
||||
parent.postMessage({ chatArtifactReady: true }, "*");
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</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"] = (
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> & {
|
||||
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<RenderHtmlArgs>();
|
||||
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 (
|
||||
<ArtifactCard
|
||||
code={code}
|
||||
title={title}
|
||||
source="tool"
|
||||
sourceToolCallId={toolCallId}
|
||||
preview={false}
|
||||
autoOpen={true}
|
||||
isStreaming={isRunning || codeIsStreaming}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolFallbackRoot open={open} onOpenChange={setOpen}>
|
||||
<ToolFallbackTrigger
|
||||
toolName={isRunning ? "Rendering HTML artifact…" : title}
|
||||
status={status}
|
||||
icon={FileTextIcon}
|
||||
/>
|
||||
<ToolFallbackContent>
|
||||
{isRunning && !hasCode ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<LoaderIcon className="size-3.5 animate-spin" />
|
||||
<span>Waiting for artifact source…</span>
|
||||
</div>
|
||||
) : hasCode ? (
|
||||
<ArtifactCard
|
||||
code={code}
|
||||
title={title}
|
||||
source="tool"
|
||||
sourceToolCallId={toolCallId}
|
||||
preview={false}
|
||||
/>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
|
||||
{resultText || "No HTML artifact source was provided."}
|
||||
<div className="my-3 overflow-hidden rounded-xl border border-border/80 bg-background/80 px-3 py-2.5 shadow-sm shadow-black/5 dark:bg-muted/10 dark:shadow-black/20">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
Generating artifact…
|
||||
</p>
|
||||
)}
|
||||
</ToolFallbackContent>
|
||||
</ToolFallbackRoot>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{resultText || "Waiting for HTML source"}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
|
||||
Generating
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="mt-2 h-1.5 overflow-hidden rounded-full bg-muted"
|
||||
aria-hidden={true}
|
||||
>
|
||||
<div className="h-full w-1/2 rounded-full bg-primary/25 shimmer motion-reduce:animate-none" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
|
||||
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<ChatArtifact>(
|
||||
() =>
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
"my-3 overflow-hidden rounded-xl border border-border bg-card/70 shadow-sm",
|
||||
"my-3 cursor-pointer overflow-hidden rounded-xl border border-border/80 bg-background/80 shadow-sm shadow-black/5 transition-colors hover:bg-muted/30",
|
||||
"dark:bg-muted/10 dark:shadow-black/20 dark:hover:bg-muted/20",
|
||||
className,
|
||||
)}
|
||||
onClick={() => openArtifact(artifact, { surface })}
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b border-border/70 px-3 py-2">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<FileTextIcon className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{artifact.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openArtifact(artifact, { surface });
|
||||
}}
|
||||
aria-label={`Open ${displayTitle}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{displayTitle}
|
||||
</p>
|
||||
{isStreaming ? (
|
||||
<span className="shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
|
||||
Generating
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
HTML artifact ·{" "}
|
||||
{source === "tool" ? "tool call" : "fenced fallback"}
|
||||
{source === "tool" ? "tool call" : "fenced fallback"} · {lineCount}{" "}
|
||||
lines
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title="Open artifact"
|
||||
aria-label="Open artifact"
|
||||
onClick={() => openArtifact(artifact, { surface })}
|
||||
>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title="Copy HTML"
|
||||
aria-label="Copy artifact HTML"
|
||||
onClick={async () => {
|
||||
onClick={async (event) => {
|
||||
event.stopPropagation();
|
||||
if (await copyToClipboard(artifact.code)) showCopied();
|
||||
}}
|
||||
>
|
||||
|
|
@ -158,17 +193,15 @@ export function ArtifactCard({
|
|||
className="flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title="Download HTML"
|
||||
aria-label="Download artifact HTML"
|
||||
onClick={() => downloadTextFile(filename, artifact.code)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
downloadTextFile(filename, artifact.code);
|
||||
}}
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{preview ? (
|
||||
<div className="max-h-[320px] overflow-auto bg-background">
|
||||
<ArtifactHtmlFrame code={artifact.code} title={artifact.title} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ArtifactViewMode>("preview");
|
||||
const [viewMode, setViewMode] = useState<ArtifactViewMode>(
|
||||
artifact.isStreaming ? "source" : "preview",
|
||||
);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const surfaceRef = useRef<HTMLElement>(null);
|
||||
const previousFocusRef = useRef<Element | null>(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({
|
|||
<p className="text-xs text-muted-foreground">
|
||||
HTML artifact ·{" "}
|
||||
{artifact.source === "tool" ? "tool call" : "fenced fallback"}
|
||||
{artifact.isStreaming ? " · streaming" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
|
|
@ -208,14 +245,18 @@ export function ArtifactSurface({
|
|||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
disabled={artifact.isStreaming && mode === "preview"}
|
||||
onClick={() => setViewMode(mode)}
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
viewMode === mode
|
||||
effectiveViewMode === mode
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
artifact.isStreaming &&
|
||||
mode === "preview" &&
|
||||
"cursor-not-allowed opacity-50",
|
||||
)}
|
||||
aria-pressed={viewMode === mode}
|
||||
aria-pressed={effectiveViewMode === mode}
|
||||
>
|
||||
{mode === "preview" ? "Preview" : "Source"}
|
||||
</button>
|
||||
|
|
@ -223,7 +264,7 @@ export function ArtifactSurface({
|
|||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden bg-background">
|
||||
{viewMode === "preview" ? (
|
||||
{effectiveViewMode === "preview" ? (
|
||||
<ArtifactHtmlFrame
|
||||
key={artifact.id}
|
||||
code={artifact.code}
|
||||
|
|
@ -232,9 +273,16 @@ export function ArtifactSurface({
|
|||
className="h-full"
|
||||
/>
|
||||
) : (
|
||||
<pre className="h-full overflow-auto p-4 text-xs leading-relaxed text-foreground whitespace-pre">
|
||||
<code>{artifact.code}</code>
|
||||
</pre>
|
||||
<div className="h-full overflow-auto text-xs leading-relaxed [&_[data-streamdown=code-block]]:!rounded-none [&_pre]:!m-0 [&_pre]:!bg-transparent [&_pre]:!p-0 [&_pre]:text-xs [&_pre]:leading-relaxed [&_code]:text-xs">
|
||||
<Streamdown
|
||||
mode="streaming"
|
||||
plugins={{ code: artifactSourceCodePlugin }}
|
||||
controls={{ code: false }}
|
||||
shikiTheme={[unslothLightTheme, unslothDarkTheme]}
|
||||
>
|
||||
{sourceMarkdown}
|
||||
</Streamdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -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<HTMLIFrameElement>(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 (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={srcDoc}
|
||||
src={src}
|
||||
sandbox="allow-scripts"
|
||||
referrerPolicy="no-referrer"
|
||||
onLoad={postArtifactHtml}
|
||||
className={cn("block w-full border-0 bg-background", className)}
|
||||
style={{ height: fill ? "100%" : height }}
|
||||
title={title}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ type ChatArtifactsState = {
|
|||
artifact: ChatArtifact,
|
||||
options?: { surface?: ChatArtifactSurface },
|
||||
) => void;
|
||||
updateArtifact: (artifact: ChatArtifact) => void;
|
||||
closeArtifactSurface: () => void;
|
||||
clearArtifactsForThread: (threadId: string | null | undefined) => void;
|
||||
resetArtifacts: () => void;
|
||||
|
|
@ -29,8 +30,14 @@ export const useChatArtifactsStore = create<ChatArtifactsState>((set) => ({
|
|||
selectedArtifactId: artifact.id,
|
||||
surface: options?.surface ?? state.surface,
|
||||
})),
|
||||
updateArtifact: (artifact) =>
|
||||
set((state) =>
|
||||
state.artifactsById[artifact.id]
|
||||
? { artifactsById: { [artifact.id]: artifact } }
|
||||
: state,
|
||||
),
|
||||
closeArtifactSurface: () =>
|
||||
set({ artifactsById: {}, selectedArtifactId: null }),
|
||||
set({ artifactsById: {}, selectedArtifactId: null, surface: "panel" }),
|
||||
clearArtifactsForThread: (threadId) =>
|
||||
set((state) => {
|
||||
if (!threadId) return state;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export interface ChatArtifact {
|
|||
sourceMessageId?: string | null;
|
||||
sourceToolCallId?: string | null;
|
||||
threadId?: string | null;
|
||||
isStreaming?: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
|
|
@ -22,6 +23,7 @@ export interface ChatArtifactInput {
|
|||
sourceMessageId?: string | null;
|
||||
sourceToolCallId?: string | null;
|
||||
threadId?: string | null;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_ARTIFACT_TITLE = "HTML artifact";
|
||||
|
|
@ -61,6 +63,7 @@ export function createChatArtifact(input: ChatArtifactInput): ChatArtifact {
|
|||
sourceMessageId: input.sourceMessageId ?? null,
|
||||
sourceToolCallId: input.sourceToolCallId ?? null,
|
||||
threadId: input.threadId ?? null,
|
||||
isStreaming: input.isStreaming,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,13 +17,15 @@ import {
|
|||
} from "@/components/ui/resizable";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { NativeModelChip } from "@/features/native-intents/components/native-model-chip";
|
||||
import { NativeModelDropOverlay } from "@/features/native-intents/components/native-model-drop-overlay";
|
||||
import { useNativeIntentStore } from "@/features/native-intents/store";
|
||||
import type { NativeIntent } from "@/features/native-intents/types";
|
||||
import { useChooseNativeModel } from "@/features/native-intents/use-native-dialogs";
|
||||
import { useNativeModelDrop } from "@/features/native-intents/use-native-drop";
|
||||
import { useNativePathLeasesSupported } from "@/features/native-intents/use-native-readiness";
|
||||
import {
|
||||
NativeModelChip,
|
||||
NativeModelDropOverlay,
|
||||
type NativeIntent,
|
||||
useChooseNativeModel,
|
||||
useNativeIntentStore,
|
||||
useNativeModelDrop,
|
||||
useNativePathLeasesSupported,
|
||||
} from "@/features/native-intents";
|
||||
import { GuidedTour, useGuidedTourController } from "@/features/tour";
|
||||
import { isTauri } from "@/lib/api-base";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -179,7 +181,9 @@ const SingleContent = memo(function SingleContent({
|
|||
const showArtifactPanel = Boolean(
|
||||
artifact &&
|
||||
artifactSurface === "panel" &&
|
||||
(!artifact.threadId || !threadId || artifact.threadId === threadId),
|
||||
(threadId
|
||||
? !artifact.threadId || artifact.threadId === threadId
|
||||
: Boolean(newThreadNonce)),
|
||||
);
|
||||
|
||||
const threadPane = (
|
||||
|
|
@ -197,21 +201,36 @@ const SingleContent = memo(function SingleContent({
|
|||
{showArtifactPanel && artifact ? (
|
||||
<ResizablePanelGroup
|
||||
orientation="horizontal"
|
||||
className="min-h-0 min-w-0 flex-1 basis-0"
|
||||
className="min-h-0 min-w-0 flex-1 basis-0 overflow-hidden"
|
||||
>
|
||||
<ResizablePanel defaultSize={64} minSize={38}>
|
||||
{threadPane}
|
||||
<ResizablePanel
|
||||
id="chat-thread"
|
||||
defaultSize="62%"
|
||||
minSize="42%"
|
||||
className="h-full min-h-0 min-w-0 overflow-hidden"
|
||||
>
|
||||
<div className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
|
||||
{threadPane}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle={true} />
|
||||
<ResizablePanel defaultSize={36} minSize={24} maxSize={55}>
|
||||
<ArtifactSurface
|
||||
artifact={artifact}
|
||||
variant="panel"
|
||||
onClose={onCloseArtifact}
|
||||
onOpenFullscreen={() =>
|
||||
openArtifact(artifact, { surface: "overlay" })
|
||||
}
|
||||
/>
|
||||
<ResizablePanel
|
||||
id="chat-artifact"
|
||||
defaultSize="38%"
|
||||
minSize="30%"
|
||||
maxSize="58%"
|
||||
className="h-full min-h-0 min-w-0 overflow-hidden"
|
||||
>
|
||||
<div className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
|
||||
<ArtifactSurface
|
||||
artifact={artifact}
|
||||
variant="panel"
|
||||
onClose={onCloseArtifact}
|
||||
onOpenFullscreen={() =>
|
||||
openArtifact(artifact, { surface: "overlay" })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
) : (
|
||||
|
|
@ -689,6 +708,7 @@ export function ChatPage(): ReactElement {
|
|||
const modelsError = useChatRuntimeStore((state) => state.modelsError);
|
||||
const modelLoading = useChatRuntimeStore((state) => state.modelLoading);
|
||||
const clearCheckpoint = useChatRuntimeStore((state) => state.clearCheckpoint);
|
||||
const resetArtifacts = useChatArtifactsStore((state) => state.resetArtifacts);
|
||||
const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId);
|
||||
const modelOperationInProgress = useChatRuntimeStore(
|
||||
(state) => state.modelLoading,
|
||||
|
|
@ -706,6 +726,7 @@ export function ChatPage(): ReactElement {
|
|||
useEffect(() => {
|
||||
const turnedOff = prevConnectionsEnabledRef.current && !connectionsEnabled;
|
||||
if (!connectionsEnabled && isExternalModelId(inferenceParams.checkpoint)) {
|
||||
resetArtifacts();
|
||||
clearCheckpoint();
|
||||
if (turnedOff) {
|
||||
toast.info("Connections disabled", {
|
||||
|
|
@ -714,7 +735,12 @@ export function ChatPage(): ReactElement {
|
|||
}
|
||||
}
|
||||
prevConnectionsEnabledRef.current = connectionsEnabled;
|
||||
}, [clearCheckpoint, connectionsEnabled, inferenceParams.checkpoint]);
|
||||
}, [
|
||||
clearCheckpoint,
|
||||
connectionsEnabled,
|
||||
inferenceParams.checkpoint,
|
||||
resetArtifacts,
|
||||
]);
|
||||
const pendingNativeModelIntent = useNativeIntentStore(
|
||||
(state) => state.pendingModelIntent,
|
||||
);
|
||||
|
|
@ -939,6 +965,12 @@ export function ChatPage(): ReactElement {
|
|||
closeArtifactSurface();
|
||||
}, [artifactViewKey, closeArtifactSurface]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view.mode !== "single") return;
|
||||
if (view.threadId || view.newThreadNonce || !selectedArtifact) return;
|
||||
closeArtifactSurface();
|
||||
}, [closeArtifactSurface, selectedArtifact, view]);
|
||||
|
||||
const hasActiveModel = Boolean(inferenceParams.checkpoint);
|
||||
const loadNativeModelIntent = useCallback(
|
||||
async (intent: NativeIntent, loadingDescription: string) => {
|
||||
|
|
@ -1190,8 +1222,9 @@ export function ChatPage(): ReactElement {
|
|||
],
|
||||
);
|
||||
const handleEject = useCallback(() => {
|
||||
resetArtifacts();
|
||||
void ejectModel();
|
||||
}, [ejectModel]);
|
||||
}, [ejectModel, resetArtifacts]);
|
||||
|
||||
const openModelSelector = useCallback(() => {
|
||||
setModelSelectorLocked(true);
|
||||
|
|
@ -1451,6 +1484,7 @@ export function ChatPage(): ReactElement {
|
|||
|
||||
const tourSteps = useMemo(
|
||||
() =>
|
||||
// eslint-disable-next-line react-hooks/refs -- buildChatTourSteps stores callbacks without invoking them during render.
|
||||
buildChatTourSteps({
|
||||
canCompare,
|
||||
openModelSelector,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { CHAT_HISTORY_UPDATED_EVENT } from "../api/chat-api";
|
||||
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
|
||||
import { useChatArtifactsStore } from "../artifacts/store";
|
||||
import type { ThreadRecord } from "../types";
|
||||
import {
|
||||
deleteStoredChatThreads,
|
||||
|
|
@ -157,6 +158,9 @@ export async function deleteChatItem(
|
|||
// generating against a thread that no longer exists.
|
||||
for (const id of threadIds) cancelIfRunning(id);
|
||||
|
||||
const artifactStore = useChatArtifactsStore.getState();
|
||||
for (const id of threadIds) artifactStore.clearArtifactsForThread(id);
|
||||
|
||||
// Optimistic tombstone: hide immediately; roll back on backend error.
|
||||
markChatThreadsDeleted(threadIds);
|
||||
notifyChatHistoryUpdated();
|
||||
|
|
|
|||
11
studio/frontend/src/features/native-intents/index.ts
Normal file
11
studio/frontend/src/features/native-intents/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
export { NativeModelChip } from "./components/native-model-chip";
|
||||
export { NativeModelDropOverlay } from "./components/native-model-drop-overlay";
|
||||
export { useNativeIntentStore } from "./store";
|
||||
export type { NativeIntent } from "./types";
|
||||
export { useChooseNativeModel } from "./use-native-dialogs";
|
||||
export { useNativeModelDrop } from "./use-native-drop";
|
||||
export type { NativeModelDropState } from "./use-native-drop";
|
||||
export { useNativePathLeasesSupported } from "./use-native-readiness";
|
||||
Loading…
Add table
Add a link
Reference in a new issue