Compare commits

...
Sign in to create a new pull request.

16 commits

Author SHA1 Message Date
wasimysaid
fcba6e785e Studio: stop local chat thread remount loop 2026-05-26 11:11:32 +02:00
wasimysaid
388128ef45 Studio: restrict artifact frame embedding to same-origin 2026-05-26 00:04:25 +02:00
wasimysaid
9b4d6256d3 Studio: fix artifact panel for local threads and surface tool errors 2026-05-25 23:47:04 +02:00
wasimysaid
6ff7f42e07 Studio: scope artifact IDs by message to prevent cross-turn collisions 2026-05-25 23:37:06 +02:00
Wasim Yousef Said
9d50f5cd9c
Merge branch 'main' into chat-artifacts 2026-05-25 23:15:41 +02:00
pre-commit-ci[bot]
aaf28c0ede [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-25 21:15:25 +00:00
wasimysaid
e8faa3d59b Studio: polish chat artifact UI affordances 2026-05-25 23:15:07 +02:00
wasimysaid
bc0ccc6768 Studio: address chat artifact review follow-ups 2026-05-25 23:15:07 +02:00
wasimysaid
b6faf7ffcd Studio: fix chat artifact panel and sandbox previews 2026-05-25 23:15:07 +02:00
wasimysaid
1aef0eca51 Studio: fix chat artifact review regressions 2026-05-25 23:15:07 +02:00
wasimysaid
46adefa148 Studio: mount chat artifact panel and overlay 2026-05-25 23:15:07 +02:00
wasimysaid
3dea07ed34 Studio: add chat artifact surface 2026-05-25 23:15:07 +02:00
pre-commit-ci[bot]
2c2f587346 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-25 16:06:24 +00:00
wasimysaid
1fd58891cd Studio: wire render_html artifacts in chat UI 2026-05-25 18:04:21 +02:00
wasimysaid
07972fd515 Studio: add local render_html tool support 2026-05-25 18:04:21 +02:00
wasimysaid
e0d3562c86 Studio: add chat HTML artifact primitives 2026-05-25 18:04:21 +02:00
23 changed files with 1761 additions and 550 deletions

View file

@ -4831,13 +4831,25 @@ class LlamaCppBackend:
"content": _stripped, "content": _stripped,
} }
) )
available_tool_names = [
tool.get("function", {}).get("name")
for tool in tools
if isinstance(tool, dict)
and isinstance(tool.get("function"), dict)
]
available_tool_names = [
name for name in available_tool_names if name
]
tool_hint = (
" or ".join(available_tool_names) or "an available tool"
)
conversation.append( conversation.append(
{ {
"role": "user", "role": "user",
"content": ( "content": (
"STOP. Do NOT write code or explain. " "STOP. Do NOT write code or explain. "
"You MUST call a tool NOW. " "You MUST call a tool NOW. "
"Call web_search or python immediately." f"Call {tool_hint} immediately."
), ),
} }
) )
@ -5009,7 +5021,12 @@ class LlamaCppBackend:
arguments = json.loads(raw_args) arguments = json.loads(raw_args)
except (json.JSONDecodeError, ValueError): except (json.JSONDecodeError, ValueError):
if auto_heal_tool_calls: if auto_heal_tool_calls:
arguments = {"query": raw_args} heal_key = {
"python": "code",
"terminal": "command",
"render_html": "code",
}.get(tool_name, "query")
arguments = {heal_key: raw_args}
else: else:
arguments = {"raw": raw_args} arguments = {"raw": raw_args}
else: else:

View file

@ -66,7 +66,11 @@ def _status_for_tool(tool_name: str, arguments: dict) -> str:
return f"Calling: {tool_name}" return f"Calling: {tool_name}"
_CANONICAL_HEAL_ARG = {"python": "code", "terminal": "command"} _CANONICAL_HEAL_ARG = {
"python": "code",
"terminal": "command",
"render_html": "code",
}
def _coerce_arguments(raw_args, *, heal: bool, tool_name: str = "") -> dict: def _coerce_arguments(raw_args, *, heal: bool, tool_name: str = "") -> dict:

View file

