Studio: add HTML artifacts to chat (#5772)

* Studio: add chat HTML artifact primitives

* Studio: add local render_html tool support

* Studio: wire render_html artifacts in chat UI

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: add chat artifact surface

* Studio: mount chat artifact panel and overlay

* Studio: fix chat artifact review regressions

* Studio: fix chat artifact panel and sandbox previews

* Studio: address chat artifact review follow-ups

* Studio: polish chat artifact UI affordances

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: scope artifact IDs by message to prevent cross-turn collisions

* Studio: fix artifact panel for local threads and surface tool errors

* Studio: restrict artifact frame embedding to same-origin

* Studio: stop local chat thread remount loop

* Studio: fix chat artifact store cleanup regressions

* Studio: shim artifact preview storage in sandbox

* feat(chat): add artifact rendering controls

* fix(chat): show artifact progress during tool calls

* fix(chat): refine artifact preview behavior

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix(chat): ignore tool markers inside arguments

* feat(chat): polish artifact preview panel

* fix(chat): stabilize artifact panel behavior

* fix(inference): merge duplicate Anthropic tool starts

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Wasim Yousef Said 2026-06-01 08:35:18 +02:00 committed by GitHub
commit dfba4cc5ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 2798 additions and 588 deletions

View file

@ -218,6 +218,8 @@ class AnthropicStreamEmitter:
def __init__(self) -> None:
self.block_index: int = 0
self._text_block_open: bool = False
self._open_tool_call_id: Optional[str] = None
self._open_tool_args_sent: bool = False
self._prev_text: str = ""
self._usage: dict = {}
@ -263,8 +265,10 @@ class AnthropicStreamEmitter:
def finish(self, stop_reason: str = "end_turn") -> list[str]:
"""Close any open block and emit message_delta + message_stop."""
events = []
if self._text_block_open:
if self._text_block_open or self._open_tool_call_id is not None:
events.append(self._close_block())
self._open_tool_call_id = None
self._open_tool_args_sent = False
events.append(
build_anthropic_sse_event(
"message_delta",
@ -310,12 +314,26 @@ class AnthropicStreamEmitter:
return events
def _handle_tool_start(self, event: dict) -> list[str]:
tool_call_id = event.get("tool_call_id", "")
args = event.get("arguments", {})
if tool_call_id and self._open_tool_call_id == tool_call_id:
return self._tool_arguments_delta(args)
events = []
# Close current text block if open
# Close current text block if open.
if self._text_block_open:
events.append(self._close_block())
# Open a tool_use block
# Defensive: if a replacement/different tool_start arrives while a
# tool_use block is open, close the stale block before starting another.
elif self._open_tool_call_id is not None:
events.append(self._close_block())
self._open_tool_call_id = None
self._open_tool_args_sent = False
# Open a tool_use block.
self.block_index += 1
self._open_tool_call_id = tool_call_id
self._open_tool_args_sent = False
events.append(
build_anthropic_sse_event(
"content_block_start",
@ -324,35 +342,43 @@ class AnthropicStreamEmitter:
"index": self.block_index,
"content_block": {
"type": "tool_use",
"id": event.get("tool_call_id", ""),
"id": tool_call_id,
"name": event.get("tool_name", ""),
"input": {},
},
},
)
)
# Emit the arguments as input_json_delta
args = event.get("arguments", {})
if args:
events.append(
build_anthropic_sse_event(
"content_block_delta",
{
"type": "content_block_delta",
"index": self.block_index,
"delta": {
"type": "input_json_delta",
"partial_json": json.dumps(args),
},
},
)
)
events.extend(self._tool_arguments_delta(args))
return events
def _tool_arguments_delta(self, args: dict) -> list[str]:
if not args:
return []
if self._open_tool_args_sent:
return []
self._open_tool_args_sent = True
return [
build_anthropic_sse_event(
"content_block_delta",
{
"type": "content_block_delta",
"index": self.block_index,
"delta": {
"type": "input_json_delta",
"partial_json": json.dumps(args),
},
},
)
]
def _handle_tool_end(self, event: dict) -> list[str]:
events = []
# Close the tool_use block
events.append(self._close_block())
# Close the tool_use block.
if self._open_tool_call_id is not None or self._text_block_open:
events.append(self._close_block())
self._open_tool_call_id = None
self._open_tool_args_sent = False
# Emit custom tool_result event (non-standard, ignored by SDKs)
events.append(
build_anthropic_sse_event(

View file

@ -52,6 +52,7 @@ from utils.subprocess_compat import (
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
)
from core.inference.tool_call_parser import (
RENDER_HTML_REPEAT_NUDGE,
parse_tool_calls_from_text as _shared_parse_tool_calls_from_text,
)
@ -4616,6 +4617,7 @@ class LlamaCppBackend:
# a transient failure are allowed (only block when the previous
# identical call succeeded).
_tool_call_history: list[tuple[str, bool]] = [] # (key, failed)
_render_html_succeeded = False
# ── Re-prompt on plan-without-action ─────────────────
# When the model describes what it intends to do (forward-looking
@ -4690,6 +4692,7 @@ class LlamaCppBackend:
_iter_timings = None
_stream_done = False
_last_emitted = ""
provisional_render_html_tool_call_ids = set()
stream_timeout = httpx.Timeout(
connect = 10,
@ -4799,6 +4802,33 @@ class LlamaCppBackend:
tool_calls_acc[idx]["function"][
"arguments"
] += func["arguments"]
current_name = tool_calls_acc[idx][
"function"
].get("name", "")
fallback_id = f"call_{idx}"
current_id = tool_calls_acc[idx].get(
"id", fallback_id
)
already_started = (
current_id
in provisional_render_html_tool_call_ids
)
has_real_id = current_id != fallback_id
if (
current_name == "render_html"
and not _render_html_succeeded
and not already_started
and has_real_id
):
provisional_render_html_tool_call_ids.add(
current_id
)
yield {
"type": "tool_start",
"tool_name": "render_html",
"tool_call_id": current_id,
"arguments": {},
}
continue
# ── Reasoning tokens ──
@ -4980,13 +5010,25 @@ class LlamaCppBackend:
"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(
{
"role": "user",
"content": (
"STOP. Do NOT write code or explain. "
"You MUST call a tool NOW. "
"Call web_search or python immediately."
f"Call {tool_hint} immediately."
),
}
)
@ -5158,7 +5200,12 @@ class LlamaCppBackend:
arguments = json.loads(raw_args)
except (json.JSONDecodeError, ValueError):
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:
arguments = {"raw": raw_args}
else:
@ -5195,14 +5242,18 @@ class LlamaCppBackend:
)
else:
status_text = f"Calling: {tool_name}"
yield {"type": "status", "text": status_text}
_repeat_render_html = (
tool_name == "render_html" and _render_html_succeeded
)
if not _repeat_render_html:
yield {"type": "status", "text": status_text}
yield {
"type": "tool_start",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"arguments": arguments,
}
yield {
"type": "tool_start",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"arguments": arguments,
}
# ── Duplicate call detection ──────────────
# str(dict) is stable here: arguments always comes from
@ -5210,7 +5261,9 @@ class LlamaCppBackend:
# so insertion order is deterministic (Python 3.7+).
_tc_key = tool_name + str(arguments)
_prev = _tool_call_history[-1] if _tool_call_history else None
if _prev and _prev[0] == _tc_key and not _prev[1]:
if _repeat_render_html:
result = RENDER_HTML_REPEAT_NUDGE
elif _prev and _prev[0] == _tc_key and not _prev[1]:
result = (
"You already made this exact call. "
"Do not repeat the same tool call. "
@ -5248,12 +5301,13 @@ class LlamaCppBackend:
session_id = session_id,
)
yield {
"type": "tool_end",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"result": result,
}
if not _repeat_render_html:
yield {
"type": "tool_end",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"result": result,
}
# Nudge model to try a different approach on errors
_error_prefixes = (
@ -5269,6 +5323,8 @@ class LlamaCppBackend:
_is_error = isinstance(result, str) and result.lstrip().startswith(
_error_prefixes
)
if tool_name == "render_html" and not _is_error:
_render_html_succeeded = True
_tool_call_history.append((_tc_key, _is_error))
# Strip image sentinel before feeding result to the LLM
# (the full result with sentinel is still yielded via

View file

@ -18,6 +18,7 @@ cumulative text and dispatches them via ``core.inference.tools``.
"""
import json
import re
import threading
from typing import Callable, Generator, Optional
from urllib.parse import urlparse
@ -27,6 +28,7 @@ from loggers import get_logger
from core.inference.tool_call_parser import (
BUDGET_EXHAUSTED_NUDGE,
DUPLICATE_CALL_NUDGE,
RENDER_HTML_REPEAT_NUDGE,
TOOL_ERROR_NUDGE,
TOOL_ERROR_PREFIXES,
TOOL_XML_SIGNALS,
@ -66,7 +68,34 @@ def _status_for_tool(tool_name: str, arguments: dict) -> str:
return f"Calling: {tool_name}"
_CANONICAL_HEAL_ARG = {"python": "code", "terminal": "command"}
_CANONICAL_HEAL_ARG = {
"python": "code",
"terminal": "command",
"render_html": "code",
}
_FUNCTION_SIGNAL_RE = re.compile(r"<function=([\w-]+)>")
_TOOL_CALL_NAME_RE = re.compile(r'"name"\s*:\s*"([\w-]+)"')
def _detect_render_html_tool_start(content: str) -> bool:
"""Return True when the first drained tool call is clearly render_html."""
function_match = _FUNCTION_SIGNAL_RE.search(content)
tool_call_index = content.find("<tool_call>")
if not function_match and tool_call_index < 0:
return False
if function_match and (
tool_call_index < 0 or function_match.start() < tool_call_index
):
return function_match.group(1) == "render_html"
if tool_call_index >= 0:
name_match = _TOOL_CALL_NAME_RE.search(content[tool_call_index:])
return bool(name_match and name_match.group(1) == "render_html")
return False
def _coerce_arguments(raw_args, *, heal: bool, tool_name: str = "") -> dict:
@ -135,6 +164,7 @@ def run_safetensors_tool_loop(
"""
conversation = list(messages)
tool_call_history: list[tuple[str, bool]] = []
render_html_succeeded = False
final_attempt_done = False
allowed_tool_names = {
(tool.get("function") or {}).get("name")
@ -161,6 +191,8 @@ def run_safetensors_tool_loop(
content_accum = ""
cumulative_display = ""
last_emitted = ""
provisional_render_html_started = False
provisional_render_html_id = f"call_{next_call_id}"
gen = single_turn(conversation)
prev_cumulative = ""
@ -179,6 +211,18 @@ def run_safetensors_tool_loop(
content_accum += delta
if detect_state == _state_draining:
if (
not render_html_succeeded
and not provisional_render_html_started
and _detect_render_html_tool_start(content_accum)
):
provisional_render_html_started = True
yield {
"type": "tool_start",
"tool_name": "render_html",
"tool_call_id": provisional_render_html_id,
"arguments": {},
}
continue
if detect_state == _state_streaming:
@ -196,6 +240,18 @@ def run_safetensors_tool_loop(
yield {"type": "content", "text": cleaned_before}
cumulative_display = candidate
detect_state = _state_draining
if (
not render_html_succeeded
and not provisional_render_html_started
and _detect_render_html_tool_start(content_accum)
):
provisional_render_html_started = True
yield {
"type": "tool_start",
"tool_name": "render_html",
"tool_call_id": provisional_render_html_id,
"arguments": {},
}
continue
cumulative_display = candidate
cleaned = strip_tool_markup(cumulative_display)
@ -222,6 +278,18 @@ def run_safetensors_tool_loop(
if is_match:
detect_state = _state_draining
if (
not render_html_succeeded
and not provisional_render_html_started
and _detect_render_html_tool_start(content_accum)
):
provisional_render_html_started = True
yield {
"type": "tool_start",
"tool_name": "render_html",
"tool_call_id": provisional_render_html_id,
"arguments": {},
}
elif is_prefix and len(stripped) < _MAX_BUFFER_CHARS:
continue
else:
@ -282,6 +350,13 @@ def run_safetensors_tool_loop(
# literal "<tool_call>" prose is preserved.
if content_accum:
yield {"type": "content", "text": content_accum}
if provisional_render_html_started:
yield {
"type": "tool_end",
"tool_name": "render_html",
"tool_call_id": provisional_render_html_id,
"result": "Error: render_html tool call could not be parsed.",
}
yield {"type": "status", "text": ""}
return
content_text = strip_tool_markup(content_accum, final = True)
@ -308,16 +383,20 @@ def run_safetensors_tool_loop(
tool_name = tool_name,
)
yield {"type": "status", "text": _status_for_tool(tool_name, arguments)}
yield {
"type": "tool_start",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"arguments": arguments,
}
repeat_render_html = tool_name == "render_html" and render_html_succeeded
if not repeat_render_html:
yield {"type": "status", "text": _status_for_tool(tool_name, arguments)}
yield {
"type": "tool_start",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"arguments": arguments,
}
tc_key = tool_name + str(arguments)
if allowed_tool_names and tool_name not in allowed_tool_names:
if repeat_render_html:
result = RENDER_HTML_REPEAT_NUDGE
elif allowed_tool_names and tool_name not in allowed_tool_names:
result = (
f"Error: tool '{tool_name}' is not enabled for this "
"request. Use one of the enabled tools or provide a "
@ -345,16 +424,19 @@ def run_safetensors_tool_loop(
logger.exception("Tool %s raised: %s", tool_name, exc)
result = f"Error: tool raised an exception: {exc}"
yield {
"type": "tool_end",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"result": result,
}
if not repeat_render_html:
yield {
"type": "tool_end",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"result": result,
}
is_error = isinstance(result, str) and result.lstrip().startswith(
TOOL_ERROR_PREFIXES
)
if tool_name == "render_html" and not is_error:
render_html_succeeded = True
tool_call_history.append((tc_key, is_error))
# Strip frontend image sentinel from the model's view.

View file

@ -49,6 +49,12 @@ DUPLICATE_CALL_NUDGE = (
"provide your final answer now."
)
RENDER_HTML_REPEAT_NUDGE = (
"Error: render_html was already called for this response. Do not call "
"render_html again in this response unless the user asks for changes. "
"Provide the final answer now."
)
TOOL_ERROR_NUDGE = (
"\n\nThe tool call encountered an issue. Please try a different "
"approach or rephrase your request."
@ -70,6 +76,20 @@ _TC_FUNC_CLOSE_RE = re.compile(r"\s*</function>\s*$")
# `issue-number`, `repo-name`); using `\w+` here dropped those keys.
_TC_PARAM_START_RE = re.compile(r"<parameter=([\w-]+)>\s*")
_TC_PARAM_CLOSE_RE = re.compile(r"\s*</parameter>\s*$")
_PARAM_CLOSE_TAG = "</parameter>"
_FUNC_CLOSE_TAG = "</function>"
def _inside_open_parameter(content: str, pos: int) -> bool:
"""Return True when ``pos`` falls inside an unclosed parameter value."""
last_param_start = -1
for match in _TC_PARAM_START_RE.finditer(content, 0, pos):
last_param_start = match.start()
if last_param_start < 0:
return False
last_param_close = content.rfind(_PARAM_CLOSE_TAG, 0, pos)
last_func_close = content.rfind(_FUNC_CLOSE_TAG, 0, pos)
return last_param_start > max(last_param_close, last_func_close)
def strip_tool_markup(text: str, *, final: bool = False) -> str:
@ -151,7 +171,11 @@ def parse_tool_calls_from_text(content: str, *, id_offset: int = 0) -> list[dict
# optional; don't use </function> as body boundary because code
# values can contain that literal.
if not tool_calls:
func_starts = list(_TC_FUNC_START_RE.finditer(content))
func_starts = [
fm
for fm in _TC_FUNC_START_RE.finditer(content)
if not _inside_open_parameter(content, fm.start())
]
for idx, fm in enumerate(func_starts):
func_name = fm.group(1)
body_start = fm.end()

View file

@ -514,7 +514,35 @@ 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. "
"Call this at most once per assistant response unless the user "
"explicitly asks for changes in that response. Future user requests "
"for new artifacts may call render_html once. 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]
# OpenAI's function.name regex: ^[a-zA-Z0-9_-]{1,64}$ -- enforced before
@ -608,6 +636,25 @@ async def get_enabled_mcp_tools() -> list[dict]:
_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}. Do not call render_html "
"again in this response unless the user asks for changes. For a later "
"user request for a new artifact, call render_html once."
)
return (
"Rendered HTML artifact. Do not call render_html again in this response "
"unless the user asks for changes. For a later user request for a new "
"artifact, call render_html once."
)
def execute_tool(
name: str,
arguments: dict,
@ -625,6 +672,8 @@ def execute_tool(
f"execute_tool: name={name}, session_id={session_id}, timeout={timeout}"
)
effective_timeout = _EXEC_TIMEOUT if timeout is _TIMEOUT_UNSET else timeout
if name == "render_html":
return _render_html_result(arguments)
if name.startswith(MCP_TOOL_PREFIX):
try:
_, server_id, tool_name = name.split("__", 2)

View file

@ -435,6 +435,7 @@ from starlette.requests import Request as _StarletteRequest # noqa: E402
_CSP_SCRIPT_NONCE_HEADER = "x-internal-script-nonce"
_ARTIFACT_PREVIEW_FRAME_PATH = "/api/inference/artifact-preview-frame"
# /content is Colab's working directory — more reliable than env vars which
@ -488,6 +489,7 @@ def _build_csp(script_nonce: "str | None" = None) -> str:
"style-src 'self' 'unsafe-inline'; "
f"{script_src}; "
"font-src 'self' data:; "
"frame-src 'self'; "
f"frame-ancestors {frame_ancestors}; "
"form-action 'self'; "
"base-uri 'self'"
@ -506,13 +508,13 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
response.headers.setdefault("Content-Security-Policy", _build_csp(nonce))
# Omit X-Frame-Options in Colab — CSP frame-ancestors handles it, and
# DENY would block serve_kernel_port_as_iframe regardless of CSP.
if not _IS_COLAB:
if not _IS_COLAB and request.url.path != _ARTIFACT_PREVIEW_FRAME_PATH:
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("Referrer-Policy", "no-referrer")
response.headers.setdefault(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(), interest-cohort=()",
"camera=(), microphone=(), geolocation=()",
)
response.headers["server"] = "unsloth-studio"
return response

View file

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

View file

@ -134,6 +134,8 @@ class ChatSettingsPayload(BaseModel):
Literal["none", "minimal", "low", "medium", "high", "max", "xhigh"]
] = None
preserveThinking: Optional[bool] = None
collapseHtmlArtifacts: Optional[bool] = None
allowArtifactNetworkAccess: Optional[bool] = None
autoHealToolCalls: Optional[bool] = None
maxToolCallsPerMessage: Optional[int] = Field(default = None, ge = 1)
toolCallTimeout: Optional[int] = Field(default = None, ge = 1)

View file

@ -239,6 +239,135 @@ router = APIRouter()
studio_router = APIRouter()
_ARTIFACT_PREVIEW_FRAME_ANCESTORS = "'self' tauri://localhost http://tauri.localhost"
_ARTIFACT_PREVIEW_FRAME_STRICT_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'; "
f"frame-ancestors {_ARTIFACT_PREVIEW_FRAME_ANCESTORS}; "
"sandbox allow-scripts"
)
_ARTIFACT_PREVIEW_FRAME_NETWORK_CSP = (
"default-src http: https: data: blob:; "
"script-src 'unsafe-inline' 'unsafe-eval' http: https: data: blob:; "
"script-src-elem 'unsafe-inline' http: https: data: blob:; "
"style-src 'unsafe-inline' http: https: data: blob:; "
"style-src-elem 'unsafe-inline' http: https: data: blob:; "
"img-src http: https: data: blob:; "
"font-src http: https: data: blob:; "
"media-src http: https: data: blob:; "
"connect-src http: https: ws: wss: data: blob:; "
"worker-src http: https: blob:; "
"object-src 'none'; "
"base-uri 'none'; "
"form-action 'none'; "
f"frame-ancestors {_ARTIFACT_PREVIEW_FRAME_ANCESTORS}; "
"sandbox allow-scripts"
)
_ARTIFACT_PREVIEW_FRAME_HTML = """<!doctype html>
<html>
<head><meta charset=\"utf-8\" /></head>
<body>
<script>
(() => {
const createMemoryStorage = () => {
const data = new Map();
return {
get length() { return data.size; },
key: (index) => Array.from(data.keys())[index] ?? null,
getItem: (key) => data.has(String(key)) ? data.get(String(key)) : null,
setItem: (key, value) => data.set(String(key), String(value)),
removeItem: (key) => data.delete(String(key)),
clear: () => data.clear(),
};
};
const installStorageFallback = (name) => {
try {
void window[name];
return;
} catch {
// Opaque-origin sandboxed frames throw SecurityError for Web Storage.
}
try {
Object.defineProperty(window, name, {
value: createMemoryStorage(),
configurable: true,
});
} catch {
// Leave the sandbox failure contained in the artifact if the
// browser refuses to shadow the Web Storage accessor.
}
};
const installStorageFallbacks = () => {
installStorageFallback("localStorage");
installStorageFallback("sessionStorage");
};
const render = (html) => {
installStorageFallbacks();
document.open();
document.write(html);
document.close();
};
installStorageFallbacks();
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(
request: Request,
allow_network: bool = False,
token: Optional[str] = None,
):
"""Serve the opaque sandbox shell used for client-side HTML artifacts."""
if allow_network:
auth_header = request.headers.get("authorization")
if auth_header and auth_header.lower().startswith("bearer "):
jwt_token = auth_header[7:]
elif token:
jwt_token = token
else:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Missing authentication token",
)
from fastapi.security import HTTPAuthorizationCredentials
creds = HTTPAuthorizationCredentials(scheme = "Bearer", credentials = jwt_token)
await get_current_subject(creds)
csp = (
_ARTIFACT_PREVIEW_FRAME_NETWORK_CSP
if allow_network
else _ARTIFACT_PREVIEW_FRAME_STRICT_CSP
)
return Response(
content = _ARTIFACT_PREVIEW_FRAME_HTML,
media_type = "text/html; charset=utf-8",
headers = {
"Cache-Control": "no-store",
"Content-Security-Policy": csp,
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
},
)
def _detect_safetensors_features(backend, chat_template: Optional[str]) -> dict:
"""Classify reasoning/tool capabilities via the GGUF classifier so
flags match across backends. gpt-oss is overridden because Harmony
@ -419,13 +548,28 @@ async def _await_cancel_then_close(cancel_event, resp) -> None:
return
# Appended to tool-use nudge to discourage plan-without-action
# Appended to tool-use nudge to discourage plan-without-action.
# Keep render_html guidance gated to turns where the artifact tool is actually
# present in the tool schema; otherwise small local models can hallucinate a
# missing tool call instead of following the fenced-HTML fallback prompt.
_TOOL_ACTION_NUDGE = (
" IMPORTANT: Always call tools directly -- never write code yourself."
" Never describe what you plan to do -- just call the tool immediately."
" For any code request, call the python tool. For any factual question, call web_search."
" Do NOT output code blocks -- use the python tool instead."
" For non-artifact code requests, call the python tool when it is available."
" For factual questions that require current information, call web_search when it is available."
" Do NOT output raw code blocks when an enabled tool can satisfy the request."
)
_ARTIFACT_TOOL_ACTION_NUDGE = (
" For HTML, CSS, or JavaScript artifact requests, call render_html once when "
"it is available. After render_html succeeds, do not call it again in the "
"same response unless the user asks for changes. Future user requests for "
"new artifacts may call render_html once."
)
def _tool_action_nudge(has_artifact: bool) -> str:
return _TOOL_ACTION_NUDGE + (_ARTIFACT_TOOL_ACTION_NUDGE if has_artifact else "")
# Strip tool-call XML the speculative buffer in core/inference/llama_cpp.py
# split across the visible/DRAIN boundary. Four leak shapes:
@ -2698,6 +2842,7 @@ async def openai_chat_completions(
_tool_names = {t["function"]["name"] for t in tools_to_use}
_has_web = "web_search" 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()}."
@ -2719,34 +2864,34 @@ async def openai_chat_completions(
"Use code execution for math, calculations, data processing, "
"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. "
"Call it once, then do not call it again in the same response unless "
"the user asks for changes. Future user requests for new artifacts may "
"call render_html once."
)
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 = (
_date_line + " "
"You have access to tools. When appropriate, prefer using "
"tools rather than answering from memory. "
+ _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
+ " ".join(_tool_tip_parts)
)
else:
_nudge = ""
if _nudge:
_nudge += _TOOL_ACTION_NUDGE
_nudge += _tool_action_nudge(_has_artifact)
# Append nudge to system prompt (preserve user's prompt)
if system_prompt:
system_prompt = system_prompt.rstrip() + "\n\n" + _nudge
@ -3221,6 +3366,7 @@ async def openai_chat_completions(
_sf_tool_names = {t["function"]["name"] for t in _sf_tools_to_use}
_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_artifact = "render_html" in _sf_tool_names
_sf_date_line = f"The current date is {_date.today().isoformat()}."
_sf_model_size_b = _extract_model_size_b(model_name)
@ -3239,35 +3385,35 @@ async def openai_chat_completions(
"Use code execution for math, calculations, data processing, "
"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. "
"Call it once, then do not call it again in the same response unless "
"the user asks for changes. Future user requests for new artifacts may "
"call render_html once."
)
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_date_line + " "
"You have access to tools. When appropriate, prefer using "
"tools rather than answering from memory. "
+ _sf_web_tips
+ " "
+ _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
+ " ".join(_sf_tool_tip_parts)
)
else:
_sf_nudge = ""
_sf_system_prompt = system_prompt
if _sf_nudge:
_sf_nudge += _TOOL_ACTION_NUDGE
_sf_nudge += _tool_action_nudge(_sf_has_artifact)
if _sf_system_prompt:
_sf_system_prompt = _sf_system_prompt.rstrip() + "\n\n" + _sf_nudge
else:
@ -4914,6 +5060,7 @@ async def anthropic_messages(
_tool_names = {t["function"]["name"] for t in openai_tools}
_has_web = "web_search" 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()}."
_model_size_b = _extract_model_size_b(model_name)
@ -4932,34 +5079,33 @@ async def anthropic_messages(
"Use code execution for math, calculations, data processing, "
"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. "
"Call it once, then do not call it again in the same response unless "
"the user asks for changes. Future user requests for new artifacts may "
"call render_html once."
)
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 = (
_date_line + " "
"You have access to tools. When appropriate, prefer using "
"tools rather than answering from memory. "
+ _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
"tools rather than answering from memory. " + " ".join(_tool_tip_parts)
)
else:
_nudge = ""
if _nudge:
_nudge += _TOOL_ACTION_NUDGE
_nudge += _tool_action_nudge(_has_artifact)
# Inject into system prompt
if openai_messages and openai_messages[0].get("role") == "system":
openai_messages[0]["content"] = (
@ -5147,6 +5293,7 @@ async def _anthropic_tool_non_streaming(run_gen, message_id, model_name):
baseline, not against turn N's final length.
"""
content_blocks: list = []
tool_blocks_by_id: dict[str, AnthropicResponseToolUseBlock] = {}
usage = {}
prev_text = ""
@ -5165,13 +5312,25 @@ async def _anthropic_tool_non_streaming(run_gen, message_id, model_name):
else:
content_blocks.append(AnthropicResponseTextBlock(text = new))
elif etype == "tool_start":
content_blocks.append(
AnthropicResponseToolUseBlock(
id = event["tool_call_id"],
name = event["tool_name"],
input = event.get("arguments", {}),
)
tool_call_id = event["tool_call_id"]
arguments = event.get("arguments", {})
existing_tool_block = (
tool_blocks_by_id.get(tool_call_id) if tool_call_id else None
)
if existing_tool_block is not None:
if arguments or not existing_tool_block.input:
existing_tool_block.input = arguments
if event.get("tool_name") and not existing_tool_block.name:
existing_tool_block.name = event["tool_name"]
else:
tool_block = AnthropicResponseToolUseBlock(
id = tool_call_id,
name = event["tool_name"],
input = arguments,
)
if tool_call_id:
tool_blocks_by_id[tool_call_id] = tool_block
content_blocks.append(tool_block)
elif etype == "tool_end":
prev_text = ""
elif etype == "metadata":

View file

@ -38,6 +38,7 @@ from routes.inference import (
_normalize_anthropic_openai_images,
_select_anthropic_server_tools,
_anthropic_requested_studio_tools,
_anthropic_tool_non_streaming,
anthropic_messages,
)
from state.tool_policy import reset_tool_policy, set_tool_policy
@ -551,6 +552,54 @@ class TestAnthropicStreamEmitter:
assert "tool_use" in events[1]
assert "input_json_delta" in events[2]
def test_duplicate_tool_start_merges_into_open_tool_block(self):
e = AnthropicStreamEmitter()
e.start("msg_1", "m")
first_events = e.feed(
{
"type": "tool_start",
"tool_name": "render_html",
"tool_call_id": "call_0",
"arguments": {},
}
)
second_events = e.feed(
{
"type": "tool_start",
"tool_name": "render_html",
"tool_call_id": "call_0",
"arguments": {"code": "<!doctype html><html></html>"},
}
)
first_payloads = [
json.loads(event.split("data: ")[1]) for event in first_events
]
second_payloads = [
json.loads(event.split("data: ")[1]) for event in second_events
]
tool_starts = [
payload
for payload in first_payloads + second_payloads
if payload["type"] == "content_block_start"
and payload["content_block"]["type"] == "tool_use"
]
assert len(tool_starts) == 1
assert tool_starts[0]["content_block"]["id"] == "call_0"
assert second_payloads == [
{
"type": "content_block_delta",
"index": tool_starts[0]["index"],
"delta": {
"type": "input_json_delta",
"partial_json": json.dumps(
{"code": "<!doctype html><html></html>"}
),
},
}
]
def test_tool_end_closes_tool_opens_new_text_block(self):
e = AnthropicStreamEmitter()
e.start("msg_1", "m")
@ -674,6 +723,49 @@ class TestAnthropicStreamEmitter:
assert parsed["delta"]["text"] == "After tool"
# =====================================================================
# Non-streaming tool response tests
# =====================================================================
class TestAnthropicToolNonStreaming:
def test_duplicate_tool_start_replaces_provisional_tool_block(self):
def _run_gen():
yield {
"type": "tool_start",
"tool_name": "render_html",
"tool_call_id": "call_0",
"arguments": {},
}
yield {
"type": "tool_start",
"tool_name": "render_html",
"tool_call_id": "call_0",
"arguments": {"code": "<!doctype html><html></html>"},
}
yield {
"type": "tool_end",
"tool_name": "render_html",
"tool_call_id": "call_0",
"result": "Rendered HTML artifact.",
}
response = asyncio.run(_anthropic_tool_non_streaming(_run_gen, "msg_1", "m"))
body = json.loads(response.body)
tool_blocks = [
block for block in body["content"] if block["type"] == "tool_use"
]
assert tool_blocks == [
{
"type": "tool_use",
"id": "call_0",
"name": "render_html",
"input": {"code": "<!doctype html><html></html>"},
}
]
# =====================================================================
# Pass-through emitter tests (client-side tool execution path)
# =====================================================================

View file

@ -28,12 +28,14 @@ Edge cases under coverage:
"""
import threading
from typing import cast
import pytest
from core.inference import safetensors_agentic
from core.inference.safetensors_agentic import (
_coerce_arguments,
_detect_render_html_tool_start,
run_safetensors_tool_loop,
)
from core.inference.tool_call_parser import (
@ -97,6 +99,17 @@ class TestParser:
assert len(result) == 1
assert "print('hi')" in result[0]["function"]["arguments"]
def test_function_signal_inside_parameter_is_literal(self):
text = (
"<function=python>"
"<parameter=code>print('<function=render_html>')</parameter>"
"</function>"
)
result = parse_tool_calls_from_text(text)
assert len(result) == 1
assert result[0]["function"]["name"] == "python"
assert "<function=render_html>" in result[0]["function"]["arguments"]
def test_multiple_calls(self):
text = (
'<tool_call>{"name":"web_search","arguments":{"query":"a"}}</tool_call>'
@ -118,6 +131,18 @@ class TestParser:
assert has_tool_signal("hi <function=foo>...")
assert not has_tool_signal("hello world")
def test_render_html_start_detector_uses_first_tool(self):
assert _detect_render_html_tool_start("<function=render_html>")
assert _detect_render_html_tool_start(
'<tool_call>{"name":"render_html","arguments":{"code":"<html>"}'
)
assert not _detect_render_html_tool_start(
"<function=python><parameter=code>'<function=render_html>'"
)
assert not _detect_render_html_tool_start(
'<tool_call>{"name":"python","arguments":{"code":"<function=render_html>"}}'
)
def test_strip_markup_closed(self):
text = "before <tool_call>{}</tool_call> after"
assert strip_tool_markup(text) == "before after"
@ -280,6 +305,104 @@ class TestLoopBasic:
contents = [e for e in events if e["type"] == "content"]
assert "Result: 1" in contents[-1]["text"]
def test_render_html_emits_provisional_tool_start(self):
exec_fn = FakeExecuteTool(["Rendered HTML artifact."])
turn_iter = iter(
[
[
"<function=render_html>",
"<parameter=code><!doctype html><html>",
"<body>Hi</body></html></parameter></function>",
],
["Done."],
]
)
def _gen(_messages):
chunks = next(turn_iter)
acc = ""
for chunk in chunks:
acc += chunk
yield acc
loop = run_safetensors_tool_loop(
single_turn = _gen,
messages = [{"role": "user", "content": "make html"}],
tools = [{"type": "function", "function": {"name": "render_html"}}],
execute_tool = exec_fn,
)
events = _collect_events(loop)
tool_starts = [e for e in events if e["type"] == "tool_start"]
assert len(tool_starts) == 2
assert tool_starts[0]["tool_name"] == "render_html"
assert tool_starts[0]["arguments"] == {}
assert tool_starts[1]["tool_name"] == "render_html"
assert "<!doctype html>" in tool_starts[1]["arguments"]["code"]
assert exec_fn.calls[0][0] == "render_html"
assert "<!doctype html>" in exec_fn.calls[0][1]["code"]
def test_python_tool_containing_render_html_signal_does_not_emit_provisional_start(
self,
):
loop, exec_fn = _make_loop(
turns = [
[
"<function=python>",
"<parameter=code>print('<function=render_html>')",
"</parameter></function>",
],
["Done."],
],
exec_results = ["ok"],
)
events = _collect_events(loop)
tool_starts = [e for e in events if e["type"] == "tool_start"]
assert len(tool_starts) == 1
assert tool_starts[0]["tool_name"] == "python"
assert exec_fn.calls == [
("python", {"code": "print('<function=render_html>')"})
]
def test_render_html_success_blocks_second_artifact_call(self):
exec_fn = FakeExecuteTool(["Rendered HTML artifact."])
turn_iter = iter(
[
[
'<tool_call>{"name":"render_html",',
'"arguments":{"code":"<html>one</html>"}}',
],
[
'<tool_call>{"name":"render_html",',
'"arguments":{"code":"<html>two</html>"}}',
],
["Done."],
]
)
def _gen(_messages):
chunks = next(turn_iter)
acc = ""
for chunk in chunks:
acc += chunk
yield acc
loop = run_safetensors_tool_loop(
single_turn = _gen,
messages = [{"role": "user", "content": "make html"}],
tools = [{"type": "function", "function": {"name": "render_html"}}],
execute_tool = exec_fn,
)
events = _collect_events(loop)
tool_starts = [e for e in events if e["type"] == "tool_start"]
assert exec_fn.calls == [("render_html", {"code": "<html>one</html>"})]
assert [e["arguments"] for e in tool_starts] == [
{},
{"code": "<html>one</html>"},
]
def test_truncated_unclosed_tool_call(self):
loop, exec_fn = _make_loop(
turns = [
@ -595,6 +718,7 @@ class TestChatTemplateHelper:
tok = self._Tok({"tools", "enable_thinking"})
self.apply(tok, [], tools = [{}], enable_thinking = True)
assert tok.call_count == 1
assert tok.last_kwargs is not None
assert "tools" in tok.last_kwargs
assert "enable_thinking" in tok.last_kwargs
@ -781,7 +905,7 @@ class TestGptOssNameDetection:
def test_empty_or_none_returns_false(self):
assert is_gpt_oss_model_name("") is False
assert is_gpt_oss_model_name(None) is False
assert is_gpt_oss_model_name(cast(str, None)) is False
if __name__ == "__main__":

View file

@ -3,18 +3,19 @@
"use client";
import { ArtifactCard, useChatRuntimeStore } from "@/features/chat";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { preprocessLaTeX } from "@/lib/latex";
import { openLink } from "@/lib/open-link";
import { INTERNAL, useMessagePartText } from "@assistant-ui/react";
import { INTERNAL, useAuiState, useMessagePartText } from "@assistant-ui/react";
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { createCodePlugin } from "./code-plugin";
import { createMathPlugin } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { DownloadIcon, Maximize2Icon, Minimize2Icon } from "lucide-react";
import { DownloadIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Block, type BlockProps, Streamdown } from "streamdown";
import { createCodePlugin } from "./code-plugin";
import "katex/dist/katex.min.css";
import { AudioPlayer } from "./audio-player";
import { unslothDarkTheme, unslothLightTheme } from "./code-themes";
@ -26,11 +27,7 @@ const code = createCodePlugin({
const { withSmoothContextProvider } = INTERNAL;
const STREAMDOWN_COMPONENTS = {
a: ({
href,
children,
...props
}: React.ComponentProps<"a">) => (
a: ({ href, children, ...props }: React.ComponentProps<"a">) => (
<a
href={href}
rel="noopener noreferrer"
@ -59,6 +56,34 @@ type CodeFence = {
source: string;
};
type ToolCallPartLike = {
type?: string;
toolName?: string;
args?: unknown;
result?: unknown;
};
function isRenderableRenderHtmlToolPart(part: unknown): boolean {
const toolPart = part as ToolCallPartLike;
if (toolPart.type !== "tool-call" || toolPart.toolName !== "render_html") {
return false;
}
if (
typeof toolPart.result === "string" &&
toolPart.result.startsWith("Error:")
) {
return false;
}
if (
typeof toolPart.result === "string" &&
toolPart.result.startsWith("Rendered HTML artifact")
) {
return true;
}
const args = toolPart.args as { code?: unknown } | undefined;
return typeof args?.code === "string" && args.code.trim().length > 0;
}
function getMermaidSource(blockContent: string): string | null {
const source = blockContent.match(MERMAID_SOURCE_RE)?.[1]?.trim();
return source && source.length > 0 ? source : null;
@ -123,7 +148,13 @@ function isHtmlFence(codeFence: CodeFence): boolean {
return lang === "html" && !isSvgFence(codeFence);
}
const UNSAFE_SVG_RE = /<script[\s>]|on\w+\s*=|javascript:|<foreignObject[\s>]|<iframe[\s>]|<embed[\s>]|<object[\s>]/i;
function isFullHtmlDocument(source: string): boolean {
const trimmed = source.trimStart();
return /^<!doctype\s+html\b/i.test(trimmed) || /^<html[\s>]/i.test(trimmed);
}
const UNSAFE_SVG_RE =
/<script[\s>]|on\w+\s*=|javascript:|<foreignObject[\s>]|<iframe[\s>]|<embed[\s>]|<object[\s>]/i;
function sanitizeSvg(source: string): string | null {
if (UNSAFE_SVG_RE.test(source)) return null;
@ -145,96 +176,6 @@ function SvgPreview({ source }: { source: string }) {
);
}
const HTML_PREVIEW_DEFAULT_HEIGHT = 400;
const HTML_PREVIEW_MAX_HEIGHT = 800;
function HtmlPreview({ source }: { source: string }) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState(HTML_PREVIEW_DEFAULT_HEIGHT);
const [enlarged, setEnlarged] = useState(false);
useEffect(() => {
const handler = (e: MessageEvent) => {
if (e.source !== iframeRef.current?.contentWindow) return;
if (typeof e.data?.htmlPreviewHeight === "number") {
setHeight(Math.min(Math.max(e.data.htmlPreviewHeight, 100), HTML_PREVIEW_MAX_HEIGHT));
}
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, []);
useEffect(() => {
if (!enlarged) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") setEnlarged(false);
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [enlarged]);
const resizeScript = `<script>new ResizeObserver(()=>{
parent.postMessage({htmlPreviewHeight:document.documentElement.scrollHeight},"*");
}).observe(document.documentElement);</script>`;
const srcDoc = source + resizeScript;
if (enlarged) {
return (
<>
<div className="mt-2 overflow-hidden rounded-lg border border-border" style={{ height }}>
{/* Placeholder keeps layout stable while overlay is shown */}
</div>
<div
className="fixed inset-0 z-50 flex flex-col bg-background/80 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) setEnlarged(false); }}
>
<div className="flex items-center justify-end gap-2 px-4 py-2">
<button
type="button"
className="flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => setEnlarged(false)}
title="Exit fullscreen (Esc)"
>
<Minimize2Icon className="size-4" />
Exit fullscreen
</button>
</div>
<div className="mx-4 mb-4 flex-1 overflow-hidden rounded-lg border border-border bg-background">
<iframe
ref={iframeRef}
srcDoc={srcDoc}
sandbox="allow-scripts"
style={{ width: "100%", height: "100%", border: "none", display: "block" }}
title="HTML preview"
/>
</div>
</div>
</>
);
}
return (
<div className="group/html-preview relative mt-2 overflow-hidden rounded-lg border border-border">
<button
type="button"
className="absolute top-2 right-2 z-10 rounded-md border border-border bg-background/80 p-1.5 text-muted-foreground opacity-0 transition-all hover:bg-muted hover:text-foreground group-hover/html-preview:opacity-100 supports-[backdrop-filter]:backdrop-blur"
onClick={() => setEnlarged(true)}
title="Enlarge preview"
>
<Maximize2Icon className="size-4" />
</button>
<iframe
ref={iframeRef}
srcDoc={srcDoc}
sandbox="allow-scripts"
style={{ width: "100%", height, border: "none", display: "block" }}
title="HTML preview"
/>
</div>
);
}
function downloadTextFile(filename: string, text: string): void {
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
@ -346,6 +287,12 @@ function CodeBlockActions({
}
function StreamdownBlock(props: BlockProps) {
const shouldCollapseHtmlArtifacts = useChatRuntimeStore(
(state) => state.artifactsEnabled || state.collapseHtmlArtifacts,
);
const messageHasRenderableRenderHtmlTool = useAuiState(({ message }) =>
message.parts.some(isRenderableRenderHtmlToolPart),
);
const hasMermaidFence = props.content.includes("```mermaid");
const mermaidSource = getMermaidSource(props.content);
const codeFence = getCodeFence(props.content);
@ -362,7 +309,9 @@ function StreamdownBlock(props: BlockProps) {
return (
<div className="relative isolate">
<div className="my-4 rounded-xl border border-border bg-muted/30 p-4">
<div className="mb-2 text-xs font-medium text-muted-foreground">svg</div>
<div className="mb-2 text-xs font-medium text-muted-foreground">
svg
</div>
<pre className="overflow-x-auto text-xs text-muted-foreground whitespace-pre-wrap break-all">
<code>{codeFence.source}</code>
</pre>
@ -371,10 +320,17 @@ function StreamdownBlock(props: BlockProps) {
);
}
if (props.isIncomplete && codeFence && isHtmlFence(codeFence)) {
if (
shouldCollapseHtmlArtifacts &&
!messageHasRenderableRenderHtmlTool &&
props.isIncomplete &&
codeFence &&
isHtmlFence(codeFence) &&
isFullHtmlDocument(codeFence.source)
) {
return (
<div className="my-4 flex h-48 items-center justify-center rounded-xl border border-border bg-muted/30 text-sm text-muted-foreground animate-pulse">
Loading preview...
Loading artifact preview...
</div>
);
}
@ -389,8 +345,24 @@ function StreamdownBlock(props: BlockProps) {
}
if (codeFence) {
const svgSource = !props.isIncomplete && isSvgFence(codeFence) ? sanitizeSvg(codeFence.source) : null;
const htmlSource = !props.isIncomplete && isHtmlFence(codeFence) ? codeFence.source : null;
const svgSource =
!props.isIncomplete && isSvgFence(codeFence)
? sanitizeSvg(codeFence.source)
: null;
const htmlSource =
shouldCollapseHtmlArtifacts &&
!messageHasRenderableRenderHtmlTool &&
!props.isIncomplete &&
isHtmlFence(codeFence) &&
isFullHtmlDocument(codeFence.source)
? codeFence.source
: null;
if (htmlSource) {
return (
<ArtifactCard code={htmlSource} title="HTML preview" source="fence" />
);
}
return (
<>
<div className="relative isolate">
@ -402,7 +374,6 @@ function StreamdownBlock(props: BlockProps) {
/>
</div>
{svgSource && <SvgPreview source={svgSource} />}
{htmlSource && <HtmlPreview source={htmlSource} />}
</>
);
}

View file

@ -24,6 +24,7 @@ import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { ToolGroup } from "@/components/assistant-ui/tool-group";
import { CodeExecutionToolUI } from "@/components/assistant-ui/tool-ui-code-execution";
import { ImageGenerationToolUI } from "@/components/assistant-ui/tool-ui-image-generation";
import { RenderHtmlToolUI } from "@/components/assistant-ui/tool-ui-render-html";
import { PythonToolUI } from "@/components/assistant-ui/tool-ui-python";
import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal";
import { WebSearchToolUI } from "@/components/assistant-ui/tool-ui-web-search";
@ -73,6 +74,7 @@ import {
ChevronLeftIcon,
ChevronRightIcon,
DownloadIcon,
FileTextIcon,
GlobeIcon,
HeadphonesIcon,
LightbulbIcon,
@ -1117,6 +1119,29 @@ const ImagesToggle: FC = () => {
);
};
const ArtifactsToggle: FC = () => {
const modelLoaded = useChatRuntimeStore(
(s) => !!s.params.checkpoint && !s.modelLoading,
);
const artifactsEnabled = useChatRuntimeStore((s) => s.artifactsEnabled);
const setArtifactsEnabled = useChatRuntimeStore((s) => s.setArtifactsEnabled);
const disabled = !modelLoaded;
return (
<button
type="button"
disabled={disabled}
onClick={() => setArtifactsEnabled(!artifactsEnabled)}
className="composer-pill-btn"
data-active={artifactsEnabled && !disabled ? "true" : "false"}
aria-label={artifactsEnabled ? "Disable artifacts" : "Enable artifacts"}
>
<FileTextIcon className="size-3.5" />
<span>Artifacts</span>
</button>
);
};
const ToolStatusDisplay: FC = () => {
const toolStatus = useChatRuntimeStore((s) => s.toolStatus);
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
@ -1189,6 +1214,7 @@ const ComposerAction: FC<{
<WebSearchToggle />
<CodeToolsToggle />
<ImagesToggle />
<ArtifactsToggle />
</div>
<div className="flex items-center gap-1">
<ComposerPrimitive.If dictation={false}>
@ -1316,6 +1342,7 @@ const AssistantMessage: FC = () => {
terminal: TerminalToolUI,
code_execution: CodeExecutionToolUI,
image_generation: ImageGenerationToolUI,
render_html: RenderHtmlToolUI,
},
Fallback: ToolFallback,
},

View file

@ -8,6 +8,7 @@ import {
type FC,
type PropsWithChildren,
} from "react";
import { useAuiState } from "@assistant-ui/react";
import { ChevronDownIcon, LoaderIcon } from "lucide-react";
import { Wrench01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
@ -27,7 +28,8 @@ const toolGroupVariants = cva("aui-tool-group-root group/tool-group w-full", {
variant: {
outline: "corner-squircle rounded-lg border py-3",
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" },
@ -209,9 +211,17 @@ const ToolGroupImpl: FC<
PropsWithChildren<{ startIndex: number; endIndex: number }>
> = ({ children, startIndex, endIndex }) => {
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
if (toolCount <= 1) {
// Single tool calls and artifacts render directly so cards never hide inside
// a collapsed tool group.
if (toolCount <= 1 || containsArtifactTool) {
return <>{children}</>;
}

View file

@ -0,0 +1,142 @@
// 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,
useChatArtifactsStore,
useSelectedChatArtifact,
} from "@/features/chat";
import {
type ToolCallMessagePartComponent,
useAuiState,
useToolArgsStatus,
} from "@assistant-ui/react";
import { BrowserIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { memo, useEffect } 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 RENDER_HTML_SESSION_STARTED_AT = Date.now();
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";
// 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;
const messageId = useAuiState(({ message }) => message.id) ?? null;
const isMessageRunning = useAuiState(
({ message }) => message.status?.type === "running",
);
const messageCreatedAtMs = useAuiState(({ message }) =>
message.createdAt instanceof Date ? message.createdAt.getTime() : null,
);
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
const isLiveGeneratingArtifact =
isThreadRunning && isMessageRunning && (isRunning || codeIsStreaming);
const isStaleGeneratingArtifact =
!(isThreadRunning && isMessageRunning) && (isRunning || codeIsStreaming);
const messageCreatedThisSession =
messageCreatedAtMs != null &&
messageCreatedAtMs >= RENDER_HTML_SESSION_STARTED_AT - 1000;
const shouldAutoOpenArtifact =
(isLiveGeneratingArtifact && (hasCode || isRunning || codeIsStreaming)) ||
(hasCode && messageCreatedThisSession);
const selectedArtifact = useSelectedChatArtifact();
const closeArtifactSurface = useChatArtifactsStore(
(state) => state.closeArtifactSurface,
);
useEffect(() => {
if (!errorText) {
return;
}
if (!(messageId && toolCallId)) {
return;
}
if (selectedArtifact?.sourceToolCallId !== toolCallId) {
return;
}
if (selectedArtifact?.sourceMessageId !== messageId) {
return;
}
closeArtifactSurface();
}, [
closeArtifactSurface,
errorText,
messageId,
selectedArtifact,
toolCallId,
]);
if (hasCode || (isLiveGeneratingArtifact && !errorText)) {
return (
<ArtifactCard
code={code}
title={title}
source="tool"
sourceToolCallId={toolCallId}
autoOpen={!errorText && shouldAutoOpenArtifact}
isStreaming={!errorText && isLiveGeneratingArtifact}
/>
);
}
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"
: isStaleGeneratingArtifact
? "Artifact interrupted"
: "Artifact unavailable"}
</span>
<span className="truncate text-[11px] leading-none text-muted-foreground">
{errorText ??
(isStaleGeneratingArtifact
? "Refresh stopped this preview"
: "HTML artifact")}
</span>
</span>
</div>
</div>
);
};
export const RenderHtmlToolUI = memo(
RenderHtmlToolUIImpl,
) as unknown as ToolCallMessagePartComponent;
RenderHtmlToolUI.displayName = "RenderHtmlToolUI";

View file

@ -1,54 +1,54 @@
// 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 type * as React from "react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils";
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.Group>): React.ReactElement {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className,
)}
{...props}
/>
);
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>): React.ReactElement {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
withHandle?: boolean;
}): React.ReactElement {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
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",
className,
)}
{...props}
>
{withHandle && (
<div className="bg-border h-6 w-1 rounded-lg z-10 flex shrink-0" />
)}
</ResizablePrimitive.Separator>
);
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
import type * as React from "react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils";
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.Group>): React.ReactElement {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className,
)}
{...props}
/>
);
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>): React.ReactElement {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
withHandle?: boolean;
}): React.ReactElement {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"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,
)}
{...props}
>
{withHandle && (
<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>
);
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View file

@ -19,7 +19,6 @@ import {
} from "../external-providers";
import { pickFriendlyContainerName } from "../lib/friendly-names";
import {
EXTERNAL_MAX_OUTPUT_TOKENS,
clampReasoningEffortToLevels,
getExternalMaxOutputTokens,
getExternalMinOutputTokens,
@ -46,12 +45,12 @@ import type {
OpenAIReasoningContentPart,
} from "../types/api";
import type { ChatModelSummary } from "../types/runtime";
import { getImageInputUnavailableReason } from "../utils/image-input-support";
import {
getStoredChatThread,
listStoredChatThreads,
updateStoredChatThread,
} from "../utils/chat-history-storage";
import { getImageInputUnavailableReason } from "../utils/image-input-support";
import {
hasClosedThinkTag,
parseAssistantContent,
@ -775,36 +774,6 @@ function toOpenAIMessages(message: RunMessage): SerializedMessage[] {
return toolResults.length > 0 ? [base, ...toolResults] : [base];
}
// Thin singular wrapper: returns only the first serialized message
// (without tool_calls or tool follow-ups) so the OpenAI image-edit
// replay path can map a thread to flat OpenAI chat messages without
// pulling in tool history.
function toOpenAIMessage(message: RunMessage): {
role: "system" | "user" | "assistant";
content: OpenAIMessageContent;
} | null {
const serialized = toOpenAIMessages(message);
if (serialized.length === 0) return null;
const first = serialized[0];
if (
first.role !== "system" &&
first.role !== "user" &&
first.role !== "assistant"
) {
return null;
}
if (first.content === null || first.content === undefined) {
return null;
}
if (typeof first.content === "string" && !first.content) {
return null;
}
return {
role: first.role,
content: first.content as OpenAIMessageContent,
};
}
function extractImageBase64(input: string): string | undefined {
if (!input) {
return undefined;
@ -1303,6 +1272,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
toolsEnabled,
codeToolsEnabled,
imageToolsEnabled,
artifactsEnabled,
mcpEnabledForChat,
webFetchToolsEnabled,
} = runtime;
@ -1537,36 +1507,62 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
"Do not return tool-call syntax inside your response.";
}
}
if (disabledToolGuard) {
const firstMessage = outboundMessages[0];
type OutboundMessage = (typeof outboundMessages)[number];
function addSystemInstruction(
targetMessages: OutboundMessage[],
text: string | null,
): void {
if (!text) return;
const firstMessage = targetMessages[0];
if (firstMessage?.role === "system") {
if (typeof firstMessage.content === "string") {
outboundMessages[0] = {
targetMessages[0] = {
...firstMessage,
content: `${firstMessage.content}\n\n${disabledToolGuard}`,
content: `${firstMessage.content}\n\n${text}`,
};
} else {
outboundMessages[0] = {
targetMessages[0] = {
...firstMessage,
content: [
...(Array.isArray(firstMessage.content)
? firstMessage.content
: []),
{ type: "text", text: `\n\n${disabledToolGuard}` },
{ type: "text", text: `\n\n${text}` },
],
};
}
} else {
outboundMessages.unshift({
role: "system",
content: disabledToolGuard,
});
return;
}
targetMessages.unshift({ role: "system", content: text });
}
// Scan post-prune history so a refused user turn's image/audio
// doesn't gate or mis-attribute the next non-refused turn.
const imageBase64 = findLatestUserImageBase64(survivingMessages);
const audioBase64 = findLatestUserAudioBase64(survivingMessages);
const hasOutboundImage = Boolean(imageBase64);
// Keep render_html local-only and mirror the backend image-turn gate.
// Artifacts are independent of Search/Code: if a local tool-capable
// model has Artifacts enabled, expose render_html even when no other
// tool pills are active.
const renderHtmlToolEnabledForThisTurn = Boolean(
!isExternalRequest &&
supportsTools &&
artifactsEnabled &&
!hasOutboundImage,
);
const artifactInstruction = artifactsEnabled
? renderHtmlToolEnabledForThisTurn
? "When the user asks for an HTML, CSS, or JavaScript artifact, call render_html once with one complete self-contained HTML document in the code argument. Embed CSS and JavaScript inside the document. After render_html succeeds, do not call it again in the same response unless the user asks for changes. Future user requests for new artifacts may call render_html once."
: "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
// prior turns) and the loaded model can't process images. Keeps
@ -2093,12 +2089,19 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
...(supportsPreserveThinking
? { preserve_thinking: preserveThinking }
: {}),
...(supportsTools && (toolsEnabled || codeToolsEnabled || mcpEnabledForChat)
...(supportsTools &&
(toolsEnabled ||
codeToolsEnabled ||
renderHtmlToolEnabledForThisTurn ||
mcpEnabledForChat)
? {
enable_tools: true,
enabled_tools: [
...(toolsEnabled ? ["web_search"] : []),
...(codeToolsEnabled ? ["python", "terminal"] : []),
...(renderHtmlToolEnabledForThisTurn
? ["render_html"]
: []),
],
mcp_enabled: mcpEnabledForChat,
auto_heal_tool_calls:
@ -2205,13 +2208,25 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
`${toolEvent.tool_name}_${Date.now()}`;
const toolArgs = (toolEvent.arguments ??
{}) as ToolCallMessagePart["args"];
toolCallParts.push({
type: "tool-call" as const,
toolCallId: id,
toolName: toolEvent.tool_name as string,
argsText: JSON.stringify(toolArgs),
args: toolArgs,
});
const idx = toolCallParts.findIndex(
(p) => p.toolCallId === id,
);
if (idx !== -1) {
toolCallParts[idx] = {
...toolCallParts[idx],
toolName: toolEvent.tool_name as string,
argsText: JSON.stringify(toolArgs),
args: toolArgs,
};
} else {
toolCallParts.push({
type: "tool-call" as const,
toolCallId: id,
toolName: toolEvent.tool_name as string,
argsText: JSON.stringify(toolArgs),
args: toolArgs,
});
}
} else if (toolEvent.type === "tool_end") {
const id =
(toolEvent.tool_call_id as string) ||

View file

@ -23,6 +23,8 @@ export interface PersistedChatSettings {
autoTitle?: boolean;
reasoningEffort?: ReasoningEffort;
preserveThinking?: boolean;
collapseHtmlArtifacts?: boolean;
allowArtifactNetworkAccess?: boolean;
autoHealToolCalls?: boolean;
maxToolCallsPerMessage?: number;
toolCallTimeout?: number;

View file

@ -0,0 +1,141 @@
// 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 { useAuiState } from "@assistant-ui/react";
import { LayoutTwoColumnIcon as Layout2ColumnIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useLayoutEffect, 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";
useLayoutEffect(() => {
if (selectedArtifactId === artifact.id) {
updateArtifact(artifact);
}
if (!autoOpen) {
return;
}
if (hasAutoOpenedArtifact(artifact.id)) {
return;
}
rememberAutoOpenedArtifact(artifact.id);
openArtifact(artifact, { surface });
}, [
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",
isStreaming &&
"border-border/80 bg-muted/20 dark:border-border/70 dark:bg-muted/15",
className,
)}
onClick={() => openArtifact(artifact, { surface })}
aria-label={`Open ${artifact.title}`}
>
{isStreaming ? (
<span
aria-hidden={true}
className="artifact-card-shimmer pointer-events-none absolute inset-0 z-0 motion-reduce:hidden"
/>
) : null}
<div className="relative z-10 flex min-w-0 flex-1 items-center gap-2.5">
<HugeiconsIcon
icon={Layout2ColumnIcon}
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="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>
) : null}
</div>
</button>
);
}

View file

@ -0,0 +1,362 @@
// 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 ArtifactLoadingLine() {
return (
<div className="absolute inset-x-0 bottom-0 h-[2.5px] overflow-hidden bg-border/45">
<span
aria-hidden={true}
className="artifact-loading-line block h-full rounded-full motion-reduce:hidden"
/>
</div>
);
}
function ArtifactGeneratingPanel() {
return (
<div className="flex h-full min-h-0 flex-col items-center justify-center bg-muted/10 px-6 text-center">
<div className="max-w-[30ch] space-y-1.5">
<img
src="/Sloth%20emojis/sloth%20w%20pc%20transparent.png"
alt=""
aria-hidden={true}
className="mx-auto mb-3 size-20 object-contain"
/>
<p className="text-sm font-medium text-foreground">
Building artifact preview
</p>
<p className="text-xs leading-relaxed text-muted-foreground">
The preview will appear here when the HTML is ready.
</p>
</div>
</div>
);
}
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>("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 hasArtifactCode = artifact.code.trim().length > 0;
const isLoadingArtifact = Boolean(artifact.isStreaming);
const effectiveViewMode = isLoadingArtifact ? "preview" : 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(
"relative flex min-h-0 flex-col border border-border bg-background",
variant === "panel"
? "artifact-panel-shell mx-2 mt-[72px] mb-8 h-[calc(100%_-_104px)] overflow-visible rounded-[28px] border-border/70 bg-card/95 [box-shadow:rgba(0,0,0,0.16)_0px_2px_8px_-2px]"
: "h-[min(92vh,900px)] w-[min(96vw,1200px)] overflow-hidden rounded-2xl shadow-xl",
)}
aria-label={`${artifact.title} artifact`}
>
<header
className={cn(
"relative flex shrink-0 items-center justify-between gap-3 px-2.5 py-2",
variant === "panel" && "rounded-t-[28px]",
)}
>
<div
className="flex items-center gap-1 rounded-full 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={isLoadingArtifact && !isPreview}
onClick={() => setViewMode(mode)}
className={cn(
"flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors",
effectiveViewMode === mode
? "bg-background text-foreground shadow-sm"
: "hover:bg-background/70 hover:text-foreground",
isLoadingArtifact &&
!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"
: isLoadingArtifact
? "Source available when generation finishes"
: "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"
disabled={isLoadingArtifact || !hasArtifactCode}
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"
disabled={isLoadingArtifact || !hasArtifactCode}
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>
{isLoadingArtifact ? (
<ArtifactLoadingLine />
) : (
<div className="absolute inset-x-0 bottom-0 h-px bg-border" />
)}
</header>
<div
className={cn(
"min-h-0 flex-1 overflow-hidden bg-background",
variant === "panel" && "rounded-b-[28px]",
)}
>
{isLoadingArtifact ? (
<ArtifactGeneratingPanel />
) : 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,100 @@
// 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 { getAuthToken } from "@/features/auth";
import { apiUrl } from "@/lib/api-base";
import { cn } from "@/lib/utils";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
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 allowNetworkAccess = useChatRuntimeStore(
(state) => state.allowArtifactNetworkAccess,
);
const [height, setHeight] = useState(HTML_FRAME_DEFAULT_HEIGHT);
const artifactHtml = useMemo(() => buildArtifactSrcDoc(code), [code]);
const src = useMemo(() => {
const query = new URLSearchParams({ v: hashArtifactCode(code) });
if (allowNetworkAccess) {
const token = getAuthToken();
if (token) {
query.set("allow_network", "1");
query.set("token", token);
}
}
return apiUrl(`/api/inference/artifact-preview-frame?${query.toString()}`);
}, [allowNetworkAccess, 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,107 @@
// 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;
clearOrphanedArtifacts: () => void;
resetArtifacts: () => void;
};
export const useChatArtifactsStore = create<ChatArtifactsState>((set) => ({
artifactsById: {},
selectedArtifactId: null,
surface: "panel",
openArtifact: (artifact, options) =>
set((state) => ({
artifactsById: {
...state.artifactsById,
[artifact.id]: artifact,
},
selectedArtifactId: artifact.id,
surface: options?.surface ?? state.surface,
})),
updateArtifact: (artifact) =>
set((state) =>
state.artifactsById[artifact.id]
? {
artifactsById: {
...state.artifactsById,
[artifact.id]: artifact,
},
}
: state,
),
closeArtifactSurface: () =>
set({ 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,
};
}),
clearOrphanedArtifacts: () =>
set((state) => {
const artifactsById = Object.fromEntries(
Object.entries(state.artifactsById).filter(
([, artifact]) => artifact.threadId != null,
),
);
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,
} from "@/components/assistant-ui/model-selector";
import { Thread } from "@/components/assistant-ui/thread";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { useSidebar } from "@/components/ui/sidebar";
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
import { NativeModelChip } from "@/features/native-intents/components/native-model-chip";
import { NativeModelDropOverlay } from "@/features/native-intents/components/native-model-drop-overlay";
import { useNativeIntentStore } from "@/features/native-intents/store";
import type { NativeIntent } from "@/features/native-intents/types";
import { useChooseNativeModel } from "@/features/native-intents/use-native-dialogs";
import { useNativeModelDrop } from "@/features/native-intents/use-native-drop";
import { useNativePathLeasesSupported } from "@/features/native-intents/use-native-readiness";
import {
NativeModelChip,
NativeModelDropOverlay,
type NativeIntent,
useChooseNativeModel,
useNativeIntentStore,
useNativeModelDrop,
useNativePathLeasesSupported,
} from "@/features/native-intents";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { isTauri } from "@/lib/api-base";
import { cn } from "@/lib/utils";
@ -26,6 +33,7 @@ import { CustomizeIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import type { PanelImperativeHandle } from "react-resizable-panels";
import {
type ReactElement,
memo,
@ -78,6 +86,13 @@ import {
} from "./stores/chat-runtime-store";
import { useExternalProvidersStore } from "./stores/external-providers-store";
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 {
getStoredChatThread,
@ -134,6 +149,12 @@ function pickBestLoraForBase(
return partial ?? sorted[0] ?? null;
}
function isAssistantLocalThreadId(
threadId: string | null | undefined,
): boolean {
return Boolean(threadId?.startsWith("__LOCALID_"));
}
function messageHasImage(message: MessageRecord): boolean {
const contentParts = Array.isArray(message.content) ? message.content : [];
if (contentParts.some((part) => part.type === "image")) {
@ -153,19 +174,161 @@ function messageHasImage(message: MessageRecord): boolean {
return false;
}
const ARTIFACT_PANEL_DEFAULT_SIZE = "38%";
const ARTIFACT_PANEL_TRANSITION_MS = 260;
const ARTIFACT_SURFACE_POP_DELAY_MS = 150;
const SingleContent = memo(function SingleContent({
threadId,
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 artifactPanelRef = useRef<PanelImperativeHandle | null>(null);
const hasInitializedArtifactPanelRef = useRef(false);
const [isArtifactLayoutAnimating, setIsArtifactLayoutAnimating] =
useState(false);
const [isArtifactPanelLayoutActive, setIsArtifactPanelLayoutActive] =
useState(false);
const [isArtifactSurfaceVisible, setIsArtifactSurfaceVisible] =
useState(false);
const showArtifactPanel = Boolean(
artifact &&
artifactSurface === "panel" &&
(threadId
? !artifact.threadId || artifact.threadId === threadId
: Boolean(newThreadNonce) ||
Boolean(artifact.threadId && artifact.threadId === activeThreadId)),
);
const artifactLayoutActive = showArtifactPanel || isArtifactPanelLayoutActive;
const artifactPanelSettledOpen =
showArtifactPanel &&
isArtifactPanelLayoutActive &&
!isArtifactLayoutAnimating;
useEffect(() => {
const panel = artifactPanelRef.current;
if (!panel) return;
setIsArtifactSurfaceVisible(false);
if (!hasInitializedArtifactPanelRef.current) {
hasInitializedArtifactPanelRef.current = true;
if (!showArtifactPanel) {
panel.resize("0%");
return;
}
}
setIsArtifactPanelLayoutActive(true);
setIsArtifactLayoutAnimating(true);
let resizeFrameId = 0;
const prepFrameId = window.requestAnimationFrame(() => {
resizeFrameId = window.requestAnimationFrame(() => {
panel.resize(showArtifactPanel ? ARTIFACT_PANEL_DEFAULT_SIZE : "0%");
});
});
const surfaceTimerId = showArtifactPanel
? window.setTimeout(() => {
setIsArtifactSurfaceVisible(true);
}, ARTIFACT_SURFACE_POP_DELAY_MS)
: 0;
const timeoutId = window.setTimeout(() => {
setIsArtifactLayoutAnimating(false);
if (!showArtifactPanel) {
setIsArtifactPanelLayoutActive(false);
}
}, ARTIFACT_PANEL_TRANSITION_MS + 60);
return () => {
window.cancelAnimationFrame(prepFrameId);
if (resizeFrameId) {
window.cancelAnimationFrame(resizeFrameId);
}
if (surfaceTimerId) {
window.clearTimeout(surfaceTimerId);
}
window.clearTimeout(timeoutId);
};
}, [showArtifactPanel]);
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 (
<ChatRuntimeProvider
modelType="base"
initialThreadId={threadId}
newThreadNonce={newThreadNonce}
>
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
<Thread hideWelcome={Boolean(threadId)} targetThreadId={threadId} />
</div>
<ResizablePanelGroup
orientation="horizontal"
data-artifact-layout-animating={
isArtifactLayoutAnimating ? "true" : "false"
}
className="chat-artifact-split min-h-0 min-w-0 flex-1 basis-0 overflow-hidden"
>
<ResizablePanel
id="chat-thread"
defaultSize="100%"
minSize={artifactLayoutActive ? "42%" : "100%"}
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={false}
className={cn(
"relative z-30 -ml-1 -mr-4 w-5 bg-transparent transition-[width,margin] duration-[260ms] ease-[var(--ease-out-cubic)] hover:bg-transparent hover:shadow-none active:bg-transparent active:shadow-none focus-visible:bg-transparent focus-visible:shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none",
!artifactLayoutActive && "pointer-events-none -ml-0 -mr-0 w-0",
)}
/>
<ResizablePanel
panelRef={artifactPanelRef}
id="chat-artifact"
defaultSize="0%"
minSize={artifactPanelSettledOpen ? "30%" : "0%"}
maxSize={artifactLayoutActive ? "58%" : "0%"}
collapsible={true}
collapsedSize="0%"
className={cn(
"h-full min-h-0 min-w-0 overflow-visible",
!showArtifactPanel && "pointer-events-none",
)}
>
<div
data-artifact-surface-visible={
isArtifactSurfaceVisible ? "true" : "false"
}
className="chat-artifact-pop-surface flex h-full min-h-0 min-w-0 flex-col overflow-visible"
>
{showArtifactPanel && artifact ? (
<ArtifactSurface
artifact={artifact}
variant="panel"
onClose={onCloseArtifact}
onOpenFullscreen={() =>
openArtifact(artifact, { surface: "overlay" })
}
/>
) : null}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ChatRuntimeProvider>
);
});
@ -331,15 +494,17 @@ const LoraCompareContent = memo(function LoraCompareContent({
useEffect(() => {
let isActive = true;
listStoredChatThreads({ pairId }).then((threads) => {
if (!isActive) return;
setBaseThreadId(threads.find((t) => t.modelType === "base")?.id);
setLoraThreadId(threads.find((t) => t.modelType === "lora")?.id);
}).catch((error) => {
if (!isExpectedBackgroundChatStorageError(error)) {
throw error;
}
});
listStoredChatThreads({ pairId })
.then((threads) => {
if (!isActive) return;
setBaseThreadId(threads.find((t) => t.modelType === "base")?.id);
setLoraThreadId(threads.find((t) => t.modelType === "lora")?.id);
})
.catch((error) => {
if (!isExpectedBackgroundChatStorageError(error)) {
throw error;
}
});
return () => {
isActive = false;
};
@ -480,21 +645,25 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
useEffect(() => {
let isActive = true;
listStoredChatThreads({ pairId }).then((threads) => {
if (!isActive) return;
setModel1ThreadId(
threads.find((t) => t.modelType === "model1" || t.modelType === "base")
?.id,
);
setModel2ThreadId(
threads.find((t) => t.modelType === "model2" || t.modelType === "lora")
?.id,
);
}).catch((error) => {
if (!isExpectedBackgroundChatStorageError(error)) {
throw error;
}
});
listStoredChatThreads({ pairId })
.then((threads) => {
if (!isActive) return;
setModel1ThreadId(
threads.find(
(t) => t.modelType === "model1" || t.modelType === "base",
)?.id,
);
setModel2ThreadId(
threads.find(
(t) => t.modelType === "model2" || t.modelType === "lora",
)?.id,
);
})
.catch((error) => {
if (!isExpectedBackgroundChatStorageError(error)) {
throw error;
}
});
return () => {
isActive = false;
};
@ -632,7 +801,11 @@ export function ChatPage(): ReactElement {
const modelsError = useChatRuntimeStore((state) => state.modelsError);
const modelLoading = useChatRuntimeStore((state) => state.modelLoading);
const clearCheckpoint = useChatRuntimeStore((state) => state.clearCheckpoint);
const resetArtifacts = useChatArtifactsStore((state) => state.resetArtifacts);
const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId);
const persistedActiveThreadId = isAssistantLocalThreadId(activeThreadId)
? null
: activeThreadId;
const modelOperationInProgress = useChatRuntimeStore(
(state) => state.modelLoading,
);
@ -647,9 +820,9 @@ export function ChatPage(): ReactElement {
} = useChatModelRuntime();
const prevConnectionsEnabledRef = useRef(connectionsEnabled);
useEffect(() => {
const turnedOff =
prevConnectionsEnabledRef.current && !connectionsEnabled;
const turnedOff = prevConnectionsEnabledRef.current && !connectionsEnabled;
if (!connectionsEnabled && isExternalModelId(inferenceParams.checkpoint)) {
resetArtifacts();
clearCheckpoint();
if (turnedOff) {
toast.info("Connections disabled", {
@ -662,6 +835,7 @@ export function ChatPage(): ReactElement {
clearCheckpoint,
connectionsEnabled,
inferenceParams.checkpoint,
resetArtifacts,
]);
const pendingNativeModelIntent = useNativeIntentStore(
(state) => state.pendingModelIntent,
@ -681,17 +855,19 @@ export function ChatPage(): ReactElement {
const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled);
const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle);
const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort);
const supportsReasoningOff = useChatRuntimeStore((s) => s.supportsReasoningOff);
const supportsReasoningOff = useChatRuntimeStore(
(s) => s.supportsReasoningOff,
);
const activeExternalProvider = useMemo(() => {
const selection = parseExternalModelId(inferenceParams.checkpoint);
if (!selection) return null;
return (
externalProvidersForChat.find(
(p) => p.id === selection.providerId,
) ?? null
externalProvidersForChat.find((p) => p.id === selection.providerId) ??
null
);
}, [externalProvidersForChat, inferenceParams.checkpoint]);
const activeExternalProviderType = activeExternalProvider?.providerType ?? null;
const activeExternalProviderType =
activeExternalProvider?.providerType ?? null;
const activeProviderCapabilities = useMemo(() => {
const selection = parseExternalModelId(inferenceParams.checkpoint);
if (!selection) return null;
@ -807,7 +983,9 @@ export function ChatPage(): ReactElement {
(provider?.providerType === "anthropic" ||
provider?.providerType === "openai");
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(
CHAT_IMAGE_TOOLS_ENABLED_KEY,
);
@ -876,14 +1054,43 @@ export function ChatPage(): ReactElement {
if (search.thread) {
return { mode: "single", threadId: search.thread };
}
if (activeThreadId && !activeThreadId.startsWith("__LOCALID_")) {
return { mode: "single", threadId: activeThreadId };
if (persistedActiveThreadId) {
return { mode: "single", threadId: persistedActiveThreadId };
}
if (search.new) {
return { mode: "single", newThreadNonce: search.new };
}
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 loadNativeModelIntent = useCallback(
@ -1018,11 +1225,12 @@ export function ChatPage(): ReactElement {
selectedExternal?.modelId,
selectedProvider?.baseUrl,
);
const supportsBuiltinCodeExecution = providerSupportsBuiltinCodeExecution(
selectedProvider?.providerType,
selectedExternal?.modelId,
selectedProvider?.baseUrl,
);
const supportsBuiltinCodeExecution =
providerSupportsBuiltinCodeExecution(
selectedProvider?.providerType,
selectedExternal?.modelId,
selectedProvider?.baseUrl,
);
const supportsBuiltinImageGeneration =
providerSupportsBuiltinImageGeneration(
selectedProvider?.providerType,
@ -1153,8 +1361,12 @@ export function ChatPage(): ReactElement {
],
);
const handleEject = useCallback(() => {
void ejectModel();
}, [ejectModel]);
void (async () => {
if (await ejectModel()) {
resetArtifacts();
}
})();
}, [ejectModel, resetArtifacts]);
const openModelSelector = useCallback(() => {
setModelSelectorLocked(true);
@ -1438,6 +1650,7 @@ export function ChatPage(): ReactElement {
const tourSteps = useMemo(
() =>
// eslint-disable-next-line react-hooks/refs -- buildChatTourSteps stores callbacks without invoking them during render.
buildChatTourSteps({
canCompare,
openModelSelector,
@ -1475,6 +1688,11 @@ export function ChatPage(): ReactElement {
return () => window.clearTimeout(timeoutId);
}, [modelSelectorLocked, tour.open]);
const showArtifactOverlay = Boolean(
selectedArtifact &&
(view.mode === "compare" || artifactSurface === "overlay"),
);
return (
<div className="flex min-h-0 min-w-0 flex-1 basis-0 bg-background overflow-hidden">
<GuidedTour {...tour.tourProps} />
@ -1593,9 +1811,12 @@ export function ChatPage(): ReactElement {
{view.mode === "single" ? (
<SingleContent
key={view.threadId ?? "single"}
key={view.threadId ?? view.newThreadNonce ?? "single"}
threadId={view.threadId}
newThreadNonce={view.newThreadNonce}
artifact={selectedArtifact}
artifactSurface={artifactSurface}
onCloseArtifact={closeArtifactSurface}
/>
) : (
<CompareContent
@ -1608,6 +1829,14 @@ export function ChatPage(): ReactElement {
deleteDisabled={modelOperationInProgress}
/>
)}
{showArtifactOverlay && selectedArtifact ? (
<ArtifactSurface
artifact={selectedArtifact}
variant="overlay"
onClose={closeArtifactSurface}
/>
) : null}
</div>
<ChatSettingsPanel

View file

@ -83,7 +83,6 @@ import {
toPresetParams,
} from "./presets/preset-policy";
import {
EXTERNAL_MAX_OUTPUT_TOKENS,
type ProviderCapabilities,
getExternalMaxOutputTokens,
getExternalMinOutputTokens,
@ -438,16 +437,6 @@ export function ChatSettingsPanel({
(s) => s.modelRequiresTrustRemoteCode,
);
const currentCheckpoint = params.checkpoint;
const currentModelIsMultimodal = useChatRuntimeStore((s) => {
if (s.loadedIsMultimodal) return true;
const m = s.models.find((m) => m.id === currentCheckpoint);
return (
Boolean(m?.isVision) ||
Boolean(m?.isAudio) ||
Boolean(m?.hasAudioInput) ||
m?.audioType === "audio_vlm"
);
});
const ggufContextLength = useChatRuntimeStore((s) => s.ggufContextLength);
const ggufMaxContextLength = useChatRuntimeStore(
(s) => s.ggufMaxContextLength,

View file

@ -1118,15 +1118,15 @@ export function useChatModelRuntime() {
],
);
const ejectModel = useCallback(async () => {
const ejectModel = useCallback(async (): Promise<boolean> => {
if (!params.checkpoint) {
return;
return false;
}
setModelsError(null);
if (isExternalModelId(params.checkpoint)) {
clearCheckpoint();
await refresh();
return;
return true;
}
try {
async function performUnload(): Promise<void> {
@ -1135,17 +1135,21 @@ export function useChatModelRuntime() {
await refresh();
}
await toast.promise(performUnload(), {
const unloadPromise = performUnload();
toast.promise(unloadPromise, {
loading: "Unloading model",
success: { message: "Model unloaded", duration: 1200 },
error: (err) =>
err instanceof Error ? err.message : "Failed to unload model",
description: "Releases VRAM and resets inference state.",
});
await unloadPromise;
return true;
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to unload model";
setModelsError(message);
return false;
}
}, [clearCheckpoint, params.checkpoint, refresh, setModelsError]);

View file

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

View file

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

View file

@ -24,6 +24,7 @@ import { useAui } from "@assistant-ui/react";
import {
ArrowUpIcon,
DownloadIcon,
FileTextIcon,
GlobeIcon,
HeadphonesIcon,
LightbulbIcon,
@ -37,7 +38,10 @@ import { Image03Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { toast } from "@/lib/toast";
import { loadModel, validateModel } from "./api/chat-api";
import { parseExternalModelId, providerTypeSupportsVision } from "./external-providers";
import {
parseExternalModelId,
providerTypeSupportsVision,
} from "./external-providers";
import { useExternalProvidersStore } from "./stores/external-providers-store";
import {
type ReasoningEffort,
@ -101,7 +105,10 @@ function fileToBase64DataURL(file: File): Promise<string> {
});
}
function formatReasoningEffortLabel(level: ReasoningEffort, modelId?: string): string {
function formatReasoningEffortLabel(
level: ReasoningEffort,
modelId?: string,
): string {
if (level === "max") return "Max";
if (level === "xhigh") {
const normalized = modelId?.trim().toLowerCase() ?? "";
@ -137,7 +144,12 @@ function useDictation(
const start = useCallback(() => {
const SpeechRecognitionAPI =
typeof window !== "undefined" &&
(window.SpeechRecognition ?? (window as unknown as { webkitSpeechRecognition?: typeof SpeechRecognition }).webkitSpeechRecognition);
(window.SpeechRecognition ??
(
window as unknown as {
webkitSpeechRecognition?: typeof SpeechRecognition;
}
).webkitSpeechRecognition);
if (!SpeechRecognitionAPI) {
return;
}
@ -183,7 +195,11 @@ function useDictation(
const supported =
typeof window !== "undefined" &&
!!(window.SpeechRecognition ?? (window as unknown as { webkitSpeechRecognition?: unknown }).webkitSpeechRecognition);
!!(
window.SpeechRecognition ??
(window as unknown as { webkitSpeechRecognition?: unknown })
.webkitSpeechRecognition
);
return { isDictating, start, stop, supported };
}
@ -222,9 +238,18 @@ export function RegisterCompareHandle({
currentHandles[name] = {
// fixes occasional reorder on reload.
append: (content) =>
aui.thread().append({ role: "user", content, createdAt: new Date() } as never),
aui
.thread()
.append({ role: "user", content, createdAt: new Date() } as never),
appendMessage: (content) =>
aui.thread().append({ role: "user", content, createdAt: new Date(), startRun: false } as never),
aui
.thread()
.append({
role: "user",
content,
createdAt: new Date(),
startRun: false,
} as never),
startRun: () => {
const msgs = aui.thread().getState().messages;
const lastId = msgs.length > 0 ? msgs[msgs.length - 1].id : null;
@ -268,7 +293,8 @@ function PendingImageThumb({
setSrc(url);
return () => URL.revokeObjectURL(url);
}, [file]);
if (!src) return <div className="size-14 animate-pulse rounded-[14px] bg-muted" />;
if (!src)
return <div className="size-14 animate-pulse rounded-[14px] bg-muted" />;
return (
<div className="relative size-14 shrink-0 overflow-hidden rounded-[14px] border border-foreground/20 bg-muted">
<img src={src} alt={file.name} className="h-full w-full object-cover" />
@ -303,7 +329,10 @@ export function SharedComposer({
const [running, setRunning] = useState(false);
const [comparing, setComparing] = useState(false);
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
const [pendingAudio, setPendingAudio] = useState<{ name: string; base64: string } | null>(null);
const [pendingAudio, setPendingAudio] = useState<{
name: string;
base64: string;
} | null>(null);
const [dragging, setDragging] = useState(false);
const [isComposing, setIsComposing] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -332,10 +361,16 @@ export function SharedComposer({
const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled);
const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle);
const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort);
const supportsReasoningOff = useChatRuntimeStore((s) => s.supportsReasoningOff);
const reasoningEffortLevels = useChatRuntimeStore((s) => s.reasoningEffortLevels);
const supportsReasoningOff = useChatRuntimeStore(
(s) => s.supportsReasoningOff,
);
const reasoningEffortLevels = useChatRuntimeStore(
(s) => s.reasoningEffortLevels,
);
const setReasoningEffort = useChatRuntimeStore((s) => s.setReasoningEffort);
const supportsPreserveThinking = useChatRuntimeStore((s) => s.supportsPreserveThinking);
const supportsPreserveThinking = useChatRuntimeStore(
(s) => s.supportsPreserveThinking,
);
const preserveThinking = useChatRuntimeStore((s) => s.preserveThinking);
const setPreserveThinking = useChatRuntimeStore((s) => s.setPreserveThinking);
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
@ -350,6 +385,8 @@ export function SharedComposer({
const setImageToolsEnabled = useChatRuntimeStore(
(s) => s.setImageToolsEnabled,
);
const artifactsEnabled = useChatRuntimeStore((s) => s.artifactsEnabled);
const setArtifactsEnabled = useChatRuntimeStore((s) => s.setArtifactsEnabled);
const webFetchToolsEnabled = useChatRuntimeStore(
(s) => s.webFetchToolsEnabled,
);
@ -481,6 +518,7 @@ export function SharedComposer({
// Images pill is only ever lit on OpenAI cloud's Responses-API models
// and Gemini Nano Banana family. No local tool runtime fallback.
const showImagePill = supportsBuiltinImageGeneration;
const artifactDisabled = !modelLoaded;
// Fetch pill: Anthropic-only (web_fetch_20250910 / web_fetch_20260209).
const webFetchDisabled = !modelLoaded || !supportsBuiltinWebFetch;
const showWebFetchPill = supportsBuiltinWebFetch;
@ -488,12 +526,17 @@ export function SharedComposer({
// reference `toolsDisabled` (rare; both pills used it before).
const toolsDisabled = codeDisabled;
const setPendingAudioStore = useChatRuntimeStore((s) => s.setPendingAudio);
const clearPendingAudioStore = useChatRuntimeStore((s) => s.clearPendingAudio);
const { isDictating, start: startDictation, stop: stopDictation, supported: dictationSupported } = useDictation(
setText,
const clearPendingAudioStore = useChatRuntimeStore(
(s) => s.clearPendingAudio,
);
const {
isDictating,
start: startDictation,
stop: stopDictation,
supported: dictationSupported,
} = useDictation(setText);
useEffect(() => {
const id = setInterval(() => {
const handles = handlesRef.current;
@ -510,43 +553,48 @@ export function SharedComposer({
ta.style.height = "auto";
const styles = window.getComputedStyle(ta);
const lineHeight = parseFloat(styles.lineHeight) || 20;
const paddingY = parseFloat(styles.paddingTop) + parseFloat(styles.paddingBottom);
const borderY = parseFloat(styles.borderTopWidth) + parseFloat(styles.borderBottomWidth);
const paddingY =
parseFloat(styles.paddingTop) + parseFloat(styles.paddingBottom);
const borderY =
parseFloat(styles.borderTopWidth) + parseFloat(styles.borderBottomWidth);
const maxHeight = lineHeight * 6 + paddingY + borderY;
const next = Math.min(ta.scrollHeight, maxHeight);
ta.style.height = `${next}px`;
ta.style.overflowY = ta.scrollHeight > maxHeight ? "auto" : "hidden";
}, [text]);
const addFiles = useCallback((files: FileList | null) => {
if (!files?.length) return;
const next: PendingImage[] = [];
let droppedImageForUnavailable = false;
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!file) continue;
// Handle audio files
if (file.type.match(/^audio\//i) && file.size <= MAX_AUDIO_SIZE) {
fileToBase64(file).then((base64) => {
setPendingAudio({ name: file.name, base64 });
setPendingAudioStore(base64, file.name);
});
continue;
const addFiles = useCallback(
(files: FileList | null) => {
if (!files?.length) return;
const next: PendingImage[] = [];
let droppedImageForUnavailable = false;
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!file) continue;
// Handle audio files
if (file.type.match(/^audio\//i) && file.size <= MAX_AUDIO_SIZE) {
fileToBase64(file).then((base64) => {
setPendingAudio({ name: file.name, base64 });
setPendingAudioStore(base64, file.name);
});
continue;
}
// Handle image files
if (!file.type.match(/^image\/(jpeg|png|webp|gif)$/i)) continue;
if (file.size > MAX_IMAGE_SIZE) continue;
if (attachUnavailableReason) {
droppedImageForUnavailable = true;
continue;
}
next.push({ id: crypto.randomUUID(), file });
}
// Handle image files
if (!file.type.match(/^image\/(jpeg|png|webp|gif)$/i)) continue;
if (file.size > MAX_IMAGE_SIZE) continue;
if (attachUnavailableReason) {
droppedImageForUnavailable = true;
continue;
if (droppedImageForUnavailable && attachUnavailableReason) {
toast.error(attachUnavailableReason);
}
next.push({ id: crypto.randomUUID(), file });
}
if (droppedImageForUnavailable && attachUnavailableReason) {
toast.error(attachUnavailableReason);
}
setPendingImages((prev) => [...prev, ...next]);
}, [setPendingAudioStore, attachUnavailableReason]);
setPendingImages((prev) => [...prev, ...next]);
},
[setPendingAudioStore, attachUnavailableReason],
);
const removePendingImage = useCallback((id: string) => {
setPendingImages((prev) => prev.filter((p) => p.id !== id));
@ -604,12 +652,17 @@ export function SharedComposer({
// LoraCompare and single-pane chats are unaffected.
if (hasCompareHandles && !isGeneralizedCompare) {
toast.error("Pick a model in each pane to compare", {
description: "Use the model dropdown above each pane, then send your prompt.",
description:
"Use the model dropdown above each pane, then send your prompt.",
});
return;
}
if (pendingImages.length > 0 && !isGeneralizedCompare && imageUnavailableReason) {
if (
pendingImages.length > 0 &&
!isGeneralizedCompare &&
imageUnavailableReason
) {
// Single mode: the loaded model's runtime capability is known
// here. Compare mode defers — each ensureModelLoaded below sets
// loadedIsMultimodal for its side, and the chat-adapter's
@ -647,8 +700,9 @@ export function SharedComposer({
const maxSeqLength = store.params.maxSeqLength;
const trustRemoteCode = store.params.trustRemoteCode ?? false;
const chatTemplateOverride = store.chatTemplateOverride;
const effectiveChatTemplateOverride =
chatTemplateOverride?.trim() ? chatTemplateOverride : null;
const effectiveChatTemplateOverride = chatTemplateOverride?.trim()
? chatTemplateOverride
: null;
function modelDisplayName(id: string): string {
const parts = id.split("/");
@ -656,11 +710,14 @@ export function SharedComposer({
}
// Helper: load a model and update store checkpoint
async function ensureModelLoaded(sel: CompareModelSelection): Promise<string> {
async function ensureModelLoaded(
sel: CompareModelSelection,
): Promise<string> {
const currentStore = useChatRuntimeStore.getState();
const isAlreadyActive =
currentStore.params.checkpoint === sel.id &&
(currentStore.activeGgufVariant ?? null) === (sel.ggufVariant ?? null);
(currentStore.activeGgufVariant ?? null) ===
(sel.ggufVariant ?? null);
if (!isAlreadyActive) {
const validation = await validateModel({
model_path: sel.id,
@ -750,9 +807,17 @@ export function SharedComposer({
try {
// Side 1: load → generate → wait
if (handle1 && model1?.id) {
toast("Loading Model 1…", { id: toastId, description: name1, duration: Infinity });
toast("Loading Model 1…", {
id: toastId,
description: name1,
duration: Infinity,
});
const status1 = await ensureModelLoaded(model1);
toast("Generating with Model 1…", { id: toastId, description: `${name1} (${status1})`, duration: Infinity });
toast("Generating with Model 1…", {
id: toastId,
description: `${name1} (${status1})`,
duration: Infinity,
});
const done = handle1.waitForRunEnd();
handle1.startRun();
await done;
@ -760,13 +825,22 @@ export function SharedComposer({
// Side 2: load → generate → wait
if (handle2 && model2?.id) {
const needsLoad = model2.id.toLowerCase() !== (model1?.id || "").toLowerCase()
|| (model2.ggufVariant ?? "") !== (model1?.ggufVariant ?? "");
const needsLoad =
model2.id.toLowerCase() !== (model1?.id || "").toLowerCase() ||
(model2.ggufVariant ?? "") !== (model1?.ggufVariant ?? "");
if (needsLoad) {
toast("Loading Model 2…", { id: toastId, description: name2, duration: Infinity });
toast("Loading Model 2…", {
id: toastId,
description: name2,
duration: Infinity,
});
}
const status2 = await ensureModelLoaded(model2);
toast("Generating with Model 2…", { id: toastId, description: `${name2} (${status2})`, duration: Infinity });
toast("Generating with Model 2…", {
id: toastId,
description: `${name2} (${status2})`,
duration: Infinity,
});
const done = handle2.waitForRunEnd();
handle2.startRun();
await done;
@ -820,7 +894,12 @@ export function SharedComposer({
}
}
const canSend = (text.trim().length > 0 || pendingImages.length > 0 || pendingAudio !== null) && !busy && !isComposing;
const canSend =
(text.trim().length > 0 ||
pendingImages.length > 0 ||
pendingAudio !== null) &&
!busy &&
!isComposing;
return (
<div
@ -855,7 +934,10 @@ export function SharedComposer({
<span className="max-w-48 truncate">{pendingAudio.name}</span>
<button
type="button"
onClick={() => { setPendingAudio(null); clearPendingAudioStore(); }}
onClick={() => {
setPendingAudio(null);
clearPendingAudioStore();
}}
className="flex size-4 items-center justify-center rounded-full hover:bg-destructive hover:text-destructive-foreground"
aria-label="Remove audio"
>
@ -953,130 +1035,136 @@ export function SharedComposer({
)}
{showReasoningControl ? (
effectiveReasoningStyle === "reasoning_effort" ? (
<DropdownMenu>
<DropdownMenuTrigger asChild={true}>
<button
type="button"
disabled={reasoningDisabled}
className={cn(
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
reasoningDisabled
<DropdownMenu>
<DropdownMenuTrigger asChild={true}>
<button
type="button"
disabled={reasoningDisabled}
className={cn(
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
reasoningDisabled
? "cursor-not-allowed opacity-40"
: effectiveReasoningVisualEnabled
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
)}
aria-label={thinkEffortAriaLabel({
modelLoaded,
reasoningDisabled,
reasoningEffort,
})}
>
{effectiveReasoningVisualEnabled ? (
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3.5" />
)}
<span>
Think:{" "}
{effectiveReasoningVisualEnabled
? formatReasoningEffortLabel(
reasoningEffort,
externalSelection?.modelId,
)
: formatReasoningDisabledLabel(
effectiveSupportsReasoningOff,
isExternalOpenAIReasoning,
checkpoint,
)}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{effectiveSupportsReasoningOff && (
<DropdownMenuItem
onSelect={() => {
setReasoningEnabled(false);
applyQwenThinkingParams(false);
}}
>
{formatReasoningDisabledLabel(
effectiveSupportsReasoningOff,
isExternalOpenAIReasoning,
checkpoint,
)}
{!effectiveReasoningVisualEnabled ? " \u2713" : ""}
</DropdownMenuItem>
)}
{effectiveReasoningEffortLevels
.filter((level) => level !== "none")
.map((level) => (
<DropdownMenuItem
key={level}
onSelect={() => {
setReasoningEffort(level);
setReasoningEnabled(true);
applyQwenThinkingParams(true);
// Mutual exclusion: turning thinking on for a
// Kimi model forces the web_search builtin off.
if (isKimiExternal && toolsEnabled) {
setToolsEnabled(false, { persist: false });
}
}}
>
{formatReasoningEffortLabel(
level,
externalSelection?.modelId,
)}
{effectiveReasoningVisualEnabled &&
reasoningEffort === level
? " \u2713"
: ""}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
disabled={reasoningDisabled || reasoningLockedOn}
aria-disabled={reasoningDisabled || reasoningLockedOn}
title={
reasoningLockedOn
? "This model requires reasoning to stay on."
: undefined
}
onClick={() => {
if (reasoningLockedOn) return;
const next = !reasoningEnabled;
setReasoningEnabled(next);
applyQwenThinkingParams(next);
// Mutual exclusion: Kimi's $web_search builtin
// requires thinking off, so turning thinking on flips
// the Search pill off (and vice versa).
if (isKimiExternal && next && toolsEnabled) {
setToolsEnabled(false, { persist: false });
}
}}
className={cn(
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
reasoningLockedOn
? "cursor-not-allowed text-primary"
: reasoningDisabled
? "cursor-not-allowed opacity-40"
: effectiveReasoningVisualEnabled
: effectiveReasoningEnabled
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
)}
aria-label={thinkEffortAriaLabel({
modelLoaded,
reasoningDisabled,
reasoningEffort,
})}
>
{effectiveReasoningVisualEnabled ? (
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3.5" />
)}
<span>
Think:{" "}
{effectiveReasoningVisualEnabled
? formatReasoningEffortLabel(
reasoningEffort,
externalSelection?.modelId,
)
: formatReasoningDisabledLabel(
effectiveSupportsReasoningOff,
isExternalOpenAIReasoning,
checkpoint,
)}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{effectiveSupportsReasoningOff && (
<DropdownMenuItem
onSelect={() => {
setReasoningEnabled(false);
applyQwenThinkingParams(false);
}}
>
{formatReasoningDisabledLabel(
effectiveSupportsReasoningOff,
isExternalOpenAIReasoning,
checkpoint,
)}
{!effectiveReasoningVisualEnabled ? " \u2713" : ""}
</DropdownMenuItem>
)}
{effectiveReasoningEffortLevels
.filter((level) => level !== "none")
.map((level) => (
<DropdownMenuItem
key={level}
onSelect={() => {
setReasoningEffort(level);
setReasoningEnabled(true);
applyQwenThinkingParams(true);
// Mutual exclusion: turning thinking on for a
// Kimi model forces the web_search builtin off.
if (isKimiExternal && toolsEnabled) {
setToolsEnabled(false, { persist: false });
}
}}
>
{formatReasoningEffortLabel(level, externalSelection?.modelId)}
{effectiveReasoningVisualEnabled && reasoningEffort === level ? " \u2713" : ""}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
disabled={reasoningDisabled || reasoningLockedOn}
aria-disabled={reasoningDisabled || reasoningLockedOn}
title={
reasoningLockedOn
? "This model requires reasoning to stay on."
: undefined
}
onClick={() => {
if (reasoningLockedOn) return;
const next = !reasoningEnabled;
setReasoningEnabled(next);
applyQwenThinkingParams(next);
// Mutual exclusion: Kimi's $web_search builtin
// requires thinking off, so turning thinking on flips
// the Search pill off (and vice versa).
if (isKimiExternal && next && toolsEnabled) {
setToolsEnabled(false, { persist: false });
}
}}
className={cn(
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
reasoningLockedOn
? "cursor-not-allowed text-primary"
: reasoningDisabled
? "cursor-not-allowed opacity-40"
: effectiveReasoningEnabled
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
)}
aria-label={thinkToggleAriaLabel({
reasoningLockedOn,
modelLoaded,
reasoningDisabled,
effectiveReasoningEnabled,
})}
>
{reasoningLockedOn ||
(effectiveReasoningEnabled && !reasoningDisabled) ? (
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3.5" />
)}
<span>Think</span>
</button>
aria-label={thinkToggleAriaLabel({
reasoningLockedOn,
modelLoaded,
reasoningDisabled,
effectiveReasoningEnabled,
})}
>
{reasoningLockedOn ||
(effectiveReasoningEnabled && !reasoningDisabled) ? (
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3.5" />
)}
<span>Think</span>
</button>
)
) : null}
{supportsPreserveThinking && (
@ -1093,7 +1181,9 @@ export function SharedComposer({
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
)}
aria-label={
preserveThinking ? "Disable preserve think" : "Enable preserve think"
preserveThinking
? "Disable preserve think"
: "Enable preserve think"
}
>
{preserveThinking && modelLoaded ? (
@ -1122,7 +1212,9 @@ export function SharedComposer({
}}
className="composer-pill-btn"
data-active={toolsEnabled && !searchDisabled ? "true" : "false"}
aria-label={toolsEnabled ? "Disable web search" : "Enable web search"}
aria-label={
toolsEnabled ? "Disable web search" : "Enable web search"
}
>
<GlobeIcon className="size-3.5" />
<span>Search</span>
@ -1133,7 +1225,11 @@ export function SharedComposer({
onClick={() => setCodeToolsEnabled(!codeToolsEnabled)}
className="composer-pill-btn"
data-active={codeToolsEnabled && !codeDisabled ? "true" : "false"}
aria-label={codeToolsEnabled ? "Disable code execution" : "Enable code execution"}
aria-label={
codeToolsEnabled
? "Disable code execution"
: "Enable code execution"
}
>
<CodeToggleIcon className="size-3.5" />
<span>Code</span>
@ -1144,9 +1240,13 @@ export function SharedComposer({
disabled={imageDisabled}
onClick={() => setImageToolsEnabled(!imageToolsEnabled)}
className="composer-pill-btn"
data-active={imageToolsEnabled && !imageDisabled ? "true" : "false"}
data-active={
imageToolsEnabled && !imageDisabled ? "true" : "false"
}
aria-label={
imageToolsEnabled ? "Disable image generation" : "Enable image generation"
imageToolsEnabled
? "Disable image generation"
: "Enable image generation"
}
>
<HugeiconsIcon
@ -1157,6 +1257,21 @@ export function SharedComposer({
<span>Images</span>
</button>
)}
<button
type="button"
disabled={artifactDisabled}
onClick={() => setArtifactsEnabled(!artifactsEnabled)}
className="composer-pill-btn"
data-active={
artifactsEnabled && !artifactDisabled ? "true" : "false"
}
aria-label={
artifactsEnabled ? "Disable artifacts" : "Enable artifacts"
}
>
<FileTextIcon className="size-3.5" />
<span>Artifacts</span>
</button>
{showWebFetchPill && (
<button
type="button"

View file

@ -27,6 +27,11 @@ export const CHAT_REASONING_ENABLED_KEY = "unsloth_chat_reasoning_enabled";
export const CHAT_TOOLS_ENABLED_KEY = "unsloth_chat_tools_enabled";
export const CHAT_CODE_TOOLS_ENABLED_KEY = "unsloth_chat_code_tools_enabled";
export const CHAT_IMAGE_TOOLS_ENABLED_KEY = "unsloth_chat_image_tools_enabled";
export const CHAT_ARTIFACTS_ENABLED_KEY = "unsloth_chat_artifacts_enabled";
export const CHAT_COLLAPSE_HTML_ARTIFACTS_KEY =
"unsloth_chat_collapse_html_artifacts";
export const CHAT_ALLOW_ARTIFACT_NETWORK_ACCESS_KEY =
"unsloth_chat_allow_artifact_network_access";
export const CHAT_MCP_ENABLED_KEY = "unsloth_chat_mcp_enabled";
export const CHAT_WEB_FETCH_TOOLS_ENABLED_KEY =
"unsloth_chat_web_fetch_tools_enabled";
@ -300,6 +305,9 @@ type ChatRuntimeStore = {
toolsEnabled: boolean;
codeToolsEnabled: boolean;
imageToolsEnabled: boolean;
artifactsEnabled: boolean;
collapseHtmlArtifacts: boolean;
allowArtifactNetworkAccess: boolean;
mcpEnabledForChat: boolean;
/**
* Fetch pill state, independent of `toolsEnabled` (Search). Only
@ -368,6 +376,12 @@ type ChatRuntimeStore = {
setToolsEnabled: (enabled: boolean, options?: { persist?: boolean }) => void;
setCodeToolsEnabled: (enabled: boolean) => void;
setImageToolsEnabled: (enabled: boolean) => void;
setArtifactsEnabled: (
enabled: boolean,
options?: { persist?: boolean },
) => void;
setCollapseHtmlArtifacts: (enabled: boolean) => void;
setAllowArtifactNetworkAccess: (enabled: boolean) => void;
setMcpEnabledForChat: (enabled: boolean) => void;
setWebFetchToolsEnabled: (enabled: boolean) => void;
setToolStatus: (status: string | null) => void;
@ -400,6 +414,8 @@ type ScalarSettingKey =
| "autoTitle"
| "reasoningEffort"
| "preserveThinking"
| "collapseHtmlArtifacts"
| "allowArtifactNetworkAccess"
| "autoHealToolCalls"
| "maxToolCallsPerMessage"
| "toolCallTimeout";
@ -434,6 +450,8 @@ const SCALAR_SETTING_KEYS = [
"autoTitle",
"reasoningEffort",
"preserveThinking",
"collapseHtmlArtifacts",
"allowArtifactNetworkAccess",
"autoHealToolCalls",
"maxToolCallsPerMessage",
"toolCallTimeout",
@ -619,6 +637,12 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
toolsEnabled: loadBool(CHAT_TOOLS_ENABLED_KEY, false),
codeToolsEnabled: loadBool(CHAT_CODE_TOOLS_ENABLED_KEY, false),
imageToolsEnabled: loadBool(CHAT_IMAGE_TOOLS_ENABLED_KEY, false),
artifactsEnabled: loadBool(CHAT_ARTIFACTS_ENABLED_KEY, false),
collapseHtmlArtifacts: loadBool(CHAT_COLLAPSE_HTML_ARTIFACTS_KEY, false),
allowArtifactNetworkAccess: loadBool(
CHAT_ALLOW_ARTIFACT_NETWORK_ACCESS_KEY,
false,
),
mcpEnabledForChat: loadBool(CHAT_MCP_ENABLED_KEY, false),
webFetchToolsEnabled: loadBool(CHAT_WEB_FETCH_TOOLS_ENABLED_KEY, false),
toolStatus: null,
@ -835,6 +859,8 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
toolsEnabled: false,
codeToolsEnabled: false,
imageToolsEnabled: false,
artifactsEnabled: false,
mcpEnabledForChat: false,
webFetchToolsEnabled: false,
toolStatus: null,
kvCacheDtype: null,
@ -896,6 +922,36 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
saveBool(CHAT_IMAGE_TOOLS_ENABLED_KEY, imageToolsEnabled);
return { imageToolsEnabled };
}),
setArtifactsEnabled: (artifactsEnabled, options) =>
set(() => {
if (options?.persist !== false) {
saveBool(CHAT_ARTIFACTS_ENABLED_KEY, artifactsEnabled);
}
return { artifactsEnabled };
}),
setCollapseHtmlArtifacts: (collapseHtmlArtifacts) =>
set((state) => {
saveBool(CHAT_COLLAPSE_HTML_ARTIFACTS_KEY, collapseHtmlArtifacts);
setScalarSettingVersion(
"collapseHtmlArtifacts",
collapseHtmlArtifacts,
state.collapseHtmlArtifacts,
);
return { collapseHtmlArtifacts };
}),
setAllowArtifactNetworkAccess: (allowArtifactNetworkAccess) =>
set((state) => {
saveBool(
CHAT_ALLOW_ARTIFACT_NETWORK_ACCESS_KEY,
allowArtifactNetworkAccess,
);
setScalarSettingVersion(
"allowArtifactNetworkAccess",
allowArtifactNetworkAccess,
state.allowArtifactNetworkAccess,
);
return { allowArtifactNetworkAccess };
}),
setMcpEnabledForChat: (mcpEnabledForChat) =>
set(() => {
saveBool(CHAT_MCP_ENABLED_KEY, mcpEnabledForChat);

View file

@ -28,6 +28,9 @@ const CHAT_ACTIVE_PRESET_KEY = "unsloth_chat_active_preset";
const CHAT_ACTIVE_PRESET_SOURCE_KEY = "unsloth_chat_active_preset_source";
const REASONING_EFFORT_KEY = "unsloth_reasoning_effort";
const PRESERVE_THINKING_KEY = "unsloth_preserve_thinking";
const COLLAPSE_HTML_ARTIFACTS_KEY = "unsloth_chat_collapse_html_artifacts";
const ALLOW_ARTIFACT_NETWORK_ACCESS_KEY =
"unsloth_chat_allow_artifact_network_access";
const CHAT_PRESETS_KEY = "unsloth_chat_custom_presets";
const LEGACY_CHAT_SYSTEM_PROMPTS_KEY = "unsloth_chat_system_prompts";
const LEGACY_CHAT_SETTINGS_IMPORT_KEY =
@ -216,6 +219,10 @@ function sanitizeChatSettings(value: unknown): PersistedChatSettings {
const reasoningEffort = sanitizeReasoningEffort(value.reasoningEffort);
const autoTitle = sanitizeBool(value.autoTitle);
const preserveThinking = sanitizeBool(value.preserveThinking);
const collapseHtmlArtifacts = sanitizeBool(value.collapseHtmlArtifacts);
const allowArtifactNetworkAccess = sanitizeBool(
value.allowArtifactNetworkAccess,
);
const autoHealToolCalls = sanitizeBool(value.autoHealToolCalls);
const maxToolCallsPerMessage = sanitizeInt(value.maxToolCallsPerMessage, 1);
const toolCallTimeout = sanitizeInt(value.toolCallTimeout, 1);
@ -230,6 +237,12 @@ function sanitizeChatSettings(value: unknown): PersistedChatSettings {
if (reasoningEffort) settings.reasoningEffort = reasoningEffort;
if (preserveThinking !== undefined)
settings.preserveThinking = preserveThinking;
if (collapseHtmlArtifacts !== undefined) {
settings.collapseHtmlArtifacts = collapseHtmlArtifacts;
}
if (allowArtifactNetworkAccess !== undefined) {
settings.allowArtifactNetworkAccess = allowArtifactNetworkAccess;
}
if (autoHealToolCalls !== undefined) {
settings.autoHealToolCalls = autoHealToolCalls;
}
@ -290,6 +303,8 @@ export function isEmptyChatSettings(settings: PersistedChatSettings): boolean {
settings.autoTitle === undefined &&
settings.reasoningEffort === undefined &&
settings.preserveThinking === undefined &&
settings.collapseHtmlArtifacts === undefined &&
settings.allowArtifactNetworkAccess === undefined &&
settings.autoHealToolCalls === undefined &&
settings.maxToolCallsPerMessage === undefined &&
settings.toolCallTimeout === undefined
@ -318,6 +333,8 @@ export function loadLegacyChatSettings(): PersistedChatSettings {
);
const autoTitle = loadBool(AUTO_TITLE_KEY);
const preserveThinking = loadBool(PRESERVE_THINKING_KEY);
const collapseHtmlArtifacts = loadBool(COLLAPSE_HTML_ARTIFACTS_KEY);
const allowArtifactNetworkAccess = loadBool(ALLOW_ARTIFACT_NETWORK_ACCESS_KEY);
const autoHealToolCalls = loadBool(AUTO_HEAL_TOOL_CALLS_KEY);
const maxToolCallsPerMessage = loadInt(MAX_TOOL_CALLS_KEY, 1);
const toolCallTimeout = loadInt(TOOL_CALL_TIMEOUT_KEY, 1);
@ -336,6 +353,12 @@ export function loadLegacyChatSettings(): PersistedChatSettings {
if (reasoningEffort) settings.reasoningEffort = reasoningEffort;
if (preserveThinking !== undefined)
settings.preserveThinking = preserveThinking;
if (collapseHtmlArtifacts !== undefined) {
settings.collapseHtmlArtifacts = collapseHtmlArtifacts;
}
if (allowArtifactNetworkAccess !== undefined) {
settings.allowArtifactNetworkAccess = allowArtifactNetworkAccess;
}
if (autoHealToolCalls !== undefined) {
settings.autoHealToolCalls = 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";

View file

@ -2,6 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { toast } from "@/lib/toast";
import {
Dialog,
@ -15,6 +16,7 @@ import {
clearAllChats,
countAllChats,
downloadChatExport,
useChatRuntimeStore,
} from "@/features/chat";
import { useT } from "@/i18n";
import { Delete02Icon, Download02Icon } from "@hugeicons/core-free-icons";
@ -29,10 +31,26 @@ export function ChatTab() {
const [count, setCount] = useState<number | null>(null);
const [exporting, setExporting] = useState(false);
const [clearing, setClearing] = useState(false);
const collapseHtmlArtifacts = useChatRuntimeStore(
(state) => state.collapseHtmlArtifacts,
);
const setCollapseHtmlArtifacts = useChatRuntimeStore(
(state) => state.setCollapseHtmlArtifacts,
);
const allowArtifactNetworkAccess = useChatRuntimeStore(
(state) => state.allowArtifactNetworkAccess,
);
const setAllowArtifactNetworkAccess = useChatRuntimeStore(
(state) => state.setAllowArtifactNetworkAccess,
);
const hydratePersistedSettings = useChatRuntimeStore(
(state) => state.hydratePersistedSettings,
);
useEffect(() => {
void countAllChats().then(setCount);
}, []);
void hydratePersistedSettings();
}, [hydratePersistedSettings]);
const handleExport = async () => {
setExporting(true);
@ -111,6 +129,31 @@ export function ChatTab() {
</p>
</header>
<SettingsSection title={t("settings.chat.artifacts.title")}>
<SettingsRow
label={t("settings.chat.artifacts.collapseHtmlBlocks")}
description={t(
"settings.chat.artifacts.collapseHtmlBlocksDescription",
)}
>
<Switch
checked={collapseHtmlArtifacts}
onCheckedChange={setCollapseHtmlArtifacts}
/>
</SettingsRow>
<SettingsRow
label={t("settings.chat.artifacts.allowNetworkAccess")}
description={t(
"settings.chat.artifacts.allowNetworkAccessDescription",
)}
>
<Switch
checked={allowArtifactNetworkAccess}
onCheckedChange={setAllowArtifactNetworkAccess}
/>
</SettingsRow>
</SettingsSection>
<SettingsSection title={t("settings.chat.data")}>
<SettingsRow
label={t("settings.chat.exportHistory")}

View file

@ -165,6 +165,15 @@ export const en = {
chat: {
title: "Chat",
description: "Manage your chat history stored on this device.",
artifacts: {
title: "Artifacts",
collapseHtmlBlocks: "Collapse HTML blocks",
collapseHtmlBlocksDescription:
"Artifacts mode collapses full HTML fallback automatically. Turn this on to also collapse full fenced HTML documents when Artifacts is off.",
allowNetworkAccess: "Allow artifact network access",
allowNetworkAccessDescription:
"Let artifact previews load scripts, styles, fonts, media, fetch, and WebSocket resources from HTTP(S) CDNs. Keep off for fully offline previews.",
},
data: "Data",
exportHistory: "Export chat history",
exportHistoryDescription:

View file

@ -818,6 +818,149 @@
font-family: var(--font-sans);
}
.chat-artifact-split[data-artifact-layout-animating="true"] > [data-panel] {
transition:
flex-basis 260ms var(--ease-out-cubic),
flex-grow 260ms var(--ease-out-cubic),
flex-shrink 260ms var(--ease-out-cubic);
will-change: flex-basis, flex-grow;
}
.chat-artifact-pop-surface {
pointer-events: none;
opacity: 0;
transform: scale(0.965) translateY(8px);
transform-origin: center center;
transition:
opacity 180ms var(--ease-out-cubic),
transform 220ms var(--ease-out-cubic);
will-change: opacity, transform;
}
.chat-artifact-pop-surface[data-artifact-surface-visible="true"] {
pointer-events: auto;
opacity: 1;
transform: scale(1) translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.chat-artifact-pop-surface {
transition: none;
transform: none;
}
}
.artifact-card-shimmer {
background: linear-gradient(
105deg,
transparent 0%,
color-mix(in oklch, var(--muted-foreground) 4%, transparent) 40%,
color-mix(in oklch, var(--muted-foreground) 8%, transparent) 50%,
color-mix(in oklch, var(--muted-foreground) 4%, transparent) 60%,
transparent 100%
);
transform: translateX(-120%);
animation: artifact-card-shimmer 1.55s var(--ease-out-cubic) infinite;
}
.artifact-loading-line {
width: 48%;
background: color-mix(in oklch, var(--primary) 88%, transparent);
transform: translate3d(-125%, 0, 0) scaleX(0.78);
transform-origin: center center;
animation: artifact-loading-line 1.7s linear infinite;
}
.artifact-panel-shell::before,
.artifact-panel-shell::after {
content: "";
position: absolute;
z-index: 20;
pointer-events: none;
transition:
border-color 150ms var(--ease-out-cubic),
background-color 150ms var(--ease-out-cubic),
box-shadow 150ms var(--ease-out-cubic),
transform 150ms var(--ease-out-cubic);
}
.artifact-panel-shell::before {
inset: 0;
border: 2px solid transparent;
border-radius: inherit;
-webkit-mask: linear-gradient(90deg, #000 0 24px, transparent 24px);
mask: linear-gradient(90deg, #000 0 24px, transparent 24px);
}
.artifact-panel-shell::after {
top: 50%;
left: -2px;
height: 28px;
width: 4px;
border-radius: 999px;
background: transparent;
transform: translateY(-50%);
}
[data-slot="resizable-handle"]:hover
+ [data-slot="resizable-panel"]
.artifact-panel-shell::before,
[data-slot="resizable-handle"]:active
+ [data-slot="resizable-panel"]
.artifact-panel-shell::before,
[data-slot="resizable-handle"][data-resize-handle-state="hover"]
+ [data-slot="resizable-panel"]
.artifact-panel-shell::before,
[data-slot="resizable-handle"][data-resize-handle-state="drag"]
+ [data-slot="resizable-panel"]
.artifact-panel-shell::before {
border-color: color-mix(in oklch, var(--primary) 58%, var(--border));
}
[data-slot="resizable-handle"]:hover
+ [data-slot="resizable-panel"]
.artifact-panel-shell::after,
[data-slot="resizable-handle"]:active
+ [data-slot="resizable-panel"]
.artifact-panel-shell::after,
[data-slot="resizable-handle"][data-resize-handle-state="hover"]
+ [data-slot="resizable-panel"]
.artifact-panel-shell::after,
[data-slot="resizable-handle"][data-resize-handle-state="drag"]
+ [data-slot="resizable-panel"]
.artifact-panel-shell::after {
background: color-mix(in oklch, var(--primary) 58%, var(--border));
box-shadow: none;
transform: translateY(-50%) scaleY(1.06);
}
@keyframes artifact-card-shimmer {
to {
transform: translateX(120%);
}
}
@keyframes artifact-loading-line {
0% {
transform: translate3d(-125%, 0, 0) scaleX(0.78);
}
35% {
transform: translate3d(-18%, 0, 0) scaleX(0.96);
}
62% {
transform: translate3d(72%, 0, 0) scaleX(1.12);
}
82% {
transform: translate3d(132%, 0, 0) scaleX(1.26);
}
94% {
transform: translate3d(188%, 0, 0) scaleX(1.36);
}
100% {
transform: translate3d(220%, 0, 0) scaleX(1.42);
}
}
/* Fine-tuning Studio: equal default height, expandable when needed (md+) */
.min-h-studio-config-column {
@apply md:min-h-[470px];