Fix tool call parsing, add tool outputs panel and UI improvements (#4416)

* Add elapsed timer to tool status pill in Studio

Show a count-up seconds timer (0s, 1s, 2s, ...) next to the tool status
text in the composer area. Helps users gauge how long a tool call (web
search, code execution) has been running. Timer resets when a new tool
starts and disappears when all tools finish.

* Fix tool call parsing, add tool outputs panel and reasoning copy button

Backend:
- Rewrite tool call XML parser to use balanced-brace JSON extraction
  instead of greedy regex, fixing truncation on nested braces in
  code/JSON arguments
- Handle optional closing tags (</tool_call>, </function>, </parameter>)
  that models frequently omit
- Support bare <function=...> tags without <tool_call> wrapper
- Strip tool call markup from streamed content so raw XML never leaks
  into the chat UI
- Use a persistent ~/studio_sandbox/ working directory for tool
  execution so files persist across calls within a session
- Emit tool_start/tool_end SSE events so the frontend can display
  tool inputs and outputs

Frontend:
- Add collapsible "Tool Outputs" panel below assistant messages showing
  each tool call's input and output with copy buttons
- Add copy button to reasoning blocks
- Add elapsed timer to tool status pill
- Update project URLs in pyproject.toml (http -> https, add docs link)

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

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

* Add interactive HTML preview with fullscreen toggle for code blocks

HTML code fences now render an interactive sandboxed iframe preview
below the syntax-highlighted code, similar to how SVG fences show
an image preview. The iframe uses sandbox="allow-scripts" to allow
JavaScript execution while blocking access to the parent page.

Includes a fullscreen toggle (enlarge/minimize button) that expands
the preview into a viewport overlay, dismissible via button, Escape
key, or backdrop click. A streaming placeholder prevents partial
HTML from rendering mid-stream.

* Add tool call settings: auto-heal toggle, max iterations, timeout

Add three user-configurable tool call settings to the Studio Settings panel:

- Auto Heal Tool Calls: toggle to control fallback XML parsing of malformed
  tool calls from model output (default: on)
- Max Tool Calls Per Message: slider 0-40 + Max to cap tool call iterations
  per message (default: 10)
- Max Tool Call Duration: slider 1-30 minutes + Max to set per-tool-call
  execution timeout (default: 5 minutes)

All settings persist to localStorage and flow through the full stack:
frontend store -> API request -> Pydantic model -> route -> llama_cpp -> tools.

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

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

* Fix tool call timeout: respect no-limit and apply to web search

- Use a sentinel to distinguish timeout=None (no limit) from the default
  (300s). Previously None was silently replaced with _EXEC_TIMEOUT.
- Pass the configured timeout to DDGS() for web searches so the setting
  applies uniformly to all tool types.

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

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

* Add input validation bounds and per-thread sandbox isolation

- Add ge=0 constraint to max_tool_calls_per_message (rejects negative values)
- Add ge=1 constraint to tool_call_timeout (minimum 1 second)
- Thread session_id from frontend through backend to tool execution
- Scope sandbox directories per conversation: ~/studio_sandbox/{thread_id}/
- Backwards compatible: API callers without session_id use ~/studio_sandbox/

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

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

* Fix non-monotonic streaming and Python temp script path

- Split tool markup stripping into closed-only (mid-stream) and full
  (final flush) to prevent cumulative text from shrinking mid-stream
- Enforce monotonicity: only emit when cleaned text grows, so the
  proxy's delta logic (cumulative[len(prev_text):]) never breaks
- Place Python temp scripts in the sandbox workdir instead of /tmp so
  sys.path[0] points to the sandbox and cross-call imports work

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

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

* Sanitize session_id to prevent path traversal in sandbox

Strip path separators and parent-dir references from session_id before
using it as a directory name. Verify the resolved path stays under
~/studio_sandbox/ as a second guard.

* feat(chat): proper assistant-ui tool call UIs with sources

Replace custom metadata-based ToolOutputsGroup with native assistant-ui
tool-call content parts. Backend SSE tool_start/tool_end events now emit
proper { type: "tool-call" } parts from the adapter, enabling per-tool
UIs registered via tools.by_name in MessagePrimitive.Parts.

- Web search: Globe icon, Source badges with favicons, auto-collapse
  when LLM starts responding
- Python: Code icon, syntax-highlighted code via Streamdown/shiki,
  output block with copy
- Terminal: Terminal icon, command in trigger, output with copy
- ToolGroup wraps consecutive tool calls (skips for single calls)
- Sources component renders URL badges at end of message
- Flattened code block CSS (single border, no nested boxes)

* fix(inference): respect empty enabled_tools allowlist

`if payload.enabled_tools:` is falsy for [], falling through to
ALL_TOOLS. Use `is not None` so an explicit empty list disables
all tools as intended.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Shine1i <wasimysdev@gmail.com>
This commit is contained in:
Daniel Han 2026-03-18 08:28:02 -07:00 committed by GitHub
parent 11b5e7abf3
commit 9c95148045
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1587 additions and 124 deletions

View file

@ -1154,8 +1154,8 @@ rocm711-torch2100 = [
]
[project.urls]
homepage = "http://www.unsloth.ai"
documentation = "https://github.com/unslothai/unsloth"
homepage = "https://unsloth.ai"
documentation = "https://unsloth.ai/docs"
repository = "https://github.com/unslothai/unsloth"
[tool.ruff]

View file

@ -1173,50 +1173,113 @@ class LlamaCppBackend:
Handles formats like:
<tool_call>{"name":"web_search","arguments":{"query":"..."}}</tool_call>
<tool_call><function=web_search><parameter=query>...</parameter></function></tool_call>
Closing </tool_call> tag is optional (models sometimes omit it).
Closing tags (</tool_call>, </function>, </parameter>) are all optional
since models frequently omit them.
"""
import re
tool_calls = []
# Pattern 1: JSON inside <tool_call> tags (closing tag optional)
for match in re.finditer(
r"<tool_call>\s*(\{.*?\})\s*(?:</tool_call>)?", content, re.DOTALL
):
try:
obj = json.loads(match.group(1))
tc = {
"id": f"call_{len(tool_calls)}",
"type": "function",
"function": {
"name": obj.get("name", ""),
"arguments": obj.get("arguments", {}),
},
}
if isinstance(tc["function"]["arguments"], dict):
tc["function"]["arguments"] = json.dumps(
tc["function"]["arguments"]
)
tool_calls.append(tc)
except (json.JSONDecodeError, ValueError):
pass
# Pattern 1: JSON inside <tool_call> tags.
# Use balanced-brace extraction that skips braces inside JSON strings.
for m in re.finditer(r"<tool_call>\s*\{", content):
brace_start = m.end() - 1 # position of the opening {
depth, i = 0, brace_start
in_string = False
while i < len(content):
ch = content[i]
if in_string:
if ch == "\\" and i + 1 < len(content):
i += 2 # skip escaped character
continue
if ch == '"':
in_string = False
elif ch == '"':
in_string = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
break
i += 1
if depth == 0:
json_str = content[brace_start : i + 1]
try:
obj = json.loads(json_str)
tc = {
"id": f"call_{len(tool_calls)}",
"type": "function",
"function": {
"name": obj.get("name", ""),
"arguments": obj.get("arguments", {}),
},
}
if isinstance(tc["function"]["arguments"], dict):
tc["function"]["arguments"] = json.dumps(
tc["function"]["arguments"]
)
tool_calls.append(tc)
except (json.JSONDecodeError, ValueError):
pass
# Pattern 2: XML-style <function=name><parameter=key>value</parameter></function>
# Closing </tool_call> optional
# All closing tags optional -- models frequently omit </parameter>,
# </function>, and/or </tool_call>.
if not tool_calls:
for match in re.finditer(
r"<tool_call>\s*<function=(\w+)>(.*?)</function>\s*(?:</tool_call>)?",
content,
re.DOTALL,
):
func_name = match.group(1)
params_text = match.group(2)
# Step 1: Find all <function=name> positions and extract their bodies.
# Body boundary: use only </tool_call> or next <function= as hard
# boundaries. We avoid using </function> as a boundary because
# code parameter values can contain that literal string.
# After extracting, we trim a trailing </function> if present.
func_starts = list(re.finditer(r"<function=(\w+)>\s*", content))
for idx, fm in enumerate(func_starts):
func_name = fm.group(1)
body_start = fm.end()
# Hard boundaries: next <function= tag or </tool_call>
next_func = (
func_starts[idx + 1].start()
if idx + 1 < len(func_starts)
else len(content)
)
end_tag = re.search(r"</tool_call>", content[body_start:])
if end_tag:
body_end = body_start + end_tag.start()
else:
body_end = len(content)
body_end = min(body_end, next_func)
body = content[body_start:body_end]
# Trim trailing </function> if present (it's the real closing tag)
body = re.sub(r"\s*</function>\s*$", "", body)
# Step 2: Extract parameters from body.
# For single-parameter functions (the common case: code, command,
# query), use body end as the only boundary to avoid false matches
# on </parameter> inside code strings.
arguments = {}
for param_match in re.finditer(
r"<parameter=(\w+)>\s*(.*?)\s*</parameter>",
params_text,
re.DOTALL,
):
arguments[param_match.group(1)] = param_match.group(2)
param_starts = list(re.finditer(r"<parameter=(\w+)>\s*", body))
if len(param_starts) == 1:
# Single parameter: value is everything from after the tag
# to end of body, trimming any trailing </parameter>.
pm = param_starts[0]
val = body[pm.end() :]
val = re.sub(r"\s*</parameter>\s*$", "", val)
arguments[pm.group(1)] = val.strip()
else:
for pidx, pm in enumerate(param_starts):
param_name = pm.group(1)
val_start = pm.end()
# Value ends at next <parameter= or end of body
next_param = (
param_starts[pidx + 1].start()
if pidx + 1 < len(param_starts)
else len(body)
)
val = body[val_start:next_param]
# Trim trailing </parameter> if present
val = re.sub(r"\s*</parameter>\s*$", "", val)
arguments[param_name] = val.strip()
tc = {
"id": f"call_{len(tool_calls)}",
"type": "function",
@ -1531,7 +1594,10 @@ class LlamaCppBackend:
stop: Optional[list[str]] = None,
cancel_event: Optional[threading.Event] = None,
enable_thinking: Optional[bool] = None,
max_tool_iterations: int = 5,
max_tool_iterations: int = 10,
auto_heal_tool_calls: bool = True,
tool_call_timeout: int = 300,
session_id: Optional[str] = None,
) -> Generator[dict, None, None]:
"""
Agentic loop: let the model call tools, execute them, and continue.
@ -1596,16 +1662,45 @@ class LlamaCppBackend:
tool_calls = message.get("tool_calls")
# Fallback: detect tool calls embedded as XML/text in content
# Some models output <tool_call> XML instead of structured tool_calls
# Some models output <tool_call> XML instead of structured tool_calls,
# or bare <function=...> tags without <tool_call> wrapper.
content_text = message.get("content", "") or ""
if not tool_calls and "<tool_call>" in content_text:
if (
auto_heal_tool_calls
and not tool_calls
and ("<tool_call>" in content_text or "<function=" in content_text)
):
tool_calls = self._parse_tool_calls_from_text(content_text)
if tool_calls:
# Strip the tool call markup from content
# Strip the tool call markup from content.
# Use greedy match within <tool_call> blocks since they
# can contain arbitrary content including code.
import re
# Strip <tool_call>...</tool_call> blocks (greedy inside)
content_text = re.sub(
r"<tool_call>.*?(?:</tool_call>|$)",
r"<tool_call>.*?</tool_call>",
"",
content_text,
flags = re.DOTALL,
)
# Strip unterminated <tool_call>... to end
content_text = re.sub(
r"<tool_call>.*$",
"",
content_text,
flags = re.DOTALL,
)
# Strip bare <function=...>...</function> blocks
content_text = re.sub(
r"<function=\w+>.*?</function>",
"",
content_text,
flags = re.DOTALL,
)
# Strip unterminated bare <function=...> to end
content_text = re.sub(
r"<function=\w+>.*$",
"",
content_text,
flags = re.DOTALL,
@ -1632,7 +1727,10 @@ class LlamaCppBackend:
try:
arguments = json.loads(raw_args)
except (json.JSONDecodeError, ValueError):
arguments = {"query": raw_args}
if auto_heal_tool_calls:
arguments = {"query": raw_args}
else:
arguments = {"raw": raw_args}
else:
arguments = raw_args
@ -1659,10 +1757,33 @@ class LlamaCppBackend:
status_text = f"Calling: {tool_name}"
yield {"type": "status", "text": status_text}
# Emit tool_start so the frontend can record inputs
yield {
"type": "tool_start",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"arguments": arguments,
}
# Execute the tool
result = execute_tool(
tool_name, arguments, cancel_event = cancel_event
_effective_timeout = (
None if tool_call_timeout >= 9999 else tool_call_timeout
)
result = execute_tool(
tool_name,
arguments,
cancel_event = cancel_event,
timeout = _effective_timeout,
session_id = session_id,
)
# Emit tool_end so the frontend can record outputs
yield {
"type": "tool_end",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"result": result,
}
# Append tool result to conversation
tool_msg = {
@ -1714,7 +1835,30 @@ class LlamaCppBackend:
if stop:
stream_payload["stop"] = stop
import re as _re_final
# Closed blocks only -- safe to strip mid-stream without shrinking later.
_TOOL_CLOSED_PATTERNS = [
_re_final.compile(r"<tool_call>.*?</tool_call>", _re_final.DOTALL),
_re_final.compile(r"<function=\w+>.*?</function>", _re_final.DOTALL),
]
# Open-ended patterns strip from an opening tag to end-of-string.
# Only applied on the final flush to avoid non-monotonic shrinking.
_TOOL_ALL_PATTERNS = _TOOL_CLOSED_PATTERNS + [
_re_final.compile(r"<tool_call>.*$", _re_final.DOTALL),
_re_final.compile(r"<function=\w+>.*$", _re_final.DOTALL),
]
def _strip_tool_markup(text: str, *, final: bool = False) -> str:
if not auto_heal_tool_calls:
return text
patterns = _TOOL_ALL_PATTERNS if final else _TOOL_CLOSED_PATTERNS
for pat in patterns:
text = pat.sub("", text)
return text.strip() if final else text
cumulative = ""
_last_emitted = ""
in_thinking = False
has_content_tokens = False
reasoning_text = ""
@ -1746,7 +1890,12 @@ class LlamaCppBackend:
if in_thinking:
if has_content_tokens:
cumulative += "</think>"
yield {"type": "content", "text": cumulative}
yield {
"type": "content",
"text": _strip_tool_markup(
cumulative, final = True
),
}
else:
cumulative = reasoning_text
yield {"type": "content", "text": cumulative}
@ -1776,7 +1925,11 @@ class LlamaCppBackend:
cumulative += "</think>"
in_thinking = False
cumulative += token
yield {"type": "content", "text": cumulative}
cleaned = _strip_tool_markup(cumulative)
# Only emit when cleaned text grows (monotonic).
if len(cleaned) > len(_last_emitted):
_last_emitted = cleaned
yield {"type": "content", "text": cleaned}
except json.JSONDecodeError:
logger.debug(
f"Skipping malformed SSE line: {line[:100]}"

View file

@ -16,12 +16,42 @@ import sys
import tempfile
import threading
from loggers import get_logger
from unsloth_zoo.rl_environments import check_signal_escape_patterns
logger = get_logger(__name__)
_EXEC_TIMEOUT = 300 # 5 minutes
_MAX_OUTPUT_CHARS = 8000 # truncate long output
_BASH_BLOCKED_WORDS = {"rm", "sudo", "dd", "chmod", "mkfs", "shutdown", "reboot"}
# Per-session working directories so each chat thread gets its own sandbox.
# Falls back to a shared ~/studio_sandbox/ for API callers without a session_id.
_workdirs: dict[str, str] = {}
def _get_workdir(session_id: str | None = None) -> str:
"""Return (and lazily create) a persistent working directory for tool execution."""
global _workdirs
key = session_id or "_default"
if key not in _workdirs or not os.path.isdir(_workdirs[key]):
home = os.path.expanduser("~")
sandbox_root = os.path.join(home, "studio_sandbox")
if session_id:
# Sanitize: strip path separators and parent-dir references
safe_id = os.path.basename(session_id.replace("..", ""))
if not safe_id:
safe_id = "_invalid"
workdir = os.path.join(sandbox_root, safe_id)
# Verify resolved path stays under sandbox root
if not os.path.realpath(workdir).startswith(os.path.realpath(sandbox_root)):
workdir = os.path.join(sandbox_root, "_invalid")
else:
workdir = sandbox_root
os.makedirs(workdir, exist_ok = True)
_workdirs[key] = workdir
return _workdirs[key]
WEB_SEARCH_TOOL = {
"type": "function",
@ -80,25 +110,47 @@ TERMINAL_TOOL = {
ALL_TOOLS = [WEB_SEARCH_TOOL, PYTHON_TOOL, TERMINAL_TOOL]
def execute_tool(name: str, arguments: dict, cancel_event = None) -> str:
"""Execute a tool by name with the given arguments. Returns result as a string."""
_TIMEOUT_UNSET = object()
def execute_tool(
name: str,
arguments: dict,
cancel_event = None,
timeout: int | None = _TIMEOUT_UNSET,
session_id: str | None = None,
) -> str:
"""Execute a tool by name with the given arguments. Returns result as a string.
``timeout``: int sets per-call limit in seconds, ``None`` means no limit,
unset (default) uses ``_EXEC_TIMEOUT`` (300 s).
``session_id``: optional thread/session ID for per-conversation sandbox isolation.
"""
logger.info(
f"execute_tool: name={name}, session_id={session_id}, timeout={timeout}"
)
effective_timeout = _EXEC_TIMEOUT if timeout is _TIMEOUT_UNSET else timeout
if name == "web_search":
return _web_search(arguments.get("query", ""))
return _web_search(arguments.get("query", ""), timeout = effective_timeout)
if name == "python":
return _python_exec(arguments.get("code", ""), cancel_event)
return _python_exec(
arguments.get("code", ""), cancel_event, effective_timeout, session_id
)
if name == "terminal":
return _bash_exec(arguments.get("command", ""), cancel_event)
return _bash_exec(
arguments.get("command", ""), cancel_event, effective_timeout, session_id
)
return f"Unknown tool: {name}"
def _web_search(query: str, max_results: int = 5) -> str:
def _web_search(query: str, max_results: int = 5, timeout: int = _EXEC_TIMEOUT) -> str:
"""Search the web using DuckDuckGo and return formatted results."""
if not query.strip():
return "No query provided."
try:
from ddgs import DDGS
results = DDGS().text(query, max_results = max_results)
results = DDGS(timeout = timeout).text(query, max_results = max_results)
if not results:
return "No results found."
parts = []
@ -147,7 +199,12 @@ def _truncate(text: str, limit: int = _MAX_OUTPUT_CHARS) -> str:
return text
def _python_exec(code: str, cancel_event = None) -> str:
def _python_exec(
code: str,
cancel_event = None,
timeout: int = _EXEC_TIMEOUT,
session_id: str | None = None,
) -> str:
"""Execute Python code in a subprocess sandbox."""
if not code or not code.strip():
return "No code provided."
@ -158,8 +215,11 @@ def _python_exec(code: str, cancel_event = None) -> str:
return error
tmp_path = None
workdir = _get_workdir(session_id)
try:
fd, tmp_path = tempfile.mkstemp(suffix = ".py", prefix = "studio_exec_")
fd, tmp_path = tempfile.mkstemp(
suffix = ".py", prefix = "studio_exec_", dir = workdir
)
with os.fdopen(fd, "w") as f:
f.write(code)
@ -168,7 +228,7 @@ def _python_exec(code: str, cancel_event = None) -> str:
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
cwd = tempfile.gettempdir(),
cwd = workdir,
)
# Spawn cancel watcher if we have a cancel event
@ -179,11 +239,11 @@ def _python_exec(code: str, cancel_event = None) -> str:
watcher.start()
try:
output, _ = proc.communicate(timeout = _EXEC_TIMEOUT)
output, _ = proc.communicate(timeout = timeout)
except subprocess.TimeoutExpired:
proc.kill()
proc.communicate()
return _truncate("Execution timed out after 5 minutes.")
return _truncate(f"Execution timed out after {timeout} seconds.")
if cancel_event is not None and cancel_event.is_set():
return "Execution cancelled."
@ -203,7 +263,12 @@ def _python_exec(code: str, cancel_event = None) -> str:
pass
def _bash_exec(command: str, cancel_event = None) -> str:
def _bash_exec(
command: str,
cancel_event = None,
timeout: int = _EXEC_TIMEOUT,
session_id: str | None = None,
) -> str:
"""Execute a bash command in a subprocess sandbox."""
if not command or not command.strip():
return "No command provided."
@ -215,35 +280,35 @@ def _bash_exec(command: str, cancel_event = None) -> str:
return f"Blocked command(s) for safety: {', '.join(sorted(blocked))}"
try:
with tempfile.TemporaryDirectory() as tmpdir:
proc = subprocess.Popen(
["bash", "-c", command],
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
cwd = tmpdir,
workdir = _get_workdir(session_id)
proc = subprocess.Popen(
["bash", "-c", command],
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
cwd = workdir,
)
if cancel_event is not None:
watcher = threading.Thread(
target = _cancel_watcher, args = (proc, cancel_event), daemon = True
)
watcher.start()
if cancel_event is not None:
watcher = threading.Thread(
target = _cancel_watcher, args = (proc, cancel_event), daemon = True
)
watcher.start()
try:
output, _ = proc.communicate(timeout = timeout)
except subprocess.TimeoutExpired:
proc.kill()
proc.communicate()
return _truncate(f"Execution timed out after {timeout} seconds.")
try:
output, _ = proc.communicate(timeout = _EXEC_TIMEOUT)
except subprocess.TimeoutExpired:
proc.kill()
proc.communicate()
return _truncate("Execution timed out after 5 minutes.")
if cancel_event is not None and cancel_event.is_set():
return "Execution cancelled."
if cancel_event is not None and cancel_event.is_set():
return "Execution cancelled."
result = output or ""
if proc.returncode != 0:
result = f"Exit code {proc.returncode}:\n{result}"
return _truncate(result) if result.strip() else "(no output)"
result = output or ""
if proc.returncode != 0:
result = f"Exit code {proc.returncode}:\n{result}"
return _truncate(result) if result.strip() else "(no output)"
except Exception as e:
return f"Execution error: {e}"

View file

@ -318,6 +318,24 @@ class ChatCompletionRequest(BaseModel):
None,
description = "[x-unsloth] List of enabled tool names (e.g. ['web_search', 'python', 'terminal']). If None, all tools are enabled.",
)
auto_heal_tool_calls: Optional[bool] = Field(
True,
description = "[x-unsloth] Auto-detect and fix malformed tool calls from model output.",
)
max_tool_calls_per_message: Optional[int] = Field(
10,
ge = 0,
description = "[x-unsloth] Maximum number of tool call iterations per message (0 = disabled, 9999 = unlimited).",
)
tool_call_timeout: Optional[int] = Field(
300,
ge = 1,
description = "[x-unsloth] Timeout in seconds for each tool call execution (9999 = no limit).",
)
session_id: Optional[str] = Field(
None,
description = "[x-unsloth] Session/thread ID for scoping tool execution sandbox.",
)
# ── Streaming response chunks ────────────────────────────────────

View file

@ -1028,7 +1028,7 @@ async def openai_chat_completions(
if use_tools:
from core.inference.tools import ALL_TOOLS
if payload.enabled_tools:
if payload.enabled_tools is not None:
tools_to_use = [
t
for t in ALL_TOOLS
@ -1050,6 +1050,16 @@ async def openai_chat_completions(
presence_penalty = payload.presence_penalty,
cancel_event = cancel_event,
enable_thinking = payload.enable_thinking,
auto_heal_tool_calls = payload.auto_heal_tool_calls
if payload.auto_heal_tool_calls is not None
else True,
max_tool_iterations = payload.max_tool_calls_per_message
if payload.max_tool_calls_per_message is not None
else 10,
tool_call_timeout = payload.tool_call_timeout
if payload.tool_call_timeout is not None
else 300,
session_id = payload.session_id,
)
_tool_sentinel = object()
@ -1093,6 +1103,10 @@ async def openai_chat_completions(
yield f"data: {status_data}\n\n"
continue
if event["type"] in ("tool_start", "tool_end"):
yield f"data: {json.dumps(event)}\n\n"
continue
# "content" type -- cumulative text
cumulative = event.get("text", "")
new_text = cumulative[len(prev_text) :]

View file

@ -5,7 +5,7 @@
"": {
"name": "unsloth-theme",
"dependencies": {
"@assistant-ui/react": "^0.12.17",
"@assistant-ui/react": "^0.12.19",
"@assistant-ui/react-markdown": "^0.12.3",
"@assistant-ui/react-streamdown": "^0.1.2",
"@base-ui/react": "^1.2.0",
@ -43,7 +43,7 @@
"framer-motion": "^11.18.2",
"js-yaml": "^4.1.1",
"katex": "^0.16.28",
"lucide-react": "^0.575.0",
"lucide-react": "^0.577.0",
"mammoth": "^1.11.0",
"motion": "^12.34.0",
"next": "^16.1.6",
@ -88,17 +88,17 @@
"@antfu/ni": ["@antfu/ni@25.0.0", "", { "dependencies": { "ansis": "^4.0.0", "fzf": "^0.5.2", "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" }, "bin": { "na": "bin/na.mjs", "ni": "bin/ni.mjs", "nr": "bin/nr.mjs", "nci": "bin/nci.mjs", "nlx": "bin/nlx.mjs", "nun": "bin/nun.mjs", "nup": "bin/nup.mjs" } }, "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA=="],
"@assistant-ui/core": ["@assistant-ui/core@0.1.5", "", { "dependencies": { "assistant-stream": "^0.3.5", "nanoid": "^5.1.6" }, "peerDependencies": { "@assistant-ui/store": "^0.2.2", "@assistant-ui/tap": "^0.5.2", "@types/react": "*", "assistant-cloud": "^0.1.21", "react": "^18 || ^19", "zustand": "^5.0.11" }, "optionalPeers": ["@types/react", "assistant-cloud", "react", "zustand"] }, "sha512-kLqFbRULZvE+hIwxGz705BW3QYhfwiVaVWoolfTGYkg+4xwah1PGuH0zqjXP5AMADtz+L69Lp+LX0xU9MQZ0DA=="],
"@assistant-ui/core": ["@assistant-ui/core@0.1.7", "", { "dependencies": { "assistant-stream": "^0.3.6", "nanoid": "^5.1.6" }, "peerDependencies": { "@assistant-ui/store": "^0.2.3", "@assistant-ui/tap": "^0.5.3", "@types/react": "*", "assistant-cloud": "^0.1.22", "react": "^18 || ^19", "zustand": "^5.0.11" }, "optionalPeers": ["@types/react", "assistant-cloud", "react", "zustand"] }, "sha512-219T42ihVOicbJXZLWgD2CW5Bylg9Nk7geC331X4RfJxTDYlm2zIjViGlGaqfj6URXBp6kMulO2BTUrHGmAvdw=="],
"@assistant-ui/react": ["@assistant-ui/react@0.12.17", "", { "dependencies": { "@assistant-ui/core": "^0.1.5", "@assistant-ui/store": "^0.2.2", "@assistant-ui/tap": "^0.5.2", "@radix-ui/primitive": "^1.1.3", "@radix-ui/react-compose-refs": "^1.1.2", "@radix-ui/react-context": "^1.1.3", "@radix-ui/react-primitive": "^2.1.4", "@radix-ui/react-use-callback-ref": "^1.1.1", "@radix-ui/react-use-escape-keydown": "^1.1.1", "assistant-cloud": "^0.1.21", "assistant-stream": "^0.3.4", "nanoid": "^5.1.6", "radix-ui": "^1.4.3", "react-textarea-autosize": "^8.5.9", "zod": "^4.3.6", "zustand": "^5.0.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^18 || ^19", "react-dom": "^18 || ^19" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t4Z8LatD3LQrtURLaYPG47r4iG7UQgkdoi5YEv+EhzvYiG8I7kAyV4SbnFH6sXPrnleV4IpBHAd8Wc7ynkQtsw=="],
"@assistant-ui/react": ["@assistant-ui/react@0.12.19", "", { "dependencies": { "@assistant-ui/core": "^0.1.7", "@assistant-ui/store": "^0.2.3", "@assistant-ui/tap": "^0.5.3", "@radix-ui/primitive": "^1.1.3", "@radix-ui/react-compose-refs": "^1.1.2", "@radix-ui/react-context": "^1.1.3", "@radix-ui/react-primitive": "^2.1.4", "@radix-ui/react-use-callback-ref": "^1.1.1", "@radix-ui/react-use-escape-keydown": "^1.1.1", "assistant-cloud": "^0.1.22", "assistant-stream": "^0.3.6", "nanoid": "^5.1.6", "radix-ui": "^1.4.3", "react-textarea-autosize": "^8.5.9", "zod": "^4.3.6", "zustand": "^5.0.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^18 || ^19", "react-dom": "^18 || ^19" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-scAf0o8cwjuHT9Y44EFGXcE2y6BSmpeMvt0NxOn8+Y/HBlNttQMLNvrM0p2AjacXCUufagiafAnWybzBV3nKEQ=="],
"@assistant-ui/react-markdown": ["@assistant-ui/react-markdown@0.12.4", "", { "dependencies": { "@radix-ui/react-primitive": "^2.1.4", "@radix-ui/react-use-callback-ref": "^1.1.1", "classnames": "^2.5.1", "react-markdown": "^10.1.0" }, "peerDependencies": { "@assistant-ui/react": "^0.12.11", "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-6TD9guiuLJxJoOwSjNHUYAVma2ctDCG9uypUqKHE0OUhDwTDD3NsMvTnQ0n0Lh8nnCEwVglOwKKlSEYpV7SnWA=="],
"@assistant-ui/react-streamdown": ["@assistant-ui/react-streamdown@0.1.3", "", { "dependencies": { "rehype-harden": "^1.1.7", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "streamdown": "^2.1.0" }, "peerDependencies": { "@assistant-ui/react": "^0.12.11", "@streamdown/cjk": "^1.0.0", "@streamdown/code": "^1.0.0", "@streamdown/math": "^1.0.0", "@streamdown/mermaid": "^1.0.0", "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@streamdown/cjk", "@streamdown/code", "@streamdown/math", "@streamdown/mermaid", "@types/react"] }, "sha512-n1UCjXQ3svmDtJBMJj/vXqz/BqAQBuy7myrXeymz2tD9l+ENQgqu2JY5ir3J19juJTe5lsi/P3+tOJ2C1jc/nw=="],
"@assistant-ui/store": ["@assistant-ui/store@0.2.2", "", { "dependencies": { "use-effect-event": "^2.0.3" }, "peerDependencies": { "@assistant-ui/tap": "^0.5.2", "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-JzQseWFp3UmbByBSWQmiGi/bz5jbfru04hIgb2DJBpnnTyns8Zl+8wDPnwiYGF/6SA+IzTg5M0V1wf77rwU0dA=="],
"@assistant-ui/store": ["@assistant-ui/store@0.2.3", "", { "dependencies": { "use-effect-event": "^2.0.3" }, "peerDependencies": { "@assistant-ui/tap": "^0.5.3", "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-daStbgSQiX7+csqK6Cvo7A8p8UZkTCSMxBHxbhJvwrlVbp7BRJWTxq3U3rpTkSGIar23SXIyVRRfXU8VW7pswA=="],
"@assistant-ui/tap": ["@assistant-ui/tap@0.5.2", "", { "peerDependencies": { "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@types/react", "react"] }, "sha512-w6gXhr+mF6cPG6ZCnkqV4kkOHzR+Fb+52S4T34PnrH0cs8l2Gqlwo/kB9BcB9fGmjwL7izdwubQ7t2VBhWpz/Q=="],
"@assistant-ui/tap": ["@assistant-ui/tap@0.5.3", "", { "peerDependencies": { "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@types/react", "react"] }, "sha512-wy06ksqF2LfFxe4JXy31Ns89N/be1Dy3c+mG363cFHFp3CbLkRu8CrCN2SQSgCkXt628E+D8QyzqdBcl9kD4NQ=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
@ -846,7 +846,7 @@
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"assistant-cloud": ["assistant-cloud@0.1.21", "", { "dependencies": { "assistant-stream": "^0.3.4" } }, "sha512-KZ9ZsF1i1zMhozvD4m8TsmTdtufqULMaqgOoSLRyVtnhwvxkDufL87tSjv7epddZ4kbebe31biWSg7KIlgzvQA=="],
"assistant-cloud": ["assistant-cloud@0.1.22", "", { "dependencies": { "assistant-stream": "^0.3.6" } }, "sha512-AEE9shV+oFrGDv/MRTRERctNKpIYS0n34UpAQXXICiOkSWD6QZnS1ljLqruFko7fJoT5CIWq8dNeJWdzQLTBLg=="],
"assistant-stream": ["assistant-stream@0.3.3", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-Ne/uTseMIiZx740dTbr/SWxONM8nYj4Z5BRmUfqQN+TNgtOCgWOlC/oTUQ+A7LIUHtmGbcoyZwDf8yd2RASnDA=="],
@ -1450,7 +1450,7 @@
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.575.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg=="],
"lucide-react": ["lucide-react@0.577.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
@ -2082,9 +2082,9 @@
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@assistant-ui/core/assistant-stream": ["assistant-stream@0.3.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-OGxVClfpEOoSsJDraPoe+GYTwh9TJX1wxK3hT5Qs7gIOyD/MZbqwyWwabRO2KTnNU4w7usvmC/vneUzxSk4bBg=="],
"@assistant-ui/core/assistant-stream": ["assistant-stream@0.3.6", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-NdtSRrQfWCDA/aqQ1xhobf/xnhuMZkhFAw9xzAt5iAoL3ouxVXOowSRN87OL4MYBQEvqtcjw9/CE6YcsXoBtuw=="],
"@assistant-ui/react/assistant-stream": ["assistant-stream@0.3.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-OGxVClfpEOoSsJDraPoe+GYTwh9TJX1wxK3hT5Qs7gIOyD/MZbqwyWwabRO2KTnNU4w7usvmC/vneUzxSk4bBg=="],
"@assistant-ui/react/assistant-stream": ["assistant-stream@0.3.6", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-NdtSRrQfWCDA/aqQ1xhobf/xnhuMZkhFAw9xzAt5iAoL3ouxVXOowSRN87OL4MYBQEvqtcjw9/CE6YcsXoBtuw=="],
"@assistant-ui/react/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
@ -2282,7 +2282,7 @@
"ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"assistant-cloud/assistant-stream": ["assistant-stream@0.3.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-OGxVClfpEOoSsJDraPoe+GYTwh9TJX1wxK3hT5Qs7gIOyD/MZbqwyWwabRO2KTnNU4w7usvmC/vneUzxSk4bBg=="],
"assistant-cloud/assistant-stream": ["assistant-stream@0.3.6", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-NdtSRrQfWCDA/aqQ1xhobf/xnhuMZkhFAw9xzAt5iAoL3ouxVXOowSRN87OL4MYBQEvqtcjw9/CE6YcsXoBtuw=="],
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],

View file

@ -13,7 +13,7 @@
"biome:fix": "biome check . --write"
},
"dependencies": {
"@assistant-ui/react": "^0.12.17",
"@assistant-ui/react": "^0.12.19",
"@assistant-ui/react-markdown": "^0.12.3",
"@assistant-ui/react-streamdown": "^0.1.2",
"@base-ui/react": "^1.2.0",
@ -51,7 +51,7 @@
"framer-motion": "^11.18.2",
"js-yaml": "^4.1.1",
"katex": "^0.16.28",
"lucide-react": "^0.575.0",
"lucide-react": "^0.577.0",
"mammoth": "^1.11.0",
"motion": "^12.34.0",
"next": "^16.1.6",

View file

@ -0,0 +1,67 @@
"use client";
import type { ComponentProps } from "react";
import { Slot } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center gap-1 rounded-md font-medium text-xs transition-colors [&_svg]:size-3 [&_svg]:shrink-0",
{
variants: {
variant: {
outline:
"border border-input bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
muted:
"bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground",
ghost:
"bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground",
info: "bg-blue-100 text-blue-700 hover:bg-blue-100/80 dark:bg-blue-900/50 dark:text-blue-300",
warning:
"bg-amber-100 text-amber-700 hover:bg-amber-100/80 dark:bg-amber-900/50 dark:text-amber-300",
success:
"bg-emerald-100 text-emerald-700 hover:bg-emerald-100/80 dark:bg-emerald-900/50 dark:text-emerald-300",
destructive:
"bg-red-100 text-red-700 hover:bg-red-100/80 dark:bg-red-900/50 dark:text-red-300",
},
size: {
sm: "px-1.5 py-0.5",
default: "px-2 py-1",
lg: "px-2.5 py-1.5 text-sm",
},
},
defaultVariants: {
variant: "outline",
size: "default",
},
},
);
export type BadgeProps = ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & {
asChild?: boolean;
};
function Badge({
className,
variant,
size,
asChild = false,
...props
}: BadgeProps) {
const Comp = asChild ? Slot.Root : "span";
return (
<Comp
data-slot="badge"
data-variant={variant}
data-size={size}
className={cn(badgeVariants({ variant, size }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View file

@ -10,7 +10,7 @@ import { HugeiconsIcon } from "@hugeicons/react";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { DownloadIcon } from "lucide-react";
import { DownloadIcon, Maximize2Icon, Minimize2Icon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Block, type BlockProps, Streamdown } from "streamdown";
import "katex/dist/katex.min.css";
@ -84,6 +84,11 @@ function isSvgFence(codeFence: CodeFence): boolean {
return false;
}
function isHtmlFence(codeFence: CodeFence): boolean {
const lang = codeFence.language?.toLowerCase() ?? "";
return lang === "html" && !codeFence.source.trimStart().startsWith("<svg");
}
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 {
@ -104,6 +109,96 @@ 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);
@ -238,6 +333,14 @@ function StreamdownBlock(props: BlockProps) {
);
}
if (props.isIncomplete && codeFence && isHtmlFence(codeFence)) {
return (
<div className="my-4 flex h-48 items-center justify-center rounded-xl border border-border bg-muted/30 text-sm text-muted-foreground animate-pulse">
Loading preview...
</div>
);
}
if (mermaidSource) {
return (
<div className="relative isolate">
@ -249,6 +352,7 @@ 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;
return (
<>
<div className="relative isolate">
@ -260,6 +364,7 @@ function StreamdownBlock(props: BlockProps) {
/>
</div>
{svgSource && <SvgPreview source={svgSource} />}
{htmlSource && <HtmlPreview source={htmlSource} />}
</>
);
}

View file

@ -19,10 +19,11 @@ import {
useAuiState,
useScrollLock,
} from "@assistant-ui/react";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { Idea01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { type VariantProps, cva } from "class-variance-authority";
import { ChevronDownIcon } from "lucide-react";
import { ChevronDownIcon, CopyIcon, CheckIcon } from "lucide-react";
import {
type CSSProperties,
type ComponentProps,
@ -263,6 +264,45 @@ function ReasoningText({
const ReasoningImpl: ReasoningMessagePartComponent = () => <MarkdownText />;
const COPY_RESET_MS = 2000;
function ReasoningCopyButton({ startIndex, endIndex }: { startIndex: number; endIndex: number }) {
const [copied, setCopied] = useState(false);
const resetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reasoningText = useAuiState(({ message }) => {
return message.parts
.slice(startIndex, endIndex + 1)
.filter((p) => p.type === "reasoning")
.map((p) => ("text" in p ? (p as { text: string }).text : ""))
.join("\n");
});
const handleCopy = useCallback(() => {
if (copyToClipboard(reasoningText)) {
setCopied(true);
if (resetRef.current) clearTimeout(resetRef.current);
resetRef.current = setTimeout(() => setCopied(false), COPY_RESET_MS);
}
}, [reasoningText]);
return (
<button
type="button"
onClick={handleCopy}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground hover:bg-muted"
aria-label="Copy reasoning"
>
{copied ? (
<CheckIcon className="size-3" />
) : (
<CopyIcon className="size-3" />
)}
{copied ? "Copied" : "Copy"}
</button>
);
}
const ReasoningGroupImpl: ReasoningGroupComponent = ({
children,
startIndex,
@ -328,10 +368,15 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({
onOpenChange={handleOpenChange}
variant={variant}
>
<ReasoningTrigger
active={isReasoningStreaming}
duration={duration || persistedDuration}
/>
<div className="flex items-center justify-between">
<ReasoningTrigger
active={isReasoningStreaming}
duration={duration || persistedDuration}
/>
{isOpen && !isReasoningStreaming && (
<ReasoningCopyButton startIndex={startIndex} endIndex={endIndex} />
)}
</div>
<ReasoningContent
aria-busy={isReasoningStreaming}
streaming={isReasoningStreaming}

View file

@ -0,0 +1,137 @@
"use client";
import { memo, useState, type ComponentProps } from "react";
import type { SourceMessagePartComponent } from "@assistant-ui/react";
import { cn } from "@/lib/utils";
import { Badge, badgeVariants, type BadgeProps } from "./badge";
const extractDomain = (url: string): string => {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return url;
}
};
const getDomainInitial = (url: string): string => {
const domain = extractDomain(url);
return domain.charAt(0).toUpperCase();
};
function SourceIcon({
url,
className,
...props
}: ComponentProps<"span"> & { url: string }) {
const [hasError, setHasError] = useState(false);
const domain = extractDomain(url);
if (hasError) {
return (
<span
data-slot="source-icon-fallback"
className={cn(
"flex size-3 shrink-0 items-center justify-center rounded-sm bg-muted font-medium text-[10px]",
className,
)}
{...props}
>
{getDomainInitial(url)}
</span>
);
}
return (
<img
data-slot="source-icon"
src={`https://www.google.com/s2/favicons?domain=${domain}&sz=32`}
alt=""
className={cn("size-3 shrink-0 rounded-sm", className)}
onError={() => setHasError(true)}
{...(props as ComponentProps<"img">)}
/>
);
}
function SourceTitle({ className, ...props }: ComponentProps<"span">) {
return (
<span
data-slot="source-title"
className={cn("max-w-37.5 truncate", className)}
{...props}
/>
);
}
export type SourceProps = Omit<BadgeProps, "asChild"> &
ComponentProps<"a"> & {
asChild?: boolean;
};
function Source({
className,
variant,
size,
asChild = false,
target = "_blank",
rel = "noopener noreferrer",
...props
}: SourceProps) {
return (
<Badge
asChild
variant={variant}
size={size}
className={cn(
"cursor-pointer outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
className,
)}
>
<a
data-slot="source"
target={target}
rel={rel}
{...(props as ComponentProps<"a">)}
/>
</Badge>
);
}
const SourcesImpl: SourceMessagePartComponent = ({
url,
title,
sourceType,
}) => {
if (sourceType !== "url" || !url) return null;
const domain = extractDomain(url);
const displayTitle = title || domain;
return (
<span className="mr-1 mt-1 inline-block first:mt-2">
<Source href={url}>
<SourceIcon url={url} />
<SourceTitle>{displayTitle}</SourceTitle>
</Source>
</span>
);
};
const Sources = memo(SourcesImpl) as unknown as SourceMessagePartComponent & {
Root: typeof Source;
Icon: typeof SourceIcon;
Title: typeof SourceTitle;
};
Sources.displayName = "Sources";
Sources.Root = Source;
Sources.Icon = SourceIcon;
Sources.Title = SourceTitle;
export {
Sources,
Source,
SourceIcon,
SourceTitle,
badgeVariants as sourceVariants,
};

View file

@ -9,7 +9,12 @@ import {
import { MessageTiming } from "@/components/assistant-ui/message-timing";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { Reasoning, ReasoningGroup } from "@/components/assistant-ui/reasoning";
import { Sources } from "@/components/assistant-ui/sources";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { ToolGroup } from "@/components/assistant-ui/tool-group";
import { WebSearchToolUI } from "@/components/assistant-ui/tool-ui-web-search";
import { PythonToolUI } from "@/components/assistant-ui/tool-ui-python";
import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Button } from "@/components/ui/button";
import { sentAudioNames } from "@/features/chat/api/chat-adapter";
@ -52,7 +57,7 @@ import {
TerminalIcon,
XIcon,
} from "lucide-react";
import { type FC, useCallback, useRef, useState } from "react";
import { type FC, useCallback, useEffect, useRef, useState } from "react";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({
@ -384,6 +389,20 @@ const CodeToolsToggle: FC = () => {
const ToolStatusDisplay: FC = () => {
const toolStatus = useChatRuntimeStore((s) => s.toolStatus);
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (!toolStatus) {
setElapsed(0);
return;
}
setElapsed(0);
const interval = setInterval(() => {
setElapsed((prev) => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, [toolStatus]);
if (!toolStatus) return null;
const isRunning = toolStatus.startsWith("Running");
const StatusIcon = isRunning ? TerminalIcon : GlobeIcon;
@ -392,6 +411,7 @@ const ToolStatusDisplay: FC = () => {
<div className="flex animate-pulse items-center gap-2 rounded-full border border-primary/20 bg-primary/5 px-3 py-1.5 text-xs text-primary">
<StatusIcon className="size-3.5" />
<span>{toolStatus}</span>
<span className="tabular-nums opacity-60">{elapsed}s</span>
</div>
</div>
);
@ -485,7 +505,16 @@ const AssistantMessage: FC = () => {
Text: MarkdownText,
Reasoning: Reasoning,
ReasoningGroup: ReasoningGroup,
tools: { Fallback: ToolFallback },
Source: Sources,
ToolGroup: ToolGroup,
tools: {
by_name: {
web_search: WebSearchToolUI,
python: PythonToolUI,
terminal: TerminalToolUI,
},
Fallback: ToolFallback,
},
}}
/>
<MessageError />

View file

@ -77,7 +77,7 @@ function ToolFallbackRoot({
open={isOpen}
onOpenChange={handleOpenChange}
className={cn(
"aui-tool-fallback-root group/tool-fallback-root w-full rounded-lg border py-3",
"aui-tool-fallback-root group/tool-fallback-root w-full corner-squircle rounded-lg border py-3",
className,
)}
style={
@ -104,18 +104,20 @@ const statusIconMap: Record<ToolStatus, ElementType> = {
function ToolFallbackTrigger({
toolName,
status,
icon: ToolIcon,
className,
...props
}: ComponentProps<typeof CollapsibleTrigger> & {
toolName: string;
status?: ToolCallMessagePartStatus;
icon?: ElementType;
}) {
const statusType = status?.type ?? "complete";
const isRunning = statusType === "running";
const isCancelled =
status?.type === "incomplete" && status.reason === "cancelled";
const Icon = statusIconMap[statusType];
const StatusIcon = statusIconMap[statusType];
const label = isCancelled ? "Cancelled tool" : "Used tool";
return (
@ -127,14 +129,30 @@ function ToolFallbackTrigger({
)}
{...props}
>
<Icon
data-slot="tool-fallback-trigger-icon"
className={cn(
"aui-tool-fallback-trigger-icon size-4 shrink-0",
isCancelled && "text-muted-foreground",
isRunning && "animate-spin",
)}
/>
{isRunning ? (
<StatusIcon
data-slot="tool-fallback-trigger-icon"
className="aui-tool-fallback-trigger-icon size-4 shrink-0 animate-spin"
/>
) : (
ToolIcon ? (
<ToolIcon
data-slot="tool-fallback-trigger-icon"
className={cn(
"aui-tool-fallback-trigger-icon size-4 shrink-0",
isCancelled && "text-muted-foreground",
)}
/>
) : (
<StatusIcon
data-slot="tool-fallback-trigger-icon"
className={cn(
"aui-tool-fallback-trigger-icon size-4 shrink-0",
isCancelled && "text-muted-foreground",
)}
/>
)
)}
<span
data-slot="tool-fallback-trigger-label"
className={cn(

View file

@ -0,0 +1,230 @@
"use client";
import {
memo,
useCallback,
useRef,
useState,
type FC,
type PropsWithChildren,
} from "react";
import { ChevronDownIcon, LoaderIcon } from "lucide-react";
import { cva, type VariantProps } from "class-variance-authority";
import { useScrollLock } from "@assistant-ui/react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
const ANIMATION_DURATION = 200;
const toolGroupVariants = cva("aui-tool-group-root group/tool-group w-full", {
variants: {
variant: {
outline: "corner-squircle rounded-lg border py-3",
ghost: "",
muted: "corner-squircle rounded-lg border border-muted-foreground/30 bg-muted/30 py-3",
},
},
defaultVariants: { variant: "outline" },
});
export type ToolGroupRootProps = Omit<
React.ComponentProps<typeof Collapsible>,
"open" | "onOpenChange"
> &
VariantProps<typeof toolGroupVariants> & {
open?: boolean;
onOpenChange?: (open: boolean) => void;
defaultOpen?: boolean;
};
function ToolGroupRoot({
className,
variant,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
defaultOpen = false,
children,
...props
}: ToolGroupRootProps) {
const collapsibleRef = useRef<HTMLDivElement>(null);
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION);
const isControlled = controlledOpen !== undefined;
const isOpen = isControlled ? controlledOpen : uncontrolledOpen;
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
lockScroll();
}
if (!isControlled) {
setUncontrolledOpen(open);
}
controlledOnOpenChange?.(open);
},
[lockScroll, isControlled, controlledOnOpenChange],
);
return (
<Collapsible
ref={collapsibleRef}
data-slot="tool-group-root"
data-variant={variant ?? "outline"}
open={isOpen}
onOpenChange={handleOpenChange}
className={cn(
toolGroupVariants({ variant }),
"group/tool-group-root",
className,
)}
style={
{
"--animation-duration": `${ANIMATION_DURATION}ms`,
} as React.CSSProperties
}
{...props}
>
{children}
</Collapsible>
);
}
function ToolGroupTrigger({
count,
active = false,
className,
...props
}: React.ComponentProps<typeof CollapsibleTrigger> & {
count: number;
active?: boolean;
}) {
const label = `${count} tool ${count === 1 ? "call" : "calls"}`;
return (
<CollapsibleTrigger
data-slot="tool-group-trigger"
className={cn(
"aui-tool-group-trigger group/trigger flex items-center gap-2 text-sm transition-colors",
"group-data-[variant=outline]/tool-group-root:w-full group-data-[variant=outline]/tool-group-root:px-4",
"group-data-[variant=muted]/tool-group-root:w-full group-data-[variant=muted]/tool-group-root:px-4",
className,
)}
{...props}
>
{active && (
<LoaderIcon
data-slot="tool-group-trigger-loader"
className="aui-tool-group-trigger-loader size-4 shrink-0 animate-spin"
/>
)}
<span
data-slot="tool-group-trigger-label"
className={cn(
"aui-tool-group-trigger-label-wrapper relative inline-block text-left font-medium leading-none",
"group-data-[variant=outline]/tool-group-root:grow",
"group-data-[variant=muted]/tool-group-root:grow",
)}
>
<span>{label}</span>
{active && (
<span
aria-hidden
data-slot="tool-group-trigger-shimmer"
className="aui-tool-group-trigger-shimmer shimmer pointer-events-none absolute inset-0 motion-reduce:animate-none"
>
{label}
</span>
)}
</span>
<ChevronDownIcon
data-slot="tool-group-trigger-chevron"
className={cn(
"aui-tool-group-trigger-chevron size-4 shrink-0",
"transition-transform duration-(--animation-duration) ease-out",
"group-data-[state=closed]/trigger:-rotate-90",
"group-data-[state=open]/trigger:rotate-0",
)}
/>
</CollapsibleTrigger>
);
}
function ToolGroupContent({
className,
children,
...props
}: React.ComponentProps<typeof CollapsibleContent>) {
return (
<CollapsibleContent
data-slot="tool-group-content"
className={cn(
"aui-tool-group-content relative overflow-hidden text-sm outline-none",
"group/collapsible-content ease-out",
"data-[state=closed]:animate-collapsible-up",
"data-[state=open]:animate-collapsible-down",
"data-[state=closed]:fill-mode-forwards",
"data-[state=closed]:pointer-events-none",
"data-[state=open]:duration-(--animation-duration)",
"data-[state=closed]:duration-(--animation-duration)",
className,
)}
{...props}
>
<div
className={cn(
"mt-2 flex flex-col gap-2",
"group-data-[variant=outline]/tool-group-root:mt-3 group-data-[variant=outline]/tool-group-root:border-t group-data-[variant=outline]/tool-group-root:px-4 group-data-[variant=outline]/tool-group-root:pt-3",
"group-data-[variant=muted]/tool-group-root:mt-3 group-data-[variant=muted]/tool-group-root:border-t group-data-[variant=muted]/tool-group-root:px-4 group-data-[variant=muted]/tool-group-root:pt-3",
)}
>
{children}
</div>
</CollapsibleContent>
);
}
type ToolGroupComponent = FC<
PropsWithChildren<{ startIndex: number; endIndex: number }>
> & {
Root: typeof ToolGroupRoot;
Trigger: typeof ToolGroupTrigger;
Content: typeof ToolGroupContent;
};
const ToolGroupImpl: FC<
PropsWithChildren<{ startIndex: number; endIndex: number }>
> = ({ children, startIndex, endIndex }) => {
const toolCount = endIndex - startIndex + 1;
// Single tool call — render directly without wrapper
if (toolCount <= 1) {
return <>{children}</>;
}
return (
<ToolGroupRoot>
<ToolGroupTrigger count={toolCount} />
<ToolGroupContent>{children}</ToolGroupContent>
</ToolGroupRoot>
);
};
const ToolGroup = memo(ToolGroupImpl) as unknown as ToolGroupComponent;
ToolGroup.displayName = "ToolGroup";
ToolGroup.Root = ToolGroupRoot;
ToolGroup.Trigger = ToolGroupTrigger;
ToolGroup.Content = ToolGroupContent;
export {
ToolGroup,
ToolGroupRoot,
ToolGroupTrigger,
ToolGroupContent,
toolGroupVariants,
};

View file

@ -0,0 +1,136 @@
// 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 { copyToClipboard } from "@/lib/copy-to-clipboard";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { code as codePlugin } from "@streamdown/code";
import { CheckIcon, CodeIcon, CopyIcon, LoaderIcon } from "lucide-react";
import { memo, useCallback, useMemo, useRef, useState } from "react";
import { Streamdown } from "streamdown";
import {
ToolFallbackContent,
ToolFallbackRoot,
ToolFallbackTrigger,
} from "./tool-fallback";
const MAX_DISPLAY = 10_000;
const COPY_RESET_MS = 2000;
const SHIKI_THEME = ["github-light", "github-dark"] as const;
function truncate(text: string): string {
return text.length <= MAX_DISPLAY
? text
: `${text.slice(0, MAX_DISPLAY)}\n... (truncated)`;
}
function CopyBtn({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const copy = useCallback(() => {
if (copyToClipboard(text)) {
setCopied(true);
if (timer.current) {
clearTimeout(timer.current);
}
timer.current = setTimeout(() => setCopied(false), COPY_RESET_MS);
}
}, [text]);
return (
<button
type="button"
onClick={copy}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Copy to clipboard"
>
{copied ? (
<CheckIcon className="size-3" />
) : (
<CopyIcon className="size-3" />
)}
{copied ? "Copied" : "Copy"}
</button>
);
}
/** Render code with syntax highlighting via Streamdown + shiki. No extra borders — inherits parent container. */
function HighlightedCode({ code: source, language }: { code: string; language: string }) {
const markdown = useMemo(
() => `\`\`\`${language}\n${truncate(source)}\n\`\`\``,
[source, language],
);
return (
<div className="max-h-48 overflow-auto text-xs [&_pre]:!m-0 [&_pre]:!bg-transparent [&_pre]:!p-0 [&_pre]:!text-xs [&_[data-streamdown=code-block]]:!my-0 [&_[data-streamdown=code-block]]:!p-0 [&_[data-streamdown=code-block]]:!border-0">
<Streamdown
mode="static"
plugins={{ code: codePlugin }}
controls={{ code: false }}
shikiTheme={SHIKI_THEME}
>
{markdown}
</Streamdown>
</div>
);
}
const PythonToolUIImpl: ToolCallMessagePartComponent = ({
args,
result,
status,
}) => {
const code = (args as { code?: string })?.code ?? "";
const firstLine = code.split("\n")[0]?.slice(0, 60) ?? "";
const isRunning = status?.type === "running";
const output =
typeof result === "string"
? result
: result
? JSON.stringify(result, null, 2)
: "";
return (
<ToolFallbackRoot>
<ToolFallbackTrigger
toolName={firstLine ? `Python: ${firstLine}` : "Python"}
status={status}
icon={CodeIcon}
/>
<ToolFallbackContent>
<div className="flex flex-col px-4">
{/* Code + copy */}
{code && (
<div className="flex justify-end">
<CopyBtn text={code} />
</div>
)}
<HighlightedCode code={code} language="python" />
{/* Output */}
{isRunning ? (
<div className="mt-2 flex items-center gap-2 text-sm text-muted-foreground">
<LoaderIcon className="size-3.5 animate-spin" />
<span>Running&hellip;</span>
</div>
) : output ? (
<div className="mt-2 border-t border-dashed pt-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">output</span>
<CopyBtn text={output} />
</div>
<pre className="mt-1 max-h-60 overflow-auto whitespace-pre-wrap break-words font-mono text-xs">
{truncate(output)}
</pre>
</div>
) : null}
</div>
</ToolFallbackContent>
</ToolFallbackRoot>
);
};
export const PythonToolUI = memo(
PythonToolUIImpl,
) as unknown as ToolCallMessagePartComponent;
PythonToolUI.displayName = "PythonToolUI";

View file

@ -0,0 +1,103 @@
// 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 { copyToClipboard } from "@/lib/copy-to-clipboard";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { CheckIcon, CopyIcon, LoaderIcon, TerminalIcon } from "lucide-react";
import { memo, useCallback, useRef, useState } from "react";
import {
ToolFallbackContent,
ToolFallbackRoot,
ToolFallbackTrigger,
} from "./tool-fallback";
const MAX_DISPLAY = 10_000;
const COPY_RESET_MS = 2000;
function truncate(text: string): string {
return text.length <= MAX_DISPLAY
? text
: `${text.slice(0, MAX_DISPLAY)}\n... (truncated)`;
}
function CopyBtn({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const copy = useCallback(() => {
if (copyToClipboard(text)) {
setCopied(true);
if (timer.current) {
clearTimeout(timer.current);
}
timer.current = setTimeout(() => setCopied(false), COPY_RESET_MS);
}
}, [text]);
return (
<button
type="button"
onClick={copy}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Copy to clipboard"
>
{copied ? (
<CheckIcon className="size-3" />
) : (
<CopyIcon className="size-3" />
)}
{copied ? "Copied" : "Copy"}
</button>
);
}
const TerminalToolUIImpl: ToolCallMessagePartComponent = ({
args,
result,
status,
}) => {
const command = (args as { command?: string })?.command ?? "";
const isRunning = status?.type === "running";
const output =
typeof result === "string"
? result
: result
? JSON.stringify(result, null, 2)
: "";
return (
<ToolFallbackRoot>
<ToolFallbackTrigger
toolName={command ? `$ ${command.slice(0, 60)}` : "Terminal"}
status={status}
icon={TerminalIcon}
/>
<ToolFallbackContent>
<div className="flex flex-col px-4">
{isRunning ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderIcon className="size-3.5 animate-spin" />
<span>Running&hellip;</span>
</div>
) : output ? (
<div>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">output</span>
<CopyBtn text={output} />
</div>
<pre className="mt-1 max-h-60 overflow-auto whitespace-pre-wrap break-words font-mono text-xs">
{truncate(output)}
</pre>
</div>
) : null}
</div>
</ToolFallbackContent>
</ToolFallbackRoot>
);
};
export const TerminalToolUI = memo(
TerminalToolUIImpl,
) as unknown as ToolCallMessagePartComponent;
TerminalToolUI.displayName = "TerminalToolUI";

View file

@ -0,0 +1,122 @@
// 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 { type ToolCallMessagePartComponent, useAuiState } from "@assistant-ui/react";
import { GlobeIcon, LoaderIcon } from "lucide-react";
import { memo, useEffect, useState } from "react";
import { Source, SourceIcon, SourceTitle } from "./sources";
import {
ToolFallbackContent,
ToolFallbackRoot,
ToolFallbackTrigger,
} from "./tool-fallback";
interface ParsedSource {
title: string;
url: string;
snippet: string;
}
const RE_BLOCK_SEP = /\n---\n/;
const RE_TITLE = /Title:\s*(.+)/;
const RE_URL = /URL:\s*(.+)/;
const RE_SNIPPET = /Snippet:\s*(.+)/s;
/** Parse the backend's "Title: ...\nURL: ...\nSnippet: ...\n---" format into structured sources. */
function parseSearchResults(raw: string): ParsedSource[] {
if (!raw) {
return [];
}
const blocks = raw.split(RE_BLOCK_SEP).filter(Boolean);
const sources: ParsedSource[] = [];
for (const block of blocks) {
const titleMatch = block.match(RE_TITLE);
const urlMatch = block.match(RE_URL);
const snippetMatch = block.match(RE_SNIPPET);
if (titleMatch && urlMatch) {
sources.push({
title: titleMatch[1].trim(),
url: urlMatch[1].trim(),
snippet: snippetMatch?.[1]?.trim() ?? "",
});
}
}
return sources;
}
const WebSearchToolUIImpl: ToolCallMessagePartComponent = ({
args,
result,
status,
}) => {
const query = (args as { query?: string })?.query ?? "";
const isRunning = status?.type === "running";
const sources = result
? parseSearchResults(
typeof result === "string" ? result : JSON.stringify(result),
)
: [];
// Collapse when LLM starts generating text after the tool call
const hasText = useAuiState(({ message }) =>
message.content.some((p) => p.type === "text" && "text" in p && (p as { text: string }).text.length > 0),
);
const [open, setOpen] = useState(isRunning);
useEffect(() => {
if (isRunning) {
setOpen(true);
} else if (hasText) {
setOpen(false);
}
}, [isRunning, hasText]);
return (
<ToolFallbackRoot open={open} onOpenChange={setOpen}>
<ToolFallbackTrigger
toolName={query ? `Searched "${query}"` : "Web Search"}
status={status}
icon={GlobeIcon}
/>
<ToolFallbackContent>
{isRunning ? (
<div className="flex items-center gap-2 px-4 text-sm text-muted-foreground">
<LoaderIcon className="size-3.5 animate-spin" />
<span>Searching for &ldquo;{query}&rdquo;&hellip;</span>
</div>
) : sources.length > 0 ? (
<div className="flex flex-col gap-1.5 px-4">
{sources.map((source) => (
<Source
key={source.url}
href={source.url}
variant="outline"
size="default"
className="flex w-full max-w-full items-center gap-2 py-1.5"
>
<SourceIcon url={source.url} className="size-3.5" />
<SourceTitle className="max-w-none flex-1 truncate">
{source.title}
</SourceTitle>
</Source>
))}
</div>
) : result ? (
<div className="px-4">
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 text-xs">
{typeof result === "string"
? result
: JSON.stringify(result, null, 2)}
</pre>
</div>
) : null}
</ToolFallbackContent>
</ToolFallbackRoot>
);
};
export const WebSearchToolUI = memo(
WebSearchToolUIImpl,
) as unknown as ToolCallMessagePartComponent;
WebSearchToolUI.displayName = "WebSearchToolUI";

View file

@ -26,6 +26,28 @@ type RunMessage = RunMessages[number];
/** Tracks which user messages were sent with an audio file (messageId → filename). */
export const sentAudioNames = new Map<string, string>();
/** Parse "Title: ...\nURL: ...\nSnippet: ..." blocks into source content parts. */
function parseSourcesFromResult(raw: string): { type: "source"; sourceType: "url"; id: string; url: string; title: string }[] {
if (!raw) return [];
const blocks = raw.split(/\n---\n/).filter(Boolean);
const sources: { type: "source"; sourceType: "url"; id: string; url: string; title: string }[] = [];
for (const block of blocks) {
const titleMatch = block.match(/Title:\s*(.+)/);
const urlMatch = block.match(/URL:\s*(.+)/);
if (titleMatch && urlMatch) {
const url = urlMatch[1].trim();
sources.push({
type: "source" as const,
sourceType: "url" as const,
id: url,
url,
title: titleMatch[1].trim(),
});
}
}
return sources;
}
function estimateTokenCount(text: string): number | undefined {
const trimmed = text.trim();
if (!trimmed) {
@ -40,6 +62,7 @@ function buildTiming(
firstTokenTime?: number,
totalStreamTime?: number,
tokenCount?: number,
toolCallCount = 0,
): MessageTiming {
return {
streamStartTime,
@ -53,7 +76,7 @@ function buildTiming(
? tokenCount / (totalStreamTime / 1000)
: undefined,
totalChunks,
toolCallCount: 0,
toolCallCount,
};
}
@ -502,6 +525,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
let cumulativeText = "";
let reasoningStartAt: number | null = null;
let reasoningDuration = 0;
// Tool call content parts — accumulated and yielded cumulatively.
// result is set directly on the tool-call part when tool_end arrives.
const toolCallParts: { type: "tool-call"; toolCallId: string; toolName: string; args: Record<string, unknown>; result?: unknown }[] = [];
try {
const { supportsReasoning, reasoningEnabled } = runtime;
@ -528,6 +554,13 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
...(toolsEnabled ? ["web_search"] : []),
...(codeToolsEnabled ? ["python", "terminal"] : []),
],
auto_heal_tool_calls: useChatRuntimeStore.getState().autoHealToolCalls,
max_tool_calls_per_message: useChatRuntimeStore.getState().maxToolCallsPerMessage,
tool_call_timeout: (() => {
const mins = useChatRuntimeStore.getState().toolCallTimeout;
return mins >= 9999 ? 9999 : mins * 60;
})(),
session_id: unstable_threadId || undefined,
}
: {}),
},
@ -542,6 +575,39 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
continue;
}
// Emit tool-call content parts for assistant-ui.
// On tool_start: add a new tool-call part (renders in "running" state).
// On tool_end: set result on the existing part (transitions to "complete").
const toolEvent = (chunk as unknown as { _toolEvent?: Record<string, unknown> })._toolEvent;
if (toolEvent !== undefined) {
if (toolEvent.type === "tool_start") {
const id = (toolEvent.tool_call_id as string) || `${toolEvent.tool_name}_${Date.now()}`;
toolCallParts.push({
type: "tool-call" as const,
toolCallId: id,
toolName: toolEvent.tool_name as string,
args: (toolEvent.arguments as Record<string, unknown>) ?? {},
});
} else if (toolEvent.type === "tool_end") {
const id = (toolEvent.tool_call_id as string) ||
toolCallParts[toolCallParts.length - 1]?.toolCallId || "";
const idx = toolCallParts.findIndex((p) => p.toolCallId === id);
if (idx !== -1) {
toolCallParts[idx] = { ...toolCallParts[idx], result: toolEvent.result as string };
}
}
// Yield cumulative state so tool UI updates (tools first, text after)
const textParts = parseAssistantContent(cumulativeText);
yield {
content: [...toolCallParts, ...textParts],
metadata: {
timing: buildTiming(streamStartTime, totalChunks, firstTokenTime),
custom: { reasoningDuration },
},
};
continue;
}
totalChunks += 1;
const delta = chunk.choices?.[0]?.delta?.content;
if (!delta) {
@ -564,9 +630,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
reasoningDuration = Math.round((Date.now() - reasoningStartAt) / 1000);
}
if (parts.length > 0) {
if (parts.length > 0 || toolCallParts.length > 0) {
yield {
content: parts,
content: [...toolCallParts, ...parts],
metadata: {
timing: buildTiming(
streamStartTime,
@ -579,7 +645,19 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
}
}
settleFirstTokenOk();
// Extract source parts from completed web_search tool calls
const sourceParts = toolCallParts.flatMap((tc) => {
if (tc.toolName !== "web_search" || !tc.result) return [];
return parseSourcesFromResult(typeof tc.result === "string" ? tc.result : "");
});
yield {
content: [
...toolCallParts,
...parseAssistantContent(cumulativeText),
...sourceParts,
],
metadata: {
timing: buildTiming(
streamStartTime,
@ -587,6 +665,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
firstTokenTime,
Date.now() - streamStartTime,
estimateTokenCount(cumulativeText),
toolCallParts.length,
),
custom: { reasoningDuration },
},

View file

@ -234,6 +234,12 @@ export async function* streamChatCompletions(
separatorIndex = buffer.search(/\r?\n\r?\n/);
continue;
}
// Tool start/end events carry full input/output for the tool outputs panel
if ("type" in parsed && (parsed.type === "tool_start" || parsed.type === "tool_end")) {
yield { _toolEvent: parsed } as unknown as OpenAIChatChunk;
separatorIndex = buffer.search(/\r?\n\r?\n/);
continue;
}
yield parsed as OpenAIChatChunk;
separatorIndex = buffer.search(/\r?\n\r?\n/);
}

View file

@ -426,6 +426,9 @@ export function ChatSettingsPanel({
</Select>
</div>
)}
<AutoHealToolCallsToggle />
<MaxToolCallsSlider />
<ToolCallTimeoutSlider />
</div>
</CollapsibleSection>
@ -436,6 +439,73 @@ export function ChatSettingsPanel({
);
}
function MaxToolCallsSlider() {
const maxToolCalls = useChatRuntimeStore((s) => s.maxToolCallsPerMessage);
const setMaxToolCalls = useChatRuntimeStore((s) => s.setMaxToolCallsPerMessage);
// Slider range 0-41; 41 maps to 9999 ("Max")
const sliderValue = maxToolCalls >= 9999 ? 41 : Math.min(maxToolCalls, 40);
return (
<ParamSlider
label="Max Tool Calls Per Message"
value={sliderValue}
min={0}
max={41}
step={1}
onChange={(v) => setMaxToolCalls(v >= 41 ? 9999 : v)}
displayValue={sliderValue >= 41 ? "Max" : sliderValue === 0 ? "Off" : undefined}
/>
);
}
function ToolCallTimeoutSlider() {
const timeout = useChatRuntimeStore((s) => s.toolCallTimeout);
const setTimeout_ = useChatRuntimeStore((s) => s.setToolCallTimeout);
// Slider 1-31; 31 maps to 9999 ("Max")
const sliderValue = timeout >= 9999 ? 31 : Math.min(Math.max(timeout, 1), 30);
const displayValue =
sliderValue >= 31
? "Max"
: sliderValue === 1
? "1 minute"
: `${sliderValue} minutes`;
return (
<ParamSlider
label="Max Tool Call Duration"
value={sliderValue}
min={1}
max={31}
step={1}
onChange={(v) => setTimeout_(v >= 31 ? 9999 : v)}
displayValue={displayValue}
/>
);
}
function AutoHealToolCallsToggle() {
const autoHealToolCalls = useChatRuntimeStore((s) => s.autoHealToolCalls);
const setAutoHealToolCalls = useChatRuntimeStore((s) => s.setAutoHealToolCalls);
return (
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-medium">Auto Heal Tool Calls 🦥</div>
<div className="text-[11px] text-muted-foreground">
Fix malformed tool calls from the model automatically.
</div>
</div>
<Switch
checked={autoHealToolCalls}
onCheckedChange={setAutoHealToolCalls}
/>
</div>
);
}
function ChatTemplateSection({
onReloadModel,
}: {

View file

@ -10,6 +10,9 @@ import {
} from "../types/runtime";
const AUTO_TITLE_KEY = "unsloth_chat_auto_title";
const AUTO_HEAL_TOOL_CALLS_KEY = "unsloth_auto_heal_tool_calls";
const MAX_TOOL_CALLS_KEY = "unsloth_max_tool_calls_per_message";
const TOOL_CALL_TIMEOUT_KEY = "unsloth_tool_call_timeout";
function canUseStorage(): boolean {
return typeof window !== "undefined";
@ -35,6 +38,27 @@ function saveBool(key: string, value: boolean): void {
}
}
function loadInt(key: string, fallback: number): number {
if (!canUseStorage()) return fallback;
try {
const raw = localStorage.getItem(key);
if (raw === null) return fallback;
const parsed = parseInt(raw, 10);
return Number.isNaN(parsed) ? fallback : parsed;
} catch {
return fallback;
}
}
function saveInt(key: string, value: number): void {
if (!canUseStorage()) return;
try {
localStorage.setItem(key, String(value));
} catch {
// ignore
}
}
type ChatRuntimeStore = {
params: InferenceParams;
models: ChatModelSummary[];
@ -51,6 +75,9 @@ type ChatRuntimeStore = {
codeToolsEnabled: boolean;
toolStatus: string | null;
generatingStatus: string | null;
autoHealToolCalls: boolean;
maxToolCallsPerMessage: number;
toolCallTimeout: number;
kvCacheDtype: string | null;
defaultChatTemplate: string | null;
chatTemplateOverride: string | null;
@ -73,6 +100,9 @@ type ChatRuntimeStore = {
setCodeToolsEnabled: (enabled: boolean) => void;
setToolStatus: (status: string | null) => void;
setGeneratingStatus: (status: string | null) => void;
setAutoHealToolCalls: (enabled: boolean) => void;
setMaxToolCallsPerMessage: (value: number) => void;
setToolCallTimeout: (value: number) => void;
setKvCacheDtype: (dtype: string | null) => void;
setChatTemplateOverride: (template: string | null) => void;
setPendingAudio: (base64: string, name: string) => void;
@ -95,6 +125,9 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
codeToolsEnabled: false,
toolStatus: null,
generatingStatus: null,
autoHealToolCalls: loadBool(AUTO_HEAL_TOOL_CALLS_KEY, true),
maxToolCallsPerMessage: loadInt(MAX_TOOL_CALLS_KEY, 10),
toolCallTimeout: loadInt(TOOL_CALL_TIMEOUT_KEY, 5),
kvCacheDtype: null,
defaultChatTemplate: null,
chatTemplateOverride: null,
@ -154,6 +187,21 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
setCodeToolsEnabled: (codeToolsEnabled) => set({ codeToolsEnabled }),
setToolStatus: (toolStatus) => set({ toolStatus }),
setGeneratingStatus: (generatingStatus) => set({ generatingStatus }),
setAutoHealToolCalls: (autoHealToolCalls) =>
set(() => {
saveBool(AUTO_HEAL_TOOL_CALLS_KEY, autoHealToolCalls);
return { autoHealToolCalls };
}),
setMaxToolCallsPerMessage: (maxToolCallsPerMessage) =>
set(() => {
saveInt(MAX_TOOL_CALLS_KEY, maxToolCallsPerMessage);
return { maxToolCallsPerMessage };
}),
setToolCallTimeout: (toolCallTimeout) =>
set(() => {
saveInt(TOOL_CALL_TIMEOUT_KEY, toolCallTimeout);
return { toolCallTimeout };
}),
setKvCacheDtype: (kvCacheDtype) => set({ kvCacheDtype }),
setChatTemplateOverride: (chatTemplateOverride) => set({ chatTemplateOverride }),
setPendingAudio: (base64, name) =>

View file

@ -156,6 +156,11 @@ export interface OpenAIChatCompletionsRequest {
use_adapter?: boolean | string | null;
enable_thinking?: boolean | null;
enable_tools?: boolean | null;
enabled_tools?: string[];
auto_heal_tool_calls?: boolean;
max_tool_calls_per_message?: number;
tool_call_timeout?: number;
session_id?: string;
}
export interface OpenAIChatDelta {

View file

@ -157,7 +157,6 @@
--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 0% / 0);
}
@theme inline {
--font-sans: "Inter Variable", ui-sans-serif, sans-serif, system-ui;
--font-heading: "Hellix", "Space Grotesk Variable", ui-sans-serif, sans-serif;
@ -325,8 +324,22 @@
[data-streamdown="list-item"] {
display: list-item;
}
}
/* Flatten code blocks: single border, language label, then code directly */
[data-streamdown="code-block-body"] {
border: none !important;
border-radius: 0 !important;
background: transparent !important;
padding: 0 !important;
}
[data-streamdown="code-block"] {
gap: 0;
padding: 0.5rem;
}
[data-streamdown="code-block-header"] {
padding-left: 0.75rem;
}
}
/* Minimal scrollbar — thumb only, no track */
* {
@ -373,4 +386,4 @@
::view-transition-old(root), ::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
}