mirror of
https://github.com/unslothai/unsloth.git
synced 2026-04-28 03:19:57 +00:00
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:
parent
11b5e7abf3
commit
9c95148045
23 changed files with 1587 additions and 124 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]}"
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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) :]
|
||||
|
|
|
|||
|
|
@ -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=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
67
studio/frontend/src/components/assistant-ui/badge.tsx
Normal file
67
studio/frontend/src/components/assistant-ui/badge.tsx
Normal 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 };
|
||||
|
|
@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
137
studio/frontend/src/components/assistant-ui/sources.tsx
Normal file
137
studio/frontend/src/components/assistant-ui/sources.tsx
Normal 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,
|
||||
};
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
230
studio/frontend/src/components/assistant-ui/tool-group.tsx
Normal file
230
studio/frontend/src/components/assistant-ui/tool-group.tsx
Normal 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,
|
||||
};
|
||||
136
studio/frontend/src/components/assistant-ui/tool-ui-python.tsx
Normal file
136
studio/frontend/src/components/assistant-ui/tool-ui-python.tsx
Normal 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…</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";
|
||||
103
studio/frontend/src/components/assistant-ui/tool-ui-terminal.tsx
Normal file
103
studio/frontend/src/components/assistant-ui/tool-ui-terminal.tsx
Normal 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…</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";
|
||||
|
|
@ -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 “{query}”…</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";
|
||||
|
|
@ -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 },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}: {
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue