Studio: wire render_html artifacts in chat UI

This commit is contained in:
wasimysaid 2026-05-25 18:04:21 +02:00
commit 1fd58891cd
6 changed files with 583 additions and 370 deletions

View file

@ -3,6 +3,7 @@
"use client";
import { ArtifactCard } from "@/features/chat";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { preprocessLaTeX } from "@/lib/latex";
import { openLink } from "@/lib/open-link";
@ -12,7 +13,7 @@ import { HugeiconsIcon } from "@hugeicons/react";
import { createCodePlugin } from "./code-plugin";
import { createMathPlugin } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { DownloadIcon, Maximize2Icon, Minimize2Icon } from "lucide-react";
import { DownloadIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Block, type BlockProps, Streamdown } from "streamdown";
import "katex/dist/katex.min.css";
@ -26,11 +27,7 @@ const code = createCodePlugin({
const { withSmoothContextProvider } = INTERNAL;
const STREAMDOWN_COMPONENTS = {
a: ({
href,
children,
...props
}: React.ComponentProps<"a">) => (
a: ({ href, children, ...props }: React.ComponentProps<"a">) => (
<a
href={href}
rel="noopener noreferrer"
@ -123,7 +120,8 @@ function isHtmlFence(codeFence: CodeFence): boolean {
return lang === "html" && !isSvgFence(codeFence);
}
const UNSAFE_SVG_RE = /<script[\s>]|on\w+\s*=|javascript:|<foreignObject[\s>]|<iframe[\s>]|<embed[\s>]|<object[\s>]/i;
const UNSAFE_SVG_RE =
/<script[\s>]|on\w+\s*=|javascript:|<foreignObject[\s>]|<iframe[\s>]|<embed[\s>]|<object[\s>]/i;
function sanitizeSvg(source: string): string | null {
if (UNSAFE_SVG_RE.test(source)) return null;
@ -145,96 +143,6 @@ function SvgPreview({ source }: { source: string }) {
);
}
const HTML_PREVIEW_DEFAULT_HEIGHT = 400;
const HTML_PREVIEW_MAX_HEIGHT = 800;
function HtmlPreview({ source }: { source: string }) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState(HTML_PREVIEW_DEFAULT_HEIGHT);
const [enlarged, setEnlarged] = useState(false);
useEffect(() => {
const handler = (e: MessageEvent) => {
if (e.source !== iframeRef.current?.contentWindow) return;
if (typeof e.data?.htmlPreviewHeight === "number") {
setHeight(Math.min(Math.max(e.data.htmlPreviewHeight, 100), HTML_PREVIEW_MAX_HEIGHT));
}
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, []);
useEffect(() => {
if (!enlarged) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") setEnlarged(false);
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [enlarged]);
const resizeScript = `<script>new ResizeObserver(()=>{
parent.postMessage({htmlPreviewHeight:document.documentElement.scrollHeight},"*");
}).observe(document.documentElement);</script>`;
const srcDoc = source + resizeScript;
if (enlarged) {
return (
<>
<div className="mt-2 overflow-hidden rounded-lg border border-border" style={{ height }}>
{/* Placeholder keeps layout stable while overlay is shown */}
</div>
<div
className="fixed inset-0 z-50 flex flex-col bg-background/80 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) setEnlarged(false); }}
>
<div className="flex items-center justify-end gap-2 px-4 py-2">
<button
type="button"
className="flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => setEnlarged(false)}
title="Exit fullscreen (Esc)"
>
<Minimize2Icon className="size-4" />
Exit fullscreen
</button>
</div>
<div className="mx-4 mb-4 flex-1 overflow-hidden rounded-lg border border-border bg-background">
<iframe
ref={iframeRef}
srcDoc={srcDoc}
sandbox="allow-scripts"
style={{ width: "100%", height: "100%", border: "none", display: "block" }}
title="HTML preview"
/>
</div>
</div>
</>
);
}
return (
<div className="group/html-preview relative mt-2 overflow-hidden rounded-lg border border-border">
<button
type="button"
className="absolute top-2 right-2 z-10 rounded-md border border-border bg-background/80 p-1.5 text-muted-foreground opacity-0 transition-all hover:bg-muted hover:text-foreground group-hover/html-preview:opacity-100 supports-[backdrop-filter]:backdrop-blur"
onClick={() => setEnlarged(true)}
title="Enlarge preview"
>
<Maximize2Icon className="size-4" />
</button>
<iframe
ref={iframeRef}
srcDoc={srcDoc}
sandbox="allow-scripts"
style={{ width: "100%", height, border: "none", display: "block" }}
title="HTML preview"
/>
</div>
);
}
function downloadTextFile(filename: string, text: string): void {
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
@ -362,7 +270,9 @@ function StreamdownBlock(props: BlockProps) {
return (
<div className="relative isolate">
<div className="my-4 rounded-xl border border-border bg-muted/30 p-4">
<div className="mb-2 text-xs font-medium text-muted-foreground">svg</div>
<div className="mb-2 text-xs font-medium text-muted-foreground">
svg
</div>
<pre className="overflow-x-auto text-xs text-muted-foreground whitespace-pre-wrap break-all">
<code>{codeFence.source}</code>
</pre>
@ -374,7 +284,7 @@ function StreamdownBlock(props: BlockProps) {
if (props.isIncomplete && codeFence && isHtmlFence(codeFence)) {
return (
<div className="my-4 flex h-48 items-center justify-center rounded-xl border border-border bg-muted/30 text-sm text-muted-foreground animate-pulse">
Loading preview...
Loading artifact preview...
</div>
);
}
@ -389,8 +299,23 @@ function StreamdownBlock(props: BlockProps) {
}
if (codeFence) {
const svgSource = !props.isIncomplete && isSvgFence(codeFence) ? sanitizeSvg(codeFence.source) : null;
const htmlSource = !props.isIncomplete && isHtmlFence(codeFence) ? codeFence.source : null;
const svgSource =
!props.isIncomplete && isSvgFence(codeFence)
? sanitizeSvg(codeFence.source)
: null;
const htmlSource =
!props.isIncomplete && isHtmlFence(codeFence) ? codeFence.source : null;
if (htmlSource) {
return (
<ArtifactCard
code={htmlSource}
title="HTML preview"
source="fence"
preview={false}
/>
);
}
return (
<>
<div className="relative isolate">
@ -402,7 +327,6 @@ function StreamdownBlock(props: BlockProps) {
/>
</div>
{svgSource && <SvgPreview source={svgSource} />}
{htmlSource && <HtmlPreview source={htmlSource} />}
</>
);
}

View file

@ -19,6 +19,7 @@ import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { ToolGroup } from "@/components/assistant-ui/tool-group";
import { CodeExecutionToolUI } from "@/components/assistant-ui/tool-ui-code-execution";
import { ImageGenerationToolUI } from "@/components/assistant-ui/tool-ui-image-generation";
import { RenderHtmlToolUI } from "@/components/assistant-ui/tool-ui-render-html";
import { PythonToolUI } from "@/components/assistant-ui/tool-ui-python";
import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal";
import { WebSearchToolUI } from "@/components/assistant-ui/tool-ui-web-search";
@ -67,6 +68,7 @@ import {
ChevronLeftIcon,
ChevronRightIcon,
DownloadIcon,
FileTextIcon,
GlobeIcon,
HeadphonesIcon,
ImageIcon,
@ -79,7 +81,12 @@ import {
TerminalIcon,
XIcon,
} from "lucide-react";
import { Copy01Icon, Delete02Icon, Edit03Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import {
Copy01Icon,
Delete02Icon,
Edit03Icon,
Tick02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
type ChangeEvent,
@ -98,11 +105,7 @@ export const Thread: FC<{
hideComposer?: boolean;
hideWelcome?: boolean;
targetThreadId?: string;
}> = ({
hideComposer,
hideWelcome,
targetThreadId,
}) => {
}> = ({ hideComposer, hideWelcome, targetThreadId }) => {
// Intent-aware autoscroll: replaces assistant-ui's built-in autoscroll
// to prevent the streaming-mutation race that makes the viewport snap
// back to the bottom while the user is scrolling up (see the hook for
@ -136,7 +139,9 @@ export const Thread: FC<{
)}
>
{!hideWelcome && (
<AuiIf condition={({ thread }) => thread.isEmpty && !thread.isLoading}>
<AuiIf
condition={({ thread }) => thread.isEmpty && !thread.isLoading}
>
<ThreadWelcome hideComposer={hideComposer} />
</AuiIf>
)}
@ -225,7 +230,8 @@ const ThreadWelcome: FC<{ hideComposer?: boolean }> = ({ hideComposer }) => {
useEffect(() => {
const hour = new Date().getHours();
if (hour >= 6 && hour < 12) setCurrentEmoji("large sloth drink.png");
else if (hour >= 12 && hour < 17) setCurrentEmoji("sloth magnify final.png");
else if (hour >= 12 && hour < 17)
setCurrentEmoji("sloth magnify final.png");
else if (hour >= 17 && hour < 21) setCurrentEmoji("sloth shy large.png");
else setCurrentEmoji("unsloth-gem.png");
}, []);
@ -240,11 +246,7 @@ const ThreadWelcome: FC<{ hideComposer?: boolean }> = ({ hideComposer }) => {
<div className="aui-thread-welcome-center flex w-full grow flex-col items-center justify-center pb-[48px]">
<div className="aui-thread-welcome-message flex w-full flex-col justify-center gap-6 px-4">
<div className="flex flex-col items-center gap-2 text-center">
<img
src={currentEmojiSrc}
alt="Sloth mascot"
className="size-20"
/>
<img src={currentEmojiSrc} alt="Sloth mascot" className="size-20" />
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in font-heading font-semibold text-2xl tracking-[-0.02em] duration-200">
Chat with your model
</h1>
@ -294,7 +296,8 @@ const PendingAudioChip: FC = () => {
};
const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => {
const { inputProps, isComposing, isComposingRef } = useImeComposerInputHandlers();
const { inputProps, isComposing, isComposingRef } =
useImeComposerInputHandlers();
const composerText = useAuiState(({ composer }) => composer.text);
const hasAttachments = useAuiState(
({ composer }) => composer.attachments.length > 0,
@ -304,7 +307,9 @@ const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => {
(attachment) => attachment.status.type === "running",
),
);
const hasPendingAudio = useChatRuntimeStore((s) => Boolean(s.pendingAudioName));
const hasPendingAudio = useChatRuntimeStore((s) =>
Boolean(s.pendingAudioName),
);
const hasSendableContent =
composerText.trim().length > 0 || hasAttachments || hasPendingAudio;
@ -342,7 +347,10 @@ const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => {
/>
<ComposerAction
disabled={
disabled || !hasSendableContent || isComposing || hasPendingAttachments
disabled ||
!hasSendableContent ||
isComposing ||
hasPendingAttachments
}
blockSend={() =>
!hasSendableContent || isComposingRef.current || hasPendingAttachments
@ -553,7 +561,6 @@ const ComposerAudioUpload: FC = () => {
);
};
const ReasoningToggle: FC = () => {
const modelLoaded = useChatRuntimeStore(
(s) => !!s.params.checkpoint && !s.modelLoading,
@ -565,8 +572,12 @@ const ReasoningToggle: FC = () => {
const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled);
const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle);
const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort);
const supportsReasoningOff = useChatRuntimeStore((s) => s.supportsReasoningOff);
const reasoningEffortLevels = useChatRuntimeStore((s) => s.reasoningEffortLevels);
const supportsReasoningOff = useChatRuntimeStore(
(s) => s.supportsReasoningOff,
);
const reasoningEffortLevels = useChatRuntimeStore(
(s) => s.reasoningEffortLevels,
);
const setReasoningEffort = useChatRuntimeStore((s) => s.setReasoningEffort);
const lastOpenRouterChosenModel = useChatRuntimeStore(
(s) => s.lastOpenRouterChosenModel,
@ -619,7 +630,8 @@ const ReasoningToggle: FC = () => {
effectiveReasoningEnabled && reasoningEffort !== "none";
const disabled = !(modelLoaded && effectiveSupportsReasoning);
const formatEffortLabel = (level: typeof reasoningEffort): string => {
if (level !== "xhigh") return level.charAt(0).toUpperCase() + level.slice(1);
if (level !== "xhigh")
return level.charAt(0).toUpperCase() + level.slice(1);
const normalized = externalSelection?.modelId?.trim().toLowerCase() ?? "";
if (
normalized.startsWith("claude-opus-4-6") ||
@ -677,23 +689,25 @@ const ReasoningToggle: FC = () => {
{effectiveReasoningEffortLevels
.filter((level) => level !== "none")
.map((level) => (
<DropdownMenuItem
key={level}
onSelect={() => {
setReasoningEffort(level);
setReasoningEnabled(true);
applyQwenThinkingParams(true);
// Kimi's $web_search builtin forbids thinking, so
// enabling thinking flips the Search pill off.
if (isKimiExternal && toolsEnabled) {
setToolsEnabled(false);
}
}}
>
{formatEffortLabel(level)}
{effectiveReasoningVisualEnabled && reasoningEffort === level ? " \u2713" : ""}
</DropdownMenuItem>
))}
<DropdownMenuItem
key={level}
onSelect={() => {
setReasoningEffort(level);
setReasoningEnabled(true);
applyQwenThinkingParams(true);
// Kimi's $web_search builtin forbids thinking, so
// enabling thinking flips the Search pill off.
if (isKimiExternal && toolsEnabled) {
setToolsEnabled(false);
}
}}
>
{formatEffortLabel(level)}
{effectiveReasoningVisualEnabled && reasoningEffort === level
? " \u2713"
: ""}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
@ -808,8 +822,7 @@ const WebSearchToggle: FC = () => {
? externalProviders.find((p) => p.id === externalSelection.providerId)
: undefined;
const isKimiExternal = selectedExternalProvider?.providerType === "kimi";
const disabled =
!modelLoaded || !(supportsTools || supportsBuiltinWebSearch);
const disabled = !modelLoaded || !(supportsTools || supportsBuiltinWebSearch);
return (
<button
@ -899,7 +912,9 @@ const ImagesToggle: FC = () => {
className="composer-pill-btn"
data-active={imageToolsEnabled && !disabled ? "true" : "false"}
aria-label={
imageToolsEnabled ? "Disable image generation" : "Enable image generation"
imageToolsEnabled
? "Disable image generation"
: "Enable image generation"
}
>
<ImageIcon className="size-3.5" />
@ -908,6 +923,29 @@ const ImagesToggle: FC = () => {
);
};
const ArtifactsToggle: FC = () => {
const modelLoaded = useChatRuntimeStore(
(s) => !!s.params.checkpoint && !s.modelLoading,
);
const artifactsEnabled = useChatRuntimeStore((s) => s.artifactsEnabled);
const setArtifactsEnabled = useChatRuntimeStore((s) => s.setArtifactsEnabled);
const disabled = !modelLoaded;
return (
<button
type="button"
disabled={disabled}
onClick={() => setArtifactsEnabled(!artifactsEnabled)}
className="composer-pill-btn"
data-active={artifactsEnabled && !disabled ? "true" : "false"}
aria-label={artifactsEnabled ? "Disable artifacts" : "Enable artifacts"}
>
<FileTextIcon className="size-3.5" />
<span>Artifacts</span>
</button>
);
};
const ToolStatusDisplay: FC = () => {
const toolStatus = useChatRuntimeStore((s) => s.toolStatus);
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
@ -980,6 +1018,7 @@ const ComposerAction: FC<{ disabled?: boolean; blockSend?: () => boolean }> = ({
<WebSearchToggle />
<CodeToolsToggle />
<ImagesToggle />
<ArtifactsToggle />
</div>
<div className="flex items-center gap-1">
<ComposerPrimitive.If dictation={false}>
@ -1107,6 +1146,7 @@ const AssistantMessage: FC = () => {
terminal: TerminalToolUI,
code_execution: CodeExecutionToolUI,
image_generation: ImageGenerationToolUI,
render_html: RenderHtmlToolUI,
},
Fallback: ToolFallback,
},
@ -1284,7 +1324,11 @@ const UserActionBar: FC = () => {
<CopyButton />
<ActionBarPrimitive.Edit asChild={true}>
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit">
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
<HugeiconsIcon
icon={Edit03Icon}
strokeWidth={1.75}
className="size-icon"
/>
</TooltipIconButton>
</ActionBarPrimitive.Edit>
<DeleteMessageButton />

View file

@ -0,0 +1,85 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"use client";
import { ArtifactCard } from "@/features/chat";
import {
type ToolCallMessagePartComponent,
useAuiState,
} from "@assistant-ui/react";
import { FileTextIcon, LoaderIcon } from "lucide-react";
import { memo, useEffect, useState } from "react";
import {
ToolFallbackContent,
ToolFallbackRoot,
ToolFallbackTrigger,
} from "./tool-fallback";
interface RenderHtmlArgs {
code?: string;
title?: string;
}
const RenderHtmlToolUIImpl: ToolCallMessagePartComponent = ({
args,
status,
toolCallId,
}) => {
const parsedArgs = (args as RenderHtmlArgs) ?? {};
const code = typeof parsedArgs.code === "string" ? parsedArgs.code : "";
const title =
typeof parsedArgs.title === "string" ? parsedArgs.title : "HTML artifact";
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);
useEffect(() => {
const nextOpen = isRunning || code ? true : hasText ? false : null;
if (nextOpen == null) return;
const timeoutId = window.setTimeout(() => setOpen(nextOpen), 0);
return () => window.clearTimeout(timeoutId);
}, [code, hasText, isRunning]);
return (
<ToolFallbackRoot open={open} onOpenChange={setOpen}>
<ToolFallbackTrigger
toolName={isRunning ? "Rendering HTML artifact…" : title}
status={status}
icon={FileTextIcon}
/>
<ToolFallbackContent>
{isRunning && !code ? (
<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 ? (
<ArtifactCard
code={code}
title={title}
source="tool"
sourceToolCallId={toolCallId}
preview={false}
/>
) : (
<p className="text-sm text-muted-foreground">
No HTML artifact source was provided.
</p>
)}
</ToolFallbackContent>
</ToolFallbackRoot>
);
};
export const RenderHtmlToolUI = memo(
RenderHtmlToolUIImpl,
) as unknown as ToolCallMessagePartComponent;
RenderHtmlToolUI.displayName = "RenderHtmlToolUI";

View file

@ -833,7 +833,13 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
// Re-read store after potential auto-load / model ready wait
runtime = useChatRuntimeStore.getState();
const { params } = runtime;
const { supportsTools, toolsEnabled, codeToolsEnabled, imageToolsEnabled } = runtime;
const {
supportsTools,
toolsEnabled,
codeToolsEnabled,
imageToolsEnabled,
artifactsEnabled,
} = runtime;
const externalSelection = parseExternalModelId(params.checkpoint);
const isExternalRequest = externalSelection !== null;
if (
@ -872,33 +878,30 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
throw new Error("Missing connection API key.");
}
const webSearchEnabledForThisTurn =
Boolean(
externalProvider &&
toolsEnabled &&
providerSupportsBuiltinWebSearch(externalProvider.providerType),
);
const codeExecEnabledForThisTurn =
Boolean(
externalProvider &&
externalSelection &&
codeToolsEnabled &&
providerSupportsBuiltinCodeExecution(
externalProvider.providerType,
externalSelection.modelId,
externalProvider.baseUrl,
),
);
const webSearchEnabledForThisTurn = Boolean(
externalProvider &&
toolsEnabled &&
providerSupportsBuiltinWebSearch(externalProvider.providerType),
);
const codeExecEnabledForThisTurn = Boolean(
externalProvider &&
externalSelection &&
codeToolsEnabled &&
providerSupportsBuiltinCodeExecution(
externalProvider.providerType,
externalSelection.modelId,
externalProvider.baseUrl,
),
);
// web_fetch shares the Search pill with web_search (no separate
// UI toggle), so it follows toolsEnabled. Anthropic is the only
// provider that ships it today; on others providerSupportsBuiltinWebFetch
// returns false and this stays inert.
const webFetchEnabledForThisTurn =
Boolean(
externalProvider &&
toolsEnabled &&
providerSupportsBuiltinWebFetch(externalProvider.providerType),
);
const webFetchEnabledForThisTurn = Boolean(
externalProvider &&
toolsEnabled &&
providerSupportsBuiltinWebFetch(externalProvider.providerType),
);
const providerShipsWebFetch = Boolean(
externalProvider &&
providerSupportsBuiltinWebFetch(externalProvider.providerType),
@ -964,30 +967,50 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
"Do not return tool-call syntax inside your response.";
}
}
if (disabledToolGuard) {
const firstMessage = outboundMessages[0];
type OutboundMessage = (typeof outboundMessages)[number];
function addSystemInstruction(
targetMessages: OutboundMessage[],
text: string | null,
): void {
if (!text) return;
const firstMessage = targetMessages[0];
if (firstMessage?.role === "system") {
if (typeof firstMessage.content === "string") {
outboundMessages[0] = {
targetMessages[0] = {
...firstMessage,
content: `${firstMessage.content}\n\n${disabledToolGuard}`,
content: `${firstMessage.content}\n\n${text}`,
};
} else {
outboundMessages[0] = {
targetMessages[0] = {
...firstMessage,
content: [
...firstMessage.content,
{ type: "text", text: `\n\n${disabledToolGuard}` },
{ type: "text", text: `\n\n${text}` },
],
};
}
} else {
outboundMessages.unshift({
role: "system",
content: disabledToolGuard,
});
return;
}
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 renderHtmlToolEnabledForThisTurn = Boolean(
!isExternalRequest && supportsTools && artifactsEnabled,
);
const artifactInstruction = artifactsEnabled
? renderHtmlToolEnabledForThisTurn
? "When the user asks for an HTML, CSS, or JavaScript artifact, use the render_html tool with one complete self-contained HTML document in the code argument. Embed CSS and JavaScript inside the document."
: "When the user asks for an HTML, CSS, or JavaScript artifact, return one complete self-contained fenced html code block. Embed CSS and JavaScript inside the document. Do not emit tool-call syntax."
: null;
const effectiveDisabledToolGuard =
disabledToolGuard && artifactsEnabled
? `${disabledToolGuard} HTML, CSS, or JavaScript artifact requests can still be answered by following the artifact fallback instruction.`
: disabledToolGuard;
addSystemInstruction(outboundMessages, effectiveDisabledToolGuard);
addSystemInstruction(outboundMessages, artifactInstruction);
const imageBase64 = findLatestUserImageBase64(messages);
const audioBase64 = findLatestUserAudioBase64(messages);
@ -1314,8 +1337,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
) {
void updateStoredChatThreadEventually(t.id, {
openaiCodeExecContainerId: null,
})
.catch(() => {});
}).catch(() => {});
continue;
}
openaiCodeExecContainerId = t.openaiCodeExecContainerId;
@ -1359,8 +1381,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
openaiCodeExecContainerId = created.id;
void updateStoredChatThreadEventually(resolvedThreadId, {
openaiCodeExecContainerId: created.id,
})
.catch(() => {});
}).catch(() => {});
} catch {
// Fall back to backend's container_auto path on
// failure — keeps the chat moving; the next turn
@ -1473,7 +1494,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
// attaches `cache_control.ttl` when the value is one of
// "5m" / "1h" (see external_provider.py near line 1375),
// so unknown values are a no-op end-to-end.
...(supportsProviderPromptCacheTtl(externalProvider.providerType) &&
...(supportsProviderPromptCacheTtl(
externalProvider.providerType,
) &&
(externalProvider.enablePromptCaching ?? true) &&
isPromptCacheTtl(externalProvider.promptCacheTtl)
? { prompt_cache_ttl: externalProvider.promptCacheTtl }
@ -1518,12 +1541,14 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
...(supportsPreserveThinking
? { preserve_thinking: preserveThinking }
: {}),
...(supportsTools && (toolsEnabled || codeToolsEnabled)
...(supportsTools &&
(toolsEnabled || codeToolsEnabled || artifactsEnabled)
? {
enable_tools: true,
enabled_tools: [
...(toolsEnabled ? ["web_search"] : []),
...(codeToolsEnabled ? ["python", "terminal"] : []),
...(artifactsEnabled ? ["render_html"] : []),
],
auto_heal_tool_calls:
useChatRuntimeStore.getState().autoHealToolCalls,
@ -1591,8 +1616,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
: "openaiCodeExecContainerId";
void updateStoredChatThreadEventually(resolvedThreadId, {
[field]: null,
})
.catch(() => {});
}).catch(() => {});
}
continue;
}

View file

@ -21,10 +21,25 @@ import { isTauri } from "@/lib/api-base";
import { isMultimodalResponse } from "./types/api";
import { getImageInputUnavailableReason } from "./utils/image-input-support";
import { useAui } from "@assistant-ui/react";
import { ArrowUpIcon, GlobeIcon, HeadphonesIcon, ImageIcon, LightbulbIcon, LightbulbOffIcon, MicIcon, PlusIcon, SquareIcon, XIcon } from "lucide-react";
import {
ArrowUpIcon,
FileTextIcon,
GlobeIcon,
HeadphonesIcon,
ImageIcon,
LightbulbIcon,
LightbulbOffIcon,
MicIcon,
PlusIcon,
SquareIcon,
XIcon,
} from "lucide-react";
import { toast } from "@/lib/toast";
import { loadModel, validateModel } from "./api/chat-api";
import { parseExternalModelId, providerTypeSupportsVision } from "./external-providers";
import {
parseExternalModelId,
providerTypeSupportsVision,
} from "./external-providers";
import { useExternalProvidersStore } from "./stores/external-providers-store";
import {
type ReasoningEffort,
@ -87,7 +102,10 @@ function fileToBase64DataURL(file: File): Promise<string> {
});
}
function formatReasoningEffortLabel(level: ReasoningEffort, modelId?: string): string {
function formatReasoningEffortLabel(
level: ReasoningEffort,
modelId?: string,
): string {
if (level === "max") return "Max";
if (level === "xhigh") {
const normalized = modelId?.trim().toLowerCase() ?? "";
@ -123,7 +141,12 @@ function useDictation(
const start = useCallback(() => {
const SpeechRecognitionAPI =
typeof window !== "undefined" &&
(window.SpeechRecognition ?? (window as unknown as { webkitSpeechRecognition?: typeof SpeechRecognition }).webkitSpeechRecognition);
(window.SpeechRecognition ??
(
window as unknown as {
webkitSpeechRecognition?: typeof SpeechRecognition;
}
).webkitSpeechRecognition);
if (!SpeechRecognitionAPI) {
return;
}
@ -169,7 +192,11 @@ function useDictation(
const supported =
typeof window !== "undefined" &&
!!(window.SpeechRecognition ?? (window as unknown as { webkitSpeechRecognition?: unknown }).webkitSpeechRecognition);
!!(
window.SpeechRecognition ??
(window as unknown as { webkitSpeechRecognition?: unknown })
.webkitSpeechRecognition
);
return { isDictating, start, stop, supported };
}
@ -208,9 +235,18 @@ export function RegisterCompareHandle({
currentHandles[name] = {
// fixes occasional reorder on reload.
append: (content) =>
aui.thread().append({ role: "user", content, createdAt: new Date() } as never),
aui
.thread()
.append({ role: "user", content, createdAt: new Date() } as never),
appendMessage: (content) =>
aui.thread().append({ role: "user", content, createdAt: new Date(), startRun: false } as never),
aui
.thread()
.append({
role: "user",
content,
createdAt: new Date(),
startRun: false,
} as never),
startRun: () => {
const msgs = aui.thread().getState().messages;
const lastId = msgs.length > 0 ? msgs[msgs.length - 1].id : null;
@ -254,7 +290,8 @@ function PendingImageThumb({
setSrc(url);
return () => URL.revokeObjectURL(url);
}, [file]);
if (!src) return <div className="size-14 animate-pulse rounded-[14px] bg-muted" />;
if (!src)
return <div className="size-14 animate-pulse rounded-[14px] bg-muted" />;
return (
<div className="relative size-14 shrink-0 overflow-hidden rounded-[14px] border border-foreground/20 bg-muted">
<img src={src} alt={file.name} className="h-full w-full object-cover" />
@ -289,7 +326,10 @@ export function SharedComposer({
const [running, setRunning] = useState(false);
const [comparing, setComparing] = useState(false);
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
const [pendingAudio, setPendingAudio] = useState<{ name: string; base64: string } | null>(null);
const [pendingAudio, setPendingAudio] = useState<{
name: string;
base64: string;
} | null>(null);
const [dragging, setDragging] = useState(false);
const [isComposing, setIsComposing] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -318,10 +358,16 @@ export function SharedComposer({
const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled);
const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle);
const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort);
const supportsReasoningOff = useChatRuntimeStore((s) => s.supportsReasoningOff);
const reasoningEffortLevels = useChatRuntimeStore((s) => s.reasoningEffortLevels);
const supportsReasoningOff = useChatRuntimeStore(
(s) => s.supportsReasoningOff,
);
const reasoningEffortLevels = useChatRuntimeStore(
(s) => s.reasoningEffortLevels,
);
const setReasoningEffort = useChatRuntimeStore((s) => s.setReasoningEffort);
const supportsPreserveThinking = useChatRuntimeStore((s) => s.supportsPreserveThinking);
const supportsPreserveThinking = useChatRuntimeStore(
(s) => s.supportsPreserveThinking,
);
const preserveThinking = useChatRuntimeStore((s) => s.preserveThinking);
const setPreserveThinking = useChatRuntimeStore((s) => s.setPreserveThinking);
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
@ -336,6 +382,8 @@ export function SharedComposer({
const setImageToolsEnabled = useChatRuntimeStore(
(s) => s.setImageToolsEnabled,
);
const artifactsEnabled = useChatRuntimeStore((s) => s.artifactsEnabled);
const setArtifactsEnabled = useChatRuntimeStore((s) => s.setArtifactsEnabled);
const lastOpenRouterChosenModel = useChatRuntimeStore(
(s) => s.lastOpenRouterChosenModel,
);
@ -437,16 +485,22 @@ export function SharedComposer({
// the pill row stays compact for providers without the capability.
const imageDisabled = !modelLoaded || !supportsBuiltinImageGeneration;
const showImagePill = supportsBuiltinImageGeneration;
const artifactDisabled = !modelLoaded;
// Backwards-compatible alias for any other call site that may still
// reference `toolsDisabled` (rare; both pills used it before).
const toolsDisabled = codeDisabled;
const setPendingAudioStore = useChatRuntimeStore((s) => s.setPendingAudio);
const clearPendingAudioStore = useChatRuntimeStore((s) => s.clearPendingAudio);
const { isDictating, start: startDictation, stop: stopDictation, supported: dictationSupported } = useDictation(
setText,
const clearPendingAudioStore = useChatRuntimeStore(
(s) => s.clearPendingAudio,
);
const {
isDictating,
start: startDictation,
stop: stopDictation,
supported: dictationSupported,
} = useDictation(setText);
useEffect(() => {
const id = setInterval(() => {
const handles = handlesRef.current;
@ -463,43 +517,48 @@ export function SharedComposer({
ta.style.height = "auto";
const styles = window.getComputedStyle(ta);
const lineHeight = parseFloat(styles.lineHeight) || 20;
const paddingY = parseFloat(styles.paddingTop) + parseFloat(styles.paddingBottom);
const borderY = parseFloat(styles.borderTopWidth) + parseFloat(styles.borderBottomWidth);
const paddingY =
parseFloat(styles.paddingTop) + parseFloat(styles.paddingBottom);
const borderY =
parseFloat(styles.borderTopWidth) + parseFloat(styles.borderBottomWidth);
const maxHeight = lineHeight * 6 + paddingY + borderY;
const next = Math.min(ta.scrollHeight, maxHeight);
ta.style.height = `${next}px`;
ta.style.overflowY = ta.scrollHeight > maxHeight ? "auto" : "hidden";
}, [text]);
const addFiles = useCallback((files: FileList | null) => {
if (!files?.length) return;
const next: PendingImage[] = [];
let droppedImageForUnavailable = false;
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!file) continue;
// Handle audio files
if (file.type.match(/^audio\//i) && file.size <= MAX_AUDIO_SIZE) {
fileToBase64(file).then((base64) => {
setPendingAudio({ name: file.name, base64 });
setPendingAudioStore(base64, file.name);
});
continue;
const addFiles = useCallback(
(files: FileList | null) => {
if (!files?.length) return;
const next: PendingImage[] = [];
let droppedImageForUnavailable = false;
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!file) continue;
// Handle audio files
if (file.type.match(/^audio\//i) && file.size <= MAX_AUDIO_SIZE) {
fileToBase64(file).then((base64) => {
setPendingAudio({ name: file.name, base64 });
setPendingAudioStore(base64, file.name);
});
continue;
}
// Handle image files
if (!file.type.match(/^image\/(jpeg|png|webp|gif)$/i)) continue;
if (file.size > MAX_IMAGE_SIZE) continue;
if (attachUnavailableReason) {
droppedImageForUnavailable = true;
continue;
}
next.push({ id: crypto.randomUUID(), file });
}
// Handle image files
if (!file.type.match(/^image\/(jpeg|png|webp|gif)$/i)) continue;
if (file.size > MAX_IMAGE_SIZE) continue;
if (attachUnavailableReason) {
droppedImageForUnavailable = true;
continue;
if (droppedImageForUnavailable && attachUnavailableReason) {
toast.error(attachUnavailableReason);
}
next.push({ id: crypto.randomUUID(), file });
}
if (droppedImageForUnavailable && attachUnavailableReason) {
toast.error(attachUnavailableReason);
}
setPendingImages((prev) => [...prev, ...next]);
}, [setPendingAudioStore, attachUnavailableReason]);
setPendingImages((prev) => [...prev, ...next]);
},
[setPendingAudioStore, attachUnavailableReason],
);
const removePendingImage = useCallback((id: string) => {
setPendingImages((prev) => prev.filter((p) => p.id !== id));
@ -557,12 +616,17 @@ export function SharedComposer({
// LoraCompare and single-pane chats are unaffected.
if (hasCompareHandles && !isGeneralizedCompare) {
toast.error("Pick a model in each pane to compare", {
description: "Use the model dropdown above each pane, then send your prompt.",
description:
"Use the model dropdown above each pane, then send your prompt.",
});
return;
}
if (pendingImages.length > 0 && !isGeneralizedCompare && imageUnavailableReason) {
if (
pendingImages.length > 0 &&
!isGeneralizedCompare &&
imageUnavailableReason
) {
// Single mode: the loaded model's runtime capability is known
// here. Compare mode defers — each ensureModelLoaded below sets
// loadedIsMultimodal for its side, and the chat-adapter's
@ -600,8 +664,9 @@ export function SharedComposer({
const maxSeqLength = store.params.maxSeqLength;
const trustRemoteCode = store.params.trustRemoteCode ?? false;
const chatTemplateOverride = store.chatTemplateOverride;
const effectiveChatTemplateOverride =
chatTemplateOverride?.trim() ? chatTemplateOverride : null;
const effectiveChatTemplateOverride = chatTemplateOverride?.trim()
? chatTemplateOverride
: null;
function modelDisplayName(id: string): string {
const parts = id.split("/");
@ -609,11 +674,14 @@ export function SharedComposer({
}
// Helper: load a model and update store checkpoint
async function ensureModelLoaded(sel: CompareModelSelection): Promise<string> {
async function ensureModelLoaded(
sel: CompareModelSelection,
): Promise<string> {
const currentStore = useChatRuntimeStore.getState();
const isAlreadyActive =
currentStore.params.checkpoint === sel.id &&
(currentStore.activeGgufVariant ?? null) === (sel.ggufVariant ?? null);
(currentStore.activeGgufVariant ?? null) ===
(sel.ggufVariant ?? null);
if (!isAlreadyActive) {
const validation = await validateModel({
model_path: sel.id,
@ -703,9 +771,17 @@ export function SharedComposer({
try {
// Side 1: load → generate → wait
if (handle1 && model1?.id) {
toast("Loading Model 1…", { id: toastId, description: name1, duration: Infinity });
toast("Loading Model 1…", {
id: toastId,
description: name1,
duration: Infinity,
});
const status1 = await ensureModelLoaded(model1);
toast("Generating with Model 1…", { id: toastId, description: `${name1} (${status1})`, duration: Infinity });
toast("Generating with Model 1…", {
id: toastId,
description: `${name1} (${status1})`,
duration: Infinity,
});
const done = handle1.waitForRunEnd();
handle1.startRun();
await done;
@ -713,13 +789,22 @@ export function SharedComposer({
// Side 2: load → generate → wait
if (handle2 && model2?.id) {
const needsLoad = model2.id.toLowerCase() !== (model1?.id || "").toLowerCase()
|| (model2.ggufVariant ?? "") !== (model1?.ggufVariant ?? "");
const needsLoad =
model2.id.toLowerCase() !== (model1?.id || "").toLowerCase() ||
(model2.ggufVariant ?? "") !== (model1?.ggufVariant ?? "");
if (needsLoad) {
toast("Loading Model 2…", { id: toastId, description: name2, duration: Infinity });
toast("Loading Model 2…", {
id: toastId,
description: name2,
duration: Infinity,
});
}
const status2 = await ensureModelLoaded(model2);
toast("Generating with Model 2…", { id: toastId, description: `${name2} (${status2})`, duration: Infinity });
toast("Generating with Model 2…", {
id: toastId,
description: `${name2} (${status2})`,
duration: Infinity,
});
const done = handle2.waitForRunEnd();
handle2.startRun();
await done;
@ -773,7 +858,12 @@ export function SharedComposer({
}
}
const canSend = (text.trim().length > 0 || pendingImages.length > 0 || pendingAudio !== null) && !busy && !isComposing;
const canSend =
(text.trim().length > 0 ||
pendingImages.length > 0 ||
pendingAudio !== null) &&
!busy &&
!isComposing;
return (
<div
@ -808,7 +898,10 @@ export function SharedComposer({
<span className="max-w-48 truncate">{pendingAudio.name}</span>
<button
type="button"
onClick={() => { setPendingAudio(null); clearPendingAudioStore(); }}
onClick={() => {
setPendingAudio(null);
clearPendingAudioStore();
}}
className="flex size-4 items-center justify-center rounded-full hover:bg-destructive hover:text-destructive-foreground"
aria-label="Remove audio"
>
@ -906,130 +999,136 @@ export function SharedComposer({
)}
{showReasoningControl ? (
effectiveReasoningStyle === "reasoning_effort" ? (
<DropdownMenu>
<DropdownMenuTrigger asChild={true}>
<button
type="button"
disabled={reasoningDisabled}
className={cn(
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
reasoningDisabled
<DropdownMenu>
<DropdownMenuTrigger asChild={true}>
<button
type="button"
disabled={reasoningDisabled}
className={cn(
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
reasoningDisabled
? "cursor-not-allowed opacity-40"
: effectiveReasoningVisualEnabled
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
)}
aria-label={thinkEffortAriaLabel({
modelLoaded,
reasoningDisabled,
reasoningEffort,
})}
>
{effectiveReasoningVisualEnabled ? (
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3.5" />
)}
<span>
Think:{" "}
{effectiveReasoningVisualEnabled
? formatReasoningEffortLabel(
reasoningEffort,
externalSelection?.modelId,
)
: formatReasoningDisabledLabel(
effectiveSupportsReasoningOff,
isExternalOpenAIReasoning,
checkpoint,
)}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{effectiveSupportsReasoningOff && (
<DropdownMenuItem
onSelect={() => {
setReasoningEnabled(false);
applyQwenThinkingParams(false);
}}
>
{formatReasoningDisabledLabel(
effectiveSupportsReasoningOff,
isExternalOpenAIReasoning,
checkpoint,
)}
{!effectiveReasoningVisualEnabled ? " \u2713" : ""}
</DropdownMenuItem>
)}
{effectiveReasoningEffortLevels
.filter((level) => level !== "none")
.map((level) => (
<DropdownMenuItem
key={level}
onSelect={() => {
setReasoningEffort(level);
setReasoningEnabled(true);
applyQwenThinkingParams(true);
// Mutual exclusion: turning thinking on for a
// Kimi model forces the web_search builtin off.
if (isKimiExternal && toolsEnabled) {
setToolsEnabled(false, { persist: false });
}
}}
>
{formatReasoningEffortLabel(
level,
externalSelection?.modelId,
)}
{effectiveReasoningVisualEnabled &&
reasoningEffort === level
? " \u2713"
: ""}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
disabled={reasoningDisabled || reasoningLockedOn}
aria-disabled={reasoningDisabled || reasoningLockedOn}
title={
reasoningLockedOn
? "This model requires reasoning to stay on."
: undefined
}
onClick={() => {
if (reasoningLockedOn) return;
const next = !reasoningEnabled;
setReasoningEnabled(next);
applyQwenThinkingParams(next);
// Mutual exclusion: Kimi's $web_search builtin
// requires thinking off, so turning thinking on flips
// the Search pill off (and vice versa).
if (isKimiExternal && next && toolsEnabled) {
setToolsEnabled(false, { persist: false });
}
}}
className={cn(
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
reasoningLockedOn
? "cursor-not-allowed text-primary"
: reasoningDisabled
? "cursor-not-allowed opacity-40"
: effectiveReasoningVisualEnabled
: effectiveReasoningEnabled
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
)}
aria-label={thinkEffortAriaLabel({
modelLoaded,
reasoningDisabled,
reasoningEffort,
})}
>
{effectiveReasoningVisualEnabled ? (
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3.5" />
)}
<span>
Think:{" "}
{effectiveReasoningVisualEnabled
? formatReasoningEffortLabel(
reasoningEffort,
externalSelection?.modelId,
)
: formatReasoningDisabledLabel(
effectiveSupportsReasoningOff,
isExternalOpenAIReasoning,
checkpoint,
)}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{effectiveSupportsReasoningOff && (
<DropdownMenuItem
onSelect={() => {
setReasoningEnabled(false);
applyQwenThinkingParams(false);
}}
>
{formatReasoningDisabledLabel(
effectiveSupportsReasoningOff,
isExternalOpenAIReasoning,
checkpoint,
)}
{!effectiveReasoningVisualEnabled ? " \u2713" : ""}
</DropdownMenuItem>
)}
{effectiveReasoningEffortLevels
.filter((level) => level !== "none")
.map((level) => (
<DropdownMenuItem
key={level}
onSelect={() => {
setReasoningEffort(level);
setReasoningEnabled(true);
applyQwenThinkingParams(true);
// Mutual exclusion: turning thinking on for a
// Kimi model forces the web_search builtin off.
if (isKimiExternal && toolsEnabled) {
setToolsEnabled(false, { persist: false });
}
}}
>
{formatReasoningEffortLabel(level, externalSelection?.modelId)}
{effectiveReasoningVisualEnabled && reasoningEffort === level ? " \u2713" : ""}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
disabled={reasoningDisabled || reasoningLockedOn}
aria-disabled={reasoningDisabled || reasoningLockedOn}
title={
reasoningLockedOn
? "This model requires reasoning to stay on."
: undefined
}
onClick={() => {
if (reasoningLockedOn) return;
const next = !reasoningEnabled;
setReasoningEnabled(next);
applyQwenThinkingParams(next);
// Mutual exclusion: Kimi's $web_search builtin
// requires thinking off, so turning thinking on flips
// the Search pill off (and vice versa).
if (isKimiExternal && next && toolsEnabled) {
setToolsEnabled(false, { persist: false });
}
}}
className={cn(
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
reasoningLockedOn
? "cursor-not-allowed text-primary"
: reasoningDisabled
? "cursor-not-allowed opacity-40"
: effectiveReasoningEnabled
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
)}
aria-label={thinkToggleAriaLabel({
reasoningLockedOn,
modelLoaded,
reasoningDisabled,
effectiveReasoningEnabled,
})}
>
{reasoningLockedOn ||
(effectiveReasoningEnabled && !reasoningDisabled) ? (
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3.5" />
)}
<span>Think</span>
</button>
aria-label={thinkToggleAriaLabel({
reasoningLockedOn,
modelLoaded,
reasoningDisabled,
effectiveReasoningEnabled,
})}
>
{reasoningLockedOn ||
(effectiveReasoningEnabled && !reasoningDisabled) ? (
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3.5" />
)}
<span>Think</span>
</button>
)
) : null}
{supportsPreserveThinking && (
@ -1046,7 +1145,9 @@ export function SharedComposer({
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
)}
aria-label={
preserveThinking ? "Disable preserve think" : "Enable preserve think"
preserveThinking
? "Disable preserve think"
: "Enable preserve think"
}
>
{preserveThinking && modelLoaded ? (
@ -1075,7 +1176,9 @@ export function SharedComposer({
}}
className="composer-pill-btn"
data-active={toolsEnabled && !searchDisabled ? "true" : "false"}
aria-label={toolsEnabled ? "Disable web search" : "Enable web search"}
aria-label={
toolsEnabled ? "Disable web search" : "Enable web search"
}
>
<GlobeIcon className="size-3.5" />
<span>Search</span>
@ -1086,7 +1189,11 @@ export function SharedComposer({
onClick={() => setCodeToolsEnabled(!codeToolsEnabled)}
className="composer-pill-btn"
data-active={codeToolsEnabled && !codeDisabled ? "true" : "false"}
aria-label={codeToolsEnabled ? "Disable code execution" : "Enable code execution"}
aria-label={
codeToolsEnabled
? "Disable code execution"
: "Enable code execution"
}
>
<CodeToggleIcon className="size-3.5" />
<span>Code</span>
@ -1097,15 +1204,34 @@ export function SharedComposer({
disabled={imageDisabled}
onClick={() => setImageToolsEnabled(!imageToolsEnabled)}
className="composer-pill-btn"
data-active={imageToolsEnabled && !imageDisabled ? "true" : "false"}
data-active={
imageToolsEnabled && !imageDisabled ? "true" : "false"
}
aria-label={
imageToolsEnabled ? "Disable image generation" : "Enable image generation"
imageToolsEnabled
? "Disable image generation"
: "Enable image generation"
}
>
<ImageIcon className="size-3.5" />
<span>Images</span>
</button>
)}
<button
type="button"
disabled={artifactDisabled}
onClick={() => setArtifactsEnabled(!artifactsEnabled)}
className="composer-pill-btn"
data-active={
artifactsEnabled && !artifactDisabled ? "true" : "false"
}
aria-label={
artifactsEnabled ? "Disable artifacts" : "Enable artifacts"
}
>
<FileTextIcon className="size-3.5" />
<span>Artifacts</span>
</button>
</div>
<div className="flex items-center gap-1">
{dictationSupported && (

View file

@ -25,6 +25,7 @@ export const CHAT_REASONING_ENABLED_KEY = "unsloth_chat_reasoning_enabled";
export const CHAT_TOOLS_ENABLED_KEY = "unsloth_chat_tools_enabled";
export const CHAT_CODE_TOOLS_ENABLED_KEY = "unsloth_chat_code_tools_enabled";
export const CHAT_IMAGE_TOOLS_ENABLED_KEY = "unsloth_chat_image_tools_enabled";
export const CHAT_ARTIFACTS_ENABLED_KEY = "unsloth_chat_artifacts_enabled";
// External provider selection is encoded into `params.checkpoint` as
// `external::<providerId>::<modelId>`. PersistedChatSettings deliberately
@ -265,6 +266,7 @@ type ChatRuntimeStore = {
toolsEnabled: boolean;
codeToolsEnabled: boolean;
imageToolsEnabled: boolean;
artifactsEnabled: boolean;
toolStatus: string | null;
generatingStatus: string | null;
autoHealToolCalls: boolean;
@ -324,6 +326,7 @@ type ChatRuntimeStore = {
setToolsEnabled: (enabled: boolean, options?: { persist?: boolean }) => void;
setCodeToolsEnabled: (enabled: boolean) => void;
setImageToolsEnabled: (enabled: boolean) => void;
setArtifactsEnabled: (enabled: boolean) => void;
setToolStatus: (status: string | null) => void;
setGeneratingStatus: (status: string | null) => void;
setAutoHealToolCalls: (enabled: boolean) => void;
@ -567,6 +570,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
toolsEnabled: loadBool(CHAT_TOOLS_ENABLED_KEY, false),
codeToolsEnabled: loadBool(CHAT_CODE_TOOLS_ENABLED_KEY, false),
imageToolsEnabled: loadBool(CHAT_IMAGE_TOOLS_ENABLED_KEY, false),
artifactsEnabled: loadBool(CHAT_ARTIFACTS_ENABLED_KEY, false),
toolStatus: null,
generatingStatus: null,
autoHealToolCalls: true,
@ -747,6 +751,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
toolsEnabled: false,
codeToolsEnabled: false,
imageToolsEnabled: false,
artifactsEnabled: false,
toolStatus: null,
kvCacheDtype: null,
loadedKvCacheDtype: null,
@ -806,6 +811,11 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
saveBool(CHAT_IMAGE_TOOLS_ENABLED_KEY, imageToolsEnabled);
return { imageToolsEnabled };
}),
setArtifactsEnabled: (artifactsEnabled) =>
set(() => {
saveBool(CHAT_ARTIFACTS_ENABLED_KEY, artifactsEnabled);
return { artifactsEnabled };
}),
setToolStatus: (toolStatus) => set({ toolStatus }),
setGeneratingStatus: (generatingStatus) => set({ generatingStatus }),
setAutoHealToolCalls: (autoHealToolCalls) =>