Studio: fix chat artifact review regressions

This commit is contained in:
wasimysaid 2026-05-25 19:15:22 +02:00
commit 1aef0eca51
8 changed files with 127 additions and 37 deletions

View file

@ -10,12 +10,12 @@ import { openLink } from "@/lib/open-link";
import { INTERNAL, useMessagePartText } from "@assistant-ui/react";
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { createCodePlugin } from "./code-plugin";
import { createMathPlugin } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { DownloadIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Block, type BlockProps, Streamdown } from "streamdown";
import { createCodePlugin } from "./code-plugin";
import "katex/dist/katex.min.css";
import { AudioPlayer } from "./audio-player";
import { unslothDarkTheme, unslothLightTheme } from "./code-themes";
@ -307,12 +307,7 @@ function StreamdownBlock(props: BlockProps) {
!props.isIncomplete && isHtmlFence(codeFence) ? codeFence.source : null;
if (htmlSource) {
return (
<ArtifactCard
code={htmlSource}
title="HTML preview"
source="fence"
preview={false}
/>
<ArtifactCard code={htmlSource} title="HTML preview" source="fence" />
);
}

View file

@ -21,15 +21,28 @@ interface RenderHtmlArgs {
title?: string;
}
function formatToolResult(result: unknown): string {
if (typeof result === "string") return result;
if (result == null) return "";
try {
return JSON.stringify(result, null, 2);
} catch {
return String(result);
}
}
const RenderHtmlToolUIImpl: ToolCallMessagePartComponent = ({
args,
result,
status,
toolCallId,
}) => {
const parsedArgs = (args as RenderHtmlArgs) ?? {};
const code = typeof parsedArgs.code === "string" ? parsedArgs.code : "";
const hasCode = code.trim().length > 0;
const title =
typeof parsedArgs.title === "string" ? parsedArgs.title : "HTML artifact";
const resultText = formatToolResult(result);
const isRunning = status?.type === "running";
const hasText = useAuiState(({ message }) =>
message.content.some(
@ -42,11 +55,10 @@ const RenderHtmlToolUIImpl: ToolCallMessagePartComponent = ({
const [open, setOpen] = useState(true);
useEffect(() => {
const nextOpen = isRunning || code ? true : hasText ? false : null;
if (nextOpen == null) return;
const nextOpen = isRunning ? true : !hasText;
const timeoutId = window.setTimeout(() => setOpen(nextOpen), 0);
return () => window.clearTimeout(timeoutId);
}, [code, hasText, isRunning]);
}, [hasText, isRunning]);
return (
<ToolFallbackRoot open={open} onOpenChange={setOpen}>
@ -56,12 +68,12 @@ const RenderHtmlToolUIImpl: ToolCallMessagePartComponent = ({
icon={FileTextIcon}
/>
<ToolFallbackContent>
{isRunning && !code ? (
{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>
) : code ? (
) : hasCode ? (
<ArtifactCard
code={code}
title={title}
@ -70,8 +82,8 @@ const RenderHtmlToolUIImpl: ToolCallMessagePartComponent = ({
preview={false}
/>
) : (
<p className="text-sm text-muted-foreground">
No HTML artifact source was provided.
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
{resultText || "No HTML artifact source was provided."}
</p>
)}
</ToolFallbackContent>

View file

@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { getAuthToken } from "@/features/auth/session";
import { getAuthToken } from "@/features/auth";
import { apiUrl } from "@/lib/api-base";
import { toast } from "@/lib/toast";
import type { MessageTiming, ToolCallMessagePart } from "@assistant-ui/core";
@ -37,12 +37,12 @@ import type {
OpenAIMessageContent,
} from "../types/api";
import type { ChatModelSummary } from "../types/runtime";
import { getImageInputUnavailableReason } from "../utils/image-input-support";
import {
getStoredChatThread,
listStoredChatThreads,
updateStoredChatThread,
} from "../utils/chat-history-storage";
import { getImageInputUnavailableReason } from "../utils/image-input-support";
import {
hasClosedThinkTag,
parseAssistantContent,
@ -994,11 +994,19 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
targetMessages.unshift({ role: "system", content: text });
}
// Keep render_html local-only for now. External providers already have
// provider-specific built-in tool translation, but no generic custom-tool
// round-trip loop; they get the fenced-html artifact fallback instead.
const imageBase64 = findLatestUserImageBase64(messages);
const audioBase64 = findLatestUserAudioBase64(messages);
const hasOutboundImage = Boolean(imageBase64);
// Keep render_html local-only and mirror the backend image-turn gate.
// GGUF/safetensors disable tool execution when an image is present, so
// image turns receive the fenced-html artifact fallback instead of being
// prompted to call a tool the backend will not expose.
const renderHtmlToolEnabledForThisTurn = Boolean(
!isExternalRequest && supportsTools && artifactsEnabled,
!isExternalRequest &&
supportsTools &&
artifactsEnabled &&
!hasOutboundImage,
);
const artifactInstruction = artifactsEnabled
? renderHtmlToolEnabledForThisTurn
@ -1011,8 +1019,6 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
: disabledToolGuard;
addSystemInstruction(outboundMessages, effectiveDisabledToolGuard);
addSystemInstruction(outboundMessages, artifactInstruction);
const imageBase64 = findLatestUserImageBase64(messages);
const audioBase64 = findLatestUserAudioBase64(messages);
// Block when ANY image is in the outbound payload (current or
// prior turns) and the loaded model can't process images. Keeps
@ -1542,13 +1548,17 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
? { preserve_thinking: preserveThinking }
: {}),
...(supportsTools &&
(toolsEnabled || codeToolsEnabled || artifactsEnabled)
(toolsEnabled ||
codeToolsEnabled ||
renderHtmlToolEnabledForThisTurn)
? {
enable_tools: true,
enabled_tools: [
...(toolsEnabled ? ["web_search"] : []),
...(codeToolsEnabled ? ["python", "terminal"] : []),
...(artifactsEnabled ? ["render_html"] : []),
...(renderHtmlToolEnabledForThisTurn
? ["render_html"]
: []),
],
auto_heal_tool_calls:
useChatRuntimeStore.getState().autoHealToolCalls,

View file

@ -5,7 +5,6 @@
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { cn } from "@/lib/utils";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
import { useAuiState } from "@assistant-ui/react";
import {
CheckIcon,
@ -15,6 +14,7 @@ import {
FileTextIcon,
} 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 {
@ -133,6 +133,7 @@ export function ArtifactCard({
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" />
@ -141,6 +142,7 @@ export function ArtifactCard({
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 () => {
if (await copyToClipboard(artifact.code)) showCopied();
}}
@ -155,6 +157,7 @@ export function ArtifactCard({
type="button"
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)}
>
<DownloadIcon className="size-4" />

View file

@ -14,12 +14,25 @@ import {
Maximize2Icon,
XIcon,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { type KeyboardEvent, useEffect, useRef, useState } from "react";
import { ArtifactHtmlFrame, type ArtifactViewMode } from "./html-frame";
import type { ChatArtifact } from "./types";
import { getArtifactFilename } from "./types";
const COPY_RESET_MS = 2000;
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
function getFocusableElements(container: HTMLElement): HTMLElement[] {
return Array.from(
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
).filter(
(element) =>
!element.hasAttribute("disabled") &&
element.getAttribute("aria-hidden") !== "true" &&
element.tabIndex !== -1,
);
}
function downloadTextFile(filename: string, text: string): void {
const blob = new Blob([text], { type: "text/html;charset=utf-8" });
@ -47,6 +60,8 @@ export function ArtifactSurface({
const [viewMode, setViewMode] = useState<ArtifactViewMode>("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);
useEffect(() => {
@ -55,6 +70,26 @@ export function ArtifactSurface({
};
}, []);
useEffect(() => {
if (variant !== "overlay") return;
previousFocusRef.current = document.activeElement;
const timeoutId = window.setTimeout(() => {
const surface = surfaceRef.current;
if (!surface) return;
const firstFocusable = getFocusableElements(surface)[0];
if (firstFocusable) {
firstFocusable.focus();
} else {
surface.focus();
}
}, 0);
return () => {
window.clearTimeout(timeoutId);
const previousFocus = previousFocusRef.current;
if (previousFocus instanceof HTMLElement) previousFocus.focus();
};
}, [variant]);
const handleCopy = async () => {
if (!(await copyToClipboard(artifact.code))) return;
setCopied(true);
@ -65,8 +100,38 @@ export function ArtifactSurface({
}, COPY_RESET_MS);
};
const handleDialogKeyDown = (event: KeyboardEvent<HTMLElement>) => {
if (variant !== "overlay") return;
if (event.key === "Escape") {
event.preventDefault();
onClose();
return;
}
if (event.key !== "Tab") return;
const focusable = getFocusableElements(event.currentTarget);
if (focusable.length === 0) {
event.preventDefault();
event.currentTarget.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
const content = (
<section
ref={surfaceRef}
role={variant === "overlay" ? "dialog" : undefined}
aria-modal={variant === "overlay" ? true : undefined}
tabIndex={variant === "overlay" ? -1 : undefined}
onKeyDown={handleDialogKeyDown}
className={cn(
"flex min-h-0 flex-col overflow-hidden border border-border bg-background shadow-xl",
variant === "panel"
@ -167,7 +232,7 @@ export function ArtifactSurface({
className="h-full"
/>
) : (
<pre className="h-full overflow-auto p-4 text-xs leading-relaxed text-foreground whitespace-pre-wrap break-words">
<pre className="h-full overflow-auto p-4 text-xs leading-relaxed text-foreground whitespace-pre">
<code>{artifact.code}</code>
</pre>
)}
@ -179,8 +244,9 @@ export function ArtifactSurface({
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 p-4 backdrop-blur-sm"
role="dialog"
aria-modal="true"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
{content}
</div>

View file

@ -24,6 +24,9 @@ export function buildArtifactSrcDoc(code: string): string {
return `${code}\n${resizeScript}`;
}
// Preview iframes intentionally omit allow-downloads: generated artifacts can
// offer their own UI, but downloads must go through Studio's explicit
// copy/download controls outside the no-same-origin sandbox.
export function ArtifactHtmlFrame({
code,
title = "HTML artifact preview",

View file

@ -24,13 +24,13 @@ export const useChatArtifactsStore = create<ChatArtifactsState>((set) => ({
openArtifact: (artifact, options) =>
set((state) => ({
artifactsById: {
...state.artifactsById,
[artifact.id]: artifact,
},
selectedArtifactId: artifact.id,
surface: options?.surface ?? state.surface,
})),
closeArtifactSurface: () => set({ selectedArtifactId: null }),
closeArtifactSurface: () =>
set({ artifactsById: {}, selectedArtifactId: null }),
clearArtifactsForThread: (threadId) =>
set((state) => {
if (!threadId) return state;

View file

@ -43,12 +43,13 @@ export function createArtifactId(input: ChatArtifactInput): string {
const threadSegment = input.threadId || "no-thread";
const sourceId =
input.sourceToolCallId || input.sourceMessageId || "transient";
return [
input.source,
threadSegment,
sourceId,
hashArtifactCode(input.code),
].join(":");
const parts = [input.source, threadSegment, sourceId];
if (input.source !== "tool" || !input.sourceToolCallId) {
parts.push(hashArtifactCode(input.code));
}
return parts.join(":");
}
export function createChatArtifact(input: ChatArtifactInput): ChatArtifact {