@ -502,12 +502,49 @@ TERMINAL_TOOL = {
}, },
} }
ALL_TOOLS = [WEB_SEARCH_TOOL, PYTHON_TOOL, TERMINAL_TOOL] RENDER_HTML_TOOL = {
"type": "function",
"function": {
"name": "render_html",
"description": (
"Render a self-contained HTML/CSS/JavaScript artifact for the user. "
"Put the entire document in code, including any CSS in <style> tags "
"and JavaScript in <script> tags."
),
"parameters": {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "A complete self-contained HTML document.",
},
"title": {
"type": "string",
"description": "Short display title for the artifact.",
},
},
"required": ["code"],
},
},
}
ALL_TOOLS = [WEB_SEARCH_TOOL, PYTHON_TOOL, TERMINAL_TOOL, RENDER_HTML_TOOL]
_TIMEOUT_UNSET = object() _TIMEOUT_UNSET = object()
def _render_html_result(arguments: dict) -> str:
code = arguments.get("code")
if not isinstance(code, str) or not code.strip():
return "Error: render_html requires a non-empty code string."
title = arguments.get("title")
if isinstance(title, str) and title.strip():
safe_title = title.strip()[:120]
return f"Rendered HTML artifact: {safe_title}"
return "Rendered HTML artifact."
def execute_tool( def execute_tool(
name: str, name: str,
arguments: dict, arguments: dict,
@ -525,6 +562,8 @@ def execute_tool(
f"execute_tool: name={name}, session_id={session_id}, timeout={timeout}" f"execute_tool: name={name}, session_id={session_id}, timeout={timeout}"
) )
effective_timeout = _EXEC_TIMEOUT if timeout is _TIMEOUT_UNSET else timeout effective_timeout = _EXEC_TIMEOUT if timeout is _TIMEOUT_UNSET else timeout
if name == "render_html":
return _render_html_result(arguments)
if name == "web_search": if name == "web_search":
return _web_search( return _web_search(
arguments.get("query", ""), arguments.get("query", ""),

View file

@ -312,6 +312,7 @@ from starlette.requests import Request as _StarletteRequest # noqa: E402
_CSP_SCRIPT_NONCE_HEADER = "x-internal-script-nonce" _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: 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'; " "style-src 'self' 'unsafe-inline'; "
f"{script_src}; " f"{script_src}; "
"font-src 'self' data:; " "font-src 'self' data:; "
"frame-src 'self'; "
"frame-ancestors 'none'; " "frame-ancestors 'none'; "
"form-action 'self'; " "form-action 'self'; "
"base-uri 'self'" "base-uri 'self'"
@ -343,7 +345,8 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
if nonce is not None: if nonce is not None:
del response.headers[_CSP_SCRIPT_NONCE_HEADER] del response.headers[_CSP_SCRIPT_NONCE_HEADER]
response.headers.setdefault("Content-Security-Policy", _build_csp(nonce)) 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("X-Content-Type-Options", "nosniff")
response.headers.setdefault("Referrer-Policy", "no-referrer") response.headers.setdefault("Referrer-Policy", "no-referrer")
response.headers.setdefault( response.headers.setdefault(

View file

@ -661,11 +661,12 @@ class ChatCompletionRequest(BaseModel):
enabled_tools: Optional[list[str]] = Field( enabled_tools: Optional[list[str]] = Field(
None, None,
description = ( description = (
"[x-unsloth] List of enabled tool names. Local GGUF models accept " "[x-unsloth] List of enabled tool names. Local GGUF/safetensors models "
"['web_search', 'python', 'terminal']. External providers accept " "accept ['web_search', 'python', 'terminal', 'render_html']. External "
"['web_search', 'web_fetch', 'code_execution'] for Anthropic and " "providers accept ['web_search', 'web_fetch', 'code_execution'] for "
"['web_search', 'code_execution'] for OpenAI Responses. If None, " "Anthropic and ['web_search', 'code_execution', 'image_generation'] for "
"all local tools are enabled and no server-side tools are forwarded." "OpenAI Responses. If None, all local tools are enabled and no "
"server-side tools are forwarded."
), ),
) )
auto_heal_tool_calls: Optional[bool] = Field( auto_heal_tool_calls: Optional[bool] = Field(

View file

@ -239,6 +239,62 @@ router = APIRouter()
studio_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'; "
"frame-ancestors 'self'; "
"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);
});
})();
</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",
# SAMEORIGIN for browsers that ignore frame-ancestors; the
# SecurityHeadersMiddleware uses setdefault so this takes
# precedence over the global DENY.
"X-Frame-Options": "SAMEORIGIN",
},
)
def _detect_safetensors_features(backend, chat_template: Optional[str]) -> dict: def _detect_safetensors_features(backend, chat_template: Optional[str]) -> dict:
"""Classify reasoning/tool capabilities via the GGUF classifier so """Classify reasoning/tool capabilities via the GGUF classifier so
flags match across backends. gpt-oss is overridden because Harmony flags match across backends. gpt-oss is overridden because Harmony
@ -419,13 +475,23 @@ async def _await_cancel_then_close(cancel_event, resp) -> None:
return 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 = ( _TOOL_ACTION_NUDGE = (
" IMPORTANT: Always call tools directly -- never write code yourself." " IMPORTANT: Always call tools directly -- never write code yourself."
" Never describe what you plan to do -- just call the tool immediately." " Never describe what you plan to do -- just call the tool immediately."
" For any code request, call the python tool. For any factual question, call web_search." " For non-artifact code requests, call the python tool when it is available."
" Do NOT output code blocks -- use the python tool instead." " 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 # Strip tool-call XML the speculative buffer in core/inference/llama_cpp.py
# split across the visible/DRAIN boundary. Four leak shapes: # split across the visible/DRAIN boundary. Four leak shapes:
@ -2399,6 +2465,7 @@ async def openai_chat_completions(
_tool_names = {t["function"]["name"] for t in tools_to_use} _tool_names = {t["function"]["name"] for t in tools_to_use}
_has_web = "web_search" in _tool_names _has_web = "web_search" in _tool_names
_has_code = "python" in _tool_names or "terminal" in _tool_names _has_code = "python" in _tool_names or "terminal" in _tool_names
_has_artifact = "render_html" in _tool_names
_date_line = f"The current date is {_date.today().isoformat()}." _date_line = f"The current date is {_date.today().isoformat()}."
@ -2420,34 +2487,31 @@ async def openai_chat_completions(
"Use code execution for math, calculations, data processing, " "Use code execution for math, calculations, data processing, "
"or to parse and analyze information from tool results." "or to parse and analyze information from tool results."
) )
_artifact_tips = (
"Use render_html for HTML, CSS, or JavaScript artifact requests "
"with one complete self-contained HTML document in the code argument."
)
if _has_web and _has_code: _tool_tip_parts = []
if _has_web:
_tool_tip_parts.append(_web_tips)
if _has_code:
_tool_tip_parts.append(_code_tips)
if _has_artifact:
_tool_tip_parts.append(_artifact_tips)
if _tool_tip_parts:
_nudge = ( _nudge = (
_date_line + " " _date_line + " "
"You have access to tools. When appropriate, prefer using " "You have access to tools. When appropriate, prefer using "
"tools rather than answering from memory. " "tools rather than answering from memory. "
+ _web_tips + " ".join(_tool_tip_parts)
+ " "
+ _code_tips
)
elif _has_code:
_nudge = (
_date_line + " "
"You have access to tools. When appropriate, prefer using "
"code execution rather than answering from memory. " + _code_tips
)
elif _has_web:
_nudge = (
_date_line + " "
"You have access to tools. When appropriate, prefer using "
"web search for up-to-date or uncertain factual "
"information rather than answering from memory. " + _web_tips
) )
else: else:
_nudge = "" _nudge = ""
if _nudge: if _nudge:
_nudge += _TOOL_ACTION_NUDGE _nudge += _tool_action_nudge(_has_artifact)
# Append nudge to system prompt (preserve user's prompt) # Append nudge to system prompt (preserve user's prompt)
if system_prompt: if system_prompt:
system_prompt = system_prompt.rstrip() + "\n\n" + _nudge system_prompt = system_prompt.rstrip() + "\n\n" + _nudge
@ -2891,6 +2955,7 @@ async def openai_chat_completions(
_sf_tool_names = {t["function"]["name"] for t in _sf_tools_to_use} _sf_tool_names = {t["function"]["name"] for t in _sf_tools_to_use}
_sf_has_web = "web_search" in _sf_tool_names _sf_has_web = "web_search" in _sf_tool_names
_sf_has_code = "python" in _sf_tool_names or "terminal" in _sf_tool_names _sf_has_code = "python" in _sf_tool_names or "terminal" in _sf_tool_names
_sf_has_artifact = "render_html" in _sf_tool_names
_sf_date_line = f"The current date is {_date.today().isoformat()}." _sf_date_line = f"The current date is {_date.today().isoformat()}."
_sf_model_size_b = _extract_model_size_b(model_name) _sf_model_size_b = _extract_model_size_b(model_name)
@ -2909,35 +2974,32 @@ async def openai_chat_completions(
"Use code execution for math, calculations, data processing, " "Use code execution for math, calculations, data processing, "
"or to parse and analyze information from tool results." "or to parse and analyze information from tool results."
) )
_sf_artifact_tips = (
"Use render_html for HTML, CSS, or JavaScript artifact requests "
"with one complete self-contained HTML document in the code argument."
)
if _sf_has_web and _sf_has_code: _sf_tool_tip_parts = []
if _sf_has_web:
_sf_tool_tip_parts.append(_sf_web_tips)
if _sf_has_code:
_sf_tool_tip_parts.append(_sf_code_tips)
if _sf_has_artifact:
_sf_tool_tip_parts.append(_sf_artifact_tips)
if _sf_tool_tip_parts:
_sf_nudge = ( _sf_nudge = (
_sf_date_line + " " _sf_date_line + " "
"You have access to tools. When appropriate, prefer using " "You have access to tools. When appropriate, prefer using "
"tools rather than answering from memory. " "tools rather than answering from memory. "
+ _sf_web_tips + " ".join(_sf_tool_tip_parts)
+ " "
+ _sf_code_tips
)
elif _sf_has_code:
_sf_nudge = (
_sf_date_line + " "
"You have access to tools. When appropriate, prefer using "
"code execution rather than answering from memory. " + _sf_code_tips
)
elif _sf_has_web:
_sf_nudge = (
_sf_date_line + " "
"You have access to tools. When appropriate, prefer using "
"web search for up-to-date or uncertain factual "
"information rather than answering from memory. " + _sf_web_tips
) )
else: else:
_sf_nudge = "" _sf_nudge = ""
_sf_system_prompt = system_prompt _sf_system_prompt = system_prompt
if _sf_nudge: if _sf_nudge:
_sf_nudge += _TOOL_ACTION_NUDGE _sf_nudge += _tool_action_nudge(_sf_has_artifact)
if _sf_system_prompt: if _sf_system_prompt:
_sf_system_prompt = _sf_system_prompt.rstrip() + "\n\n" + _sf_nudge _sf_system_prompt = _sf_system_prompt.rstrip() + "\n\n" + _sf_nudge
else: else:
@ -4574,6 +4636,7 @@ async def anthropic_messages(
_tool_names = {t["function"]["name"] for t in openai_tools} _tool_names = {t["function"]["name"] for t in openai_tools}
_has_web = "web_search" in _tool_names _has_web = "web_search" in _tool_names
_has_code = "python" in _tool_names or "terminal" in _tool_names _has_code = "python" in _tool_names or "terminal" in _tool_names
_has_artifact = "render_html" in _tool_names
_date_line = f"The current date is {_date.today().isoformat()}." _date_line = f"The current date is {_date.today().isoformat()}."
_model_size_b = _extract_model_size_b(model_name) _model_size_b = _extract_model_size_b(model_name)
@ -4592,34 +4655,30 @@ async def anthropic_messages(
"Use code execution for math, calculations, data processing, " "Use code execution for math, calculations, data processing, "
"or to parse and analyze information from tool results." "or to parse and analyze information from tool results."
) )
_artifact_tips = (
"Use render_html for HTML, CSS, or JavaScript artifact requests "
"with one complete self-contained HTML document in the code argument."
)
if _has_web and _has_code: _tool_tip_parts = []
if _has_web:
_tool_tip_parts.append(_web_tips)
if _has_code:
_tool_tip_parts.append(_code_tips)
if _has_artifact:
_tool_tip_parts.append(_artifact_tips)
if _tool_tip_parts:
_nudge = ( _nudge = (
_date_line + " " _date_line + " "
"You have access to tools. When appropriate, prefer using " "You have access to tools. When appropriate, prefer using "
"tools rather than answering from memory. " "tools rather than answering from memory. " + " ".join(_tool_tip_parts)
+ _web_tips
+ " "
+ _code_tips
)
elif _has_code:
_nudge = (
_date_line + " "
"You have access to tools. When appropriate, prefer using "
"code execution rather than answering from memory. " + _code_tips
)
elif _has_web:
_nudge = (
_date_line + " "
"You have access to tools. When appropriate, prefer using "
"web search for up-to-date or uncertain factual "
"information rather than answering from memory. " + _web_tips
) )
else: else:
_nudge = "" _nudge = ""
if _nudge: if _nudge:
_nudge += _TOOL_ACTION_NUDGE _nudge += _tool_action_nudge(_has_artifact)
# Inject into system prompt # Inject into system prompt
if openai_messages and openai_messages[0].get("role") == "system": if openai_messages and openai_messages[0].get("role") == "system":
openai_messages[0]["content"] = ( openai_messages[0]["content"] = (

View file

@ -3,18 +3,19 @@
"use client"; "use client";
import { ArtifactCard } from "@/features/chat";
import { copyToClipboard } from "@/lib/copy-to-clipboard"; import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { preprocessLaTeX } from "@/lib/latex"; import { preprocessLaTeX } from "@/lib/latex";
import { openLink } from "@/lib/open-link"; import { openLink } from "@/lib/open-link";
import { INTERNAL, useMessagePartText } from "@assistant-ui/react"; import { INTERNAL, useMessagePartText } from "@assistant-ui/react";
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons"; import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { createCodePlugin } from "./code-plugin";
import { createMathPlugin } from "@streamdown/math"; import { createMathPlugin } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid"; 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 { useEffect, useMemo, useRef, useState } from "react";
import { Block, type BlockProps, Streamdown } from "streamdown"; import { Block, type BlockProps, Streamdown } from "streamdown";
import { createCodePlugin } from "./code-plugin";
import "katex/dist/katex.min.css"; import "katex/dist/katex.min.css";
import { AudioPlayer } from "./audio-player"; import { AudioPlayer } from "./audio-player";
import { unslothDarkTheme, unslothLightTheme } from "./code-themes"; import { unslothDarkTheme, unslothLightTheme } from "./code-themes";
@ -26,11 +27,7 @@ const code = createCodePlugin({
const { withSmoothContextProvider } = INTERNAL; const { withSmoothContextProvider } = INTERNAL;
const STREAMDOWN_COMPONENTS = { const STREAMDOWN_COMPONENTS = {
a: ({ a: ({ href, children, ...props }: React.ComponentProps<"a">) => (
href,
children,
...props
}: React.ComponentProps<"a">) => (
<a <a
href={href} href={href}
rel="noopener noreferrer" rel="noopener noreferrer"
@ -123,7 +120,8 @@ function isHtmlFence(codeFence: CodeFence): boolean {
return lang === "html" && !isSvgFence(codeFence); 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 { function sanitizeSvg(source: string): string | null {
if (UNSAFE_SVG_RE.test(source)) return 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 { function downloadTextFile(filename: string, text: string): void {
const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@ -362,7 +270,9 @@ function StreamdownBlock(props: BlockProps) {
return ( return (
<div className="relative isolate"> <div className="relative isolate">
<div className="my-4 rounded-xl border border-border bg-muted/30 p-4"> <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"> <pre className="overflow-x-auto text-xs text-muted-foreground whitespace-pre-wrap break-all">
<code>{codeFence.source}</code> <code>{codeFence.source}</code>
</pre> </pre>
@ -374,7 +284,7 @@ function StreamdownBlock(props: BlockProps) {
if (props.isIncomplete && codeFence && isHtmlFence(codeFence)) { if (props.isIncomplete && codeFence && isHtmlFence(codeFence)) {
return ( 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"> <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> </div>
); );
} }
@ -389,8 +299,18 @@ function StreamdownBlock(props: BlockProps) {
} }
if (codeFence) { if (codeFence) {
const svgSource = !props.isIncomplete && isSvgFence(codeFence) ? sanitizeSvg(codeFence.source) : null; const svgSource =
const htmlSource = !props.isIncomplete && isHtmlFence(codeFence) ? codeFence.source : null; !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" />
);
}
return ( return (
<> <>
<div className="relative isolate"> <div className="relative isolate">
@ -402,7 +322,6 @@ function StreamdownBlock(props: BlockProps) {
/> />
</div> </div>
{svgSource && <SvgPreview source={svgSource} />} {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 { ToolGroup } from "@/components/assistant-ui/tool-group";
import { CodeExecutionToolUI } from "@/components/assistant-ui/tool-ui-code-execution"; import { CodeExecutionToolUI } from "@/components/assistant-ui/tool-ui-code-execution";
import { ImageGenerationToolUI } from "@/components/assistant-ui/tool-ui-image-generation"; 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 { PythonToolUI } from "@/components/assistant-ui/tool-ui-python";
import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal"; import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal";
import { WebSearchToolUI } from "@/components/assistant-ui/tool-ui-web-search"; import { WebSearchToolUI } from "@/components/assistant-ui/tool-ui-web-search";
@ -67,6 +68,7 @@ import {
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
DownloadIcon, DownloadIcon,
FileTextIcon,
GlobeIcon, GlobeIcon,
HeadphonesIcon, HeadphonesIcon,
ImageIcon, ImageIcon,
@ -79,7 +81,12 @@ import {
TerminalIcon, TerminalIcon,
XIcon, XIcon,
} from "lucide-react"; } 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 { HugeiconsIcon } from "@hugeicons/react";
import { import {
type ChangeEvent, type ChangeEvent,
@ -98,11 +105,7 @@ export const Thread: FC<{
hideComposer?: boolean; hideComposer?: boolean;
hideWelcome?: boolean; hideWelcome?: boolean;
targetThreadId?: string; targetThreadId?: string;
}> = ({ }> = ({ hideComposer, hideWelcome, targetThreadId }) => {
hideComposer,
hideWelcome,
targetThreadId,
}) => {
// Intent-aware autoscroll: replaces assistant-ui's built-in autoscroll // Intent-aware autoscroll: replaces assistant-ui's built-in autoscroll
// to prevent the streaming-mutation race that makes the viewport snap // 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 // back to the bottom while the user is scrolling up (see the hook for
@ -136,7 +139,9 @@ export const Thread: FC<{
)} )}
> >
{!hideWelcome && ( {!hideWelcome && (
<AuiIf condition={({ thread }) => thread.isEmpty && !thread.isLoading}> <AuiIf
condition={({ thread }) => thread.isEmpty && !thread.isLoading}
>
<ThreadWelcome hideComposer={hideComposer} /> <ThreadWelcome hideComposer={hideComposer} />
</AuiIf> </AuiIf>
)} )}
@ -225,7 +230,8 @@ const ThreadWelcome: FC<{ hideComposer?: boolean }> = ({ hideComposer }) => {
useEffect(() => { useEffect(() => {
const hour = new Date().getHours(); const hour = new Date().getHours();
if (hour >= 6 && hour < 12) setCurrentEmoji("large sloth drink.png"); 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 if (hour >= 17 && hour < 21) setCurrentEmoji("sloth shy large.png");
else setCurrentEmoji("unsloth-gem.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-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="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"> <div className="flex flex-col items-center gap-2 text-center">
<img <img src={currentEmojiSrc} alt="Sloth mascot" className="size-20" />
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"> <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 Chat with your model
</h1> </h1>
@ -294,7 +296,8 @@ const PendingAudioChip: FC = () => {
}; };
const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => { const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => {
const { inputProps, isComposing, isComposingRef } = useImeComposerInputHandlers(); const { inputProps, isComposing, isComposingRef } =
useImeComposerInputHandlers();
const composerText = useAuiState(({ composer }) => composer.text); const composerText = useAuiState(({ composer }) => composer.text);
const hasAttachments = useAuiState( const hasAttachments = useAuiState(
({ composer }) => composer.attachments.length > 0, ({ composer }) => composer.attachments.length > 0,
@ -304,7 +307,9 @@ const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => {
(attachment) => attachment.status.type === "running", (attachment) => attachment.status.type === "running",
), ),
); );
const hasPendingAudio = useChatRuntimeStore((s) => Boolean(s.pendingAudioName)); const hasPendingAudio = useChatRuntimeStore((s) =>
Boolean(s.pendingAudioName),
);
const hasSendableContent = const hasSendableContent =
composerText.trim().length > 0 || hasAttachments || hasPendingAudio; composerText.trim().length > 0 || hasAttachments || hasPendingAudio;
@ -342,7 +347,10 @@ const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => {
/> />
<ComposerAction <ComposerAction
disabled={ disabled={
disabled || !hasSendableContent || isComposing || hasPendingAttachments disabled ||
!hasSendableContent ||
isComposing ||
hasPendingAttachments
} }
blockSend={() => blockSend={() =>
!hasSendableContent || isComposingRef.current || hasPendingAttachments !hasSendableContent || isComposingRef.current || hasPendingAttachments
@ -553,7 +561,6 @@ const ComposerAudioUpload: FC = () => {
); );
}; };
const ReasoningToggle: FC = () => { const ReasoningToggle: FC = () => {
const modelLoaded = useChatRuntimeStore( const modelLoaded = useChatRuntimeStore(
(s) => !!s.params.checkpoint && !s.modelLoading, (s) => !!s.params.checkpoint && !s.modelLoading,
@ -565,8 +572,12 @@ const ReasoningToggle: FC = () => {
const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled); const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled);
const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle); const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle);
const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort); const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort);
const supportsReasoningOff = useChatRuntimeStore((s) => s.supportsReasoningOff); const supportsReasoningOff = useChatRuntimeStore(
const reasoningEffortLevels = useChatRuntimeStore((s) => s.reasoningEffortLevels); (s) => s.supportsReasoningOff,
);
const reasoningEffortLevels = useChatRuntimeStore(
(s) => s.reasoningEffortLevels,
);
const setReasoningEffort = useChatRuntimeStore((s) => s.setReasoningEffort); const setReasoningEffort = useChatRuntimeStore((s) => s.setReasoningEffort);
const lastOpenRouterChosenModel = useChatRuntimeStore( const lastOpenRouterChosenModel = useChatRuntimeStore(
(s) => s.lastOpenRouterChosenModel, (s) => s.lastOpenRouterChosenModel,
@ -619,7 +630,8 @@ const ReasoningToggle: FC = () => {
effectiveReasoningEnabled && reasoningEffort !== "none"; effectiveReasoningEnabled && reasoningEffort !== "none";
const disabled = !(modelLoaded && effectiveSupportsReasoning); const disabled = !(modelLoaded && effectiveSupportsReasoning);
const formatEffortLabel = (level: typeof reasoningEffort): string => { 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() ?? ""; const normalized = externalSelection?.modelId?.trim().toLowerCase() ?? "";
if ( if (
normalized.startsWith("claude-opus-4-6") || normalized.startsWith("claude-opus-4-6") ||
@ -677,23 +689,25 @@ const ReasoningToggle: FC = () => {
{effectiveReasoningEffortLevels {effectiveReasoningEffortLevels
.filter((level) => level !== "none") .filter((level) => level !== "none")
.map((level) => ( .map((level) => (
<DropdownMenuItem <DropdownMenuItem
key={level} key={level}
onSelect={() => { onSelect={() => {
setReasoningEffort(level); setReasoningEffort(level);
setReasoningEnabled(true); setReasoningEnabled(true);
applyQwenThinkingParams(true); applyQwenThinkingParams(true);
// Kimi's $web_search builtin forbids thinking, so // Kimi's $web_search builtin forbids thinking, so
// enabling thinking flips the Search pill off. // enabling thinking flips the Search pill off.
if (isKimiExternal && toolsEnabled) { if (isKimiExternal && toolsEnabled) {
setToolsEnabled(false); setToolsEnabled(false);
} }
}} }}
> >
{formatEffortLabel(level)} {formatEffortLabel(level)}
{effectiveReasoningVisualEnabled && reasoningEffort === level ? " \u2713" : ""} {effectiveReasoningVisualEnabled && reasoningEffort === level
</DropdownMenuItem> ? " \u2713"
))} : ""}
</DropdownMenuItem>
))}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );
@ -808,8 +822,7 @@ const WebSearchToggle: FC = () => {
? externalProviders.find((p) => p.id === externalSelection.providerId) ? externalProviders.find((p) => p.id === externalSelection.providerId)
: undefined; : undefined;
const isKimiExternal = selectedExternalProvider?.providerType === "kimi"; const isKimiExternal = selectedExternalProvider?.providerType === "kimi";
const disabled = const disabled = !modelLoaded || !(supportsTools || supportsBuiltinWebSearch);
!modelLoaded || !(supportsTools || supportsBuiltinWebSearch);
return ( return (
<button <button
@ -899,7 +912,9 @@ const ImagesToggle: FC = () => {
className="composer-pill-btn" className="composer-pill-btn"
data-active={imageToolsEnabled && !disabled ? "true" : "false"} data-active={imageToolsEnabled && !disabled ? "true" : "false"}
aria-label={ aria-label={
imageToolsEnabled ? "Disable image generation" : "Enable image generation" imageToolsEnabled
? "Disable image generation"
: "Enable image generation"
} }
> >
<ImageIcon className="size-3.5" /> <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 ToolStatusDisplay: FC = () => {
const toolStatus = useChatRuntimeStore((s) => s.toolStatus); const toolStatus = useChatRuntimeStore((s) => s.toolStatus);
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning); const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
@ -980,6 +1018,7 @@ const ComposerAction: FC<{ disabled?: boolean; blockSend?: () => boolean }> = ({
<WebSearchToggle /> <WebSearchToggle />
<CodeToolsToggle /> <CodeToolsToggle />
<ImagesToggle /> <ImagesToggle />
<ArtifactsToggle />
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<ComposerPrimitive.If dictation={false}> <ComposerPrimitive.If dictation={false}>
@ -1107,6 +1146,7 @@ const AssistantMessage: FC = () => {
terminal: TerminalToolUI, terminal: TerminalToolUI,
code_execution: CodeExecutionToolUI, code_execution: CodeExecutionToolUI,
image_generation: ImageGenerationToolUI, image_generation: ImageGenerationToolUI,
render_html: RenderHtmlToolUI,
}, },
Fallback: ToolFallback, Fallback: ToolFallback,
}, },
@ -1284,7 +1324,11 @@ const UserActionBar: FC = () => {
<CopyButton /> <CopyButton />
<ActionBarPrimitive.Edit asChild={true}> <ActionBarPrimitive.Edit asChild={true}>
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit"> <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> </TooltipIconButton>
</ActionBarPrimitive.Edit> </ActionBarPrimitive.Edit>
<DeleteMessageButton /> <DeleteMessageButton />

View file

@ -8,6 +8,7 @@ import {
type FC, type FC,
type PropsWithChildren, type PropsWithChildren,
} from "react"; } from "react";
import { useAuiState } from "@assistant-ui/react";
import { ChevronDownIcon, LoaderIcon } from "lucide-react"; import { ChevronDownIcon, LoaderIcon } from "lucide-react";
import { Wrench01Icon } from "@hugeicons/core-free-icons"; import { Wrench01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
@ -27,7 +28,8 @@ const toolGroupVariants = cva("aui-tool-group-root group/tool-group w-full", {
variant: { variant: {
outline: "corner-squircle rounded-lg border py-3", outline: "corner-squircle rounded-lg border py-3",
ghost: "rounded-lg bg-muted/10 py-2", ghost: "rounded-lg bg-muted/10 py-2",
muted: "corner-squircle rounded-lg border border-muted-foreground/30 bg-muted/30 py-3", muted:
"corner-squircle rounded-lg border border-muted-foreground/30 bg-muted/30 py-3",
}, },
}, },
defaultVariants: { variant: "ghost" }, defaultVariants: { variant: "ghost" },
@ -209,9 +211,17 @@ const ToolGroupImpl: FC<
PropsWithChildren<{ startIndex: number; endIndex: number }> PropsWithChildren<{ startIndex: number; endIndex: number }>
> = ({ children, startIndex, endIndex }) => { > = ({ children, startIndex, endIndex }) => {
const toolCount = endIndex - startIndex + 1; const toolCount = endIndex - startIndex + 1;
const containsArtifactTool = useAuiState(({ message }) =>
message.parts
.slice(startIndex, endIndex + 1)
.some(
(part) => part.type === "tool-call" && part.toolName === "render_html",
),
);
// Single tool call — render directly without wrapper // Single tool calls and artifacts render directly so cards never hide inside
if (toolCount <= 1) { // a collapsed tool group.
if (toolCount <= 1 || containsArtifactTool) {
return <>{children}</>; return <>{children}</>;
} }

View file

@ -0,0 +1,89 @@
// 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 { BrowserIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
type ToolCallMessagePartComponent,
useToolArgsStatus,
} from "@assistant-ui/react";
import { memo } from "react";
// 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;
};
const RenderHtmlToolUIImpl: ToolCallMessagePartComponent = ({
args,
result,
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;
const title =
typeof parsedArgs.title === "string" ? parsedArgs.title : "HTML artifact";
const isRunning = status?.type === "running";
const codeIsStreaming = propStatus.code === "streaming";
if (hasCode) {
return (
<ArtifactCard
code={code}
title={title}
source="tool"
sourceToolCallId={toolCallId}
autoOpen={true}
isStreaming={isRunning || codeIsStreaming}
/>
);
}
// Surface the backend error when the tool call completed with invalid
// args. Backend success results start with "Rendered HTML artifact";
// error results start with "Error:".
const errorText =
status?.type === "complete" &&
typeof result === "string" &&
result.startsWith("Error:")
? result
: null;
return (
<div className="relative my-2 flex min-h-[52px] w-full max-w-md items-center overflow-hidden rounded-lg border border-border/70 bg-muted/15 px-3 py-2 text-left dark:bg-muted/10">
<div className="relative z-10 flex min-w-0 flex-1 items-center gap-2.5">
<HugeiconsIcon
icon={BrowserIcon}
strokeWidth={1.75}
className="size-5 shrink-0 text-muted-foreground"
/>
<span className="grid min-w-0 flex-1 gap-1">
<span className="truncate text-sm font-medium leading-tight text-foreground">
{errorText ? "Artifact error" : "Generating artifact"}
</span>
<span className="truncate text-[11px] leading-none text-muted-foreground">
{errorText ?? "HTML artifact"}
</span>
</span>
{errorText ? null : (
<span className="shimmer shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary motion-reduce:animate-none">
Generating
</span>
)}
</div>
</div>
);
};
export const RenderHtmlToolUI = memo(
RenderHtmlToolUIImpl,
) as unknown as ToolCallMessagePartComponent;
RenderHtmlToolUI.displayName = "RenderHtmlToolUI";

View file

@ -39,13 +39,13 @@ function ResizableHandle({
<ResizablePrimitive.Separator <ResizablePrimitive.Separator
data-slot="resizable-handle" data-slot="resizable-handle"
className={cn( className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90", "group bg-border/80 relative z-10 flex w-px cursor-col-resize items-center justify-center transition-[background-color,box-shadow] duration-150 ease-out after:absolute after:inset-y-0 after:left-1/2 after:w-2 after:-translate-x-1/2 hover:bg-primary/80 hover:shadow-[0_0_16px_rgba(23,184,139,0.55)] active:bg-primary/90 active:shadow-[0_0_18px_rgba(23,184,139,0.7)] focus-visible:bg-primary/80 focus-visible:ring-1 focus-visible:ring-primary/50 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:cursor-row-resize data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-2 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className, className,
)} )}
{...props} {...props}
> >
{withHandle && ( {withHandle && (
<div className="bg-border h-6 w-1 rounded-lg z-10 flex shrink-0" /> <div className="z-10 flex h-6 w-1 shrink-0 rounded-lg bg-border transition-[background-color,box-shadow,transform] duration-150 ease-out group-hover:scale-y-110 group-hover:bg-primary/80 group-hover:shadow-[0_0_12px_rgba(23,184,139,0.65)] group-active:bg-primary" />
)} )}
</ResizablePrimitive.Separator> </ResizablePrimitive.Separator>
); );

View file

@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 // 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 { apiUrl } from "@/lib/api-base";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import type { MessageTiming, ToolCallMessagePart } from "@assistant-ui/core"; import type { MessageTiming, ToolCallMessagePart } from "@assistant-ui/core";
@ -37,12 +37,12 @@ import type {
OpenAIMessageContent, OpenAIMessageContent,
} from "../types/api"; } from "../types/api";
import type { ChatModelSummary } from "../types/runtime"; import type { ChatModelSummary } from "../types/runtime";
import { getImageInputUnavailableReason } from "../utils/image-input-support";
import { import {
getStoredChatThread, getStoredChatThread,
listStoredChatThreads, listStoredChatThreads,
updateStoredChatThread, updateStoredChatThread,
} from "../utils/chat-history-storage"; } from "../utils/chat-history-storage";
import { getImageInputUnavailableReason } from "../utils/image-input-support";
import { import {
hasClosedThinkTag, hasClosedThinkTag,
parseAssistantContent, parseAssistantContent,
@ -833,7 +833,13 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
// Re-read store after potential auto-load / model ready wait // Re-read store after potential auto-load / model ready wait
runtime = useChatRuntimeStore.getState(); runtime = useChatRuntimeStore.getState();
const { params } = runtime; const { params } = runtime;
const { supportsTools, toolsEnabled, codeToolsEnabled, imageToolsEnabled } = runtime; const {
supportsTools,
toolsEnabled,
codeToolsEnabled,
imageToolsEnabled,
artifactsEnabled,
} = runtime;
const externalSelection = parseExternalModelId(params.checkpoint); const externalSelection = parseExternalModelId(params.checkpoint);
const isExternalRequest = externalSelection !== null; const isExternalRequest = externalSelection !== null;
if ( if (
@ -872,33 +878,30 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
throw new Error("Missing connection API key."); throw new Error("Missing connection API key.");
} }
const webSearchEnabledForThisTurn = const webSearchEnabledForThisTurn = Boolean(
Boolean( externalProvider &&
externalProvider && toolsEnabled &&
toolsEnabled && providerSupportsBuiltinWebSearch(externalProvider.providerType),
providerSupportsBuiltinWebSearch(externalProvider.providerType), );
); const codeExecEnabledForThisTurn = Boolean(
const codeExecEnabledForThisTurn = externalProvider &&
Boolean( externalSelection &&
externalProvider && codeToolsEnabled &&
externalSelection && providerSupportsBuiltinCodeExecution(
codeToolsEnabled && externalProvider.providerType,
providerSupportsBuiltinCodeExecution( externalSelection.modelId,
externalProvider.providerType, externalProvider.baseUrl,
externalSelection.modelId, ),
externalProvider.baseUrl, );
),
);
// web_fetch shares the Search pill with web_search (no separate // web_fetch shares the Search pill with web_search (no separate
// UI toggle), so it follows toolsEnabled. Anthropic is the only // UI toggle), so it follows toolsEnabled. Anthropic is the only
// provider that ships it today; on others providerSupportsBuiltinWebFetch // provider that ships it today; on others providerSupportsBuiltinWebFetch
// returns false and this stays inert. // returns false and this stays inert.
const webFetchEnabledForThisTurn = const webFetchEnabledForThisTurn = Boolean(
Boolean( externalProvider &&
externalProvider && toolsEnabled &&
toolsEnabled && providerSupportsBuiltinWebFetch(externalProvider.providerType),
providerSupportsBuiltinWebFetch(externalProvider.providerType), );
);
const providerShipsWebFetch = Boolean( const providerShipsWebFetch = Boolean(
externalProvider && externalProvider &&
providerSupportsBuiltinWebFetch(externalProvider.providerType), providerSupportsBuiltinWebFetch(externalProvider.providerType),
@ -964,32 +967,58 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
"Do not return tool-call syntax inside your response."; "Do not return tool-call syntax inside your response.";
} }
} }
if (disabledToolGuard) { type OutboundMessage = (typeof outboundMessages)[number];
const firstMessage = outboundMessages[0]; function addSystemInstruction(
targetMessages: OutboundMessage[],
text: string | null,
): void {
if (!text) return;
const firstMessage = targetMessages[0];
if (firstMessage?.role === "system") { if (firstMessage?.role === "system") {
if (typeof firstMessage.content === "string") { if (typeof firstMessage.content === "string") {
outboundMessages[0] = { targetMessages[0] = {
...firstMessage, ...firstMessage,
content: `${firstMessage.content}\n\n${disabledToolGuard}`, content: `${firstMessage.content}\n\n${text}`,
}; };
} else { } else {
outboundMessages[0] = { targetMessages[0] = {
...firstMessage, ...firstMessage,
content: [ content: [
...firstMessage.content, ...firstMessage.content,
{ type: "text", text: `\n\n${disabledToolGuard}` }, { type: "text", text: `\n\n${text}` },
], ],
}; };
} }
} else { return;
outboundMessages.unshift({
role: "system",
content: disabledToolGuard,
});
} }
targetMessages.unshift({ role: "system", content: text });
} }
const imageBase64 = findLatestUserImageBase64(messages); const imageBase64 = findLatestUserImageBase64(messages);
const audioBase64 = findLatestUserAudioBase64(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 &&
!hasOutboundImage,
);
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);
// Block when ANY image is in the outbound payload (current or // Block when ANY image is in the outbound payload (current or
// prior turns) and the loaded model can't process images. Keeps // prior turns) and the loaded model can't process images. Keeps
@ -1314,8 +1343,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
) { ) {
void updateStoredChatThreadEventually(t.id, { void updateStoredChatThreadEventually(t.id, {
openaiCodeExecContainerId: null, openaiCodeExecContainerId: null,
}) }).catch(() => {});
.catch(() => {});
continue; continue;
} }
openaiCodeExecContainerId = t.openaiCodeExecContainerId; openaiCodeExecContainerId = t.openaiCodeExecContainerId;
@ -1359,8 +1387,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
openaiCodeExecContainerId = created.id; openaiCodeExecContainerId = created.id;
void updateStoredChatThreadEventually(resolvedThreadId, { void updateStoredChatThreadEventually(resolvedThreadId, {
openaiCodeExecContainerId: created.id, openaiCodeExecContainerId: created.id,
}) }).catch(() => {});
.catch(() => {});
} catch { } catch {
// Fall back to backend's container_auto path on // Fall back to backend's container_auto path on
// failure — keeps the chat moving; the next turn // failure — keeps the chat moving; the next turn
@ -1473,7 +1500,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
// attaches `cache_control.ttl` when the value is one of // attaches `cache_control.ttl` when the value is one of
// "5m" / "1h" (see external_provider.py near line 1375), // "5m" / "1h" (see external_provider.py near line 1375),
// so unknown values are a no-op end-to-end. // so unknown values are a no-op end-to-end.
...(supportsProviderPromptCacheTtl(externalProvider.providerType) && ...(supportsProviderPromptCacheTtl(
externalProvider.providerType,
) &&
(externalProvider.enablePromptCaching ?? true) && (externalProvider.enablePromptCaching ?? true) &&
isPromptCacheTtl(externalProvider.promptCacheTtl) isPromptCacheTtl(externalProvider.promptCacheTtl)
? { prompt_cache_ttl: externalProvider.promptCacheTtl } ? { prompt_cache_ttl: externalProvider.promptCacheTtl }
@ -1518,12 +1547,18 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
...(supportsPreserveThinking ...(supportsPreserveThinking
? { preserve_thinking: preserveThinking } ? { preserve_thinking: preserveThinking }
: {}), : {}),
...(supportsTools && (toolsEnabled || codeToolsEnabled) ...(supportsTools &&
(toolsEnabled ||
codeToolsEnabled ||
renderHtmlToolEnabledForThisTurn)
? { ? {
enable_tools: true, enable_tools: true,
enabled_tools: [ enabled_tools: [
...(toolsEnabled ? ["web_search"] : []), ...(toolsEnabled ? ["web_search"] : []),
...(codeToolsEnabled ? ["python", "terminal"] : []), ...(codeToolsEnabled ? ["python", "terminal"] : []),
...(renderHtmlToolEnabledForThisTurn
? ["render_html"]
: []),
], ],
auto_heal_tool_calls: auto_heal_tool_calls:
useChatRuntimeStore.getState().autoHealToolCalls, useChatRuntimeStore.getState().autoHealToolCalls,
@ -1591,8 +1626,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
: "openaiCodeExecContainerId"; : "openaiCodeExecContainerId";
void updateStoredChatThreadEventually(resolvedThreadId, { void updateStoredChatThreadEventually(resolvedThreadId, {
[field]: null, [field]: null,
}) }).catch(() => {});
.catch(() => {});
} }
continue; continue;
} }

View file

@ -0,0 +1,129 @@
// 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 { cn } from "@/lib/utils";
import { BrowserIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useAuiState } from "@assistant-ui/react";
import { useEffect, useMemo } from "react";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
import {
hasAutoOpenedArtifact,
rememberAutoOpenedArtifact,
useChatArtifactsStore,
} from "./store";
import {
type ChatArtifact,
type ChatArtifactSource,
createChatArtifact,
} from "./types";
export function ArtifactCard({
code,
title,
source,
sourceToolCallId,
sourceMessageId,
className,
autoOpen = false,
isStreaming = false,
}: {
code: string;
title?: string | null;
source: ChatArtifactSource;
sourceToolCallId?: string | null;
sourceMessageId?: string | null;
className?: string;
autoOpen?: boolean;
isStreaming?: boolean;
}) {
const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId);
const messageIdFromContext = useAuiState(({ message }) => message.id);
const threadIdFromContext = useAuiState(
({ threads }) => threads.mainThreadId,
);
const artifactThreadId = threadIdFromContext ?? activeThreadId ?? null;
const openArtifact = useChatArtifactsStore((state) => state.openArtifact);
const updateArtifact = useChatArtifactsStore((state) => state.updateArtifact);
const selectedArtifactId = useChatArtifactsStore(
(state) => state.selectedArtifactId,
);
const artifact = useMemo<ChatArtifact>(
() =>
createChatArtifact({
code,
title,
source,
sourceMessageId: sourceMessageId ?? messageIdFromContext ?? null,
sourceToolCallId: sourceToolCallId ?? null,
threadId: artifactThreadId,
isStreaming,
}),
[
artifactThreadId,
code,
isStreaming,
messageIdFromContext,
source,
sourceMessageId,
sourceToolCallId,
title,
],
);
const surface = artifactThreadId ? "panel" : "overlay";
useEffect(() => {
if (!autoOpen) return;
if (!hasAutoOpenedArtifact(artifact.id)) {
rememberAutoOpenedArtifact(artifact.id);
openArtifact(artifact, { surface });
return;
}
if (selectedArtifactId === artifact.id) {
updateArtifact(artifact);
}
}, [
artifact,
autoOpen,
openArtifact,
selectedArtifactId,
surface,
updateArtifact,
]);
return (
<button
type="button"
className={cn(
"group/artifact-card relative my-2 flex min-h-[52px] w-full max-w-md cursor-pointer items-center overflow-hidden rounded-lg border border-border/70 bg-muted/15 px-3 py-2 text-left transition-colors hover:bg-muted/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"dark:bg-muted/10 dark:hover:bg-muted/20",
className,
)}
onClick={() => openArtifact(artifact, { surface })}
aria-label={`Open ${artifact.title}`}
>
<div className="relative z-10 flex min-w-0 flex-1 items-center gap-2.5">
<HugeiconsIcon
icon={BrowserIcon}
strokeWidth={1.75}
className="size-5 shrink-0 text-muted-foreground"
/>
<span className="grid min-w-0 flex-1 gap-1">
<span className="truncate text-sm font-medium leading-tight text-foreground">
{artifact.title}
</span>
<span className="truncate text-[11px] leading-none text-muted-foreground">
HTML artifact
</span>
</span>
{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>
</button>
);
}

View file

@ -0,0 +1,306 @@
// 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 { createCodePlugin } from "@/components/assistant-ui/code-plugin";
import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon";
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";
import {
CheckIcon,
CopyIcon,
DownloadIcon,
EyeIcon,
Maximize2Icon,
XIcon,
} from "lucide-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"])';
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" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}
export function ArtifactSurface({
artifact,
variant,
onClose,
onOpenFullscreen,
}: {
artifact: ChatArtifact;
variant: "panel" | "overlay";
onClose: () => void;
onOpenFullscreen?: () => void;
}) {
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 () => {
if (copyResetRef.current) clearTimeout(copyResetRef.current);
};
}, []);
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);
if (copyResetRef.current) clearTimeout(copyResetRef.current);
copyResetRef.current = setTimeout(() => {
setCopied(false);
copyResetRef.current = null;
}, 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"
? "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`}
>
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-2.5 py-2">
<div
className="flex items-center gap-1 rounded-md bg-muted/40 p-0.5"
role="tablist"
aria-label="Artifact view"
>
{(["preview", "source"] as const).map((mode) => {
const isPreview = mode === "preview";
const Icon = isPreview ? EyeIcon : CodeToggleIcon;
return (
<button
key={mode}
type="button"
role="tab"
disabled={artifact.isStreaming && isPreview}
onClick={() => setViewMode(mode)}
className={cn(
"flex size-8 items-center justify-center rounded-[4px] text-muted-foreground transition-colors",
effectiveViewMode === mode
? "bg-background text-foreground shadow-sm"
: "hover:bg-background/70 hover:text-foreground",
artifact.isStreaming &&
isPreview &&
"cursor-not-allowed opacity-50",
)}
aria-label={
isPreview ? "Preview artifact" : "View artifact source"
}
aria-selected={effectiveViewMode === mode}
aria-pressed={effectiveViewMode === mode}
title={isPreview ? "Preview" : "Source"}
>
<Icon className="size-4" />
</button>
);
})}
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
onClick={() => downloadTextFile(filename, artifact.code)}
aria-label="Download artifact HTML"
>
<DownloadIcon className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
onClick={handleCopy}
aria-label="Copy artifact HTML"
>
{copied ? (
<CheckIcon className="size-4" />
) : (
<CopyIcon className="size-4" />
)}
</Button>
{variant === "panel" && onOpenFullscreen ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
onClick={onOpenFullscreen}
aria-label="Open artifact fullscreen"
>
<Maximize2Icon className="size-4" />
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
onClick={onClose}
aria-label="Close artifact"
>
<XIcon className="size-4" />
</Button>
</div>
</header>
<div className="min-h-0 flex-1 overflow-hidden bg-background">
{effectiveViewMode === "preview" ? (
<ArtifactHtmlFrame
key={artifact.id}
code={artifact.code}
title={artifact.title}
fill={true}
className="h-full"
/>
) : (
<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>
);
if (variant === "overlay") {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 p-4 backdrop-blur-sm"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
{content}
</div>
);
}
return content;
}

View file

@ -0,0 +1,91 @@
// 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 { apiUrl } from "@/lib/api-base";
import { cn } from "@/lib/utils";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { hashArtifactCode } from "./types";
const HTML_FRAME_DEFAULT_HEIGHT = 400;
const HTML_FRAME_MAX_HEIGHT = 900;
export type ArtifactViewMode = "preview" | "source";
export const ARTIFACT_VIEW_MODES: readonly ArtifactViewMode[] = [
"preview",
"source",
];
export function isArtifactViewMode(value: string): value is ArtifactViewMode {
return (ARTIFACT_VIEW_MODES as readonly string[]).includes(value);
}
export function buildArtifactSrcDoc(code: string): string {
const resizeScript = `<script>(()=>{const post=()=>parent.postMessage({chatArtifactHeight:document.documentElement.scrollHeight},"*");new ResizeObserver(post).observe(document.documentElement);window.addEventListener("load",post);post();})();</script>`;
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",
className,
fill = false,
}: {
code: string;
title?: string;
className?: string;
fill?: boolean;
}) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState(HTML_FRAME_DEFAULT_HEIGHT);
const artifactHtml = useMemo(() => buildArtifactSrcDoc(code), [code]);
const src = useMemo(
() =>
apiUrl(
`/api/inference/artifact-preview-frame?v=${encodeURIComponent(hashArtifactCode(code))}`,
),
[code],
);
const postArtifactHtml = useCallback(() => {
// The sandboxed frame intentionally has an opaque origin ("null").
// A wildcard target is required here;
// the payload is sent only to this iframe's contentWindow.
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.origin !== "null") return;
if (typeof event.data?.chatArtifactHeight !== "number") return;
setHeight(
Math.min(
Math.max(event.data.chatArtifactHeight, 160),
HTML_FRAME_MAX_HEIGHT,
),
);
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, [postArtifactHtml]);
return (
<iframe
ref={iframeRef}
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}
/>
);
}

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
import { create } from "zustand";
import type { ChatArtifact, ChatArtifactSurface } from "./types";
const autoOpenedArtifactIds = new Set<string>();
export function hasAutoOpenedArtifact(artifactId: string): boolean {
return autoOpenedArtifactIds.has(artifactId);
}
export function rememberAutoOpenedArtifact(artifactId: string): void {
autoOpenedArtifactIds.add(artifactId);
}
export function clearAutoOpenedArtifacts(): void {
autoOpenedArtifactIds.clear();
}
type ChatArtifactsState = {
artifactsById: Record<string, ChatArtifact>;
selectedArtifactId: string | null;
surface: ChatArtifactSurface;
openArtifact: (
artifact: ChatArtifact,
options?: { surface?: ChatArtifactSurface },
) => void;
updateArtifact: (artifact: ChatArtifact) => void;
closeArtifactSurface: () => void;
clearArtifactsForThread: (threadId: string | null | undefined) => void;
resetArtifacts: () => void;
};
export const useChatArtifactsStore = create<ChatArtifactsState>((set) => ({
artifactsById: {},
selectedArtifactId: null,
surface: "panel",
openArtifact: (artifact, options) =>
set((state) => ({
artifactsById: {
[artifact.id]: artifact,
},
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, surface: "panel" }),
clearArtifactsForThread: (threadId) =>
set((state) => {
if (!threadId) return state;
const artifactsById = Object.fromEntries(
Object.entries(state.artifactsById).filter(
([, artifact]) => artifact.threadId !== threadId,
),
);
const selected = state.selectedArtifactId
? artifactsById[state.selectedArtifactId]
: null;
return {
artifactsById,
selectedArtifactId: selected ? selected.id : null,
};
}),
resetArtifacts: () =>
set({
artifactsById: {},
selectedArtifactId: null,
surface: "panel",
}),
}));
export function useSelectedChatArtifact(): ChatArtifact | null {
return useChatArtifactsStore((state) =>
state.selectedArtifactId
? (state.artifactsById[state.selectedArtifactId] ?? null)
: null,
);
}

View file

@ -0,0 +1,84 @@
// 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 type ChatArtifactSource = "tool" | "fence";
export type ChatArtifactSurface = "panel" | "overlay";
export interface ChatArtifact {
id: string;
title: string;
code: string;
source: ChatArtifactSource;
sourceMessageId?: string | null;
sourceToolCallId?: string | null;
threadId?: string | null;
isStreaming?: boolean;
createdAt: number;
}
export interface ChatArtifactInput {
title?: string | null;
code: string;
source: ChatArtifactSource;
sourceMessageId?: string | null;
sourceToolCallId?: string | null;
threadId?: string | null;
isStreaming?: boolean;
}
const DEFAULT_ARTIFACT_TITLE = "HTML artifact";
export function normalizeArtifactTitle(title?: string | null): string {
const trimmed = title?.trim();
return trimmed && trimmed.length > 0 ? trimmed : DEFAULT_ARTIFACT_TITLE;
}
export function hashArtifactCode(code: string): string {
let hash = 5381;
for (let i = 0; i < code.length; i += 1) {
hash = ((hash << 5) + hash) ^ code.charCodeAt(i);
}
return (hash >>> 0).toString(36);
}
export function createArtifactId(input: ChatArtifactInput): string {
const threadSegment = input.threadId || "no-thread";
const messageSegment = input.sourceMessageId || "transient";
// Backend tool call IDs (call_0, call_1, …) reset per request, so
// the message ID is needed to scope them to a specific turn.
const parts = [input.source, threadSegment, messageSegment];
if (input.source === "tool" && input.sourceToolCallId) {
parts.push(input.sourceToolCallId);
} else {
parts.push(hashArtifactCode(input.code));
}
return parts.join(":");
}
export function createChatArtifact(input: ChatArtifactInput): ChatArtifact {
return {
id: createArtifactId(input),
title: normalizeArtifactTitle(input.title),
code: input.code,
source: input.source,
sourceMessageId: input.sourceMessageId ?? null,
sourceToolCallId: input.sourceToolCallId ?? null,
threadId: input.threadId ?? null,
isStreaming: input.isStreaming,
createdAt: Date.now(),
};
}
export function getArtifactFilename(
artifact: Pick<ChatArtifact, "title">,
): string {
const slug = artifact.title
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48);
return `${slug || "artifact"}.html`;
}

View file

@ -10,15 +10,22 @@ import {
ModelSelector, ModelSelector,
} from "@/components/assistant-ui/model-selector"; } from "@/components/assistant-ui/model-selector";
import { Thread } from "@/components/assistant-ui/thread"; import { Thread } from "@/components/assistant-ui/thread";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { useSidebar } from "@/components/ui/sidebar"; import { useSidebar } from "@/components/ui/sidebar";
import { Tooltip, TooltipContent } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
import { NativeModelChip } from "@/features/native-intents/components/native-model-chip"; import {
import { NativeModelDropOverlay } from "@/features/native-intents/components/native-model-drop-overlay"; NativeModelChip,
import { useNativeIntentStore } from "@/features/native-intents/store"; NativeModelDropOverlay,
import type { NativeIntent } from "@/features/native-intents/types"; type NativeIntent,
import { useChooseNativeModel } from "@/features/native-intents/use-native-dialogs"; useChooseNativeModel,
import { useNativeModelDrop } from "@/features/native-intents/use-native-drop"; useNativeIntentStore,
import { useNativePathLeasesSupported } from "@/features/native-intents/use-native-readiness"; useNativeModelDrop,
useNativePathLeasesSupported,
} from "@/features/native-intents";
import { GuidedTour, useGuidedTourController } from "@/features/tour"; import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { isTauri } from "@/lib/api-base"; import { isTauri } from "@/lib/api-base";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -76,6 +83,13 @@ import {
} from "./stores/chat-runtime-store"; } from "./stores/chat-runtime-store";
import { useExternalProvidersStore } from "./stores/external-providers-store"; import { useExternalProvidersStore } from "./stores/external-providers-store";
import { buildChatTourSteps } from "./tour"; import { buildChatTourSteps } from "./tour";
import { ArtifactSurface } from "./artifacts/artifact-surface";
import {
clearAutoOpenedArtifacts,
useChatArtifactsStore,
useSelectedChatArtifact,
} from "./artifacts/store";
import type { ChatArtifact, ChatArtifactSurface } from "./artifacts/types";
import type { ChatView, MessageRecord } from "./types"; import type { ChatView, MessageRecord } from "./types";
import { import {
getStoredChatThread, getStoredChatThread,
@ -132,6 +146,12 @@ function pickBestLoraForBase(
return partial ?? sorted[0] ?? null; return partial ?? sorted[0] ?? null;
} }
function isAssistantLocalThreadId(
threadId: string | null | undefined,
): boolean {
return Boolean(threadId?.startsWith("__LOCALID_"));
}
function messageHasImage(message: MessageRecord): boolean { function messageHasImage(message: MessageRecord): boolean {
const contentParts = Array.isArray(message.content) ? message.content : []; const contentParts = Array.isArray(message.content) ? message.content : [];
if (contentParts.some((part) => part.type === "image")) { if (contentParts.some((part) => part.type === "image")) {
@ -154,16 +174,77 @@ function messageHasImage(message: MessageRecord): boolean {
const SingleContent = memo(function SingleContent({ const SingleContent = memo(function SingleContent({
threadId, threadId,
newThreadNonce, newThreadNonce,
}: { threadId?: string; newThreadNonce?: string }): ReactElement { artifact,
artifactSurface,
onCloseArtifact,
}: {
threadId?: string;
newThreadNonce?: string;
artifact?: ChatArtifact | null;
artifactSurface: ChatArtifactSurface;
onCloseArtifact: () => void;
}): ReactElement {
const openArtifact = useChatArtifactsStore((state) => state.openArtifact);
const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId);
const showArtifactPanel = Boolean(
artifact &&
artifactSurface === "panel" &&
(threadId
? !artifact.threadId || artifact.threadId === threadId
: Boolean(newThreadNonce) ||
Boolean(artifact.threadId && artifact.threadId === activeThreadId)),
);
const threadPane = (
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
<Thread hideWelcome={Boolean(threadId)} targetThreadId={threadId} />
</div>
);
return ( return (
<ChatRuntimeProvider <ChatRuntimeProvider
modelType="base" modelType="base"
initialThreadId={threadId} initialThreadId={threadId}
newThreadNonce={newThreadNonce} newThreadNonce={newThreadNonce}
> >
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden"> {showArtifactPanel && artifact ? (
<Thread hideWelcome={Boolean(threadId)} targetThreadId={threadId} /> <ResizablePanelGroup
</div> orientation="horizontal"
className="min-h-0 min-w-0 flex-1 basis-0 overflow-hidden"
>
<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
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>
) : (
threadPane
)}
</ChatRuntimeProvider> </ChatRuntimeProvider>
); );
}); });
@ -329,15 +410,17 @@ const LoraCompareContent = memo(function LoraCompareContent({
useEffect(() => { useEffect(() => {
let isActive = true; let isActive = true;
listStoredChatThreads({ pairId }).then((threads) => { listStoredChatThreads({ pairId })
if (!isActive) return; .then((threads) => {
setBaseThreadId(threads.find((t) => t.modelType === "base")?.id); if (!isActive) return;
setLoraThreadId(threads.find((t) => t.modelType === "lora")?.id); setBaseThreadId(threads.find((t) => t.modelType === "base")?.id);
}).catch((error) => { setLoraThreadId(threads.find((t) => t.modelType === "lora")?.id);
if (!isExpectedBackgroundChatStorageError(error)) { })
throw error; .catch((error) => {
} if (!isExpectedBackgroundChatStorageError(error)) {
}); throw error;
}
});
return () => { return () => {
isActive = false; isActive = false;
}; };
@ -478,21 +561,25 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
useEffect(() => { useEffect(() => {
let isActive = true; let isActive = true;
listStoredChatThreads({ pairId }).then((threads) => { listStoredChatThreads({ pairId })
if (!isActive) return; .then((threads) => {
setModel1ThreadId( if (!isActive) return;
threads.find((t) => t.modelType === "model1" || t.modelType === "base") setModel1ThreadId(
?.id, threads.find(
); (t) => t.modelType === "model1" || t.modelType === "base",
setModel2ThreadId( )?.id,
threads.find((t) => t.modelType === "model2" || t.modelType === "lora") );
?.id, setModel2ThreadId(
); threads.find(
}).catch((error) => { (t) => t.modelType === "model2" || t.modelType === "lora",
if (!isExpectedBackgroundChatStorageError(error)) { )?.id,
throw error; );
} })
}); .catch((error) => {
if (!isExpectedBackgroundChatStorageError(error)) {
throw error;
}
});
return () => { return () => {
isActive = false; isActive = false;
}; };
@ -630,7 +717,11 @@ export function ChatPage(): ReactElement {
const modelsError = useChatRuntimeStore((state) => state.modelsError); const modelsError = useChatRuntimeStore((state) => state.modelsError);
const modelLoading = useChatRuntimeStore((state) => state.modelLoading); const modelLoading = useChatRuntimeStore((state) => state.modelLoading);
const clearCheckpoint = useChatRuntimeStore((state) => state.clearCheckpoint); const clearCheckpoint = useChatRuntimeStore((state) => state.clearCheckpoint);
const resetArtifacts = useChatArtifactsStore((state) => state.resetArtifacts);
const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId); const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId);
const persistedActiveThreadId = isAssistantLocalThreadId(activeThreadId)
? null
: activeThreadId;
const modelOperationInProgress = useChatRuntimeStore( const modelOperationInProgress = useChatRuntimeStore(
(state) => state.modelLoading, (state) => state.modelLoading,
); );
@ -645,9 +736,9 @@ export function ChatPage(): ReactElement {
} = useChatModelRuntime(); } = useChatModelRuntime();
const prevConnectionsEnabledRef = useRef(connectionsEnabled); const prevConnectionsEnabledRef = useRef(connectionsEnabled);
useEffect(() => { useEffect(() => {
const turnedOff = const turnedOff = prevConnectionsEnabledRef.current && !connectionsEnabled;
prevConnectionsEnabledRef.current && !connectionsEnabled;
if (!connectionsEnabled && isExternalModelId(inferenceParams.checkpoint)) { if (!connectionsEnabled && isExternalModelId(inferenceParams.checkpoint)) {
resetArtifacts();
clearCheckpoint(); clearCheckpoint();
if (turnedOff) { if (turnedOff) {
toast.info("Connections disabled", { toast.info("Connections disabled", {
@ -660,6 +751,7 @@ export function ChatPage(): ReactElement {
clearCheckpoint, clearCheckpoint,
connectionsEnabled, connectionsEnabled,
inferenceParams.checkpoint, inferenceParams.checkpoint,
resetArtifacts,
]); ]);
const pendingNativeModelIntent = useNativeIntentStore( const pendingNativeModelIntent = useNativeIntentStore(
(state) => state.pendingModelIntent, (state) => state.pendingModelIntent,
@ -679,17 +771,19 @@ export function ChatPage(): ReactElement {
const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled); const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled);
const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle); const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle);
const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort); const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort);
const supportsReasoningOff = useChatRuntimeStore((s) => s.supportsReasoningOff); const supportsReasoningOff = useChatRuntimeStore(
(s) => s.supportsReasoningOff,
);
const activeExternalProvider = useMemo(() => { const activeExternalProvider = useMemo(() => {
const selection = parseExternalModelId(inferenceParams.checkpoint); const selection = parseExternalModelId(inferenceParams.checkpoint);
if (!selection) return null; if (!selection) return null;
return ( return (
externalProvidersForChat.find( externalProvidersForChat.find((p) => p.id === selection.providerId) ??
(p) => p.id === selection.providerId, null
) ?? null
); );
}, [externalProvidersForChat, inferenceParams.checkpoint]); }, [externalProvidersForChat, inferenceParams.checkpoint]);
const activeExternalProviderType = activeExternalProvider?.providerType ?? null; const activeExternalProviderType =
activeExternalProvider?.providerType ?? null;
const activeProviderCapabilities = useMemo(() => { const activeProviderCapabilities = useMemo(() => {
const selection = parseExternalModelId(inferenceParams.checkpoint); const selection = parseExternalModelId(inferenceParams.checkpoint);
if (!selection) return null; if (!selection) return null;
@ -797,7 +891,9 @@ export function ChatPage(): ReactElement {
(provider?.providerType === "anthropic" || (provider?.providerType === "anthropic" ||
provider?.providerType === "openai"); provider?.providerType === "openai");
const storedToolsEnabled = loadOptionalBool(CHAT_TOOLS_ENABLED_KEY); const storedToolsEnabled = loadOptionalBool(CHAT_TOOLS_ENABLED_KEY);
const storedCodeToolsEnabled = loadOptionalBool(CHAT_CODE_TOOLS_ENABLED_KEY); const storedCodeToolsEnabled = loadOptionalBool(
CHAT_CODE_TOOLS_ENABLED_KEY,
);
const storedImageToolsEnabled = loadOptionalBool( const storedImageToolsEnabled = loadOptionalBool(
CHAT_IMAGE_TOOLS_ENABLED_KEY, CHAT_IMAGE_TOOLS_ENABLED_KEY,
); );
@ -858,14 +954,43 @@ export function ChatPage(): ReactElement {
if (search.thread) { if (search.thread) {
return { mode: "single", threadId: search.thread }; return { mode: "single", threadId: search.thread };
} }
if (activeThreadId && !activeThreadId.startsWith("__LOCALID_")) { if (persistedActiveThreadId) {
return { mode: "single", threadId: activeThreadId }; return { mode: "single", threadId: persistedActiveThreadId };
} }
if (search.new) { if (search.new) {
return { mode: "single", newThreadNonce: search.new }; return { mode: "single", newThreadNonce: search.new };
} }
return { mode: "single" }; return { mode: "single" };
}, [search.thread, search.compare, search.new, activeThreadId]); }, [search.thread, search.compare, search.new, persistedActiveThreadId]);
const selectedArtifact = useSelectedChatArtifact();
const artifactSurface = useChatArtifactsStore((state) => state.surface);
const closeArtifactSurface = useChatArtifactsStore(
(state) => state.closeArtifactSurface,
);
const artifactViewKey =
view.mode === "single"
? `single:${view.threadId ?? view.newThreadNonce ?? "new"}`
: `compare:${view.pairId}`;
useEffect(() => {
clearAutoOpenedArtifacts();
closeArtifactSurface();
}, [artifactViewKey, closeArtifactSurface]);
useEffect(() => {
if (view.mode !== "single") return;
if (view.threadId || view.newThreadNonce || !selectedArtifact) return;
// view intentionally excludes __LOCALID_ threads (they fall through to
// { mode: "single" } with no threadId/nonce). Don't close an artifact
// whose thread is the currently active local thread.
if (
selectedArtifact.threadId &&
selectedArtifact.threadId === activeThreadId
)
return;
closeArtifactSurface();
}, [activeThreadId, closeArtifactSurface, selectedArtifact, view]);
const hasActiveModel = Boolean(inferenceParams.checkpoint); const hasActiveModel = Boolean(inferenceParams.checkpoint);
const loadNativeModelIntent = useCallback( const loadNativeModelIntent = useCallback(
@ -953,8 +1078,7 @@ export function ChatPage(): ReactElement {
selectedProvider?.providerType, selectedProvider?.providerType,
selectedExternal?.modelId, selectedExternal?.modelId,
{ {
isReasoningProvider: isReasoningProvider: selectedProvider?.isReasoningModel === true,
selectedProvider?.isReasoningModel === true,
}, },
); );
const preferredEffort = store.reasoningEffort; const preferredEffort = store.reasoningEffort;
@ -997,11 +1121,12 @@ export function ChatPage(): ReactElement {
const supportsBuiltinWebSearch = providerSupportsBuiltinWebSearch( const supportsBuiltinWebSearch = providerSupportsBuiltinWebSearch(
selectedProvider?.providerType, selectedProvider?.providerType,
); );
const supportsBuiltinCodeExecution = providerSupportsBuiltinCodeExecution( const supportsBuiltinCodeExecution =
selectedProvider?.providerType, providerSupportsBuiltinCodeExecution(
selectedExternal?.modelId, selectedProvider?.providerType,
selectedProvider?.baseUrl, selectedExternal?.modelId,
); selectedProvider?.baseUrl,
);
const supportsBuiltinImageGeneration = const supportsBuiltinImageGeneration =
providerSupportsBuiltinImageGeneration( providerSupportsBuiltinImageGeneration(
selectedProvider?.providerType, selectedProvider?.providerType,
@ -1118,8 +1243,9 @@ export function ChatPage(): ReactElement {
], ],
); );
const handleEject = useCallback(() => { const handleEject = useCallback(() => {
resetArtifacts();
void ejectModel(); void ejectModel();
}, [ejectModel]); }, [ejectModel, resetArtifacts]);
const openModelSelector = useCallback(() => { const openModelSelector = useCallback(() => {
setModelSelectorLocked(true); setModelSelectorLocked(true);
@ -1379,6 +1505,7 @@ export function ChatPage(): ReactElement {
const tourSteps = useMemo( const tourSteps = useMemo(
() => () =>
// eslint-disable-next-line react-hooks/refs -- buildChatTourSteps stores callbacks without invoking them during render.
buildChatTourSteps({ buildChatTourSteps({
canCompare, canCompare,
openModelSelector, openModelSelector,
@ -1416,6 +1543,11 @@ export function ChatPage(): ReactElement {
return () => window.clearTimeout(timeoutId); return () => window.clearTimeout(timeoutId);
}, [modelSelectorLocked, tour.open]); }, [modelSelectorLocked, tour.open]);
const showArtifactOverlay = Boolean(
selectedArtifact &&
(view.mode === "compare" || artifactSurface === "overlay"),
);
return ( return (
<div className="flex min-h-0 min-w-0 flex-1 basis-0 bg-background overflow-hidden"> <div className="flex min-h-0 min-w-0 flex-1 basis-0 bg-background overflow-hidden">
<GuidedTour {...tour.tourProps} /> <GuidedTour {...tour.tourProps} />
@ -1532,9 +1664,12 @@ export function ChatPage(): ReactElement {
{view.mode === "single" ? ( {view.mode === "single" ? (
<SingleContent <SingleContent
key={view.threadId ?? "single"} key={view.threadId ?? view.newThreadNonce ?? "single"}
threadId={view.threadId} threadId={view.threadId}
newThreadNonce={view.newThreadNonce} newThreadNonce={view.newThreadNonce}
artifact={selectedArtifact}
artifactSurface={artifactSurface}
onCloseArtifact={closeArtifactSurface}
/> />
) : ( ) : (
<CompareContent <CompareContent
@ -1547,6 +1682,14 @@ export function ChatPage(): ReactElement {
deleteDisabled={modelOperationInProgress} deleteDisabled={modelOperationInProgress}
/> />
)} )}
{showArtifactOverlay && selectedArtifact ? (
<ArtifactSurface
artifact={selectedArtifact}
variant="overlay"
onClose={closeArtifactSurface}
/>
) : null}
</div> </div>
<ChatSettingsPanel <ChatSettingsPanel

View file

@ -4,6 +4,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { CHAT_HISTORY_UPDATED_EVENT } from "../api/chat-api"; import { CHAT_HISTORY_UPDATED_EVENT } from "../api/chat-api";
import { useChatRuntimeStore } from "../stores/chat-runtime-store"; import { useChatRuntimeStore } from "../stores/chat-runtime-store";
import { useChatArtifactsStore } from "../artifacts/store";
import type { ThreadRecord } from "../types"; import type { ThreadRecord } from "../types";
import { import {
deleteStoredChatThreads, deleteStoredChatThreads,
@ -157,6 +158,9 @@ export async function deleteChatItem(
// generating against a thread that no longer exists. // generating against a thread that no longer exists.
for (const id of threadIds) cancelIfRunning(id); 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. // Optimistic tombstone: hide immediately; roll back on backend error.
markChatThreadsDeleted(threadIds); markChatThreadsDeleted(threadIds);
notifyChatHistoryUpdated(); notifyChatHistoryUpdated();

View file

@ -14,6 +14,7 @@ export { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
export { ChatSearchDialog } from "./components/chat-search-dialog"; export { ChatSearchDialog } from "./components/chat-search-dialog";
export { setTrainingCompareHandoff } from "./lib/training-compare-handoff"; export { setTrainingCompareHandoff } from "./lib/training-compare-handoff";
export { clearAllChats, countAllChats } from "./utils/clear-all-chats"; export { clearAllChats, countAllChats } from "./utils/clear-all-chats";
export { ArtifactCard } from "./artifacts/artifact-card";
export { downloadChatExport } from "./utils/export-chat-history"; export { downloadChatExport } from "./utils/export-chat-history";
export { export {
deleteChatItem, deleteChatItem,

View file

@ -21,10 +21,25 @@ import { isTauri } from "@/lib/api-base";
import { isMultimodalResponse } from "./types/api"; import { isMultimodalResponse } from "./types/api";
import { getImageInputUnavailableReason } from "./utils/image-input-support"; import { getImageInputUnavailableReason } from "./utils/image-input-support";
import { useAui } from "@assistant-ui/react"; 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 { toast } from "@/lib/toast";
import { loadModel, validateModel } from "./api/chat-api"; 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 { useExternalProvidersStore } from "./stores/external-providers-store";
import { import {
type ReasoningEffort, 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 === "max") return "Max";
if (level === "xhigh") { if (level === "xhigh") {
const normalized = modelId?.trim().toLowerCase() ?? ""; const normalized = modelId?.trim().toLowerCase() ?? "";
@ -123,7 +141,12 @@ function useDictation(
const start = useCallback(() => { const start = useCallback(() => {
const SpeechRecognitionAPI = const SpeechRecognitionAPI =
typeof window !== "undefined" && 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) { if (!SpeechRecognitionAPI) {
return; return;
} }
@ -169,7 +192,11 @@ function useDictation(
const supported = const supported =
typeof window !== "undefined" && 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 }; return { isDictating, start, stop, supported };
} }
@ -208,9 +235,18 @@ export function RegisterCompareHandle({
currentHandles[name] = { currentHandles[name] = {
// fixes occasional reorder on reload. // fixes occasional reorder on reload.
append: (content) => 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) => 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: () => { startRun: () => {
const msgs = aui.thread().getState().messages; const msgs = aui.thread().getState().messages;
const lastId = msgs.length > 0 ? msgs[msgs.length - 1].id : null; const lastId = msgs.length > 0 ? msgs[msgs.length - 1].id : null;
@ -254,7 +290,8 @@ function PendingImageThumb({
setSrc(url); setSrc(url);
return () => URL.revokeObjectURL(url); return () => URL.revokeObjectURL(url);
}, [file]); }, [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 ( return (
<div className="relative size-14 shrink-0 overflow-hidden rounded-[14px] border border-foreground/20 bg-muted"> <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" /> <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 [running, setRunning] = useState(false);
const [comparing, setComparing] = useState(false); const [comparing, setComparing] = useState(false);
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]); 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 [dragging, setDragging] = useState(false);
const [isComposing, setIsComposing] = useState(false); const [isComposing, setIsComposing] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -318,10 +358,16 @@ export function SharedComposer({
const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled); const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled);
const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle); const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle);
const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort); const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort);
const supportsReasoningOff = useChatRuntimeStore((s) => s.supportsReasoningOff); const supportsReasoningOff = useChatRuntimeStore(
const reasoningEffortLevels = useChatRuntimeStore((s) => s.reasoningEffortLevels); (s) => s.supportsReasoningOff,
);
const reasoningEffortLevels = useChatRuntimeStore(
(s) => s.reasoningEffortLevels,
);
const setReasoningEffort = useChatRuntimeStore((s) => s.setReasoningEffort); 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 preserveThinking = useChatRuntimeStore((s) => s.preserveThinking);
const setPreserveThinking = useChatRuntimeStore((s) => s.setPreserveThinking); const setPreserveThinking = useChatRuntimeStore((s) => s.setPreserveThinking);
const supportsTools = useChatRuntimeStore((s) => s.supportsTools); const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
@ -336,6 +382,8 @@ export function SharedComposer({
const setImageToolsEnabled = useChatRuntimeStore( const setImageToolsEnabled = useChatRuntimeStore(
(s) => s.setImageToolsEnabled, (s) => s.setImageToolsEnabled,
); );
const artifactsEnabled = useChatRuntimeStore((s) => s.artifactsEnabled);
const setArtifactsEnabled = useChatRuntimeStore((s) => s.setArtifactsEnabled);
const lastOpenRouterChosenModel = useChatRuntimeStore( const lastOpenRouterChosenModel = useChatRuntimeStore(
(s) => s.lastOpenRouterChosenModel, (s) => s.lastOpenRouterChosenModel,
); );
@ -437,16 +485,22 @@ export function SharedComposer({
// the pill row stays compact for providers without the capability. // the pill row stays compact for providers without the capability.
const imageDisabled = !modelLoaded || !supportsBuiltinImageGeneration; const imageDisabled = !modelLoaded || !supportsBuiltinImageGeneration;
const showImagePill = supportsBuiltinImageGeneration; const showImagePill = supportsBuiltinImageGeneration;
const artifactDisabled = !modelLoaded;
// Backwards-compatible alias for any other call site that may still // Backwards-compatible alias for any other call site that may still
// reference `toolsDisabled` (rare; both pills used it before). // reference `toolsDisabled` (rare; both pills used it before).
const toolsDisabled = codeDisabled; const toolsDisabled = codeDisabled;
const setPendingAudioStore = useChatRuntimeStore((s) => s.setPendingAudio); const setPendingAudioStore = useChatRuntimeStore((s) => s.setPendingAudio);
const clearPendingAudioStore = useChatRuntimeStore((s) => s.clearPendingAudio); const clearPendingAudioStore = useChatRuntimeStore(
(s) => s.clearPendingAudio,
const { isDictating, start: startDictation, stop: stopDictation, supported: dictationSupported } = useDictation(
setText,
); );
const {
isDictating,
start: startDictation,
stop: stopDictation,
supported: dictationSupported,
} = useDictation(setText);
useEffect(() => { useEffect(() => {
const id = setInterval(() => { const id = setInterval(() => {
const handles = handlesRef.current; const handles = handlesRef.current;
@ -463,43 +517,48 @@ export function SharedComposer({
ta.style.height = "auto"; ta.style.height = "auto";
const styles = window.getComputedStyle(ta); const styles = window.getComputedStyle(ta);
const lineHeight = parseFloat(styles.lineHeight) || 20; const lineHeight = parseFloat(styles.lineHeight) || 20;
const paddingY = parseFloat(styles.paddingTop) + parseFloat(styles.paddingBottom); const paddingY =
const borderY = parseFloat(styles.borderTopWidth) + parseFloat(styles.borderBottomWidth); parseFloat(styles.paddingTop) + parseFloat(styles.paddingBottom);
const borderY =
parseFloat(styles.borderTopWidth) + parseFloat(styles.borderBottomWidth);
const maxHeight = lineHeight * 6 + paddingY + borderY; const maxHeight = lineHeight * 6 + paddingY + borderY;
const next = Math.min(ta.scrollHeight, maxHeight); const next = Math.min(ta.scrollHeight, maxHeight);
ta.style.height = `${next}px`; ta.style.height = `${next}px`;
ta.style.overflowY = ta.scrollHeight > maxHeight ? "auto" : "hidden"; ta.style.overflowY = ta.scrollHeight > maxHeight ? "auto" : "hidden";
}, [text]); }, [text]);
const addFiles = useCallback((files: FileList | null) => { const addFiles = useCallback(
if (!files?.length) return; (files: FileList | null) => {
const next: PendingImage[] = []; if (!files?.length) return;
let droppedImageForUnavailable = false; const next: PendingImage[] = [];
for (let i = 0; i < files.length; i++) { let droppedImageForUnavailable = false;
const file = files[i]; for (let i = 0; i < files.length; i++) {
if (!file) continue; const file = files[i];
// Handle audio files if (!file) continue;
if (file.type.match(/^audio\//i) && file.size <= MAX_AUDIO_SIZE) { // Handle audio files
fileToBase64(file).then((base64) => { if (file.type.match(/^audio\//i) && file.size <= MAX_AUDIO_SIZE) {
setPendingAudio({ name: file.name, base64 }); fileToBase64(file).then((base64) => {
setPendingAudioStore(base64, file.name); setPendingAudio({ name: file.name, base64 });
}); setPendingAudioStore(base64, file.name);
continue; });
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 (droppedImageForUnavailable && attachUnavailableReason) {
if (!file.type.match(/^image\/(jpeg|png|webp|gif)$/i)) continue; toast.error(attachUnavailableReason);
if (file.size > MAX_IMAGE_SIZE) continue;
if (attachUnavailableReason) {
droppedImageForUnavailable = true;
continue;
} }
next.push({ id: crypto.randomUUID(), file }); setPendingImages((prev) => [...prev, ...next]);
} },
if (droppedImageForUnavailable && attachUnavailableReason) { [setPendingAudioStore, attachUnavailableReason],
toast.error(attachUnavailableReason); );
}
setPendingImages((prev) => [...prev, ...next]);
}, [setPendingAudioStore, attachUnavailableReason]);
const removePendingImage = useCallback((id: string) => { const removePendingImage = useCallback((id: string) => {
setPendingImages((prev) => prev.filter((p) => p.id !== id)); setPendingImages((prev) => prev.filter((p) => p.id !== id));
@ -557,12 +616,17 @@ export function SharedComposer({
// LoraCompare and single-pane chats are unaffected. // LoraCompare and single-pane chats are unaffected.
if (hasCompareHandles && !isGeneralizedCompare) { if (hasCompareHandles && !isGeneralizedCompare) {
toast.error("Pick a model in each pane to compare", { 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; return;
} }
if (pendingImages.length > 0 && !isGeneralizedCompare && imageUnavailableReason) { if (
pendingImages.length > 0 &&
!isGeneralizedCompare &&
imageUnavailableReason
) {
// Single mode: the loaded model's runtime capability is known // Single mode: the loaded model's runtime capability is known
// here. Compare mode defers — each ensureModelLoaded below sets // here. Compare mode defers — each ensureModelLoaded below sets
// loadedIsMultimodal for its side, and the chat-adapter's // loadedIsMultimodal for its side, and the chat-adapter's
@ -600,8 +664,9 @@ export function SharedComposer({
const maxSeqLength = store.params.maxSeqLength; const maxSeqLength = store.params.maxSeqLength;
const trustRemoteCode = store.params.trustRemoteCode ?? false; const trustRemoteCode = store.params.trustRemoteCode ?? false;
const chatTemplateOverride = store.chatTemplateOverride; const chatTemplateOverride = store.chatTemplateOverride;
const effectiveChatTemplateOverride = const effectiveChatTemplateOverride = chatTemplateOverride?.trim()
chatTemplateOverride?.trim() ? chatTemplateOverride : null; ? chatTemplateOverride
: null;
function modelDisplayName(id: string): string { function modelDisplayName(id: string): string {
const parts = id.split("/"); const parts = id.split("/");
@ -609,11 +674,14 @@ export function SharedComposer({
} }
// Helper: load a model and update store checkpoint // 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 currentStore = useChatRuntimeStore.getState();
const isAlreadyActive = const isAlreadyActive =
currentStore.params.checkpoint === sel.id && currentStore.params.checkpoint === sel.id &&
(currentStore.activeGgufVariant ?? null) === (sel.ggufVariant ?? null); (currentStore.activeGgufVariant ?? null) ===
(sel.ggufVariant ?? null);
if (!isAlreadyActive) { if (!isAlreadyActive) {
const validation = await validateModel({ const validation = await validateModel({
model_path: sel.id, model_path: sel.id,
@ -703,9 +771,17 @@ export function SharedComposer({
try { try {
// Side 1: load → generate → wait // Side 1: load → generate → wait
if (handle1 && model1?.id) { 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); 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(); const done = handle1.waitForRunEnd();
handle1.startRun(); handle1.startRun();
await done; await done;
@ -713,13 +789,22 @@ export function SharedComposer({
// Side 2: load → generate → wait // Side 2: load → generate → wait
if (handle2 && model2?.id) { if (handle2 && model2?.id) {
const needsLoad = model2.id.toLowerCase() !== (model1?.id || "").toLowerCase() const needsLoad =
|| (model2.ggufVariant ?? "") !== (model1?.ggufVariant ?? ""); model2.id.toLowerCase() !== (model1?.id || "").toLowerCase() ||
(model2.ggufVariant ?? "") !== (model1?.ggufVariant ?? "");
if (needsLoad) { 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); 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(); const done = handle2.waitForRunEnd();
handle2.startRun(); handle2.startRun();
await done; 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 ( return (
<div <div
@ -808,7 +898,10 @@ export function SharedComposer({
<span className="max-w-48 truncate">{pendingAudio.name}</span> <span className="max-w-48 truncate">{pendingAudio.name}</span>
<button <button
type="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" className="flex size-4 items-center justify-center rounded-full hover:bg-destructive hover:text-destructive-foreground"
aria-label="Remove audio" aria-label="Remove audio"
> >
@ -906,130 +999,136 @@ export function SharedComposer({
)} )}
{showReasoningControl ? ( {showReasoningControl ? (
effectiveReasoningStyle === "reasoning_effort" ? ( effectiveReasoningStyle === "reasoning_effort" ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild={true}> <DropdownMenuTrigger asChild={true}>
<button <button
type="button" type="button"
disabled={reasoningDisabled} disabled={reasoningDisabled}
className={cn( 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", "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 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" ? "cursor-not-allowed opacity-40"
: effectiveReasoningVisualEnabled : effectiveReasoningEnabled
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]" ? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
: "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 aria-label={thinkToggleAriaLabel({
.filter((level) => level !== "none") reasoningLockedOn,
.map((level) => ( modelLoaded,
<DropdownMenuItem reasoningDisabled,
key={level} effectiveReasoningEnabled,
onSelect={() => { })}
setReasoningEffort(level); >
setReasoningEnabled(true); {reasoningLockedOn ||
applyQwenThinkingParams(true); (effectiveReasoningEnabled && !reasoningDisabled) ? (
// Mutual exclusion: turning thinking on for a <LightbulbIcon className="size-3.5" />
// Kimi model forces the web_search builtin off. ) : (
if (isKimiExternal && toolsEnabled) { <LightbulbOffIcon className="size-3.5" />
setToolsEnabled(false, { persist: false }); )}
} <span>Think</span>
}} </button>
>
{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>
) )
) : null} ) : null}
{supportsPreserveThinking && ( {supportsPreserveThinking && (
@ -1046,7 +1145,9 @@ export function SharedComposer({
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]", : "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
)} )}
aria-label={ aria-label={
preserveThinking ? "Disable preserve think" : "Enable preserve think" preserveThinking
? "Disable preserve think"
: "Enable preserve think"
} }
> >
{preserveThinking && modelLoaded ? ( {preserveThinking && modelLoaded ? (
@ -1075,7 +1176,9 @@ export function SharedComposer({
}} }}
className="composer-pill-btn" className="composer-pill-btn"
data-active={toolsEnabled && !searchDisabled ? "true" : "false"} 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" /> <GlobeIcon className="size-3.5" />
<span>Search</span> <span>Search</span>
@ -1086,7 +1189,11 @@ export function SharedComposer({
onClick={() => setCodeToolsEnabled(!codeToolsEnabled)} onClick={() => setCodeToolsEnabled(!codeToolsEnabled)}
className="composer-pill-btn" className="composer-pill-btn"
data-active={codeToolsEnabled && !codeDisabled ? "true" : "false"} 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" /> <CodeToggleIcon className="size-3.5" />
<span>Code</span> <span>Code</span>
@ -1097,15 +1204,34 @@ export function SharedComposer({
disabled={imageDisabled} disabled={imageDisabled}
onClick={() => setImageToolsEnabled(!imageToolsEnabled)} onClick={() => setImageToolsEnabled(!imageToolsEnabled)}
className="composer-pill-btn" className="composer-pill-btn"
data-active={imageToolsEnabled && !imageDisabled ? "true" : "false"} data-active={
imageToolsEnabled && !imageDisabled ? "true" : "false"
}
aria-label={ aria-label={
imageToolsEnabled ? "Disable image generation" : "Enable image generation" imageToolsEnabled
? "Disable image generation"
: "Enable image generation"
} }
> >
<ImageIcon className="size-3.5" /> <ImageIcon className="size-3.5" />
<span>Images</span> <span>Images</span>
</button> </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>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{dictationSupported && ( {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_TOOLS_ENABLED_KEY = "unsloth_chat_tools_enabled";
export const CHAT_CODE_TOOLS_ENABLED_KEY = "unsloth_chat_code_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_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 provider selection is encoded into `params.checkpoint` as
// `external::<providerId>::<modelId>`. PersistedChatSettings deliberately // `external::<providerId>::<modelId>`. PersistedChatSettings deliberately
@ -265,6 +266,7 @@ type ChatRuntimeStore = {
toolsEnabled: boolean; toolsEnabled: boolean;
codeToolsEnabled: boolean; codeToolsEnabled: boolean;
imageToolsEnabled: boolean; imageToolsEnabled: boolean;
artifactsEnabled: boolean;
toolStatus: string | null; toolStatus: string | null;
generatingStatus: string | null; generatingStatus: string | null;
autoHealToolCalls: boolean; autoHealToolCalls: boolean;
@ -324,6 +326,7 @@ type ChatRuntimeStore = {
setToolsEnabled: (enabled: boolean, options?: { persist?: boolean }) => void; setToolsEnabled: (enabled: boolean, options?: { persist?: boolean }) => void;
setCodeToolsEnabled: (enabled: boolean) => void; setCodeToolsEnabled: (enabled: boolean) => void;
setImageToolsEnabled: (enabled: boolean) => void; setImageToolsEnabled: (enabled: boolean) => void;
setArtifactsEnabled: (enabled: boolean, options?: { persist?: boolean }) => void;
setToolStatus: (status: string | null) => void; setToolStatus: (status: string | null) => void;
setGeneratingStatus: (status: string | null) => void; setGeneratingStatus: (status: string | null) => void;
setAutoHealToolCalls: (enabled: boolean) => void; setAutoHealToolCalls: (enabled: boolean) => void;
@ -567,6 +570,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
toolsEnabled: loadBool(CHAT_TOOLS_ENABLED_KEY, false), toolsEnabled: loadBool(CHAT_TOOLS_ENABLED_KEY, false),
codeToolsEnabled: loadBool(CHAT_CODE_TOOLS_ENABLED_KEY, false), codeToolsEnabled: loadBool(CHAT_CODE_TOOLS_ENABLED_KEY, false),
imageToolsEnabled: loadBool(CHAT_IMAGE_TOOLS_ENABLED_KEY, false), imageToolsEnabled: loadBool(CHAT_IMAGE_TOOLS_ENABLED_KEY, false),
artifactsEnabled: loadBool(CHAT_ARTIFACTS_ENABLED_KEY, false),
toolStatus: null, toolStatus: null,
generatingStatus: null, generatingStatus: null,
autoHealToolCalls: true, autoHealToolCalls: true,
@ -747,6 +751,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
toolsEnabled: false, toolsEnabled: false,
codeToolsEnabled: false, codeToolsEnabled: false,
imageToolsEnabled: false, imageToolsEnabled: false,
artifactsEnabled: false,
toolStatus: null, toolStatus: null,
kvCacheDtype: null, kvCacheDtype: null,
loadedKvCacheDtype: null, loadedKvCacheDtype: null,
@ -806,6 +811,13 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
saveBool(CHAT_IMAGE_TOOLS_ENABLED_KEY, imageToolsEnabled); saveBool(CHAT_IMAGE_TOOLS_ENABLED_KEY, imageToolsEnabled);
return { imageToolsEnabled }; return { imageToolsEnabled };
}), }),
setArtifactsEnabled: (artifactsEnabled, options) =>
set(() => {
if (options?.persist !== false) {
saveBool(CHAT_ARTIFACTS_ENABLED_KEY, artifactsEnabled);
}
return { artifactsEnabled };
}),
setToolStatus: (toolStatus) => set({ toolStatus }), setToolStatus: (toolStatus) => set({ toolStatus }),
setGeneratingStatus: (generatingStatus) => set({ generatingStatus }), setGeneratingStatus: (generatingStatus) => set({ generatingStatus }),
setAutoHealToolCalls: (autoHealToolCalls) => setAutoHealToolCalls: (autoHealToolCalls) =>

View 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";