Studio: add HTML artifacts to chat (#5772)
* Studio: add chat HTML artifact primitives * Studio: add local render_html tool support * Studio: wire render_html artifacts in chat UI * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: add chat artifact surface * Studio: mount chat artifact panel and overlay * Studio: fix chat artifact review regressions * Studio: fix chat artifact panel and sandbox previews * Studio: address chat artifact review follow-ups * Studio: polish chat artifact UI affordances * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: scope artifact IDs by message to prevent cross-turn collisions * Studio: fix artifact panel for local threads and surface tool errors * Studio: restrict artifact frame embedding to same-origin * Studio: stop local chat thread remount loop * Studio: fix chat artifact store cleanup regressions * Studio: shim artifact preview storage in sandbox * feat(chat): add artifact rendering controls * fix(chat): show artifact progress during tool calls * fix(chat): refine artifact preview behavior * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix(chat): ignore tool markers inside arguments * feat(chat): polish artifact preview panel * fix(chat): stabilize artifact panel behavior * fix(inference): merge duplicate Anthropic tool starts * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
e3b52eb98a
commit
dfba4cc5ca
35 changed files with 2798 additions and 588 deletions
|
|
@ -218,6 +218,8 @@ class AnthropicStreamEmitter:
|
|||
def __init__(self) -> None:
|
||||
self.block_index: int = 0
|
||||
self._text_block_open: bool = False
|
||||
self._open_tool_call_id: Optional[str] = None
|
||||
self._open_tool_args_sent: bool = False
|
||||
self._prev_text: str = ""
|
||||
self._usage: dict = {}
|
||||
|
||||
|
|
@ -263,8 +265,10 @@ class AnthropicStreamEmitter:
|
|||
def finish(self, stop_reason: str = "end_turn") -> list[str]:
|
||||
"""Close any open block and emit message_delta + message_stop."""
|
||||
events = []
|
||||
if self._text_block_open:
|
||||
if self._text_block_open or self._open_tool_call_id is not None:
|
||||
events.append(self._close_block())
|
||||
self._open_tool_call_id = None
|
||||
self._open_tool_args_sent = False
|
||||
events.append(
|
||||
build_anthropic_sse_event(
|
||||
"message_delta",
|
||||
|
|
@ -310,12 +314,26 @@ class AnthropicStreamEmitter:
|
|||
return events
|
||||
|
||||
def _handle_tool_start(self, event: dict) -> list[str]:
|
||||
tool_call_id = event.get("tool_call_id", "")
|
||||
args = event.get("arguments", {})
|
||||
if tool_call_id and self._open_tool_call_id == tool_call_id:
|
||||
return self._tool_arguments_delta(args)
|
||||
|
||||
events = []
|
||||
# Close current text block if open
|
||||
# Close current text block if open.
|
||||
if self._text_block_open:
|
||||
events.append(self._close_block())
|
||||
# Open a tool_use block
|
||||
# Defensive: if a replacement/different tool_start arrives while a
|
||||
# tool_use block is open, close the stale block before starting another.
|
||||
elif self._open_tool_call_id is not None:
|
||||
events.append(self._close_block())
|
||||
self._open_tool_call_id = None
|
||||
self._open_tool_args_sent = False
|
||||
|
||||
# Open a tool_use block.
|
||||
self.block_index += 1
|
||||
self._open_tool_call_id = tool_call_id
|
||||
self._open_tool_args_sent = False
|
||||
events.append(
|
||||
build_anthropic_sse_event(
|
||||
"content_block_start",
|
||||
|
|
@ -324,35 +342,43 @@ class AnthropicStreamEmitter:
|
|||
"index": self.block_index,
|
||||
"content_block": {
|
||||
"type": "tool_use",
|
||||
"id": event.get("tool_call_id", ""),
|
||||
"id": tool_call_id,
|
||||
"name": event.get("tool_name", ""),
|
||||
"input": {},
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
# Emit the arguments as input_json_delta
|
||||
args = event.get("arguments", {})
|
||||
if args:
|
||||
events.append(
|
||||
build_anthropic_sse_event(
|
||||
"content_block_delta",
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": self.block_index,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": json.dumps(args),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
events.extend(self._tool_arguments_delta(args))
|
||||
return events
|
||||
|
||||
def _tool_arguments_delta(self, args: dict) -> list[str]:
|
||||
if not args:
|
||||
return []
|
||||
if self._open_tool_args_sent:
|
||||
return []
|
||||
self._open_tool_args_sent = True
|
||||
return [
|
||||
build_anthropic_sse_event(
|
||||
"content_block_delta",
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": self.block_index,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": json.dumps(args),
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
def _handle_tool_end(self, event: dict) -> list[str]:
|
||||
events = []
|
||||
# Close the tool_use block
|
||||
events.append(self._close_block())
|
||||
# Close the tool_use block.
|
||||
if self._open_tool_call_id is not None or self._text_block_open:
|
||||
events.append(self._close_block())
|
||||
self._open_tool_call_id = None
|
||||
self._open_tool_args_sent = False
|
||||
# Emit custom tool_result event (non-standard, ignored by SDKs)
|
||||
events.append(
|
||||
build_anthropic_sse_event(
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ from utils.subprocess_compat import (
|
|||
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
|
||||
)
|
||||
from core.inference.tool_call_parser import (
|
||||
RENDER_HTML_REPEAT_NUDGE,
|
||||
parse_tool_calls_from_text as _shared_parse_tool_calls_from_text,
|
||||
)
|
||||
|
||||
|
|
@ -4616,6 +4617,7 @@ class LlamaCppBackend:
|
|||
# a transient failure are allowed (only block when the previous
|
||||
# identical call succeeded).
|
||||
_tool_call_history: list[tuple[str, bool]] = [] # (key, failed)
|
||||
_render_html_succeeded = False
|
||||
|
||||
# ── Re-prompt on plan-without-action ─────────────────
|
||||
# When the model describes what it intends to do (forward-looking
|
||||
|
|
@ -4690,6 +4692,7 @@ class LlamaCppBackend:
|
|||
_iter_timings = None
|
||||
_stream_done = False
|
||||
_last_emitted = ""
|
||||
provisional_render_html_tool_call_ids = set()
|
||||
|
||||
stream_timeout = httpx.Timeout(
|
||||
connect = 10,
|
||||
|
|
@ -4799,6 +4802,33 @@ class LlamaCppBackend:
|
|||
tool_calls_acc[idx]["function"][
|
||||
"arguments"
|
||||
] += func["arguments"]
|
||||
current_name = tool_calls_acc[idx][
|
||||
"function"
|
||||
].get("name", "")
|
||||
fallback_id = f"call_{idx}"
|
||||
current_id = tool_calls_acc[idx].get(
|
||||
"id", fallback_id
|
||||
)
|
||||
already_started = (
|
||||
current_id
|
||||
in provisional_render_html_tool_call_ids
|
||||
)
|
||||
has_real_id = current_id != fallback_id
|
||||
if (
|
||||
current_name == "render_html"
|
||||
and not _render_html_succeeded
|
||||
and not already_started
|
||||
and has_real_id
|
||||
):
|
||||
provisional_render_html_tool_call_ids.add(
|
||||
current_id
|
||||
)
|
||||
yield {
|
||||
"type": "tool_start",
|
||||
"tool_name": "render_html",
|
||||
"tool_call_id": current_id,
|
||||
"arguments": {},
|
||||
}
|
||||
continue
|
||||
|
||||
# ── Reasoning tokens ──
|
||||
|
|
@ -4980,13 +5010,25 @@ class LlamaCppBackend:
|
|||
"content": _stripped,
|
||||
}
|
||||
)
|
||||
available_tool_names = [
|
||||
tool.get("function", {}).get("name")
|
||||
for tool in tools
|
||||
if isinstance(tool, dict)
|
||||
and isinstance(tool.get("function"), dict)
|
||||
]
|
||||
available_tool_names = [
|
||||
name for name in available_tool_names if name
|
||||
]
|
||||
tool_hint = (
|
||||
" or ".join(available_tool_names) or "an available tool"
|
||||
)
|
||||
conversation.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"STOP. Do NOT write code or explain. "
|
||||
"You MUST call a tool NOW. "
|
||||
"Call web_search or python immediately."
|
||||
f"Call {tool_hint} immediately."
|
||||
),
|
||||
}
|
||||
)
|
||||
|
|
@ -5158,7 +5200,12 @@ class LlamaCppBackend:
|
|||
arguments = json.loads(raw_args)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
if auto_heal_tool_calls:
|
||||
arguments = {"query": raw_args}
|
||||
heal_key = {
|
||||
"python": "code",
|
||||
"terminal": "command",
|
||||
"render_html": "code",
|
||||
}.get(tool_name, "query")
|
||||
arguments = {heal_key: raw_args}
|
||||
else:
|
||||
arguments = {"raw": raw_args}
|
||||
else:
|
||||
|
|
@ -5195,14 +5242,18 @@ class LlamaCppBackend:
|
|||
)
|
||||
else:
|
||||
status_text = f"Calling: {tool_name}"
|
||||
yield {"type": "status", "text": status_text}
|
||||
_repeat_render_html = (
|
||||
tool_name == "render_html" and _render_html_succeeded
|
||||
)
|
||||
if not _repeat_render_html:
|
||||
yield {"type": "status", "text": status_text}
|
||||
|
||||
yield {
|
||||
"type": "tool_start",
|
||||
"tool_name": tool_name,
|
||||
"tool_call_id": tc.get("id", ""),
|
||||
"arguments": arguments,
|
||||
}
|
||||
yield {
|
||||
"type": "tool_start",
|
||||
"tool_name": tool_name,
|
||||
"tool_call_id": tc.get("id", ""),
|
||||
"arguments": arguments,
|
||||
}
|
||||
|
||||
# ── Duplicate call detection ──────────────
|
||||
# str(dict) is stable here: arguments always comes from
|
||||
|
|
@ -5210,7 +5261,9 @@ class LlamaCppBackend:
|
|||
# so insertion order is deterministic (Python 3.7+).
|
||||
_tc_key = tool_name + str(arguments)
|
||||
_prev = _tool_call_history[-1] if _tool_call_history else None
|
||||
if _prev and _prev[0] == _tc_key and not _prev[1]:
|
||||
if _repeat_render_html:
|
||||
result = RENDER_HTML_REPEAT_NUDGE
|
||||
elif _prev and _prev[0] == _tc_key and not _prev[1]:
|
||||
result = (
|
||||
"You already made this exact call. "
|
||||
"Do not repeat the same tool call. "
|
||||
|
|
@ -5248,12 +5301,13 @@ class LlamaCppBackend:
|
|||
session_id = session_id,
|
||||
)
|
||||
|
||||
yield {
|
||||
"type": "tool_end",
|
||||
"tool_name": tool_name,
|
||||
"tool_call_id": tc.get("id", ""),
|
||||
"result": result,
|
||||
}
|
||||
if not _repeat_render_html:
|
||||
yield {
|
||||
"type": "tool_end",
|
||||
"tool_name": tool_name,
|
||||
"tool_call_id": tc.get("id", ""),
|
||||
"result": result,
|
||||
}
|
||||
|
||||
# Nudge model to try a different approach on errors
|
||||
_error_prefixes = (
|
||||
|
|
@ -5269,6 +5323,8 @@ class LlamaCppBackend:
|
|||
_is_error = isinstance(result, str) and result.lstrip().startswith(
|
||||
_error_prefixes
|
||||
)
|
||||
if tool_name == "render_html" and not _is_error:
|
||||
_render_html_succeeded = True
|
||||
_tool_call_history.append((_tc_key, _is_error))
|
||||
# Strip image sentinel before feeding result to the LLM
|
||||
# (the full result with sentinel is still yielded via
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ cumulative text and dispatches them via ``core.inference.tools``.
|
|||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
from typing import Callable, Generator, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
|
@ -27,6 +28,7 @@ from loggers import get_logger
|
|||
from core.inference.tool_call_parser import (
|
||||
BUDGET_EXHAUSTED_NUDGE,
|
||||
DUPLICATE_CALL_NUDGE,
|
||||
RENDER_HTML_REPEAT_NUDGE,
|
||||
TOOL_ERROR_NUDGE,
|
||||
TOOL_ERROR_PREFIXES,
|
||||
TOOL_XML_SIGNALS,
|
||||
|
|
@ -66,7 +68,34 @@ def _status_for_tool(tool_name: str, arguments: dict) -> str:
|
|||
return f"Calling: {tool_name}"
|
||||
|
||||
|
||||
_CANONICAL_HEAL_ARG = {"python": "code", "terminal": "command"}
|
||||
_CANONICAL_HEAL_ARG = {
|
||||
"python": "code",
|
||||
"terminal": "command",
|
||||
"render_html": "code",
|
||||
}
|
||||
|
||||
|
||||
_FUNCTION_SIGNAL_RE = re.compile(r"<function=([\w-]+)>")
|
||||
_TOOL_CALL_NAME_RE = re.compile(r'"name"\s*:\s*"([\w-]+)"')
|
||||
|
||||
|
||||
def _detect_render_html_tool_start(content: str) -> bool:
|
||||
"""Return True when the first drained tool call is clearly render_html."""
|
||||
function_match = _FUNCTION_SIGNAL_RE.search(content)
|
||||
tool_call_index = content.find("<tool_call>")
|
||||
if not function_match and tool_call_index < 0:
|
||||
return False
|
||||
|
||||
if function_match and (
|
||||
tool_call_index < 0 or function_match.start() < tool_call_index
|
||||
):
|
||||
return function_match.group(1) == "render_html"
|
||||
|
||||
if tool_call_index >= 0:
|
||||
name_match = _TOOL_CALL_NAME_RE.search(content[tool_call_index:])
|
||||
return bool(name_match and name_match.group(1) == "render_html")
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _coerce_arguments(raw_args, *, heal: bool, tool_name: str = "") -> dict:
|
||||
|
|
@ -135,6 +164,7 @@ def run_safetensors_tool_loop(
|
|||
"""
|
||||
conversation = list(messages)
|
||||
tool_call_history: list[tuple[str, bool]] = []
|
||||
render_html_succeeded = False
|
||||
final_attempt_done = False
|
||||
allowed_tool_names = {
|
||||
(tool.get("function") or {}).get("name")
|
||||
|
|
@ -161,6 +191,8 @@ def run_safetensors_tool_loop(
|
|||
content_accum = ""
|
||||
cumulative_display = ""
|
||||
last_emitted = ""
|
||||
provisional_render_html_started = False
|
||||
provisional_render_html_id = f"call_{next_call_id}"
|
||||
|
||||
gen = single_turn(conversation)
|
||||
prev_cumulative = ""
|
||||
|
|
@ -179,6 +211,18 @@ def run_safetensors_tool_loop(
|
|||
content_accum += delta
|
||||
|
||||
if detect_state == _state_draining:
|
||||
if (
|
||||
not render_html_succeeded
|
||||
and not provisional_render_html_started
|
||||
and _detect_render_html_tool_start(content_accum)
|
||||
):
|
||||
provisional_render_html_started = True
|
||||
yield {
|
||||
"type": "tool_start",
|
||||
"tool_name": "render_html",
|
||||
"tool_call_id": provisional_render_html_id,
|
||||
"arguments": {},
|
||||
}
|
||||
continue
|
||||
|
||||
if detect_state == _state_streaming:
|
||||
|
|
@ -196,6 +240,18 @@ def run_safetensors_tool_loop(
|
|||
yield {"type": "content", "text": cleaned_before}
|
||||
cumulative_display = candidate
|
||||
detect_state = _state_draining
|
||||
if (
|
||||
not render_html_succeeded
|
||||
and not provisional_render_html_started
|
||||
and _detect_render_html_tool_start(content_accum)
|
||||
):
|
||||
provisional_render_html_started = True
|
||||
yield {
|
||||
"type": "tool_start",
|
||||
"tool_name": "render_html",
|
||||
"tool_call_id": provisional_render_html_id,
|
||||
"arguments": {},
|
||||
}
|
||||
continue
|
||||
cumulative_display = candidate
|
||||
cleaned = strip_tool_markup(cumulative_display)
|
||||
|
|
@ -222,6 +278,18 @@ def run_safetensors_tool_loop(
|
|||
|
||||
if is_match:
|
||||
detect_state = _state_draining
|
||||
if (
|
||||
not render_html_succeeded
|
||||
and not provisional_render_html_started
|
||||
and _detect_render_html_tool_start(content_accum)
|
||||
):
|
||||
provisional_render_html_started = True
|
||||
yield {
|
||||
"type": "tool_start",
|
||||
"tool_name": "render_html",
|
||||
"tool_call_id": provisional_render_html_id,
|
||||
"arguments": {},
|
||||
}
|
||||
elif is_prefix and len(stripped) < _MAX_BUFFER_CHARS:
|
||||
continue
|
||||
else:
|
||||
|
|
@ -282,6 +350,13 @@ def run_safetensors_tool_loop(
|
|||
# literal "<tool_call>" prose is preserved.
|
||||
if content_accum:
|
||||
yield {"type": "content", "text": content_accum}
|
||||
if provisional_render_html_started:
|
||||
yield {
|
||||
"type": "tool_end",
|
||||
"tool_name": "render_html",
|
||||
"tool_call_id": provisional_render_html_id,
|
||||
"result": "Error: render_html tool call could not be parsed.",
|
||||
}
|
||||
yield {"type": "status", "text": ""}
|
||||
return
|
||||
content_text = strip_tool_markup(content_accum, final = True)
|
||||
|
|
@ -308,16 +383,20 @@ def run_safetensors_tool_loop(
|
|||
tool_name = tool_name,
|
||||
)
|
||||
|
||||
yield {"type": "status", "text": _status_for_tool(tool_name, arguments)}
|
||||
yield {
|
||||
"type": "tool_start",
|
||||
"tool_name": tool_name,
|
||||
"tool_call_id": tc.get("id", ""),
|
||||
"arguments": arguments,
|
||||
}
|
||||
repeat_render_html = tool_name == "render_html" and render_html_succeeded
|
||||
if not repeat_render_html:
|
||||
yield {"type": "status", "text": _status_for_tool(tool_name, arguments)}
|
||||
yield {
|
||||
"type": "tool_start",
|
||||
"tool_name": tool_name,
|
||||
"tool_call_id": tc.get("id", ""),
|
||||
"arguments": arguments,
|
||||
}
|
||||
|
||||
tc_key = tool_name + str(arguments)
|
||||
if allowed_tool_names and tool_name not in allowed_tool_names:
|
||||
if repeat_render_html:
|
||||
result = RENDER_HTML_REPEAT_NUDGE
|
||||
elif allowed_tool_names and tool_name not in allowed_tool_names:
|
||||
result = (
|
||||
f"Error: tool '{tool_name}' is not enabled for this "
|
||||
"request. Use one of the enabled tools or provide a "
|
||||
|
|
@ -345,16 +424,19 @@ def run_safetensors_tool_loop(
|
|||
logger.exception("Tool %s raised: %s", tool_name, exc)
|
||||
result = f"Error: tool raised an exception: {exc}"
|
||||
|
||||
yield {
|
||||
"type": "tool_end",
|
||||
"tool_name": tool_name,
|
||||
"tool_call_id": tc.get("id", ""),
|
||||
"result": result,
|
||||
}
|
||||
if not repeat_render_html:
|
||||
yield {
|
||||
"type": "tool_end",
|
||||
"tool_name": tool_name,
|
||||
"tool_call_id": tc.get("id", ""),
|
||||
"result": result,
|
||||
}
|
||||
|
||||
is_error = isinstance(result, str) and result.lstrip().startswith(
|
||||
TOOL_ERROR_PREFIXES
|
||||
)
|
||||
if tool_name == "render_html" and not is_error:
|
||||
render_html_succeeded = True
|
||||
tool_call_history.append((tc_key, is_error))
|
||||
|
||||
# Strip frontend image sentinel from the model's view.
|
||||
|
|
|
|||
|
|
@ -49,6 +49,12 @@ DUPLICATE_CALL_NUDGE = (
|
|||
"provide your final answer now."
|
||||
)
|
||||
|
||||
RENDER_HTML_REPEAT_NUDGE = (
|
||||
"Error: render_html was already called for this response. Do not call "
|
||||
"render_html again in this response unless the user asks for changes. "
|
||||
"Provide the final answer now."
|
||||
)
|
||||
|
||||
TOOL_ERROR_NUDGE = (
|
||||
"\n\nThe tool call encountered an issue. Please try a different "
|
||||
"approach or rephrase your request."
|
||||
|
|
@ -70,6 +76,20 @@ _TC_FUNC_CLOSE_RE = re.compile(r"\s*</function>\s*$")
|
|||
# `issue-number`, `repo-name`); using `\w+` here dropped those keys.
|
||||
_TC_PARAM_START_RE = re.compile(r"<parameter=([\w-]+)>\s*")
|
||||
_TC_PARAM_CLOSE_RE = re.compile(r"\s*</parameter>\s*$")
|
||||
_PARAM_CLOSE_TAG = "</parameter>"
|
||||
_FUNC_CLOSE_TAG = "</function>"
|
||||
|
||||
|
||||
def _inside_open_parameter(content: str, pos: int) -> bool:
|
||||
"""Return True when ``pos`` falls inside an unclosed parameter value."""
|
||||
last_param_start = -1
|
||||
for match in _TC_PARAM_START_RE.finditer(content, 0, pos):
|
||||
last_param_start = match.start()
|
||||
if last_param_start < 0:
|
||||
return False
|
||||
last_param_close = content.rfind(_PARAM_CLOSE_TAG, 0, pos)
|
||||
last_func_close = content.rfind(_FUNC_CLOSE_TAG, 0, pos)
|
||||
return last_param_start > max(last_param_close, last_func_close)
|
||||
|
||||
|
||||
def strip_tool_markup(text: str, *, final: bool = False) -> str:
|
||||
|
|
@ -151,7 +171,11 @@ def parse_tool_calls_from_text(content: str, *, id_offset: int = 0) -> list[dict
|
|||
# optional; don't use </function> as body boundary because code
|
||||
# values can contain that literal.
|
||||
if not tool_calls:
|
||||
func_starts = list(_TC_FUNC_START_RE.finditer(content))
|
||||
func_starts = [
|
||||
fm
|
||||
for fm in _TC_FUNC_START_RE.finditer(content)
|
||||
if not _inside_open_parameter(content, fm.start())
|
||||
]
|
||||
for idx, fm in enumerate(func_starts):
|
||||
func_name = fm.group(1)
|
||||
body_start = fm.end()
|
||||
|
|
|
|||
|
|
@ -514,7 +514,35 @@ TERMINAL_TOOL = {
|
|||
},
|
||||
}
|
||||
|
||||
ALL_TOOLS = [WEB_SEARCH_TOOL, PYTHON_TOOL, TERMINAL_TOOL]
|
||||
RENDER_HTML_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "render_html",
|
||||
"description": (
|
||||
"Render a self-contained HTML/CSS/JavaScript artifact for the user. "
|
||||
"Call this at most once per assistant response unless the user "
|
||||
"explicitly asks for changes in that response. Future user requests "
|
||||
"for new artifacts may call render_html once. Put the entire document "
|
||||
"in code, including any CSS in <style> tags and JavaScript in <script> tags."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"description": "A complete self-contained HTML document.",
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Short display title for the artifact.",
|
||||
},
|
||||
},
|
||||
"required": ["code"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ALL_TOOLS = [WEB_SEARCH_TOOL, PYTHON_TOOL, TERMINAL_TOOL, RENDER_HTML_TOOL]
|
||||
|
||||
|
||||
# OpenAI's function.name regex: ^[a-zA-Z0-9_-]{1,64}$ -- enforced before
|
||||
|
|
@ -608,6 +636,25 @@ async def get_enabled_mcp_tools() -> list[dict]:
|
|||
_TIMEOUT_UNSET = object()
|
||||
|
||||
|
||||
def _render_html_result(arguments: dict) -> str:
|
||||
code = arguments.get("code")
|
||||
if not isinstance(code, str) or not code.strip():
|
||||
return "Error: render_html requires a non-empty code string."
|
||||
title = arguments.get("title")
|
||||
if isinstance(title, str) and title.strip():
|
||||
safe_title = title.strip()[:120]
|
||||
return (
|
||||
f"Rendered HTML artifact: {safe_title}. Do not call render_html "
|
||||
"again in this response unless the user asks for changes. For a later "
|
||||
"user request for a new artifact, call render_html once."
|
||||
)
|
||||
return (
|
||||
"Rendered HTML artifact. Do not call render_html again in this response "
|
||||
"unless the user asks for changes. For a later user request for a new "
|
||||
"artifact, call render_html once."
|
||||
)
|
||||
|
||||
|
||||
def execute_tool(
|
||||
name: str,
|
||||
arguments: dict,
|
||||
|
|
@ -625,6 +672,8 @@ def execute_tool(
|
|||
f"execute_tool: name={name}, session_id={session_id}, timeout={timeout}"
|
||||
)
|
||||
effective_timeout = _EXEC_TIMEOUT if timeout is _TIMEOUT_UNSET else timeout
|
||||
if name == "render_html":
|
||||
return _render_html_result(arguments)
|
||||
if name.startswith(MCP_TOOL_PREFIX):
|
||||
try:
|
||||
_, server_id, tool_name = name.split("__", 2)
|
||||
|
|
|
|||
|
|
@ -435,6 +435,7 @@ from starlette.requests import Request as _StarletteRequest # noqa: E402
|
|||
|
||||
|
||||
_CSP_SCRIPT_NONCE_HEADER = "x-internal-script-nonce"
|
||||
_ARTIFACT_PREVIEW_FRAME_PATH = "/api/inference/artifact-preview-frame"
|
||||
|
||||
|
||||
# /content is Colab's working directory — more reliable than env vars which
|
||||
|
|
@ -488,6 +489,7 @@ def _build_csp(script_nonce: "str | None" = None) -> str:
|
|||
"style-src 'self' 'unsafe-inline'; "
|
||||
f"{script_src}; "
|
||||
"font-src 'self' data:; "
|
||||
"frame-src 'self'; "
|
||||
f"frame-ancestors {frame_ancestors}; "
|
||||
"form-action 'self'; "
|
||||
"base-uri 'self'"
|
||||
|
|
@ -506,13 +508,13 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||
response.headers.setdefault("Content-Security-Policy", _build_csp(nonce))
|
||||
# Omit X-Frame-Options in Colab — CSP frame-ancestors handles it, and
|
||||
# DENY would block serve_kernel_port_as_iframe regardless of CSP.
|
||||
if not _IS_COLAB:
|
||||
if not _IS_COLAB and request.url.path != _ARTIFACT_PREVIEW_FRAME_PATH:
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
||||
response.headers.setdefault(
|
||||
"Permissions-Policy",
|
||||
"camera=(), microphone=(), geolocation=(), interest-cohort=()",
|
||||
"camera=(), microphone=(), geolocation=()",
|
||||
)
|
||||
response.headers["server"] = "unsloth-studio"
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -709,11 +709,12 @@ class ChatCompletionRequest(BaseModel):
|
|||
enabled_tools: Optional[list[str]] = Field(
|
||||
None,
|
||||
description = (
|
||||
"[x-unsloth] List of enabled tool names. Local GGUF models accept "
|
||||
"['web_search', 'python', 'terminal']. External providers accept "
|
||||
"['web_search', 'web_fetch', 'code_execution'] for Anthropic and "
|
||||
"['web_search', 'code_execution'] for OpenAI Responses. If None, "
|
||||
"all local tools are enabled and no server-side tools are forwarded."
|
||||
"[x-unsloth] List of enabled tool names. Local GGUF/safetensors models "
|
||||
"accept ['web_search', 'python', 'terminal', 'render_html']. External "
|
||||
"providers accept ['web_search', 'web_fetch', 'code_execution'] for "
|
||||
"Anthropic and ['web_search', 'code_execution', 'image_generation'] for "
|
||||
"OpenAI Responses. If None, all local tools are enabled and no "
|
||||
"server-side tools are forwarded."
|
||||
),
|
||||
)
|
||||
mcp_enabled: Optional[bool] = Field(
|
||||
|
|
|
|||
|
|
@ -134,6 +134,8 @@ class ChatSettingsPayload(BaseModel):
|
|||
Literal["none", "minimal", "low", "medium", "high", "max", "xhigh"]
|
||||
] = None
|
||||
preserveThinking: Optional[bool] = None
|
||||
collapseHtmlArtifacts: Optional[bool] = None
|
||||
allowArtifactNetworkAccess: Optional[bool] = None
|
||||
autoHealToolCalls: Optional[bool] = None
|
||||
maxToolCallsPerMessage: Optional[int] = Field(default = None, ge = 1)
|
||||
toolCallTimeout: Optional[int] = Field(default = None, ge = 1)
|
||||
|
|
|
|||
|
|
@ -239,6 +239,135 @@ router = APIRouter()
|
|||
studio_router = APIRouter()
|
||||
|
||||
|
||||
_ARTIFACT_PREVIEW_FRAME_ANCESTORS = "'self' tauri://localhost http://tauri.localhost"
|
||||
_ARTIFACT_PREVIEW_FRAME_STRICT_CSP = (
|
||||
"default-src 'none'; "
|
||||
"script-src 'unsafe-inline'; "
|
||||
"style-src 'unsafe-inline'; "
|
||||
"img-src data: blob:; "
|
||||
"font-src data:; "
|
||||
"media-src data: blob:; "
|
||||
"connect-src 'none'; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'none'; "
|
||||
"form-action 'none'; "
|
||||
f"frame-ancestors {_ARTIFACT_PREVIEW_FRAME_ANCESTORS}; "
|
||||
"sandbox allow-scripts"
|
||||
)
|
||||
_ARTIFACT_PREVIEW_FRAME_NETWORK_CSP = (
|
||||
"default-src http: https: data: blob:; "
|
||||
"script-src 'unsafe-inline' 'unsafe-eval' http: https: data: blob:; "
|
||||
"script-src-elem 'unsafe-inline' http: https: data: blob:; "
|
||||
"style-src 'unsafe-inline' http: https: data: blob:; "
|
||||
"style-src-elem 'unsafe-inline' http: https: data: blob:; "
|
||||
"img-src http: https: data: blob:; "
|
||||
"font-src http: https: data: blob:; "
|
||||
"media-src http: https: data: blob:; "
|
||||
"connect-src http: https: ws: wss: data: blob:; "
|
||||
"worker-src http: https: blob:; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'none'; "
|
||||
"form-action 'none'; "
|
||||
f"frame-ancestors {_ARTIFACT_PREVIEW_FRAME_ANCESTORS}; "
|
||||
"sandbox allow-scripts"
|
||||
)
|
||||
_ARTIFACT_PREVIEW_FRAME_HTML = """<!doctype html>
|
||||
<html>
|
||||
<head><meta charset=\"utf-8\" /></head>
|
||||
<body>
|
||||
<script>
|
||||
(() => {
|
||||
const createMemoryStorage = () => {
|
||||
const data = new Map();
|
||||
return {
|
||||
get length() { return data.size; },
|
||||
key: (index) => Array.from(data.keys())[index] ?? null,
|
||||
getItem: (key) => data.has(String(key)) ? data.get(String(key)) : null,
|
||||
setItem: (key, value) => data.set(String(key), String(value)),
|
||||
removeItem: (key) => data.delete(String(key)),
|
||||
clear: () => data.clear(),
|
||||
};
|
||||
};
|
||||
const installStorageFallback = (name) => {
|
||||
try {
|
||||
void window[name];
|
||||
return;
|
||||
} catch {
|
||||
// Opaque-origin sandboxed frames throw SecurityError for Web Storage.
|
||||
}
|
||||
try {
|
||||
Object.defineProperty(window, name, {
|
||||
value: createMemoryStorage(),
|
||||
configurable: true,
|
||||
});
|
||||
} catch {
|
||||
// Leave the sandbox failure contained in the artifact if the
|
||||
// browser refuses to shadow the Web Storage accessor.
|
||||
}
|
||||
};
|
||||
const installStorageFallbacks = () => {
|
||||
installStorageFallback("localStorage");
|
||||
installStorageFallback("sessionStorage");
|
||||
};
|
||||
const render = (html) => {
|
||||
installStorageFallbacks();
|
||||
document.open();
|
||||
document.write(html);
|
||||
document.close();
|
||||
};
|
||||
installStorageFallbacks();
|
||||
window.addEventListener("message", (event) => {
|
||||
const data = event.data;
|
||||
if (!data || data.type !== "unsloth:artifact-html" || typeof data.html !== "string") return;
|
||||
render(data.html);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
@studio_router.get("/artifact-preview-frame", include_in_schema = False)
|
||||
async def artifact_preview_frame(
|
||||
request: Request,
|
||||
allow_network: bool = False,
|
||||
token: Optional[str] = None,
|
||||
):
|
||||
"""Serve the opaque sandbox shell used for client-side HTML artifacts."""
|
||||
|
||||
if allow_network:
|
||||
auth_header = request.headers.get("authorization")
|
||||
if auth_header and auth_header.lower().startswith("bearer "):
|
||||
jwt_token = auth_header[7:]
|
||||
elif token:
|
||||
jwt_token = token
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code = status.HTTP_401_UNAUTHORIZED,
|
||||
detail = "Missing authentication token",
|
||||
)
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
|
||||
creds = HTTPAuthorizationCredentials(scheme = "Bearer", credentials = jwt_token)
|
||||
await get_current_subject(creds)
|
||||
|
||||
csp = (
|
||||
_ARTIFACT_PREVIEW_FRAME_NETWORK_CSP
|
||||
if allow_network
|
||||
else _ARTIFACT_PREVIEW_FRAME_STRICT_CSP
|
||||
)
|
||||
return Response(
|
||||
content = _ARTIFACT_PREVIEW_FRAME_HTML,
|
||||
media_type = "text/html; charset=utf-8",
|
||||
headers = {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Security-Policy": csp,
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _detect_safetensors_features(backend, chat_template: Optional[str]) -> dict:
|
||||
"""Classify reasoning/tool capabilities via the GGUF classifier so
|
||||
flags match across backends. gpt-oss is overridden because Harmony
|
||||
|
|
@ -419,13 +548,28 @@ async def _await_cancel_then_close(cancel_event, resp) -> None:
|
|||
return
|
||||
|
||||
|
||||
# Appended to tool-use nudge to discourage plan-without-action
|
||||
# Appended to tool-use nudge to discourage plan-without-action.
|
||||
# Keep render_html guidance gated to turns where the artifact tool is actually
|
||||
# present in the tool schema; otherwise small local models can hallucinate a
|
||||
# missing tool call instead of following the fenced-HTML fallback prompt.
|
||||
_TOOL_ACTION_NUDGE = (
|
||||
" IMPORTANT: Always call tools directly -- never write code yourself."
|
||||
" Never describe what you plan to do -- just call the tool immediately."
|
||||
" For any code request, call the python tool. For any factual question, call web_search."
|
||||
" Do NOT output code blocks -- use the python tool instead."
|
||||
" For non-artifact code requests, call the python tool when it is available."
|
||||
" For factual questions that require current information, call web_search when it is available."
|
||||
" Do NOT output raw code blocks when an enabled tool can satisfy the request."
|
||||
)
|
||||
_ARTIFACT_TOOL_ACTION_NUDGE = (
|
||||
" For HTML, CSS, or JavaScript artifact requests, call render_html once when "
|
||||
"it is available. After render_html succeeds, do not call it again in the "
|
||||
"same response unless the user asks for changes. Future user requests for "
|
||||
"new artifacts may call render_html once."
|
||||
)
|
||||
|
||||
|
||||
def _tool_action_nudge(has_artifact: bool) -> str:
|
||||
return _TOOL_ACTION_NUDGE + (_ARTIFACT_TOOL_ACTION_NUDGE if has_artifact else "")
|
||||
|
||||
|
||||
# Strip tool-call XML the speculative buffer in core/inference/llama_cpp.py
|
||||
# split across the visible/DRAIN boundary. Four leak shapes:
|
||||
|
|
@ -2698,6 +2842,7 @@ async def openai_chat_completions(
|
|||
_tool_names = {t["function"]["name"] for t in tools_to_use}
|
||||
_has_web = "web_search" in _tool_names
|
||||
_has_code = "python" in _tool_names or "terminal" in _tool_names
|
||||
_has_artifact = "render_html" in _tool_names
|
||||
|
||||
_date_line = f"The current date is {_date.today().isoformat()}."
|
||||
|
||||
|
|
@ -2719,34 +2864,34 @@ async def openai_chat_completions(
|
|||
"Use code execution for math, calculations, data processing, "
|
||||
"or to parse and analyze information from tool results."
|
||||
)
|
||||
_artifact_tips = (
|
||||
"Use render_html for HTML, CSS, or JavaScript artifact requests "
|
||||
"with one complete self-contained HTML document in the code argument. "
|
||||
"Call it once, then do not call it again in the same response unless "
|
||||
"the user asks for changes. Future user requests for new artifacts may "
|
||||
"call render_html once."
|
||||
)
|
||||
|
||||
if _has_web and _has_code:
|
||||
_tool_tip_parts = []
|
||||
if _has_web:
|
||||
_tool_tip_parts.append(_web_tips)
|
||||
if _has_code:
|
||||
_tool_tip_parts.append(_code_tips)
|
||||
if _has_artifact:
|
||||
_tool_tip_parts.append(_artifact_tips)
|
||||
|
||||
if _tool_tip_parts:
|
||||
_nudge = (
|
||||
_date_line + " "
|
||||
"You have access to tools. When appropriate, prefer using "
|
||||
"tools rather than answering from memory. "
|
||||
+ _web_tips
|
||||
+ " "
|
||||
+ _code_tips
|
||||
)
|
||||
elif _has_code:
|
||||
_nudge = (
|
||||
_date_line + " "
|
||||
"You have access to tools. When appropriate, prefer using "
|
||||
"code execution rather than answering from memory. " + _code_tips
|
||||
)
|
||||
elif _has_web:
|
||||
_nudge = (
|
||||
_date_line + " "
|
||||
"You have access to tools. When appropriate, prefer using "
|
||||
"web search for up-to-date or uncertain factual "
|
||||
"information rather than answering from memory. " + _web_tips
|
||||
+ " ".join(_tool_tip_parts)
|
||||
)
|
||||
else:
|
||||
_nudge = ""
|
||||
|
||||
if _nudge:
|
||||
_nudge += _TOOL_ACTION_NUDGE
|
||||
_nudge += _tool_action_nudge(_has_artifact)
|
||||
# Append nudge to system prompt (preserve user's prompt)
|
||||
if system_prompt:
|
||||
system_prompt = system_prompt.rstrip() + "\n\n" + _nudge
|
||||
|
|
@ -3221,6 +3366,7 @@ async def openai_chat_completions(
|
|||
_sf_tool_names = {t["function"]["name"] for t in _sf_tools_to_use}
|
||||
_sf_has_web = "web_search" in _sf_tool_names
|
||||
_sf_has_code = "python" in _sf_tool_names or "terminal" in _sf_tool_names
|
||||
_sf_has_artifact = "render_html" in _sf_tool_names
|
||||
|
||||
_sf_date_line = f"The current date is {_date.today().isoformat()}."
|
||||
_sf_model_size_b = _extract_model_size_b(model_name)
|
||||
|
|
@ -3239,35 +3385,35 @@ async def openai_chat_completions(
|
|||
"Use code execution for math, calculations, data processing, "
|
||||
"or to parse and analyze information from tool results."
|
||||
)
|
||||
_sf_artifact_tips = (
|
||||
"Use render_html for HTML, CSS, or JavaScript artifact requests "
|
||||
"with one complete self-contained HTML document in the code argument. "
|
||||
"Call it once, then do not call it again in the same response unless "
|
||||
"the user asks for changes. Future user requests for new artifacts may "
|
||||
"call render_html once."
|
||||
)
|
||||
|
||||
if _sf_has_web and _sf_has_code:
|
||||
_sf_tool_tip_parts = []
|
||||
if _sf_has_web:
|
||||
_sf_tool_tip_parts.append(_sf_web_tips)
|
||||
if _sf_has_code:
|
||||
_sf_tool_tip_parts.append(_sf_code_tips)
|
||||
if _sf_has_artifact:
|
||||
_sf_tool_tip_parts.append(_sf_artifact_tips)
|
||||
|
||||
if _sf_tool_tip_parts:
|
||||
_sf_nudge = (
|
||||
_sf_date_line + " "
|
||||
"You have access to tools. When appropriate, prefer using "
|
||||
"tools rather than answering from memory. "
|
||||
+ _sf_web_tips
|
||||
+ " "
|
||||
+ _sf_code_tips
|
||||
)
|
||||
elif _sf_has_code:
|
||||
_sf_nudge = (
|
||||
_sf_date_line + " "
|
||||
"You have access to tools. When appropriate, prefer using "
|
||||
"code execution rather than answering from memory. " + _sf_code_tips
|
||||
)
|
||||
elif _sf_has_web:
|
||||
_sf_nudge = (
|
||||
_sf_date_line + " "
|
||||
"You have access to tools. When appropriate, prefer using "
|
||||
"web search for up-to-date or uncertain factual "
|
||||
"information rather than answering from memory. " + _sf_web_tips
|
||||
+ " ".join(_sf_tool_tip_parts)
|
||||
)
|
||||
else:
|
||||
_sf_nudge = ""
|
||||
|
||||
_sf_system_prompt = system_prompt
|
||||
if _sf_nudge:
|
||||
_sf_nudge += _TOOL_ACTION_NUDGE
|
||||
_sf_nudge += _tool_action_nudge(_sf_has_artifact)
|
||||
if _sf_system_prompt:
|
||||
_sf_system_prompt = _sf_system_prompt.rstrip() + "\n\n" + _sf_nudge
|
||||
else:
|
||||
|
|
@ -4914,6 +5060,7 @@ async def anthropic_messages(
|
|||
_tool_names = {t["function"]["name"] for t in openai_tools}
|
||||
_has_web = "web_search" in _tool_names
|
||||
_has_code = "python" in _tool_names or "terminal" in _tool_names
|
||||
_has_artifact = "render_html" in _tool_names
|
||||
|
||||
_date_line = f"The current date is {_date.today().isoformat()}."
|
||||
_model_size_b = _extract_model_size_b(model_name)
|
||||
|
|
@ -4932,34 +5079,33 @@ async def anthropic_messages(
|
|||
"Use code execution for math, calculations, data processing, "
|
||||
"or to parse and analyze information from tool results."
|
||||
)
|
||||
_artifact_tips = (
|
||||
"Use render_html for HTML, CSS, or JavaScript artifact requests "
|
||||
"with one complete self-contained HTML document in the code argument. "
|
||||
"Call it once, then do not call it again in the same response unless "
|
||||
"the user asks for changes. Future user requests for new artifacts may "
|
||||
"call render_html once."
|
||||
)
|
||||
|
||||
if _has_web and _has_code:
|
||||
_tool_tip_parts = []
|
||||
if _has_web:
|
||||
_tool_tip_parts.append(_web_tips)
|
||||
if _has_code:
|
||||
_tool_tip_parts.append(_code_tips)
|
||||
if _has_artifact:
|
||||
_tool_tip_parts.append(_artifact_tips)
|
||||
|
||||
if _tool_tip_parts:
|
||||
_nudge = (
|
||||
_date_line + " "
|
||||
"You have access to tools. When appropriate, prefer using "
|
||||
"tools rather than answering from memory. "
|
||||
+ _web_tips
|
||||
+ " "
|
||||
+ _code_tips
|
||||
)
|
||||
elif _has_code:
|
||||
_nudge = (
|
||||
_date_line + " "
|
||||
"You have access to tools. When appropriate, prefer using "
|
||||
"code execution rather than answering from memory. " + _code_tips
|
||||
)
|
||||
elif _has_web:
|
||||
_nudge = (
|
||||
_date_line + " "
|
||||
"You have access to tools. When appropriate, prefer using "
|
||||
"web search for up-to-date or uncertain factual "
|
||||
"information rather than answering from memory. " + _web_tips
|
||||
"tools rather than answering from memory. " + " ".join(_tool_tip_parts)
|
||||
)
|
||||
else:
|
||||
_nudge = ""
|
||||
|
||||
if _nudge:
|
||||
_nudge += _TOOL_ACTION_NUDGE
|
||||
_nudge += _tool_action_nudge(_has_artifact)
|
||||
# Inject into system prompt
|
||||
if openai_messages and openai_messages[0].get("role") == "system":
|
||||
openai_messages[0]["content"] = (
|
||||
|
|
@ -5147,6 +5293,7 @@ async def _anthropic_tool_non_streaming(run_gen, message_id, model_name):
|
|||
baseline, not against turn N's final length.
|
||||
"""
|
||||
content_blocks: list = []
|
||||
tool_blocks_by_id: dict[str, AnthropicResponseToolUseBlock] = {}
|
||||
usage = {}
|
||||
prev_text = ""
|
||||
|
||||
|
|
@ -5165,13 +5312,25 @@ async def _anthropic_tool_non_streaming(run_gen, message_id, model_name):
|
|||
else:
|
||||
content_blocks.append(AnthropicResponseTextBlock(text = new))
|
||||
elif etype == "tool_start":
|
||||
content_blocks.append(
|
||||
AnthropicResponseToolUseBlock(
|
||||
id = event["tool_call_id"],
|
||||
name = event["tool_name"],
|
||||
input = event.get("arguments", {}),
|
||||
)
|
||||
tool_call_id = event["tool_call_id"]
|
||||
arguments = event.get("arguments", {})
|
||||
existing_tool_block = (
|
||||
tool_blocks_by_id.get(tool_call_id) if tool_call_id else None
|
||||
)
|
||||
if existing_tool_block is not None:
|
||||
if arguments or not existing_tool_block.input:
|
||||
existing_tool_block.input = arguments
|
||||
if event.get("tool_name") and not existing_tool_block.name:
|
||||
existing_tool_block.name = event["tool_name"]
|
||||
else:
|
||||
tool_block = AnthropicResponseToolUseBlock(
|
||||
id = tool_call_id,
|
||||
name = event["tool_name"],
|
||||
input = arguments,
|
||||
)
|
||||
if tool_call_id:
|
||||
tool_blocks_by_id[tool_call_id] = tool_block
|
||||
content_blocks.append(tool_block)
|
||||
elif etype == "tool_end":
|
||||
prev_text = ""
|
||||
elif etype == "metadata":
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ from routes.inference import (
|
|||
_normalize_anthropic_openai_images,
|
||||
_select_anthropic_server_tools,
|
||||
_anthropic_requested_studio_tools,
|
||||
_anthropic_tool_non_streaming,
|
||||
anthropic_messages,
|
||||
)
|
||||
from state.tool_policy import reset_tool_policy, set_tool_policy
|
||||
|
|
@ -551,6 +552,54 @@ class TestAnthropicStreamEmitter:
|
|||
assert "tool_use" in events[1]
|
||||
assert "input_json_delta" in events[2]
|
||||
|
||||
def test_duplicate_tool_start_merges_into_open_tool_block(self):
|
||||
e = AnthropicStreamEmitter()
|
||||
e.start("msg_1", "m")
|
||||
first_events = e.feed(
|
||||
{
|
||||
"type": "tool_start",
|
||||
"tool_name": "render_html",
|
||||
"tool_call_id": "call_0",
|
||||
"arguments": {},
|
||||
}
|
||||
)
|
||||
second_events = e.feed(
|
||||
{
|
||||
"type": "tool_start",
|
||||
"tool_name": "render_html",
|
||||
"tool_call_id": "call_0",
|
||||
"arguments": {"code": "<!doctype html><html></html>"},
|
||||
}
|
||||
)
|
||||
|
||||
first_payloads = [
|
||||
json.loads(event.split("data: ")[1]) for event in first_events
|
||||
]
|
||||
second_payloads = [
|
||||
json.loads(event.split("data: ")[1]) for event in second_events
|
||||
]
|
||||
|
||||
tool_starts = [
|
||||
payload
|
||||
for payload in first_payloads + second_payloads
|
||||
if payload["type"] == "content_block_start"
|
||||
and payload["content_block"]["type"] == "tool_use"
|
||||
]
|
||||
assert len(tool_starts) == 1
|
||||
assert tool_starts[0]["content_block"]["id"] == "call_0"
|
||||
assert second_payloads == [
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": tool_starts[0]["index"],
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": json.dumps(
|
||||
{"code": "<!doctype html><html></html>"}
|
||||
),
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
def test_tool_end_closes_tool_opens_new_text_block(self):
|
||||
e = AnthropicStreamEmitter()
|
||||
e.start("msg_1", "m")
|
||||
|
|
@ -674,6 +723,49 @@ class TestAnthropicStreamEmitter:
|
|||
assert parsed["delta"]["text"] == "After tool"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Non-streaming tool response tests
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class TestAnthropicToolNonStreaming:
|
||||
def test_duplicate_tool_start_replaces_provisional_tool_block(self):
|
||||
def _run_gen():
|
||||
yield {
|
||||
"type": "tool_start",
|
||||
"tool_name": "render_html",
|
||||
"tool_call_id": "call_0",
|
||||
"arguments": {},
|
||||
}
|
||||
yield {
|
||||
"type": "tool_start",
|
||||
"tool_name": "render_html",
|
||||
"tool_call_id": "call_0",
|
||||
"arguments": {"code": "<!doctype html><html></html>"},
|
||||
}
|
||||
yield {
|
||||
"type": "tool_end",
|
||||
"tool_name": "render_html",
|
||||
"tool_call_id": "call_0",
|
||||
"result": "Rendered HTML artifact.",
|
||||
}
|
||||
|
||||
response = asyncio.run(_anthropic_tool_non_streaming(_run_gen, "msg_1", "m"))
|
||||
body = json.loads(response.body)
|
||||
tool_blocks = [
|
||||
block for block in body["content"] if block["type"] == "tool_use"
|
||||
]
|
||||
|
||||
assert tool_blocks == [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "call_0",
|
||||
"name": "render_html",
|
||||
"input": {"code": "<!doctype html><html></html>"},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Pass-through emitter tests (client-side tool execution path)
|
||||
# =====================================================================
|
||||
|
|
|
|||
|
|
@ -28,12 +28,14 @@ Edge cases under coverage:
|
|||
"""
|
||||
|
||||
import threading
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from core.inference import safetensors_agentic
|
||||
from core.inference.safetensors_agentic import (
|
||||
_coerce_arguments,
|
||||
_detect_render_html_tool_start,
|
||||
run_safetensors_tool_loop,
|
||||
)
|
||||
from core.inference.tool_call_parser import (
|
||||
|
|
@ -97,6 +99,17 @@ class TestParser:
|
|||
assert len(result) == 1
|
||||
assert "print('hi')" in result[0]["function"]["arguments"]
|
||||
|
||||
def test_function_signal_inside_parameter_is_literal(self):
|
||||
text = (
|
||||
"<function=python>"
|
||||
"<parameter=code>print('<function=render_html>')</parameter>"
|
||||
"</function>"
|
||||
)
|
||||
result = parse_tool_calls_from_text(text)
|
||||
assert len(result) == 1
|
||||
assert result[0]["function"]["name"] == "python"
|
||||
assert "<function=render_html>" in result[0]["function"]["arguments"]
|
||||
|
||||
def test_multiple_calls(self):
|
||||
text = (
|
||||
'<tool_call>{"name":"web_search","arguments":{"query":"a"}}</tool_call>'
|
||||
|
|
@ -118,6 +131,18 @@ class TestParser:
|
|||
assert has_tool_signal("hi <function=foo>...")
|
||||
assert not has_tool_signal("hello world")
|
||||
|
||||
def test_render_html_start_detector_uses_first_tool(self):
|
||||
assert _detect_render_html_tool_start("<function=render_html>")
|
||||
assert _detect_render_html_tool_start(
|
||||
'<tool_call>{"name":"render_html","arguments":{"code":"<html>"}'
|
||||
)
|
||||
assert not _detect_render_html_tool_start(
|
||||
"<function=python><parameter=code>'<function=render_html>'"
|
||||
)
|
||||
assert not _detect_render_html_tool_start(
|
||||
'<tool_call>{"name":"python","arguments":{"code":"<function=render_html>"}}'
|
||||
)
|
||||
|
||||
def test_strip_markup_closed(self):
|
||||
text = "before <tool_call>{}</tool_call> after"
|
||||
assert strip_tool_markup(text) == "before after"
|
||||
|
|
@ -280,6 +305,104 @@ class TestLoopBasic:
|
|||
contents = [e for e in events if e["type"] == "content"]
|
||||
assert "Result: 1" in contents[-1]["text"]
|
||||
|
||||
def test_render_html_emits_provisional_tool_start(self):
|
||||
exec_fn = FakeExecuteTool(["Rendered HTML artifact."])
|
||||
turn_iter = iter(
|
||||
[
|
||||
[
|
||||
"<function=render_html>",
|
||||
"<parameter=code><!doctype html><html>",
|
||||
"<body>Hi</body></html></parameter></function>",
|
||||
],
|
||||
["Done."],
|
||||
]
|
||||
)
|
||||
|
||||
def _gen(_messages):
|
||||
chunks = next(turn_iter)
|
||||
acc = ""
|
||||
for chunk in chunks:
|
||||
acc += chunk
|
||||
yield acc
|
||||
|
||||
loop = run_safetensors_tool_loop(
|
||||
single_turn = _gen,
|
||||
messages = [{"role": "user", "content": "make html"}],
|
||||
tools = [{"type": "function", "function": {"name": "render_html"}}],
|
||||
execute_tool = exec_fn,
|
||||
)
|
||||
events = _collect_events(loop)
|
||||
tool_starts = [e for e in events if e["type"] == "tool_start"]
|
||||
|
||||
assert len(tool_starts) == 2
|
||||
assert tool_starts[0]["tool_name"] == "render_html"
|
||||
assert tool_starts[0]["arguments"] == {}
|
||||
assert tool_starts[1]["tool_name"] == "render_html"
|
||||
assert "<!doctype html>" in tool_starts[1]["arguments"]["code"]
|
||||
assert exec_fn.calls[0][0] == "render_html"
|
||||
assert "<!doctype html>" in exec_fn.calls[0][1]["code"]
|
||||
|
||||
def test_python_tool_containing_render_html_signal_does_not_emit_provisional_start(
|
||||
self,
|
||||
):
|
||||
loop, exec_fn = _make_loop(
|
||||
turns = [
|
||||
[
|
||||
"<function=python>",
|
||||
"<parameter=code>print('<function=render_html>')",
|
||||
"</parameter></function>",
|
||||
],
|
||||
["Done."],
|
||||
],
|
||||
exec_results = ["ok"],
|
||||
)
|
||||
events = _collect_events(loop)
|
||||
tool_starts = [e for e in events if e["type"] == "tool_start"]
|
||||
|
||||
assert len(tool_starts) == 1
|
||||
assert tool_starts[0]["tool_name"] == "python"
|
||||
assert exec_fn.calls == [
|
||||
("python", {"code": "print('<function=render_html>')"})
|
||||
]
|
||||
|
||||
def test_render_html_success_blocks_second_artifact_call(self):
|
||||
exec_fn = FakeExecuteTool(["Rendered HTML artifact."])
|
||||
turn_iter = iter(
|
||||
[
|
||||
[
|
||||
'<tool_call>{"name":"render_html",',
|
||||
'"arguments":{"code":"<html>one</html>"}}',
|
||||
],
|
||||
[
|
||||
'<tool_call>{"name":"render_html",',
|
||||
'"arguments":{"code":"<html>two</html>"}}',
|
||||
],
|
||||
["Done."],
|
||||
]
|
||||
)
|
||||
|
||||
def _gen(_messages):
|
||||
chunks = next(turn_iter)
|
||||
acc = ""
|
||||
for chunk in chunks:
|
||||
acc += chunk
|
||||
yield acc
|
||||
|
||||
loop = run_safetensors_tool_loop(
|
||||
single_turn = _gen,
|
||||
messages = [{"role": "user", "content": "make html"}],
|
||||
tools = [{"type": "function", "function": {"name": "render_html"}}],
|
||||
execute_tool = exec_fn,
|
||||
)
|
||||
events = _collect_events(loop)
|
||||
tool_starts = [e for e in events if e["type"] == "tool_start"]
|
||||
|
||||
assert exec_fn.calls == [("render_html", {"code": "<html>one</html>"})]
|
||||
assert [e["arguments"] for e in tool_starts] == [
|
||||
{},
|
||||
{"code": "<html>one</html>"},
|
||||
]
|
||||
|
||||
def test_truncated_unclosed_tool_call(self):
|
||||
loop, exec_fn = _make_loop(
|
||||
turns = [
|
||||
|
|
@ -595,6 +718,7 @@ class TestChatTemplateHelper:
|
|||
tok = self._Tok({"tools", "enable_thinking"})
|
||||
self.apply(tok, [], tools = [{}], enable_thinking = True)
|
||||
assert tok.call_count == 1
|
||||
assert tok.last_kwargs is not None
|
||||
assert "tools" in tok.last_kwargs
|
||||
assert "enable_thinking" in tok.last_kwargs
|
||||
|
||||
|
|
@ -781,7 +905,7 @@ class TestGptOssNameDetection:
|
|||
|
||||
def test_empty_or_none_returns_false(self):
|
||||
assert is_gpt_oss_model_name("") is False
|
||||
assert is_gpt_oss_model_name(None) is False
|
||||
assert is_gpt_oss_model_name(cast(str, None)) is False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -3,18 +3,19 @@
|
|||
|
||||
"use client";
|
||||
|
||||
import { ArtifactCard, useChatRuntimeStore } from "@/features/chat";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { preprocessLaTeX } from "@/lib/latex";
|
||||
import { openLink } from "@/lib/open-link";
|
||||
import { INTERNAL, useMessagePartText } from "@assistant-ui/react";
|
||||
import { INTERNAL, useAuiState, useMessagePartText } from "@assistant-ui/react";
|
||||
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { createCodePlugin } from "./code-plugin";
|
||||
import { createMathPlugin } from "@streamdown/math";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import { DownloadIcon, Maximize2Icon, Minimize2Icon } from "lucide-react";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Block, type BlockProps, Streamdown } from "streamdown";
|
||||
import { createCodePlugin } from "./code-plugin";
|
||||
import "katex/dist/katex.min.css";
|
||||
import { AudioPlayer } from "./audio-player";
|
||||
import { unslothDarkTheme, unslothLightTheme } from "./code-themes";
|
||||
|
|
@ -26,11 +27,7 @@ const code = createCodePlugin({
|
|||
const { withSmoothContextProvider } = INTERNAL;
|
||||
|
||||
const STREAMDOWN_COMPONENTS = {
|
||||
a: ({
|
||||
href,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"a">) => (
|
||||
a: ({ href, children, ...props }: React.ComponentProps<"a">) => (
|
||||
<a
|
||||
href={href}
|
||||
rel="noopener noreferrer"
|
||||
|
|
@ -59,6 +56,34 @@ type CodeFence = {
|
|||
source: string;
|
||||
};
|
||||
|
||||
type ToolCallPartLike = {
|
||||
type?: string;
|
||||
toolName?: string;
|
||||
args?: unknown;
|
||||
result?: unknown;
|
||||
};
|
||||
|
||||
function isRenderableRenderHtmlToolPart(part: unknown): boolean {
|
||||
const toolPart = part as ToolCallPartLike;
|
||||
if (toolPart.type !== "tool-call" || toolPart.toolName !== "render_html") {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
typeof toolPart.result === "string" &&
|
||||
toolPart.result.startsWith("Error:")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
typeof toolPart.result === "string" &&
|
||||
toolPart.result.startsWith("Rendered HTML artifact")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const args = toolPart.args as { code?: unknown } | undefined;
|
||||
return typeof args?.code === "string" && args.code.trim().length > 0;
|
||||
}
|
||||
|
||||
function getMermaidSource(blockContent: string): string | null {
|
||||
const source = blockContent.match(MERMAID_SOURCE_RE)?.[1]?.trim();
|
||||
return source && source.length > 0 ? source : null;
|
||||
|
|
@ -123,7 +148,13 @@ function isHtmlFence(codeFence: CodeFence): boolean {
|
|||
return lang === "html" && !isSvgFence(codeFence);
|
||||
}
|
||||
|
||||
const UNSAFE_SVG_RE = /<script[\s>]|on\w+\s*=|javascript:|<foreignObject[\s>]|<iframe[\s>]|<embed[\s>]|<object[\s>]/i;
|
||||
function isFullHtmlDocument(source: string): boolean {
|
||||
const trimmed = source.trimStart();
|
||||
return /^<!doctype\s+html\b/i.test(trimmed) || /^<html[\s>]/i.test(trimmed);
|
||||
}
|
||||
|
||||
const UNSAFE_SVG_RE =
|
||||
/<script[\s>]|on\w+\s*=|javascript:|<foreignObject[\s>]|<iframe[\s>]|<embed[\s>]|<object[\s>]/i;
|
||||
|
||||
function sanitizeSvg(source: string): string | null {
|
||||
if (UNSAFE_SVG_RE.test(source)) return null;
|
||||
|
|
@ -145,96 +176,6 @@ function SvgPreview({ source }: { source: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
const HTML_PREVIEW_DEFAULT_HEIGHT = 400;
|
||||
const HTML_PREVIEW_MAX_HEIGHT = 800;
|
||||
|
||||
function HtmlPreview({ source }: { source: string }) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [height, setHeight] = useState(HTML_PREVIEW_DEFAULT_HEIGHT);
|
||||
const [enlarged, setEnlarged] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MessageEvent) => {
|
||||
if (e.source !== iframeRef.current?.contentWindow) return;
|
||||
if (typeof e.data?.htmlPreviewHeight === "number") {
|
||||
setHeight(Math.min(Math.max(e.data.htmlPreviewHeight, 100), HTML_PREVIEW_MAX_HEIGHT));
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", handler);
|
||||
return () => window.removeEventListener("message", handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enlarged) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setEnlarged(false);
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [enlarged]);
|
||||
|
||||
const resizeScript = `<script>new ResizeObserver(()=>{
|
||||
parent.postMessage({htmlPreviewHeight:document.documentElement.scrollHeight},"*");
|
||||
}).observe(document.documentElement);</script>`;
|
||||
|
||||
const srcDoc = source + resizeScript;
|
||||
|
||||
if (enlarged) {
|
||||
return (
|
||||
<>
|
||||
<div className="mt-2 overflow-hidden rounded-lg border border-border" style={{ height }}>
|
||||
{/* Placeholder keeps layout stable while overlay is shown */}
|
||||
</div>
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex flex-col bg-background/80 backdrop-blur-sm"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setEnlarged(false); }}
|
||||
>
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={() => setEnlarged(false)}
|
||||
title="Exit fullscreen (Esc)"
|
||||
>
|
||||
<Minimize2Icon className="size-4" />
|
||||
Exit fullscreen
|
||||
</button>
|
||||
</div>
|
||||
<div className="mx-4 mb-4 flex-1 overflow-hidden rounded-lg border border-border bg-background">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={srcDoc}
|
||||
sandbox="allow-scripts"
|
||||
style={{ width: "100%", height: "100%", border: "none", display: "block" }}
|
||||
title="HTML preview"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/html-preview relative mt-2 overflow-hidden rounded-lg border border-border">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-2 right-2 z-10 rounded-md border border-border bg-background/80 p-1.5 text-muted-foreground opacity-0 transition-all hover:bg-muted hover:text-foreground group-hover/html-preview:opacity-100 supports-[backdrop-filter]:backdrop-blur"
|
||||
onClick={() => setEnlarged(true)}
|
||||
title="Enlarge preview"
|
||||
>
|
||||
<Maximize2Icon className="size-4" />
|
||||
</button>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={srcDoc}
|
||||
sandbox="allow-scripts"
|
||||
style={{ width: "100%", height, border: "none", display: "block" }}
|
||||
title="HTML preview"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function downloadTextFile(filename: string, text: string): void {
|
||||
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
|
@ -346,6 +287,12 @@ function CodeBlockActions({
|
|||
}
|
||||
|
||||
function StreamdownBlock(props: BlockProps) {
|
||||
const shouldCollapseHtmlArtifacts = useChatRuntimeStore(
|
||||
(state) => state.artifactsEnabled || state.collapseHtmlArtifacts,
|
||||
);
|
||||
const messageHasRenderableRenderHtmlTool = useAuiState(({ message }) =>
|
||||
message.parts.some(isRenderableRenderHtmlToolPart),
|
||||
);
|
||||
const hasMermaidFence = props.content.includes("```mermaid");
|
||||
const mermaidSource = getMermaidSource(props.content);
|
||||
const codeFence = getCodeFence(props.content);
|
||||
|
|
@ -362,7 +309,9 @@ function StreamdownBlock(props: BlockProps) {
|
|||
return (
|
||||
<div className="relative isolate">
|
||||
<div className="my-4 rounded-xl border border-border bg-muted/30 p-4">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">svg</div>
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
svg
|
||||
</div>
|
||||
<pre className="overflow-x-auto text-xs text-muted-foreground whitespace-pre-wrap break-all">
|
||||
<code>{codeFence.source}</code>
|
||||
</pre>
|
||||
|
|
@ -371,10 +320,17 @@ function StreamdownBlock(props: BlockProps) {
|
|||
);
|
||||
}
|
||||
|
||||
if (props.isIncomplete && codeFence && isHtmlFence(codeFence)) {
|
||||
if (
|
||||
shouldCollapseHtmlArtifacts &&
|
||||
!messageHasRenderableRenderHtmlTool &&
|
||||
props.isIncomplete &&
|
||||
codeFence &&
|
||||
isHtmlFence(codeFence) &&
|
||||
isFullHtmlDocument(codeFence.source)
|
||||
) {
|
||||
return (
|
||||
<div className="my-4 flex h-48 items-center justify-center rounded-xl border border-border bg-muted/30 text-sm text-muted-foreground animate-pulse">
|
||||
Loading preview...
|
||||
Loading artifact preview...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -389,8 +345,24 @@ function StreamdownBlock(props: BlockProps) {
|
|||
}
|
||||
|
||||
if (codeFence) {
|
||||
const svgSource = !props.isIncomplete && isSvgFence(codeFence) ? sanitizeSvg(codeFence.source) : null;
|
||||
const htmlSource = !props.isIncomplete && isHtmlFence(codeFence) ? codeFence.source : null;
|
||||
const svgSource =
|
||||
!props.isIncomplete && isSvgFence(codeFence)
|
||||
? sanitizeSvg(codeFence.source)
|
||||
: null;
|
||||
const htmlSource =
|
||||
shouldCollapseHtmlArtifacts &&
|
||||
!messageHasRenderableRenderHtmlTool &&
|
||||
!props.isIncomplete &&
|
||||
isHtmlFence(codeFence) &&
|
||||
isFullHtmlDocument(codeFence.source)
|
||||
? codeFence.source
|
||||
: null;
|
||||
if (htmlSource) {
|
||||
return (
|
||||
<ArtifactCard code={htmlSource} title="HTML preview" source="fence" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative isolate">
|
||||
|
|
@ -402,7 +374,6 @@ function StreamdownBlock(props: BlockProps) {
|
|||
/>
|
||||
</div>
|
||||
{svgSource && <SvgPreview source={svgSource} />}
|
||||
{htmlSource && <HtmlPreview source={htmlSource} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
|||
import { ToolGroup } from "@/components/assistant-ui/tool-group";
|
||||
import { CodeExecutionToolUI } from "@/components/assistant-ui/tool-ui-code-execution";
|
||||
import { ImageGenerationToolUI } from "@/components/assistant-ui/tool-ui-image-generation";
|
||||
import { RenderHtmlToolUI } from "@/components/assistant-ui/tool-ui-render-html";
|
||||
import { PythonToolUI } from "@/components/assistant-ui/tool-ui-python";
|
||||
import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal";
|
||||
import { WebSearchToolUI } from "@/components/assistant-ui/tool-ui-web-search";
|
||||
|
|
@ -73,6 +74,7 @@ import {
|
|||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
DownloadIcon,
|
||||
FileTextIcon,
|
||||
GlobeIcon,
|
||||
HeadphonesIcon,
|
||||
LightbulbIcon,
|
||||
|
|
@ -1117,6 +1119,29 @@ const ImagesToggle: FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const ArtifactsToggle: FC = () => {
|
||||
const modelLoaded = useChatRuntimeStore(
|
||||
(s) => !!s.params.checkpoint && !s.modelLoading,
|
||||
);
|
||||
const artifactsEnabled = useChatRuntimeStore((s) => s.artifactsEnabled);
|
||||
const setArtifactsEnabled = useChatRuntimeStore((s) => s.setArtifactsEnabled);
|
||||
const disabled = !modelLoaded;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setArtifactsEnabled(!artifactsEnabled)}
|
||||
className="composer-pill-btn"
|
||||
data-active={artifactsEnabled && !disabled ? "true" : "false"}
|
||||
aria-label={artifactsEnabled ? "Disable artifacts" : "Enable artifacts"}
|
||||
>
|
||||
<FileTextIcon className="size-3.5" />
|
||||
<span>Artifacts</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolStatusDisplay: FC = () => {
|
||||
const toolStatus = useChatRuntimeStore((s) => s.toolStatus);
|
||||
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
|
||||
|
|
@ -1189,6 +1214,7 @@ const ComposerAction: FC<{
|
|||
<WebSearchToggle />
|
||||
<CodeToolsToggle />
|
||||
<ImagesToggle />
|
||||
<ArtifactsToggle />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ComposerPrimitive.If dictation={false}>
|
||||
|
|
@ -1316,6 +1342,7 @@ const AssistantMessage: FC = () => {
|
|||
terminal: TerminalToolUI,
|
||||
code_execution: CodeExecutionToolUI,
|
||||
image_generation: ImageGenerationToolUI,
|
||||
render_html: RenderHtmlToolUI,
|
||||
},
|
||||
Fallback: ToolFallback,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
type FC,
|
||||
type PropsWithChildren,
|
||||
} from "react";
|
||||
import { useAuiState } from "@assistant-ui/react";
|
||||
import { ChevronDownIcon, LoaderIcon } from "lucide-react";
|
||||
import { Wrench01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
|
|
@ -27,7 +28,8 @@ const toolGroupVariants = cva("aui-tool-group-root group/tool-group w-full", {
|
|||
variant: {
|
||||
outline: "corner-squircle rounded-lg border py-3",
|
||||
ghost: "rounded-lg bg-muted/10 py-2",
|
||||
muted: "corner-squircle rounded-lg border border-muted-foreground/30 bg-muted/30 py-3",
|
||||
muted:
|
||||
"corner-squircle rounded-lg border border-muted-foreground/30 bg-muted/30 py-3",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "ghost" },
|
||||
|
|
@ -209,9 +211,17 @@ const ToolGroupImpl: FC<
|
|||
PropsWithChildren<{ startIndex: number; endIndex: number }>
|
||||
> = ({ children, startIndex, endIndex }) => {
|
||||
const toolCount = endIndex - startIndex + 1;
|
||||
const containsArtifactTool = useAuiState(({ message }) =>
|
||||
message.parts
|
||||
.slice(startIndex, endIndex + 1)
|
||||
.some(
|
||||
(part) => part.type === "tool-call" && part.toolName === "render_html",
|
||||
),
|
||||
);
|
||||
|
||||
// Single tool call — render directly without wrapper
|
||||
if (toolCount <= 1) {
|
||||
// Single tool calls and artifacts render directly so cards never hide inside
|
||||
// a collapsed tool group.
|
||||
if (toolCount <= 1 || containsArtifactTool) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,142 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArtifactCard,
|
||||
useChatArtifactsStore,
|
||||
useSelectedChatArtifact,
|
||||
} from "@/features/chat";
|
||||
import {
|
||||
type ToolCallMessagePartComponent,
|
||||
useAuiState,
|
||||
useToolArgsStatus,
|
||||
} from "@assistant-ui/react";
|
||||
import { BrowserIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { memo, useEffect } from "react";
|
||||
|
||||
// Context7 assistant-ui docs: tool UIs can read streaming args via
|
||||
// useToolArgsStatus, so render_html does not need to wait for tool completion.
|
||||
type RenderHtmlArgs = Record<string, unknown> & {
|
||||
code?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
const RENDER_HTML_SESSION_STARTED_AT = Date.now();
|
||||
|
||||
const RenderHtmlToolUIImpl: ToolCallMessagePartComponent = ({
|
||||
args,
|
||||
result,
|
||||
status,
|
||||
toolCallId,
|
||||
}) => {
|
||||
const { propStatus } = useToolArgsStatus<RenderHtmlArgs>();
|
||||
const parsedArgs = (args as RenderHtmlArgs) ?? {};
|
||||
const code = typeof parsedArgs.code === "string" ? parsedArgs.code : "";
|
||||
const hasCode = code.trim().length > 0;
|
||||
const title =
|
||||
typeof parsedArgs.title === "string" ? parsedArgs.title : "HTML artifact";
|
||||
const isRunning = status?.type === "running";
|
||||
const codeIsStreaming = propStatus.code === "streaming";
|
||||
|
||||
// Surface the backend error when the tool call completed with invalid
|
||||
// args. Backend success results start with "Rendered HTML artifact";
|
||||
// error results start with "Error:".
|
||||
const errorText =
|
||||
status?.type === "complete" &&
|
||||
typeof result === "string" &&
|
||||
result.startsWith("Error:")
|
||||
? result
|
||||
: null;
|
||||
const messageId = useAuiState(({ message }) => message.id) ?? null;
|
||||
const isMessageRunning = useAuiState(
|
||||
({ message }) => message.status?.type === "running",
|
||||
);
|
||||
const messageCreatedAtMs = useAuiState(({ message }) =>
|
||||
message.createdAt instanceof Date ? message.createdAt.getTime() : null,
|
||||
);
|
||||
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
|
||||
const isLiveGeneratingArtifact =
|
||||
isThreadRunning && isMessageRunning && (isRunning || codeIsStreaming);
|
||||
const isStaleGeneratingArtifact =
|
||||
!(isThreadRunning && isMessageRunning) && (isRunning || codeIsStreaming);
|
||||
const messageCreatedThisSession =
|
||||
messageCreatedAtMs != null &&
|
||||
messageCreatedAtMs >= RENDER_HTML_SESSION_STARTED_AT - 1000;
|
||||
const shouldAutoOpenArtifact =
|
||||
(isLiveGeneratingArtifact && (hasCode || isRunning || codeIsStreaming)) ||
|
||||
(hasCode && messageCreatedThisSession);
|
||||
const selectedArtifact = useSelectedChatArtifact();
|
||||
const closeArtifactSurface = useChatArtifactsStore(
|
||||
(state) => state.closeArtifactSurface,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!errorText) {
|
||||
return;
|
||||
}
|
||||
if (!(messageId && toolCallId)) {
|
||||
return;
|
||||
}
|
||||
if (selectedArtifact?.sourceToolCallId !== toolCallId) {
|
||||
return;
|
||||
}
|
||||
if (selectedArtifact?.sourceMessageId !== messageId) {
|
||||
return;
|
||||
}
|
||||
closeArtifactSurface();
|
||||
}, [
|
||||
closeArtifactSurface,
|
||||
errorText,
|
||||
messageId,
|
||||
selectedArtifact,
|
||||
toolCallId,
|
||||
]);
|
||||
|
||||
if (hasCode || (isLiveGeneratingArtifact && !errorText)) {
|
||||
return (
|
||||
<ArtifactCard
|
||||
code={code}
|
||||
title={title}
|
||||
source="tool"
|
||||
sourceToolCallId={toolCallId}
|
||||
autoOpen={!errorText && shouldAutoOpenArtifact}
|
||||
isStreaming={!errorText && isLiveGeneratingArtifact}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative my-2 flex min-h-[52px] w-full max-w-md items-center overflow-hidden rounded-lg border border-border/70 bg-muted/15 px-3 py-2 text-left dark:bg-muted/10">
|
||||
<div className="relative z-10 flex min-w-0 flex-1 items-center gap-2.5">
|
||||
<HugeiconsIcon
|
||||
icon={BrowserIcon}
|
||||
strokeWidth={1.75}
|
||||
className="size-5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span className="grid min-w-0 flex-1 gap-1">
|
||||
<span className="truncate text-sm font-medium leading-tight text-foreground">
|
||||
{errorText
|
||||
? "Artifact error"
|
||||
: isStaleGeneratingArtifact
|
||||
? "Artifact interrupted"
|
||||
: "Artifact unavailable"}
|
||||
</span>
|
||||
<span className="truncate text-[11px] leading-none text-muted-foreground">
|
||||
{errorText ??
|
||||
(isStaleGeneratingArtifact
|
||||
? "Refresh stopped this preview"
|
||||
: "HTML artifact")}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const RenderHtmlToolUI = memo(
|
||||
RenderHtmlToolUIImpl,
|
||||
) as unknown as ToolCallMessagePartComponent;
|
||||
RenderHtmlToolUI.displayName = "RenderHtmlToolUI";
|
||||
|
|
@ -1,54 +1,54 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import type * as React from "react";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Group>): React.ReactElement {
|
||||
return (
|
||||
<ResizablePrimitive.Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ResizablePanel({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>): React.ReactElement {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
|
||||
withHandle?: boolean;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<ResizablePrimitive.Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border h-6 w-1 rounded-lg z-10 flex shrink-0" />
|
||||
)}
|
||||
</ResizablePrimitive.Separator>
|
||||
);
|
||||
}
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||
import type * as React from "react";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Group>): React.ReactElement {
|
||||
return (
|
||||
<ResizablePrimitive.Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ResizablePanel({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>): React.ReactElement {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
|
||||
withHandle?: boolean;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<ResizablePrimitive.Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"group bg-border/80 relative z-10 flex w-px cursor-col-resize items-center justify-center transition-[background-color,box-shadow] duration-150 ease-out after:absolute after:inset-y-0 after:left-1/2 after:w-2 after:-translate-x-1/2 hover:bg-primary/80 hover:shadow-[0_0_16px_rgba(23,184,139,0.55)] active:bg-primary/90 active:shadow-[0_0_18px_rgba(23,184,139,0.7)] focus-visible:bg-primary/80 focus-visible:ring-1 focus-visible:ring-primary/50 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:cursor-row-resize data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-2 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-6 w-1 shrink-0 rounded-lg bg-border transition-[background-color,box-shadow,transform] duration-150 ease-out group-hover:scale-y-110 group-hover:bg-primary/80 group-hover:shadow-[0_0_12px_rgba(23,184,139,0.65)] group-active:bg-primary" />
|
||||
)}
|
||||
</ResizablePrimitive.Separator>
|
||||
);
|
||||
}
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import {
|
|||
} from "../external-providers";
|
||||
import { pickFriendlyContainerName } from "../lib/friendly-names";
|
||||
import {
|
||||
EXTERNAL_MAX_OUTPUT_TOKENS,
|
||||
clampReasoningEffortToLevels,
|
||||
getExternalMaxOutputTokens,
|
||||
getExternalMinOutputTokens,
|
||||
|
|
@ -46,12 +45,12 @@ import type {
|
|||
OpenAIReasoningContentPart,
|
||||
} from "../types/api";
|
||||
import type { ChatModelSummary } from "../types/runtime";
|
||||
import { getImageInputUnavailableReason } from "../utils/image-input-support";
|
||||
import {
|
||||
getStoredChatThread,
|
||||
listStoredChatThreads,
|
||||
updateStoredChatThread,
|
||||
} from "../utils/chat-history-storage";
|
||||
import { getImageInputUnavailableReason } from "../utils/image-input-support";
|
||||
import {
|
||||
hasClosedThinkTag,
|
||||
parseAssistantContent,
|
||||
|
|
@ -775,36 +774,6 @@ function toOpenAIMessages(message: RunMessage): SerializedMessage[] {
|
|||
return toolResults.length > 0 ? [base, ...toolResults] : [base];
|
||||
}
|
||||
|
||||
// Thin singular wrapper: returns only the first serialized message
|
||||
// (without tool_calls or tool follow-ups) so the OpenAI image-edit
|
||||
// replay path can map a thread to flat OpenAI chat messages without
|
||||
// pulling in tool history.
|
||||
function toOpenAIMessage(message: RunMessage): {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: OpenAIMessageContent;
|
||||
} | null {
|
||||
const serialized = toOpenAIMessages(message);
|
||||
if (serialized.length === 0) return null;
|
||||
const first = serialized[0];
|
||||
if (
|
||||
first.role !== "system" &&
|
||||
first.role !== "user" &&
|
||||
first.role !== "assistant"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (first.content === null || first.content === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (typeof first.content === "string" && !first.content) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
role: first.role,
|
||||
content: first.content as OpenAIMessageContent,
|
||||
};
|
||||
}
|
||||
|
||||
function extractImageBase64(input: string): string | undefined {
|
||||
if (!input) {
|
||||
return undefined;
|
||||
|
|
@ -1303,6 +1272,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
toolsEnabled,
|
||||
codeToolsEnabled,
|
||||
imageToolsEnabled,
|
||||
artifactsEnabled,
|
||||
mcpEnabledForChat,
|
||||
webFetchToolsEnabled,
|
||||
} = runtime;
|
||||
|
|
@ -1537,36 +1507,62 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
"Do not return tool-call syntax inside your response.";
|
||||
}
|
||||
}
|
||||
if (disabledToolGuard) {
|
||||
const firstMessage = outboundMessages[0];
|
||||
type OutboundMessage = (typeof outboundMessages)[number];
|
||||
function addSystemInstruction(
|
||||
targetMessages: OutboundMessage[],
|
||||
text: string | null,
|
||||
): void {
|
||||
if (!text) return;
|
||||
const firstMessage = targetMessages[0];
|
||||
if (firstMessage?.role === "system") {
|
||||
if (typeof firstMessage.content === "string") {
|
||||
outboundMessages[0] = {
|
||||
targetMessages[0] = {
|
||||
...firstMessage,
|
||||
content: `${firstMessage.content}\n\n${disabledToolGuard}`,
|
||||
content: `${firstMessage.content}\n\n${text}`,
|
||||
};
|
||||
} else {
|
||||
outboundMessages[0] = {
|
||||
targetMessages[0] = {
|
||||
...firstMessage,
|
||||
content: [
|
||||
...(Array.isArray(firstMessage.content)
|
||||
? firstMessage.content
|
||||
: []),
|
||||
{ type: "text", text: `\n\n${disabledToolGuard}` },
|
||||
{ type: "text", text: `\n\n${text}` },
|
||||
],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
outboundMessages.unshift({
|
||||
role: "system",
|
||||
content: disabledToolGuard,
|
||||
});
|
||||
return;
|
||||
}
|
||||
targetMessages.unshift({ role: "system", content: text });
|
||||
}
|
||||
|
||||
// Scan post-prune history so a refused user turn's image/audio
|
||||
// doesn't gate or mis-attribute the next non-refused turn.
|
||||
const imageBase64 = findLatestUserImageBase64(survivingMessages);
|
||||
const audioBase64 = findLatestUserAudioBase64(survivingMessages);
|
||||
const hasOutboundImage = Boolean(imageBase64);
|
||||
|
||||
// Keep render_html local-only and mirror the backend image-turn gate.
|
||||
// Artifacts are independent of Search/Code: if a local tool-capable
|
||||
// model has Artifacts enabled, expose render_html even when no other
|
||||
// tool pills are active.
|
||||
const renderHtmlToolEnabledForThisTurn = Boolean(
|
||||
!isExternalRequest &&
|
||||
supportsTools &&
|
||||
artifactsEnabled &&
|
||||
!hasOutboundImage,
|
||||
);
|
||||
const artifactInstruction = artifactsEnabled
|
||||
? renderHtmlToolEnabledForThisTurn
|
||||
? "When the user asks for an HTML, CSS, or JavaScript artifact, call render_html once with one complete self-contained HTML document in the code argument. Embed CSS and JavaScript inside the document. After render_html succeeds, do not call it again in the same response unless the user asks for changes. Future user requests for new artifacts may call render_html once."
|
||||
: "When the user asks for an HTML, CSS, or JavaScript artifact, return one complete self-contained fenced html code block. Embed CSS and JavaScript inside the document. Do not emit tool-call syntax."
|
||||
: null;
|
||||
const effectiveDisabledToolGuard =
|
||||
disabledToolGuard && artifactsEnabled
|
||||
? `${disabledToolGuard} HTML, CSS, or JavaScript artifact requests can still be answered by following the artifact fallback instruction.`
|
||||
: disabledToolGuard;
|
||||
addSystemInstruction(outboundMessages, effectiveDisabledToolGuard);
|
||||
addSystemInstruction(outboundMessages, artifactInstruction);
|
||||
|
||||
// Block when ANY image is in the outbound payload (current or
|
||||
// prior turns) and the loaded model can't process images. Keeps
|
||||
|
|
@ -2093,12 +2089,19 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
...(supportsPreserveThinking
|
||||
? { preserve_thinking: preserveThinking }
|
||||
: {}),
|
||||
...(supportsTools && (toolsEnabled || codeToolsEnabled || mcpEnabledForChat)
|
||||
...(supportsTools &&
|
||||
(toolsEnabled ||
|
||||
codeToolsEnabled ||
|
||||
renderHtmlToolEnabledForThisTurn ||
|
||||
mcpEnabledForChat)
|
||||
? {
|
||||
enable_tools: true,
|
||||
enabled_tools: [
|
||||
...(toolsEnabled ? ["web_search"] : []),
|
||||
...(codeToolsEnabled ? ["python", "terminal"] : []),
|
||||
...(renderHtmlToolEnabledForThisTurn
|
||||
? ["render_html"]
|
||||
: []),
|
||||
],
|
||||
mcp_enabled: mcpEnabledForChat,
|
||||
auto_heal_tool_calls:
|
||||
|
|
@ -2205,13 +2208,25 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
`${toolEvent.tool_name}_${Date.now()}`;
|
||||
const toolArgs = (toolEvent.arguments ??
|
||||
{}) as ToolCallMessagePart["args"];
|
||||
toolCallParts.push({
|
||||
type: "tool-call" as const,
|
||||
toolCallId: id,
|
||||
toolName: toolEvent.tool_name as string,
|
||||
argsText: JSON.stringify(toolArgs),
|
||||
args: toolArgs,
|
||||
});
|
||||
const idx = toolCallParts.findIndex(
|
||||
(p) => p.toolCallId === id,
|
||||
);
|
||||
if (idx !== -1) {
|
||||
toolCallParts[idx] = {
|
||||
...toolCallParts[idx],
|
||||
toolName: toolEvent.tool_name as string,
|
||||
argsText: JSON.stringify(toolArgs),
|
||||
args: toolArgs,
|
||||
};
|
||||
} else {
|
||||
toolCallParts.push({
|
||||
type: "tool-call" as const,
|
||||
toolCallId: id,
|
||||
toolName: toolEvent.tool_name as string,
|
||||
argsText: JSON.stringify(toolArgs),
|
||||
args: toolArgs,
|
||||
});
|
||||
}
|
||||
} else if (toolEvent.type === "tool_end") {
|
||||
const id =
|
||||
(toolEvent.tool_call_id as string) ||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ export interface PersistedChatSettings {
|
|||
autoTitle?: boolean;
|
||||
reasoningEffort?: ReasoningEffort;
|
||||
preserveThinking?: boolean;
|
||||
collapseHtmlArtifacts?: boolean;
|
||||
allowArtifactNetworkAccess?: boolean;
|
||||
autoHealToolCalls?: boolean;
|
||||
maxToolCallsPerMessage?: number;
|
||||
toolCallTimeout?: number;
|
||||
|
|
|
|||
141
studio/frontend/src/features/chat/artifacts/artifact-card.tsx
Normal file
141
studio/frontend/src/features/chat/artifacts/artifact-card.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuiState } from "@assistant-ui/react";
|
||||
import { LayoutTwoColumnIcon as Layout2ColumnIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useLayoutEffect, useMemo } from "react";
|
||||
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
|
||||
import {
|
||||
hasAutoOpenedArtifact,
|
||||
rememberAutoOpenedArtifact,
|
||||
useChatArtifactsStore,
|
||||
} from "./store";
|
||||
import {
|
||||
type ChatArtifact,
|
||||
type ChatArtifactSource,
|
||||
createChatArtifact,
|
||||
} from "./types";
|
||||
|
||||
export function ArtifactCard({
|
||||
code,
|
||||
title,
|
||||
source,
|
||||
sourceToolCallId,
|
||||
sourceMessageId,
|
||||
className,
|
||||
autoOpen = false,
|
||||
isStreaming = false,
|
||||
}: {
|
||||
code: string;
|
||||
title?: string | null;
|
||||
source: ChatArtifactSource;
|
||||
sourceToolCallId?: string | null;
|
||||
sourceMessageId?: string | null;
|
||||
className?: string;
|
||||
autoOpen?: boolean;
|
||||
isStreaming?: boolean;
|
||||
}) {
|
||||
const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId);
|
||||
const messageIdFromContext = useAuiState(({ message }) => message.id);
|
||||
const threadIdFromContext = useAuiState(
|
||||
({ threads }) => threads.mainThreadId,
|
||||
);
|
||||
const artifactThreadId = threadIdFromContext ?? activeThreadId ?? null;
|
||||
const openArtifact = useChatArtifactsStore((state) => state.openArtifact);
|
||||
const updateArtifact = useChatArtifactsStore((state) => state.updateArtifact);
|
||||
const selectedArtifactId = useChatArtifactsStore(
|
||||
(state) => state.selectedArtifactId,
|
||||
);
|
||||
const artifact = useMemo<ChatArtifact>(
|
||||
() =>
|
||||
createChatArtifact({
|
||||
code,
|
||||
title,
|
||||
source,
|
||||
sourceMessageId: sourceMessageId ?? messageIdFromContext ?? null,
|
||||
sourceToolCallId: sourceToolCallId ?? null,
|
||||
threadId: artifactThreadId,
|
||||
isStreaming,
|
||||
}),
|
||||
[
|
||||
artifactThreadId,
|
||||
code,
|
||||
isStreaming,
|
||||
messageIdFromContext,
|
||||
source,
|
||||
sourceMessageId,
|
||||
sourceToolCallId,
|
||||
title,
|
||||
],
|
||||
);
|
||||
const surface = artifactThreadId ? "panel" : "overlay";
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (selectedArtifactId === artifact.id) {
|
||||
updateArtifact(artifact);
|
||||
}
|
||||
|
||||
if (!autoOpen) {
|
||||
return;
|
||||
}
|
||||
if (hasAutoOpenedArtifact(artifact.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
rememberAutoOpenedArtifact(artifact.id);
|
||||
openArtifact(artifact, { surface });
|
||||
}, [
|
||||
artifact,
|
||||
autoOpen,
|
||||
openArtifact,
|
||||
selectedArtifactId,
|
||||
surface,
|
||||
updateArtifact,
|
||||
]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"group/artifact-card relative my-2 flex min-h-[52px] w-full max-w-md cursor-pointer items-center overflow-hidden rounded-lg border border-border/70 bg-muted/15 px-3 py-2 text-left transition-colors hover:bg-muted/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"dark:bg-muted/10 dark:hover:bg-muted/20",
|
||||
isStreaming &&
|
||||
"border-border/80 bg-muted/20 dark:border-border/70 dark:bg-muted/15",
|
||||
className,
|
||||
)}
|
||||
onClick={() => openArtifact(artifact, { surface })}
|
||||
aria-label={`Open ${artifact.title}`}
|
||||
>
|
||||
{isStreaming ? (
|
||||
<span
|
||||
aria-hidden={true}
|
||||
className="artifact-card-shimmer pointer-events-none absolute inset-0 z-0 motion-reduce:hidden"
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative z-10 flex min-w-0 flex-1 items-center gap-2.5">
|
||||
<HugeiconsIcon
|
||||
icon={Layout2ColumnIcon}
|
||||
strokeWidth={1.75}
|
||||
className="size-5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span className="grid min-w-0 flex-1 gap-1">
|
||||
<span className="truncate text-sm font-medium leading-tight text-foreground">
|
||||
{artifact.title}
|
||||
</span>
|
||||
<span className="truncate text-[11px] leading-none text-muted-foreground">
|
||||
HTML artifact
|
||||
</span>
|
||||
</span>
|
||||
{isStreaming ? (
|
||||
<span className="shimmer shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary motion-reduce:animate-none">
|
||||
Generating
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
362
studio/frontend/src/features/chat/artifacts/artifact-surface.tsx
Normal file
362
studio/frontend/src/features/chat/artifacts/artifact-surface.tsx
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"use client";
|
||||
|
||||
import { createCodePlugin } from "@/components/assistant-ui/code-plugin";
|
||||
import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon";
|
||||
import {
|
||||
unslothDarkTheme,
|
||||
unslothLightTheme,
|
||||
} from "@/components/assistant-ui/code-themes";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
Maximize2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
import { ArtifactHtmlFrame, type ArtifactViewMode } from "./html-frame";
|
||||
import type { ChatArtifact } from "./types";
|
||||
import { getArtifactFilename } from "./types";
|
||||
|
||||
const COPY_RESET_MS = 2000;
|
||||
const artifactSourceCodePlugin = createCodePlugin({
|
||||
themes: [unslothLightTheme, unslothDarkTheme],
|
||||
});
|
||||
|
||||
function buildHtmlFence(source: string): string {
|
||||
const longestBacktickRun = Math.max(
|
||||
2,
|
||||
...(source.match(/`+/g) ?? []).map((match) => match.length),
|
||||
);
|
||||
const fence = "`".repeat(longestBacktickRun + 1);
|
||||
return `${fence}html\n${source}\n${fence}`;
|
||||
}
|
||||
// Sandboxed artifact iframes are intentionally excluded from the overlay focus
|
||||
// trap. Granting same-origin sandbox privileges would weaken isolation, so
|
||||
// keyboard users can reach Studio controls here while fully interactive artifact
|
||||
// content remains a known sandbox limitation.
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
function getFocusableElements(container: HTMLElement): HTMLElement[] {
|
||||
return Array.from(
|
||||
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
|
||||
).filter(
|
||||
(element) =>
|
||||
!element.hasAttribute("disabled") &&
|
||||
element.getAttribute("aria-hidden") !== "true" &&
|
||||
element.tabIndex !== -1,
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactLoadingLine() {
|
||||
return (
|
||||
<div className="absolute inset-x-0 bottom-0 h-[2.5px] overflow-hidden bg-border/45">
|
||||
<span
|
||||
aria-hidden={true}
|
||||
className="artifact-loading-line block h-full rounded-full motion-reduce:hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactGeneratingPanel() {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col items-center justify-center bg-muted/10 px-6 text-center">
|
||||
<div className="max-w-[30ch] space-y-1.5">
|
||||
<img
|
||||
src="/Sloth%20emojis/sloth%20w%20pc%20transparent.png"
|
||||
alt=""
|
||||
aria-hidden={true}
|
||||
className="mx-auto mb-3 size-20 object-contain"
|
||||
/>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Building artifact preview…
|
||||
</p>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
The preview will appear here when the HTML is ready.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function downloadTextFile(filename: string, text: string): void {
|
||||
const blob = new Blob([text], { type: "text/html;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
export function ArtifactSurface({
|
||||
artifact,
|
||||
variant,
|
||||
onClose,
|
||||
onOpenFullscreen,
|
||||
}: {
|
||||
artifact: ChatArtifact;
|
||||
variant: "panel" | "overlay";
|
||||
onClose: () => void;
|
||||
onOpenFullscreen?: () => void;
|
||||
}) {
|
||||
const [viewMode, setViewMode] = useState<ArtifactViewMode>("preview");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const surfaceRef = useRef<HTMLElement>(null);
|
||||
const previousFocusRef = useRef<Element | null>(null);
|
||||
const filename = getArtifactFilename(artifact);
|
||||
const sourceMarkdown = useMemo(
|
||||
() => buildHtmlFence(artifact.code),
|
||||
[artifact.code],
|
||||
);
|
||||
const hasArtifactCode = artifact.code.trim().length > 0;
|
||||
const isLoadingArtifact = Boolean(artifact.isStreaming);
|
||||
const effectiveViewMode = isLoadingArtifact ? "preview" : viewMode;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copyResetRef.current) clearTimeout(copyResetRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (variant !== "overlay") return;
|
||||
previousFocusRef.current = document.activeElement;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
const surface = surfaceRef.current;
|
||||
if (!surface) return;
|
||||
const firstFocusable = getFocusableElements(surface)[0];
|
||||
if (firstFocusable) {
|
||||
firstFocusable.focus();
|
||||
} else {
|
||||
surface.focus();
|
||||
}
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
const previousFocus = previousFocusRef.current;
|
||||
if (previousFocus instanceof HTMLElement) previousFocus.focus();
|
||||
};
|
||||
}, [variant]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!(await copyToClipboard(artifact.code))) return;
|
||||
setCopied(true);
|
||||
if (copyResetRef.current) clearTimeout(copyResetRef.current);
|
||||
copyResetRef.current = setTimeout(() => {
|
||||
setCopied(false);
|
||||
copyResetRef.current = null;
|
||||
}, COPY_RESET_MS);
|
||||
};
|
||||
|
||||
const handleDialogKeyDown = (event: KeyboardEvent<HTMLElement>) => {
|
||||
if (variant !== "overlay") return;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = getFocusableElements(event.currentTarget);
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.focus();
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const content = (
|
||||
<section
|
||||
ref={surfaceRef}
|
||||
role={variant === "overlay" ? "dialog" : undefined}
|
||||
aria-modal={variant === "overlay" ? true : undefined}
|
||||
tabIndex={variant === "overlay" ? -1 : undefined}
|
||||
onKeyDown={handleDialogKeyDown}
|
||||
className={cn(
|
||||
"relative flex min-h-0 flex-col border border-border bg-background",
|
||||
variant === "panel"
|
||||
? "artifact-panel-shell mx-2 mt-[72px] mb-8 h-[calc(100%_-_104px)] overflow-visible rounded-[28px] border-border/70 bg-card/95 [box-shadow:rgba(0,0,0,0.16)_0px_2px_8px_-2px]"
|
||||
: "h-[min(92vh,900px)] w-[min(96vw,1200px)] overflow-hidden rounded-2xl shadow-xl",
|
||||
)}
|
||||
aria-label={`${artifact.title} artifact`}
|
||||
>
|
||||
<header
|
||||
className={cn(
|
||||
"relative flex shrink-0 items-center justify-between gap-3 px-2.5 py-2",
|
||||
variant === "panel" && "rounded-t-[28px]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1 rounded-full bg-muted/40 p-0.5"
|
||||
role="tablist"
|
||||
aria-label="Artifact view"
|
||||
>
|
||||
{(["preview", "source"] as const).map((mode) => {
|
||||
const isPreview = mode === "preview";
|
||||
const Icon = isPreview ? EyeIcon : CodeToggleIcon;
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
role="tab"
|
||||
disabled={isLoadingArtifact && !isPreview}
|
||||
onClick={() => setViewMode(mode)}
|
||||
className={cn(
|
||||
"flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors",
|
||||
effectiveViewMode === mode
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "hover:bg-background/70 hover:text-foreground",
|
||||
isLoadingArtifact &&
|
||||
!isPreview &&
|
||||
"cursor-not-allowed opacity-50",
|
||||
)}
|
||||
aria-label={
|
||||
isPreview ? "Preview artifact" : "View artifact source"
|
||||
}
|
||||
aria-selected={effectiveViewMode === mode}
|
||||
aria-pressed={effectiveViewMode === mode}
|
||||
title={
|
||||
isPreview
|
||||
? "Preview"
|
||||
: isLoadingArtifact
|
||||
? "Source available when generation finishes"
|
||||
: "Source"
|
||||
}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
disabled={isLoadingArtifact || !hasArtifactCode}
|
||||
onClick={() => downloadTextFile(filename, artifact.code)}
|
||||
aria-label="Download artifact HTML"
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
disabled={isLoadingArtifact || !hasArtifactCode}
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy artifact HTML"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="size-4" />
|
||||
) : (
|
||||
<CopyIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
{variant === "panel" && onOpenFullscreen ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={onOpenFullscreen}
|
||||
aria-label="Open artifact fullscreen"
|
||||
>
|
||||
<Maximize2Icon className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={onClose}
|
||||
aria-label="Close artifact"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{isLoadingArtifact ? (
|
||||
<ArtifactLoadingLine />
|
||||
) : (
|
||||
<div className="absolute inset-x-0 bottom-0 h-px bg-border" />
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-hidden bg-background",
|
||||
variant === "panel" && "rounded-b-[28px]",
|
||||
)}
|
||||
>
|
||||
{isLoadingArtifact ? (
|
||||
<ArtifactGeneratingPanel />
|
||||
) : effectiveViewMode === "preview" ? (
|
||||
<ArtifactHtmlFrame
|
||||
key={artifact.id}
|
||||
code={artifact.code}
|
||||
title={artifact.title}
|
||||
fill={true}
|
||||
className="h-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full overflow-auto text-xs leading-relaxed [&_[data-streamdown=code-block]]:!rounded-none [&_pre]:!m-0 [&_pre]:!bg-transparent [&_pre]:!p-0 [&_pre]:text-xs [&_pre]:leading-relaxed [&_code]:text-xs">
|
||||
<Streamdown
|
||||
mode="streaming"
|
||||
plugins={{ code: artifactSourceCodePlugin }}
|
||||
controls={{ code: false }}
|
||||
shikiTheme={[unslothLightTheme, unslothDarkTheme]}
|
||||
>
|
||||
{sourceMarkdown}
|
||||
</Streamdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
if (variant === "overlay") {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 p-4 backdrop-blur-sm"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
100
studio/frontend/src/features/chat/artifacts/html-frame.tsx
Normal file
100
studio/frontend/src/features/chat/artifacts/html-frame.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"use client";
|
||||
|
||||
import { getAuthToken } from "@/features/auth";
|
||||
import { apiUrl } from "@/lib/api-base";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
|
||||
import { hashArtifactCode } from "./types";
|
||||
|
||||
const HTML_FRAME_DEFAULT_HEIGHT = 400;
|
||||
const HTML_FRAME_MAX_HEIGHT = 900;
|
||||
|
||||
export type ArtifactViewMode = "preview" | "source";
|
||||
export const ARTIFACT_VIEW_MODES: readonly ArtifactViewMode[] = [
|
||||
"preview",
|
||||
"source",
|
||||
];
|
||||
|
||||
export function isArtifactViewMode(value: string): value is ArtifactViewMode {
|
||||
return (ARTIFACT_VIEW_MODES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function buildArtifactSrcDoc(code: string): string {
|
||||
const resizeScript = `<script>(()=>{const post=()=>parent.postMessage({chatArtifactHeight:document.documentElement.scrollHeight},"*");new ResizeObserver(post).observe(document.documentElement);window.addEventListener("load",post);post();})();</script>`;
|
||||
return `${code}\n${resizeScript}`;
|
||||
}
|
||||
|
||||
// Preview iframes intentionally omit allow-downloads: generated artifacts can
|
||||
// offer their own UI, but downloads must go through Studio's explicit
|
||||
// copy/download controls outside the no-same-origin sandbox.
|
||||
export function ArtifactHtmlFrame({
|
||||
code,
|
||||
title = "HTML artifact preview",
|
||||
className,
|
||||
fill = false,
|
||||
}: {
|
||||
code: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
fill?: boolean;
|
||||
}) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const allowNetworkAccess = useChatRuntimeStore(
|
||||
(state) => state.allowArtifactNetworkAccess,
|
||||
);
|
||||
const [height, setHeight] = useState(HTML_FRAME_DEFAULT_HEIGHT);
|
||||
const artifactHtml = useMemo(() => buildArtifactSrcDoc(code), [code]);
|
||||
const src = useMemo(() => {
|
||||
const query = new URLSearchParams({ v: hashArtifactCode(code) });
|
||||
if (allowNetworkAccess) {
|
||||
const token = getAuthToken();
|
||||
if (token) {
|
||||
query.set("allow_network", "1");
|
||||
query.set("token", token);
|
||||
}
|
||||
}
|
||||
return apiUrl(`/api/inference/artifact-preview-frame?${query.toString()}`);
|
||||
}, [allowNetworkAccess, code]);
|
||||
const postArtifactHtml = useCallback(() => {
|
||||
// The sandboxed frame intentionally has an opaque origin ("null").
|
||||
// A wildcard target is required here;
|
||||
// the payload is sent only to this iframe's contentWindow.
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{ type: "unsloth:artifact-html", html: artifactHtml },
|
||||
"*",
|
||||
);
|
||||
}, [artifactHtml]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: MessageEvent) => {
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
if (event.origin !== "null") return;
|
||||
if (typeof event.data?.chatArtifactHeight !== "number") return;
|
||||
setHeight(
|
||||
Math.min(
|
||||
Math.max(event.data.chatArtifactHeight, 160),
|
||||
HTML_FRAME_MAX_HEIGHT,
|
||||
),
|
||||
);
|
||||
};
|
||||
window.addEventListener("message", handler);
|
||||
return () => window.removeEventListener("message", handler);
|
||||
}, [postArtifactHtml]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={src}
|
||||
sandbox="allow-scripts"
|
||||
referrerPolicy="no-referrer"
|
||||
onLoad={postArtifactHtml}
|
||||
className={cn("block w-full border-0 bg-background", className)}
|
||||
style={{ height: fill ? "100%" : height }}
|
||||
title={title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
107
studio/frontend/src/features/chat/artifacts/store.ts
Normal file
107
studio/frontend/src/features/chat/artifacts/store.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { ChatArtifact, ChatArtifactSurface } from "./types";
|
||||
|
||||
const autoOpenedArtifactIds = new Set<string>();
|
||||
|
||||
export function hasAutoOpenedArtifact(artifactId: string): boolean {
|
||||
return autoOpenedArtifactIds.has(artifactId);
|
||||
}
|
||||
|
||||
export function rememberAutoOpenedArtifact(artifactId: string): void {
|
||||
autoOpenedArtifactIds.add(artifactId);
|
||||
}
|
||||
|
||||
export function clearAutoOpenedArtifacts(): void {
|
||||
autoOpenedArtifactIds.clear();
|
||||
}
|
||||
|
||||
type ChatArtifactsState = {
|
||||
artifactsById: Record<string, ChatArtifact>;
|
||||
selectedArtifactId: string | null;
|
||||
surface: ChatArtifactSurface;
|
||||
openArtifact: (
|
||||
artifact: ChatArtifact,
|
||||
options?: { surface?: ChatArtifactSurface },
|
||||
) => void;
|
||||
updateArtifact: (artifact: ChatArtifact) => void;
|
||||
closeArtifactSurface: () => void;
|
||||
clearArtifactsForThread: (threadId: string | null | undefined) => void;
|
||||
clearOrphanedArtifacts: () => void;
|
||||
resetArtifacts: () => void;
|
||||
};
|
||||
|
||||
export const useChatArtifactsStore = create<ChatArtifactsState>((set) => ({
|
||||
artifactsById: {},
|
||||
selectedArtifactId: null,
|
||||
surface: "panel",
|
||||
openArtifact: (artifact, options) =>
|
||||
set((state) => ({
|
||||
artifactsById: {
|
||||
...state.artifactsById,
|
||||
[artifact.id]: artifact,
|
||||
},
|
||||
selectedArtifactId: artifact.id,
|
||||
surface: options?.surface ?? state.surface,
|
||||
})),
|
||||
updateArtifact: (artifact) =>
|
||||
set((state) =>
|
||||
state.artifactsById[artifact.id]
|
||||
? {
|
||||
artifactsById: {
|
||||
...state.artifactsById,
|
||||
[artifact.id]: artifact,
|
||||
},
|
||||
}
|
||||
: state,
|
||||
),
|
||||
closeArtifactSurface: () =>
|
||||
set({ selectedArtifactId: null, surface: "panel" }),
|
||||
clearArtifactsForThread: (threadId) =>
|
||||
set((state) => {
|
||||
if (!threadId) return state;
|
||||
const artifactsById = Object.fromEntries(
|
||||
Object.entries(state.artifactsById).filter(
|
||||
([, artifact]) => artifact.threadId !== threadId,
|
||||
),
|
||||
);
|
||||
const selected = state.selectedArtifactId
|
||||
? artifactsById[state.selectedArtifactId]
|
||||
: null;
|
||||
return {
|
||||
artifactsById,
|
||||
selectedArtifactId: selected ? selected.id : null,
|
||||
};
|
||||
}),
|
||||
clearOrphanedArtifacts: () =>
|
||||
set((state) => {
|
||||
const artifactsById = Object.fromEntries(
|
||||
Object.entries(state.artifactsById).filter(
|
||||
([, artifact]) => artifact.threadId != null,
|
||||
),
|
||||
);
|
||||
const selected = state.selectedArtifactId
|
||||
? artifactsById[state.selectedArtifactId]
|
||||
: null;
|
||||
return {
|
||||
artifactsById,
|
||||
selectedArtifactId: selected ? selected.id : null,
|
||||
};
|
||||
}),
|
||||
resetArtifacts: () =>
|
||||
set({
|
||||
artifactsById: {},
|
||||
selectedArtifactId: null,
|
||||
surface: "panel",
|
||||
}),
|
||||
}));
|
||||
|
||||
export function useSelectedChatArtifact(): ChatArtifact | null {
|
||||
return useChatArtifactsStore((state) =>
|
||||
state.selectedArtifactId
|
||||
? (state.artifactsById[state.selectedArtifactId] ?? null)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
84
studio/frontend/src/features/chat/artifacts/types.ts
Normal file
84
studio/frontend/src/features/chat/artifacts/types.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
export type ChatArtifactSource = "tool" | "fence";
|
||||
export type ChatArtifactSurface = "panel" | "overlay";
|
||||
|
||||
export interface ChatArtifact {
|
||||
id: string;
|
||||
title: string;
|
||||
code: string;
|
||||
source: ChatArtifactSource;
|
||||
sourceMessageId?: string | null;
|
||||
sourceToolCallId?: string | null;
|
||||
threadId?: string | null;
|
||||
isStreaming?: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface ChatArtifactInput {
|
||||
title?: string | null;
|
||||
code: string;
|
||||
source: ChatArtifactSource;
|
||||
sourceMessageId?: string | null;
|
||||
sourceToolCallId?: string | null;
|
||||
threadId?: string | null;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_ARTIFACT_TITLE = "HTML artifact";
|
||||
|
||||
export function normalizeArtifactTitle(title?: string | null): string {
|
||||
const trimmed = title?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : DEFAULT_ARTIFACT_TITLE;
|
||||
}
|
||||
|
||||
export function hashArtifactCode(code: string): string {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < code.length; i += 1) {
|
||||
hash = ((hash << 5) + hash) ^ code.charCodeAt(i);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
export function createArtifactId(input: ChatArtifactInput): string {
|
||||
const threadSegment = input.threadId || "no-thread";
|
||||
const messageSegment = input.sourceMessageId || "transient";
|
||||
// Backend tool call IDs (call_0, call_1, …) reset per request, so
|
||||
// the message ID is needed to scope them to a specific turn.
|
||||
const parts = [input.source, threadSegment, messageSegment];
|
||||
|
||||
if (input.source === "tool" && input.sourceToolCallId) {
|
||||
parts.push(input.sourceToolCallId);
|
||||
} else {
|
||||
parts.push(hashArtifactCode(input.code));
|
||||
}
|
||||
|
||||
return parts.join(":");
|
||||
}
|
||||
|
||||
export function createChatArtifact(input: ChatArtifactInput): ChatArtifact {
|
||||
return {
|
||||
id: createArtifactId(input),
|
||||
title: normalizeArtifactTitle(input.title),
|
||||
code: input.code,
|
||||
source: input.source,
|
||||
sourceMessageId: input.sourceMessageId ?? null,
|
||||
sourceToolCallId: input.sourceToolCallId ?? null,
|
||||
threadId: input.threadId ?? null,
|
||||
isStreaming: input.isStreaming,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function getArtifactFilename(
|
||||
artifact: Pick<ChatArtifact, "title">,
|
||||
): string {
|
||||
const slug = artifact.title
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48);
|
||||
return `${slug || "artifact"}.html`;
|
||||
}
|
||||
|
|
@ -10,15 +10,22 @@ import {
|
|||
ModelSelector,
|
||||
} from "@/components/assistant-ui/model-selector";
|
||||
import { Thread } from "@/components/assistant-ui/thread";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { NativeModelChip } from "@/features/native-intents/components/native-model-chip";
|
||||
import { NativeModelDropOverlay } from "@/features/native-intents/components/native-model-drop-overlay";
|
||||
import { useNativeIntentStore } from "@/features/native-intents/store";
|
||||
import type { NativeIntent } from "@/features/native-intents/types";
|
||||
import { useChooseNativeModel } from "@/features/native-intents/use-native-dialogs";
|
||||
import { useNativeModelDrop } from "@/features/native-intents/use-native-drop";
|
||||
import { useNativePathLeasesSupported } from "@/features/native-intents/use-native-readiness";
|
||||
import {
|
||||
NativeModelChip,
|
||||
NativeModelDropOverlay,
|
||||
type NativeIntent,
|
||||
useChooseNativeModel,
|
||||
useNativeIntentStore,
|
||||
useNativeModelDrop,
|
||||
useNativePathLeasesSupported,
|
||||
} from "@/features/native-intents";
|
||||
import { GuidedTour, useGuidedTourController } from "@/features/tour";
|
||||
import { isTauri } from "@/lib/api-base";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -26,6 +33,7 @@ import { CustomizeIcon } from "@hugeicons/core-free-icons";
|
|||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||
import type { PanelImperativeHandle } from "react-resizable-panels";
|
||||
import {
|
||||
type ReactElement,
|
||||
memo,
|
||||
|
|
@ -78,6 +86,13 @@ import {
|
|||
} from "./stores/chat-runtime-store";
|
||||
import { useExternalProvidersStore } from "./stores/external-providers-store";
|
||||
import { buildChatTourSteps } from "./tour";
|
||||
import { ArtifactSurface } from "./artifacts/artifact-surface";
|
||||
import {
|
||||
clearAutoOpenedArtifacts,
|
||||
useChatArtifactsStore,
|
||||
useSelectedChatArtifact,
|
||||
} from "./artifacts/store";
|
||||
import type { ChatArtifact, ChatArtifactSurface } from "./artifacts/types";
|
||||
import type { ChatView, MessageRecord } from "./types";
|
||||
import {
|
||||
getStoredChatThread,
|
||||
|
|
@ -134,6 +149,12 @@ function pickBestLoraForBase(
|
|||
return partial ?? sorted[0] ?? null;
|
||||
}
|
||||
|
||||
function isAssistantLocalThreadId(
|
||||
threadId: string | null | undefined,
|
||||
): boolean {
|
||||
return Boolean(threadId?.startsWith("__LOCALID_"));
|
||||
}
|
||||
|
||||
function messageHasImage(message: MessageRecord): boolean {
|
||||
const contentParts = Array.isArray(message.content) ? message.content : [];
|
||||
if (contentParts.some((part) => part.type === "image")) {
|
||||
|
|
@ -153,19 +174,161 @@ function messageHasImage(message: MessageRecord): boolean {
|
|||
return false;
|
||||
}
|
||||
|
||||
const ARTIFACT_PANEL_DEFAULT_SIZE = "38%";
|
||||
const ARTIFACT_PANEL_TRANSITION_MS = 260;
|
||||
const ARTIFACT_SURFACE_POP_DELAY_MS = 150;
|
||||
|
||||
const SingleContent = memo(function SingleContent({
|
||||
threadId,
|
||||
newThreadNonce,
|
||||
}: { threadId?: string; newThreadNonce?: string }): ReactElement {
|
||||
artifact,
|
||||
artifactSurface,
|
||||
onCloseArtifact,
|
||||
}: {
|
||||
threadId?: string;
|
||||
newThreadNonce?: string;
|
||||
artifact?: ChatArtifact | null;
|
||||
artifactSurface: ChatArtifactSurface;
|
||||
onCloseArtifact: () => void;
|
||||
}): ReactElement {
|
||||
const openArtifact = useChatArtifactsStore((state) => state.openArtifact);
|
||||
const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId);
|
||||
const artifactPanelRef = useRef<PanelImperativeHandle | null>(null);
|
||||
const hasInitializedArtifactPanelRef = useRef(false);
|
||||
const [isArtifactLayoutAnimating, setIsArtifactLayoutAnimating] =
|
||||
useState(false);
|
||||
const [isArtifactPanelLayoutActive, setIsArtifactPanelLayoutActive] =
|
||||
useState(false);
|
||||
const [isArtifactSurfaceVisible, setIsArtifactSurfaceVisible] =
|
||||
useState(false);
|
||||
const showArtifactPanel = Boolean(
|
||||
artifact &&
|
||||
artifactSurface === "panel" &&
|
||||
(threadId
|
||||
? !artifact.threadId || artifact.threadId === threadId
|
||||
: Boolean(newThreadNonce) ||
|
||||
Boolean(artifact.threadId && artifact.threadId === activeThreadId)),
|
||||
);
|
||||
|
||||
const artifactLayoutActive = showArtifactPanel || isArtifactPanelLayoutActive;
|
||||
const artifactPanelSettledOpen =
|
||||
showArtifactPanel &&
|
||||
isArtifactPanelLayoutActive &&
|
||||
!isArtifactLayoutAnimating;
|
||||
|
||||
useEffect(() => {
|
||||
const panel = artifactPanelRef.current;
|
||||
if (!panel) return;
|
||||
|
||||
setIsArtifactSurfaceVisible(false);
|
||||
|
||||
if (!hasInitializedArtifactPanelRef.current) {
|
||||
hasInitializedArtifactPanelRef.current = true;
|
||||
if (!showArtifactPanel) {
|
||||
panel.resize("0%");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsArtifactPanelLayoutActive(true);
|
||||
setIsArtifactLayoutAnimating(true);
|
||||
let resizeFrameId = 0;
|
||||
const prepFrameId = window.requestAnimationFrame(() => {
|
||||
resizeFrameId = window.requestAnimationFrame(() => {
|
||||
panel.resize(showArtifactPanel ? ARTIFACT_PANEL_DEFAULT_SIZE : "0%");
|
||||
});
|
||||
});
|
||||
const surfaceTimerId = showArtifactPanel
|
||||
? window.setTimeout(() => {
|
||||
setIsArtifactSurfaceVisible(true);
|
||||
}, ARTIFACT_SURFACE_POP_DELAY_MS)
|
||||
: 0;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setIsArtifactLayoutAnimating(false);
|
||||
if (!showArtifactPanel) {
|
||||
setIsArtifactPanelLayoutActive(false);
|
||||
}
|
||||
}, ARTIFACT_PANEL_TRANSITION_MS + 60);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(prepFrameId);
|
||||
if (resizeFrameId) {
|
||||
window.cancelAnimationFrame(resizeFrameId);
|
||||
}
|
||||
if (surfaceTimerId) {
|
||||
window.clearTimeout(surfaceTimerId);
|
||||
}
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [showArtifactPanel]);
|
||||
|
||||
const threadPane = (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
|
||||
<Thread hideWelcome={Boolean(threadId)} targetThreadId={threadId} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ChatRuntimeProvider
|
||||
modelType="base"
|
||||
initialThreadId={threadId}
|
||||
newThreadNonce={newThreadNonce}
|
||||
>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
|
||||
<Thread hideWelcome={Boolean(threadId)} targetThreadId={threadId} />
|
||||
</div>
|
||||
<ResizablePanelGroup
|
||||
orientation="horizontal"
|
||||
data-artifact-layout-animating={
|
||||
isArtifactLayoutAnimating ? "true" : "false"
|
||||
}
|
||||
className="chat-artifact-split min-h-0 min-w-0 flex-1 basis-0 overflow-hidden"
|
||||
>
|
||||
<ResizablePanel
|
||||
id="chat-thread"
|
||||
defaultSize="100%"
|
||||
minSize={artifactLayoutActive ? "42%" : "100%"}
|
||||
className="h-full min-h-0 min-w-0 overflow-hidden"
|
||||
>
|
||||
<div className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
|
||||
{threadPane}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
withHandle={false}
|
||||
className={cn(
|
||||
"relative z-30 -ml-1 -mr-4 w-5 bg-transparent transition-[width,margin] duration-[260ms] ease-[var(--ease-out-cubic)] hover:bg-transparent hover:shadow-none active:bg-transparent active:shadow-none focus-visible:bg-transparent focus-visible:shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none",
|
||||
!artifactLayoutActive && "pointer-events-none -ml-0 -mr-0 w-0",
|
||||
)}
|
||||
/>
|
||||
<ResizablePanel
|
||||
panelRef={artifactPanelRef}
|
||||
id="chat-artifact"
|
||||
defaultSize="0%"
|
||||
minSize={artifactPanelSettledOpen ? "30%" : "0%"}
|
||||
maxSize={artifactLayoutActive ? "58%" : "0%"}
|
||||
collapsible={true}
|
||||
collapsedSize="0%"
|
||||
className={cn(
|
||||
"h-full min-h-0 min-w-0 overflow-visible",
|
||||
!showArtifactPanel && "pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
data-artifact-surface-visible={
|
||||
isArtifactSurfaceVisible ? "true" : "false"
|
||||
}
|
||||
className="chat-artifact-pop-surface flex h-full min-h-0 min-w-0 flex-col overflow-visible"
|
||||
>
|
||||
{showArtifactPanel && artifact ? (
|
||||
<ArtifactSurface
|
||||
artifact={artifact}
|
||||
variant="panel"
|
||||
onClose={onCloseArtifact}
|
||||
onOpenFullscreen={() =>
|
||||
openArtifact(artifact, { surface: "overlay" })
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ChatRuntimeProvider>
|
||||
);
|
||||
});
|
||||
|
|
@ -331,15 +494,17 @@ const LoraCompareContent = memo(function LoraCompareContent({
|
|||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
listStoredChatThreads({ pairId }).then((threads) => {
|
||||
if (!isActive) return;
|
||||
setBaseThreadId(threads.find((t) => t.modelType === "base")?.id);
|
||||
setLoraThreadId(threads.find((t) => t.modelType === "lora")?.id);
|
||||
}).catch((error) => {
|
||||
if (!isExpectedBackgroundChatStorageError(error)) {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
listStoredChatThreads({ pairId })
|
||||
.then((threads) => {
|
||||
if (!isActive) return;
|
||||
setBaseThreadId(threads.find((t) => t.modelType === "base")?.id);
|
||||
setLoraThreadId(threads.find((t) => t.modelType === "lora")?.id);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!isExpectedBackgroundChatStorageError(error)) {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
|
|
@ -480,21 +645,25 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
|
|||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
listStoredChatThreads({ pairId }).then((threads) => {
|
||||
if (!isActive) return;
|
||||
setModel1ThreadId(
|
||||
threads.find((t) => t.modelType === "model1" || t.modelType === "base")
|
||||
?.id,
|
||||
);
|
||||
setModel2ThreadId(
|
||||
threads.find((t) => t.modelType === "model2" || t.modelType === "lora")
|
||||
?.id,
|
||||
);
|
||||
}).catch((error) => {
|
||||
if (!isExpectedBackgroundChatStorageError(error)) {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
listStoredChatThreads({ pairId })
|
||||
.then((threads) => {
|
||||
if (!isActive) return;
|
||||
setModel1ThreadId(
|
||||
threads.find(
|
||||
(t) => t.modelType === "model1" || t.modelType === "base",
|
||||
)?.id,
|
||||
);
|
||||
setModel2ThreadId(
|
||||
threads.find(
|
||||
(t) => t.modelType === "model2" || t.modelType === "lora",
|
||||
)?.id,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!isExpectedBackgroundChatStorageError(error)) {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
|
|
@ -632,7 +801,11 @@ export function ChatPage(): ReactElement {
|
|||
const modelsError = useChatRuntimeStore((state) => state.modelsError);
|
||||
const modelLoading = useChatRuntimeStore((state) => state.modelLoading);
|
||||
const clearCheckpoint = useChatRuntimeStore((state) => state.clearCheckpoint);
|
||||
const resetArtifacts = useChatArtifactsStore((state) => state.resetArtifacts);
|
||||
const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId);
|
||||
const persistedActiveThreadId = isAssistantLocalThreadId(activeThreadId)
|
||||
? null
|
||||
: activeThreadId;
|
||||
const modelOperationInProgress = useChatRuntimeStore(
|
||||
(state) => state.modelLoading,
|
||||
);
|
||||
|
|
@ -647,9 +820,9 @@ export function ChatPage(): ReactElement {
|
|||
} = useChatModelRuntime();
|
||||
const prevConnectionsEnabledRef = useRef(connectionsEnabled);
|
||||
useEffect(() => {
|
||||
const turnedOff =
|
||||
prevConnectionsEnabledRef.current && !connectionsEnabled;
|
||||
const turnedOff = prevConnectionsEnabledRef.current && !connectionsEnabled;
|
||||
if (!connectionsEnabled && isExternalModelId(inferenceParams.checkpoint)) {
|
||||
resetArtifacts();
|
||||
clearCheckpoint();
|
||||
if (turnedOff) {
|
||||
toast.info("Connections disabled", {
|
||||
|
|
@ -662,6 +835,7 @@ export function ChatPage(): ReactElement {
|
|||
clearCheckpoint,
|
||||
connectionsEnabled,
|
||||
inferenceParams.checkpoint,
|
||||
resetArtifacts,
|
||||
]);
|
||||
const pendingNativeModelIntent = useNativeIntentStore(
|
||||
(state) => state.pendingModelIntent,
|
||||
|
|
@ -681,17 +855,19 @@ export function ChatPage(): ReactElement {
|
|||
const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled);
|
||||
const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle);
|
||||
const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort);
|
||||
const supportsReasoningOff = useChatRuntimeStore((s) => s.supportsReasoningOff);
|
||||
const supportsReasoningOff = useChatRuntimeStore(
|
||||
(s) => s.supportsReasoningOff,
|
||||
);
|
||||
const activeExternalProvider = useMemo(() => {
|
||||
const selection = parseExternalModelId(inferenceParams.checkpoint);
|
||||
if (!selection) return null;
|
||||
return (
|
||||
externalProvidersForChat.find(
|
||||
(p) => p.id === selection.providerId,
|
||||
) ?? null
|
||||
externalProvidersForChat.find((p) => p.id === selection.providerId) ??
|
||||
null
|
||||
);
|
||||
}, [externalProvidersForChat, inferenceParams.checkpoint]);
|
||||
const activeExternalProviderType = activeExternalProvider?.providerType ?? null;
|
||||
const activeExternalProviderType =
|
||||
activeExternalProvider?.providerType ?? null;
|
||||
const activeProviderCapabilities = useMemo(() => {
|
||||
const selection = parseExternalModelId(inferenceParams.checkpoint);
|
||||
if (!selection) return null;
|
||||
|
|
@ -807,7 +983,9 @@ export function ChatPage(): ReactElement {
|
|||
(provider?.providerType === "anthropic" ||
|
||||
provider?.providerType === "openai");
|
||||
const storedToolsEnabled = loadOptionalBool(CHAT_TOOLS_ENABLED_KEY);
|
||||
const storedCodeToolsEnabled = loadOptionalBool(CHAT_CODE_TOOLS_ENABLED_KEY);
|
||||
const storedCodeToolsEnabled = loadOptionalBool(
|
||||
CHAT_CODE_TOOLS_ENABLED_KEY,
|
||||
);
|
||||
const storedImageToolsEnabled = loadOptionalBool(
|
||||
CHAT_IMAGE_TOOLS_ENABLED_KEY,
|
||||
);
|
||||
|
|
@ -876,14 +1054,43 @@ export function ChatPage(): ReactElement {
|
|||
if (search.thread) {
|
||||
return { mode: "single", threadId: search.thread };
|
||||
}
|
||||
if (activeThreadId && !activeThreadId.startsWith("__LOCALID_")) {
|
||||
return { mode: "single", threadId: activeThreadId };
|
||||
if (persistedActiveThreadId) {
|
||||
return { mode: "single", threadId: persistedActiveThreadId };
|
||||
}
|
||||
if (search.new) {
|
||||
return { mode: "single", newThreadNonce: search.new };
|
||||
}
|
||||
return { mode: "single" };
|
||||
}, [search.thread, search.compare, search.new, activeThreadId]);
|
||||
}, [search.thread, search.compare, search.new, persistedActiveThreadId]);
|
||||
|
||||
const selectedArtifact = useSelectedChatArtifact();
|
||||
const artifactSurface = useChatArtifactsStore((state) => state.surface);
|
||||
const closeArtifactSurface = useChatArtifactsStore(
|
||||
(state) => state.closeArtifactSurface,
|
||||
);
|
||||
const artifactViewKey =
|
||||
view.mode === "single"
|
||||
? `single:${view.threadId ?? view.newThreadNonce ?? "new"}`
|
||||
: `compare:${view.pairId}`;
|
||||
|
||||
useEffect(() => {
|
||||
clearAutoOpenedArtifacts();
|
||||
closeArtifactSurface();
|
||||
}, [artifactViewKey, closeArtifactSurface]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view.mode !== "single") return;
|
||||
if (view.threadId || view.newThreadNonce || !selectedArtifact) return;
|
||||
// view intentionally excludes __LOCALID_ threads (they fall through to
|
||||
// { mode: "single" } with no threadId/nonce). Don't close an artifact
|
||||
// whose thread is the currently active local thread.
|
||||
if (
|
||||
selectedArtifact.threadId &&
|
||||
selectedArtifact.threadId === activeThreadId
|
||||
)
|
||||
return;
|
||||
closeArtifactSurface();
|
||||
}, [activeThreadId, closeArtifactSurface, selectedArtifact, view]);
|
||||
|
||||
const hasActiveModel = Boolean(inferenceParams.checkpoint);
|
||||
const loadNativeModelIntent = useCallback(
|
||||
|
|
@ -1018,11 +1225,12 @@ export function ChatPage(): ReactElement {
|
|||
selectedExternal?.modelId,
|
||||
selectedProvider?.baseUrl,
|
||||
);
|
||||
const supportsBuiltinCodeExecution = providerSupportsBuiltinCodeExecution(
|
||||
selectedProvider?.providerType,
|
||||
selectedExternal?.modelId,
|
||||
selectedProvider?.baseUrl,
|
||||
);
|
||||
const supportsBuiltinCodeExecution =
|
||||
providerSupportsBuiltinCodeExecution(
|
||||
selectedProvider?.providerType,
|
||||
selectedExternal?.modelId,
|
||||
selectedProvider?.baseUrl,
|
||||
);
|
||||
const supportsBuiltinImageGeneration =
|
||||
providerSupportsBuiltinImageGeneration(
|
||||
selectedProvider?.providerType,
|
||||
|
|
@ -1153,8 +1361,12 @@ export function ChatPage(): ReactElement {
|
|||
],
|
||||
);
|
||||
const handleEject = useCallback(() => {
|
||||
void ejectModel();
|
||||
}, [ejectModel]);
|
||||
void (async () => {
|
||||
if (await ejectModel()) {
|
||||
resetArtifacts();
|
||||
}
|
||||
})();
|
||||
}, [ejectModel, resetArtifacts]);
|
||||
|
||||
const openModelSelector = useCallback(() => {
|
||||
setModelSelectorLocked(true);
|
||||
|
|
@ -1438,6 +1650,7 @@ export function ChatPage(): ReactElement {
|
|||
|
||||
const tourSteps = useMemo(
|
||||
() =>
|
||||
// eslint-disable-next-line react-hooks/refs -- buildChatTourSteps stores callbacks without invoking them during render.
|
||||
buildChatTourSteps({
|
||||
canCompare,
|
||||
openModelSelector,
|
||||
|
|
@ -1475,6 +1688,11 @@ export function ChatPage(): ReactElement {
|
|||
return () => window.clearTimeout(timeoutId);
|
||||
}, [modelSelectorLocked, tour.open]);
|
||||
|
||||
const showArtifactOverlay = Boolean(
|
||||
selectedArtifact &&
|
||||
(view.mode === "compare" || artifactSurface === "overlay"),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 basis-0 bg-background overflow-hidden">
|
||||
<GuidedTour {...tour.tourProps} />
|
||||
|
|
@ -1593,9 +1811,12 @@ export function ChatPage(): ReactElement {
|
|||
|
||||
{view.mode === "single" ? (
|
||||
<SingleContent
|
||||
key={view.threadId ?? "single"}
|
||||
key={view.threadId ?? view.newThreadNonce ?? "single"}
|
||||
threadId={view.threadId}
|
||||
newThreadNonce={view.newThreadNonce}
|
||||
artifact={selectedArtifact}
|
||||
artifactSurface={artifactSurface}
|
||||
onCloseArtifact={closeArtifactSurface}
|
||||
/>
|
||||
) : (
|
||||
<CompareContent
|
||||
|
|
@ -1608,6 +1829,14 @@ export function ChatPage(): ReactElement {
|
|||
deleteDisabled={modelOperationInProgress}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showArtifactOverlay && selectedArtifact ? (
|
||||
<ArtifactSurface
|
||||
artifact={selectedArtifact}
|
||||
variant="overlay"
|
||||
onClose={closeArtifactSurface}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<ChatSettingsPanel
|
||||
|
|
|
|||
|
|
@ -83,7 +83,6 @@ import {
|
|||
toPresetParams,
|
||||
} from "./presets/preset-policy";
|
||||
import {
|
||||
EXTERNAL_MAX_OUTPUT_TOKENS,
|
||||
type ProviderCapabilities,
|
||||
getExternalMaxOutputTokens,
|
||||
getExternalMinOutputTokens,
|
||||
|
|
@ -438,16 +437,6 @@ export function ChatSettingsPanel({
|
|||
(s) => s.modelRequiresTrustRemoteCode,
|
||||
);
|
||||
const currentCheckpoint = params.checkpoint;
|
||||
const currentModelIsMultimodal = useChatRuntimeStore((s) => {
|
||||
if (s.loadedIsMultimodal) return true;
|
||||
const m = s.models.find((m) => m.id === currentCheckpoint);
|
||||
return (
|
||||
Boolean(m?.isVision) ||
|
||||
Boolean(m?.isAudio) ||
|
||||
Boolean(m?.hasAudioInput) ||
|
||||
m?.audioType === "audio_vlm"
|
||||
);
|
||||
});
|
||||
const ggufContextLength = useChatRuntimeStore((s) => s.ggufContextLength);
|
||||
const ggufMaxContextLength = useChatRuntimeStore(
|
||||
(s) => s.ggufMaxContextLength,
|
||||
|
|
|
|||
|
|
@ -1118,15 +1118,15 @@ export function useChatModelRuntime() {
|
|||
],
|
||||
);
|
||||
|
||||
const ejectModel = useCallback(async () => {
|
||||
const ejectModel = useCallback(async (): Promise<boolean> => {
|
||||
if (!params.checkpoint) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
setModelsError(null);
|
||||
if (isExternalModelId(params.checkpoint)) {
|
||||
clearCheckpoint();
|
||||
await refresh();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
async function performUnload(): Promise<void> {
|
||||
|
|
@ -1135,17 +1135,21 @@ export function useChatModelRuntime() {
|
|||
await refresh();
|
||||
}
|
||||
|
||||
await toast.promise(performUnload(), {
|
||||
const unloadPromise = performUnload();
|
||||
toast.promise(unloadPromise, {
|
||||
loading: "Unloading model",
|
||||
success: { message: "Model unloaded", duration: 1200 },
|
||||
error: (err) =>
|
||||
err instanceof Error ? err.message : "Failed to unload model",
|
||||
description: "Releases VRAM and resets inference state.",
|
||||
});
|
||||
await unloadPromise;
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to unload model";
|
||||
setModelsError(message);
|
||||
return false;
|
||||
}
|
||||
}, [clearCheckpoint, params.checkpoint, refresh, setModelsError]);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { CHAT_HISTORY_UPDATED_EVENT } from "../api/chat-api";
|
||||
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
|
||||
import { useChatArtifactsStore } from "../artifacts/store";
|
||||
import type { ThreadRecord } from "../types";
|
||||
import {
|
||||
deleteStoredChatThreads,
|
||||
|
|
@ -157,6 +158,10 @@ export async function deleteChatItem(
|
|||
// generating against a thread that no longer exists.
|
||||
for (const id of threadIds) cancelIfRunning(id);
|
||||
|
||||
const artifactStore = useChatArtifactsStore.getState();
|
||||
for (const id of threadIds) artifactStore.clearArtifactsForThread(id);
|
||||
artifactStore.clearOrphanedArtifacts();
|
||||
|
||||
// Optimistic tombstone: hide immediately; roll back on backend error.
|
||||
markChatThreadsDeleted(threadIds);
|
||||
notifyChatHistoryUpdated();
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ export { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
|
|||
export { ChatSearchDialog } from "./components/chat-search-dialog";
|
||||
export { setTrainingCompareHandoff } from "./lib/training-compare-handoff";
|
||||
export { clearAllChats, countAllChats } from "./utils/clear-all-chats";
|
||||
export { ArtifactCard } from "./artifacts/artifact-card";
|
||||
export {
|
||||
useChatArtifactsStore,
|
||||
useSelectedChatArtifact,
|
||||
} from "./artifacts/store";
|
||||
export { downloadChatExport } from "./utils/export-chat-history";
|
||||
export {
|
||||
deleteChatItem,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { useAui } from "@assistant-ui/react";
|
|||
import {
|
||||
ArrowUpIcon,
|
||||
DownloadIcon,
|
||||
FileTextIcon,
|
||||
GlobeIcon,
|
||||
HeadphonesIcon,
|
||||
LightbulbIcon,
|
||||
|
|
@ -37,7 +38,10 @@ import { Image03Icon } from "@hugeicons/core-free-icons";
|
|||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { loadModel, validateModel } from "./api/chat-api";
|
||||
import { parseExternalModelId, providerTypeSupportsVision } from "./external-providers";
|
||||
import {
|
||||
parseExternalModelId,
|
||||
providerTypeSupportsVision,
|
||||
} from "./external-providers";
|
||||
import { useExternalProvidersStore } from "./stores/external-providers-store";
|
||||
import {
|
||||
type ReasoningEffort,
|
||||
|
|
@ -101,7 +105,10 @@ function fileToBase64DataURL(file: File): Promise<string> {
|
|||
});
|
||||
}
|
||||
|
||||
function formatReasoningEffortLabel(level: ReasoningEffort, modelId?: string): string {
|
||||
function formatReasoningEffortLabel(
|
||||
level: ReasoningEffort,
|
||||
modelId?: string,
|
||||
): string {
|
||||
if (level === "max") return "Max";
|
||||
if (level === "xhigh") {
|
||||
const normalized = modelId?.trim().toLowerCase() ?? "";
|
||||
|
|
@ -137,7 +144,12 @@ function useDictation(
|
|||
const start = useCallback(() => {
|
||||
const SpeechRecognitionAPI =
|
||||
typeof window !== "undefined" &&
|
||||
(window.SpeechRecognition ?? (window as unknown as { webkitSpeechRecognition?: typeof SpeechRecognition }).webkitSpeechRecognition);
|
||||
(window.SpeechRecognition ??
|
||||
(
|
||||
window as unknown as {
|
||||
webkitSpeechRecognition?: typeof SpeechRecognition;
|
||||
}
|
||||
).webkitSpeechRecognition);
|
||||
if (!SpeechRecognitionAPI) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -183,7 +195,11 @@ function useDictation(
|
|||
|
||||
const supported =
|
||||
typeof window !== "undefined" &&
|
||||
!!(window.SpeechRecognition ?? (window as unknown as { webkitSpeechRecognition?: unknown }).webkitSpeechRecognition);
|
||||
!!(
|
||||
window.SpeechRecognition ??
|
||||
(window as unknown as { webkitSpeechRecognition?: unknown })
|
||||
.webkitSpeechRecognition
|
||||
);
|
||||
|
||||
return { isDictating, start, stop, supported };
|
||||
}
|
||||
|
|
@ -222,9 +238,18 @@ export function RegisterCompareHandle({
|
|||
currentHandles[name] = {
|
||||
// fixes occasional reorder on reload.
|
||||
append: (content) =>
|
||||
aui.thread().append({ role: "user", content, createdAt: new Date() } as never),
|
||||
aui
|
||||
.thread()
|
||||
.append({ role: "user", content, createdAt: new Date() } as never),
|
||||
appendMessage: (content) =>
|
||||
aui.thread().append({ role: "user", content, createdAt: new Date(), startRun: false } as never),
|
||||
aui
|
||||
.thread()
|
||||
.append({
|
||||
role: "user",
|
||||
content,
|
||||
createdAt: new Date(),
|
||||
startRun: false,
|
||||
} as never),
|
||||
startRun: () => {
|
||||
const msgs = aui.thread().getState().messages;
|
||||
const lastId = msgs.length > 0 ? msgs[msgs.length - 1].id : null;
|
||||
|
|
@ -268,7 +293,8 @@ function PendingImageThumb({
|
|||
setSrc(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file]);
|
||||
if (!src) return <div className="size-14 animate-pulse rounded-[14px] bg-muted" />;
|
||||
if (!src)
|
||||
return <div className="size-14 animate-pulse rounded-[14px] bg-muted" />;
|
||||
return (
|
||||
<div className="relative size-14 shrink-0 overflow-hidden rounded-[14px] border border-foreground/20 bg-muted">
|
||||
<img src={src} alt={file.name} className="h-full w-full object-cover" />
|
||||
|
|
@ -303,7 +329,10 @@ export function SharedComposer({
|
|||
const [running, setRunning] = useState(false);
|
||||
const [comparing, setComparing] = useState(false);
|
||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||
const [pendingAudio, setPendingAudio] = useState<{ name: string; base64: string } | null>(null);
|
||||
const [pendingAudio, setPendingAudio] = useState<{
|
||||
name: string;
|
||||
base64: string;
|
||||
} | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
|
@ -332,10 +361,16 @@ export function SharedComposer({
|
|||
const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled);
|
||||
const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle);
|
||||
const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort);
|
||||
const supportsReasoningOff = useChatRuntimeStore((s) => s.supportsReasoningOff);
|
||||
const reasoningEffortLevels = useChatRuntimeStore((s) => s.reasoningEffortLevels);
|
||||
const supportsReasoningOff = useChatRuntimeStore(
|
||||
(s) => s.supportsReasoningOff,
|
||||
);
|
||||
const reasoningEffortLevels = useChatRuntimeStore(
|
||||
(s) => s.reasoningEffortLevels,
|
||||
);
|
||||
const setReasoningEffort = useChatRuntimeStore((s) => s.setReasoningEffort);
|
||||
const supportsPreserveThinking = useChatRuntimeStore((s) => s.supportsPreserveThinking);
|
||||
const supportsPreserveThinking = useChatRuntimeStore(
|
||||
(s) => s.supportsPreserveThinking,
|
||||
);
|
||||
const preserveThinking = useChatRuntimeStore((s) => s.preserveThinking);
|
||||
const setPreserveThinking = useChatRuntimeStore((s) => s.setPreserveThinking);
|
||||
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
|
||||
|
|
@ -350,6 +385,8 @@ export function SharedComposer({
|
|||
const setImageToolsEnabled = useChatRuntimeStore(
|
||||
(s) => s.setImageToolsEnabled,
|
||||
);
|
||||
const artifactsEnabled = useChatRuntimeStore((s) => s.artifactsEnabled);
|
||||
const setArtifactsEnabled = useChatRuntimeStore((s) => s.setArtifactsEnabled);
|
||||
const webFetchToolsEnabled = useChatRuntimeStore(
|
||||
(s) => s.webFetchToolsEnabled,
|
||||
);
|
||||
|
|
@ -481,6 +518,7 @@ export function SharedComposer({
|
|||
// Images pill is only ever lit on OpenAI cloud's Responses-API models
|
||||
// and Gemini Nano Banana family. No local tool runtime fallback.
|
||||
const showImagePill = supportsBuiltinImageGeneration;
|
||||
const artifactDisabled = !modelLoaded;
|
||||
// Fetch pill: Anthropic-only (web_fetch_20250910 / web_fetch_20260209).
|
||||
const webFetchDisabled = !modelLoaded || !supportsBuiltinWebFetch;
|
||||
const showWebFetchPill = supportsBuiltinWebFetch;
|
||||
|
|
@ -488,12 +526,17 @@ export function SharedComposer({
|
|||
// reference `toolsDisabled` (rare; both pills used it before).
|
||||
const toolsDisabled = codeDisabled;
|
||||
const setPendingAudioStore = useChatRuntimeStore((s) => s.setPendingAudio);
|
||||
const clearPendingAudioStore = useChatRuntimeStore((s) => s.clearPendingAudio);
|
||||
|
||||
const { isDictating, start: startDictation, stop: stopDictation, supported: dictationSupported } = useDictation(
|
||||
setText,
|
||||
const clearPendingAudioStore = useChatRuntimeStore(
|
||||
(s) => s.clearPendingAudio,
|
||||
);
|
||||
|
||||
const {
|
||||
isDictating,
|
||||
start: startDictation,
|
||||
stop: stopDictation,
|
||||
supported: dictationSupported,
|
||||
} = useDictation(setText);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
const handles = handlesRef.current;
|
||||
|
|
@ -510,43 +553,48 @@ export function SharedComposer({
|
|||
ta.style.height = "auto";
|
||||
const styles = window.getComputedStyle(ta);
|
||||
const lineHeight = parseFloat(styles.lineHeight) || 20;
|
||||
const paddingY = parseFloat(styles.paddingTop) + parseFloat(styles.paddingBottom);
|
||||
const borderY = parseFloat(styles.borderTopWidth) + parseFloat(styles.borderBottomWidth);
|
||||
const paddingY =
|
||||
parseFloat(styles.paddingTop) + parseFloat(styles.paddingBottom);
|
||||
const borderY =
|
||||
parseFloat(styles.borderTopWidth) + parseFloat(styles.borderBottomWidth);
|
||||
const maxHeight = lineHeight * 6 + paddingY + borderY;
|
||||
const next = Math.min(ta.scrollHeight, maxHeight);
|
||||
ta.style.height = `${next}px`;
|
||||
ta.style.overflowY = ta.scrollHeight > maxHeight ? "auto" : "hidden";
|
||||
}, [text]);
|
||||
|
||||
const addFiles = useCallback((files: FileList | null) => {
|
||||
if (!files?.length) return;
|
||||
const next: PendingImage[] = [];
|
||||
let droppedImageForUnavailable = false;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (!file) continue;
|
||||
// Handle audio files
|
||||
if (file.type.match(/^audio\//i) && file.size <= MAX_AUDIO_SIZE) {
|
||||
fileToBase64(file).then((base64) => {
|
||||
setPendingAudio({ name: file.name, base64 });
|
||||
setPendingAudioStore(base64, file.name);
|
||||
});
|
||||
continue;
|
||||
const addFiles = useCallback(
|
||||
(files: FileList | null) => {
|
||||
if (!files?.length) return;
|
||||
const next: PendingImage[] = [];
|
||||
let droppedImageForUnavailable = false;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (!file) continue;
|
||||
// Handle audio files
|
||||
if (file.type.match(/^audio\//i) && file.size <= MAX_AUDIO_SIZE) {
|
||||
fileToBase64(file).then((base64) => {
|
||||
setPendingAudio({ name: file.name, base64 });
|
||||
setPendingAudioStore(base64, file.name);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Handle image files
|
||||
if (!file.type.match(/^image\/(jpeg|png|webp|gif)$/i)) continue;
|
||||
if (file.size > MAX_IMAGE_SIZE) continue;
|
||||
if (attachUnavailableReason) {
|
||||
droppedImageForUnavailable = true;
|
||||
continue;
|
||||
}
|
||||
next.push({ id: crypto.randomUUID(), file });
|
||||
}
|
||||
// Handle image files
|
||||
if (!file.type.match(/^image\/(jpeg|png|webp|gif)$/i)) continue;
|
||||
if (file.size > MAX_IMAGE_SIZE) continue;
|
||||
if (attachUnavailableReason) {
|
||||
droppedImageForUnavailable = true;
|
||||
continue;
|
||||
if (droppedImageForUnavailable && attachUnavailableReason) {
|
||||
toast.error(attachUnavailableReason);
|
||||
}
|
||||
next.push({ id: crypto.randomUUID(), file });
|
||||
}
|
||||
if (droppedImageForUnavailable && attachUnavailableReason) {
|
||||
toast.error(attachUnavailableReason);
|
||||
}
|
||||
setPendingImages((prev) => [...prev, ...next]);
|
||||
}, [setPendingAudioStore, attachUnavailableReason]);
|
||||
setPendingImages((prev) => [...prev, ...next]);
|
||||
},
|
||||
[setPendingAudioStore, attachUnavailableReason],
|
||||
);
|
||||
|
||||
const removePendingImage = useCallback((id: string) => {
|
||||
setPendingImages((prev) => prev.filter((p) => p.id !== id));
|
||||
|
|
@ -604,12 +652,17 @@ export function SharedComposer({
|
|||
// LoraCompare and single-pane chats are unaffected.
|
||||
if (hasCompareHandles && !isGeneralizedCompare) {
|
||||
toast.error("Pick a model in each pane to compare", {
|
||||
description: "Use the model dropdown above each pane, then send your prompt.",
|
||||
description:
|
||||
"Use the model dropdown above each pane, then send your prompt.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingImages.length > 0 && !isGeneralizedCompare && imageUnavailableReason) {
|
||||
if (
|
||||
pendingImages.length > 0 &&
|
||||
!isGeneralizedCompare &&
|
||||
imageUnavailableReason
|
||||
) {
|
||||
// Single mode: the loaded model's runtime capability is known
|
||||
// here. Compare mode defers — each ensureModelLoaded below sets
|
||||
// loadedIsMultimodal for its side, and the chat-adapter's
|
||||
|
|
@ -647,8 +700,9 @@ export function SharedComposer({
|
|||
const maxSeqLength = store.params.maxSeqLength;
|
||||
const trustRemoteCode = store.params.trustRemoteCode ?? false;
|
||||
const chatTemplateOverride = store.chatTemplateOverride;
|
||||
const effectiveChatTemplateOverride =
|
||||
chatTemplateOverride?.trim() ? chatTemplateOverride : null;
|
||||
const effectiveChatTemplateOverride = chatTemplateOverride?.trim()
|
||||
? chatTemplateOverride
|
||||
: null;
|
||||
|
||||
function modelDisplayName(id: string): string {
|
||||
const parts = id.split("/");
|
||||
|
|
@ -656,11 +710,14 @@ export function SharedComposer({
|
|||
}
|
||||
|
||||
// Helper: load a model and update store checkpoint
|
||||
async function ensureModelLoaded(sel: CompareModelSelection): Promise<string> {
|
||||
async function ensureModelLoaded(
|
||||
sel: CompareModelSelection,
|
||||
): Promise<string> {
|
||||
const currentStore = useChatRuntimeStore.getState();
|
||||
const isAlreadyActive =
|
||||
currentStore.params.checkpoint === sel.id &&
|
||||
(currentStore.activeGgufVariant ?? null) === (sel.ggufVariant ?? null);
|
||||
(currentStore.activeGgufVariant ?? null) ===
|
||||
(sel.ggufVariant ?? null);
|
||||
if (!isAlreadyActive) {
|
||||
const validation = await validateModel({
|
||||
model_path: sel.id,
|
||||
|
|
@ -750,9 +807,17 @@ export function SharedComposer({
|
|||
try {
|
||||
// Side 1: load → generate → wait
|
||||
if (handle1 && model1?.id) {
|
||||
toast("Loading Model 1…", { id: toastId, description: name1, duration: Infinity });
|
||||
toast("Loading Model 1…", {
|
||||
id: toastId,
|
||||
description: name1,
|
||||
duration: Infinity,
|
||||
});
|
||||
const status1 = await ensureModelLoaded(model1);
|
||||
toast("Generating with Model 1…", { id: toastId, description: `${name1} (${status1})`, duration: Infinity });
|
||||
toast("Generating with Model 1…", {
|
||||
id: toastId,
|
||||
description: `${name1} (${status1})`,
|
||||
duration: Infinity,
|
||||
});
|
||||
const done = handle1.waitForRunEnd();
|
||||
handle1.startRun();
|
||||
await done;
|
||||
|
|
@ -760,13 +825,22 @@ export function SharedComposer({
|
|||
|
||||
// Side 2: load → generate → wait
|
||||
if (handle2 && model2?.id) {
|
||||
const needsLoad = model2.id.toLowerCase() !== (model1?.id || "").toLowerCase()
|
||||
|| (model2.ggufVariant ?? "") !== (model1?.ggufVariant ?? "");
|
||||
const needsLoad =
|
||||
model2.id.toLowerCase() !== (model1?.id || "").toLowerCase() ||
|
||||
(model2.ggufVariant ?? "") !== (model1?.ggufVariant ?? "");
|
||||
if (needsLoad) {
|
||||
toast("Loading Model 2…", { id: toastId, description: name2, duration: Infinity });
|
||||
toast("Loading Model 2…", {
|
||||
id: toastId,
|
||||
description: name2,
|
||||
duration: Infinity,
|
||||
});
|
||||
}
|
||||
const status2 = await ensureModelLoaded(model2);
|
||||
toast("Generating with Model 2…", { id: toastId, description: `${name2} (${status2})`, duration: Infinity });
|
||||
toast("Generating with Model 2…", {
|
||||
id: toastId,
|
||||
description: `${name2} (${status2})`,
|
||||
duration: Infinity,
|
||||
});
|
||||
const done = handle2.waitForRunEnd();
|
||||
handle2.startRun();
|
||||
await done;
|
||||
|
|
@ -820,7 +894,12 @@ export function SharedComposer({
|
|||
}
|
||||
}
|
||||
|
||||
const canSend = (text.trim().length > 0 || pendingImages.length > 0 || pendingAudio !== null) && !busy && !isComposing;
|
||||
const canSend =
|
||||
(text.trim().length > 0 ||
|
||||
pendingImages.length > 0 ||
|
||||
pendingAudio !== null) &&
|
||||
!busy &&
|
||||
!isComposing;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -855,7 +934,10 @@ export function SharedComposer({
|
|||
<span className="max-w-48 truncate">{pendingAudio.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setPendingAudio(null); clearPendingAudioStore(); }}
|
||||
onClick={() => {
|
||||
setPendingAudio(null);
|
||||
clearPendingAudioStore();
|
||||
}}
|
||||
className="flex size-4 items-center justify-center rounded-full hover:bg-destructive hover:text-destructive-foreground"
|
||||
aria-label="Remove audio"
|
||||
>
|
||||
|
|
@ -953,130 +1035,136 @@ export function SharedComposer({
|
|||
)}
|
||||
{showReasoningControl ? (
|
||||
effectiveReasoningStyle === "reasoning_effort" ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={reasoningDisabled}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
|
||||
reasoningDisabled
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={reasoningDisabled}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
|
||||
reasoningDisabled
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: effectiveReasoningVisualEnabled
|
||||
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
|
||||
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
|
||||
)}
|
||||
aria-label={thinkEffortAriaLabel({
|
||||
modelLoaded,
|
||||
reasoningDisabled,
|
||||
reasoningEffort,
|
||||
})}
|
||||
>
|
||||
{effectiveReasoningVisualEnabled ? (
|
||||
<LightbulbIcon className="size-3.5" />
|
||||
) : (
|
||||
<LightbulbOffIcon className="size-3.5" />
|
||||
)}
|
||||
<span>
|
||||
Think:{" "}
|
||||
{effectiveReasoningVisualEnabled
|
||||
? formatReasoningEffortLabel(
|
||||
reasoningEffort,
|
||||
externalSelection?.modelId,
|
||||
)
|
||||
: formatReasoningDisabledLabel(
|
||||
effectiveSupportsReasoningOff,
|
||||
isExternalOpenAIReasoning,
|
||||
checkpoint,
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{effectiveSupportsReasoningOff && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setReasoningEnabled(false);
|
||||
applyQwenThinkingParams(false);
|
||||
}}
|
||||
>
|
||||
{formatReasoningDisabledLabel(
|
||||
effectiveSupportsReasoningOff,
|
||||
isExternalOpenAIReasoning,
|
||||
checkpoint,
|
||||
)}
|
||||
{!effectiveReasoningVisualEnabled ? " \u2713" : ""}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{effectiveReasoningEffortLevels
|
||||
.filter((level) => level !== "none")
|
||||
.map((level) => (
|
||||
<DropdownMenuItem
|
||||
key={level}
|
||||
onSelect={() => {
|
||||
setReasoningEffort(level);
|
||||
setReasoningEnabled(true);
|
||||
applyQwenThinkingParams(true);
|
||||
// Mutual exclusion: turning thinking on for a
|
||||
// Kimi model forces the web_search builtin off.
|
||||
if (isKimiExternal && toolsEnabled) {
|
||||
setToolsEnabled(false, { persist: false });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{formatReasoningEffortLabel(
|
||||
level,
|
||||
externalSelection?.modelId,
|
||||
)}
|
||||
{effectiveReasoningVisualEnabled &&
|
||||
reasoningEffort === level
|
||||
? " \u2713"
|
||||
: ""}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={reasoningDisabled || reasoningLockedOn}
|
||||
aria-disabled={reasoningDisabled || reasoningLockedOn}
|
||||
title={
|
||||
reasoningLockedOn
|
||||
? "This model requires reasoning to stay on."
|
||||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
if (reasoningLockedOn) return;
|
||||
const next = !reasoningEnabled;
|
||||
setReasoningEnabled(next);
|
||||
applyQwenThinkingParams(next);
|
||||
// Mutual exclusion: Kimi's $web_search builtin
|
||||
// requires thinking off, so turning thinking on flips
|
||||
// the Search pill off (and vice versa).
|
||||
if (isKimiExternal && next && toolsEnabled) {
|
||||
setToolsEnabled(false, { persist: false });
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
|
||||
reasoningLockedOn
|
||||
? "cursor-not-allowed text-primary"
|
||||
: reasoningDisabled
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: effectiveReasoningVisualEnabled
|
||||
: effectiveReasoningEnabled
|
||||
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
|
||||
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
|
||||
)}
|
||||
aria-label={thinkEffortAriaLabel({
|
||||
modelLoaded,
|
||||
reasoningDisabled,
|
||||
reasoningEffort,
|
||||
})}
|
||||
>
|
||||
{effectiveReasoningVisualEnabled ? (
|
||||
<LightbulbIcon className="size-3.5" />
|
||||
) : (
|
||||
<LightbulbOffIcon className="size-3.5" />
|
||||
)}
|
||||
<span>
|
||||
Think:{" "}
|
||||
{effectiveReasoningVisualEnabled
|
||||
? formatReasoningEffortLabel(
|
||||
reasoningEffort,
|
||||
externalSelection?.modelId,
|
||||
)
|
||||
: formatReasoningDisabledLabel(
|
||||
effectiveSupportsReasoningOff,
|
||||
isExternalOpenAIReasoning,
|
||||
checkpoint,
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{effectiveSupportsReasoningOff && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setReasoningEnabled(false);
|
||||
applyQwenThinkingParams(false);
|
||||
}}
|
||||
>
|
||||
{formatReasoningDisabledLabel(
|
||||
effectiveSupportsReasoningOff,
|
||||
isExternalOpenAIReasoning,
|
||||
checkpoint,
|
||||
)}
|
||||
{!effectiveReasoningVisualEnabled ? " \u2713" : ""}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{effectiveReasoningEffortLevels
|
||||
.filter((level) => level !== "none")
|
||||
.map((level) => (
|
||||
<DropdownMenuItem
|
||||
key={level}
|
||||
onSelect={() => {
|
||||
setReasoningEffort(level);
|
||||
setReasoningEnabled(true);
|
||||
applyQwenThinkingParams(true);
|
||||
// Mutual exclusion: turning thinking on for a
|
||||
// Kimi model forces the web_search builtin off.
|
||||
if (isKimiExternal && toolsEnabled) {
|
||||
setToolsEnabled(false, { persist: false });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{formatReasoningEffortLabel(level, externalSelection?.modelId)}
|
||||
{effectiveReasoningVisualEnabled && reasoningEffort === level ? " \u2713" : ""}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={reasoningDisabled || reasoningLockedOn}
|
||||
aria-disabled={reasoningDisabled || reasoningLockedOn}
|
||||
title={
|
||||
reasoningLockedOn
|
||||
? "This model requires reasoning to stay on."
|
||||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
if (reasoningLockedOn) return;
|
||||
const next = !reasoningEnabled;
|
||||
setReasoningEnabled(next);
|
||||
applyQwenThinkingParams(next);
|
||||
// Mutual exclusion: Kimi's $web_search builtin
|
||||
// requires thinking off, so turning thinking on flips
|
||||
// the Search pill off (and vice versa).
|
||||
if (isKimiExternal && next && toolsEnabled) {
|
||||
setToolsEnabled(false, { persist: false });
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
|
||||
reasoningLockedOn
|
||||
? "cursor-not-allowed text-primary"
|
||||
: reasoningDisabled
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: effectiveReasoningEnabled
|
||||
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
|
||||
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
|
||||
)}
|
||||
aria-label={thinkToggleAriaLabel({
|
||||
reasoningLockedOn,
|
||||
modelLoaded,
|
||||
reasoningDisabled,
|
||||
effectiveReasoningEnabled,
|
||||
})}
|
||||
>
|
||||
{reasoningLockedOn ||
|
||||
(effectiveReasoningEnabled && !reasoningDisabled) ? (
|
||||
<LightbulbIcon className="size-3.5" />
|
||||
) : (
|
||||
<LightbulbOffIcon className="size-3.5" />
|
||||
)}
|
||||
<span>Think</span>
|
||||
</button>
|
||||
aria-label={thinkToggleAriaLabel({
|
||||
reasoningLockedOn,
|
||||
modelLoaded,
|
||||
reasoningDisabled,
|
||||
effectiveReasoningEnabled,
|
||||
})}
|
||||
>
|
||||
{reasoningLockedOn ||
|
||||
(effectiveReasoningEnabled && !reasoningDisabled) ? (
|
||||
<LightbulbIcon className="size-3.5" />
|
||||
) : (
|
||||
<LightbulbOffIcon className="size-3.5" />
|
||||
)}
|
||||
<span>Think</span>
|
||||
</button>
|
||||
)
|
||||
) : null}
|
||||
{supportsPreserveThinking && (
|
||||
|
|
@ -1093,7 +1181,9 @@ export function SharedComposer({
|
|||
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
|
||||
)}
|
||||
aria-label={
|
||||
preserveThinking ? "Disable preserve think" : "Enable preserve think"
|
||||
preserveThinking
|
||||
? "Disable preserve think"
|
||||
: "Enable preserve think"
|
||||
}
|
||||
>
|
||||
{preserveThinking && modelLoaded ? (
|
||||
|
|
@ -1122,7 +1212,9 @@ export function SharedComposer({
|
|||
}}
|
||||
className="composer-pill-btn"
|
||||
data-active={toolsEnabled && !searchDisabled ? "true" : "false"}
|
||||
aria-label={toolsEnabled ? "Disable web search" : "Enable web search"}
|
||||
aria-label={
|
||||
toolsEnabled ? "Disable web search" : "Enable web search"
|
||||
}
|
||||
>
|
||||
<GlobeIcon className="size-3.5" />
|
||||
<span>Search</span>
|
||||
|
|
@ -1133,7 +1225,11 @@ export function SharedComposer({
|
|||
onClick={() => setCodeToolsEnabled(!codeToolsEnabled)}
|
||||
className="composer-pill-btn"
|
||||
data-active={codeToolsEnabled && !codeDisabled ? "true" : "false"}
|
||||
aria-label={codeToolsEnabled ? "Disable code execution" : "Enable code execution"}
|
||||
aria-label={
|
||||
codeToolsEnabled
|
||||
? "Disable code execution"
|
||||
: "Enable code execution"
|
||||
}
|
||||
>
|
||||
<CodeToggleIcon className="size-3.5" />
|
||||
<span>Code</span>
|
||||
|
|
@ -1144,9 +1240,13 @@ export function SharedComposer({
|
|||
disabled={imageDisabled}
|
||||
onClick={() => setImageToolsEnabled(!imageToolsEnabled)}
|
||||
className="composer-pill-btn"
|
||||
data-active={imageToolsEnabled && !imageDisabled ? "true" : "false"}
|
||||
data-active={
|
||||
imageToolsEnabled && !imageDisabled ? "true" : "false"
|
||||
}
|
||||
aria-label={
|
||||
imageToolsEnabled ? "Disable image generation" : "Enable image generation"
|
||||
imageToolsEnabled
|
||||
? "Disable image generation"
|
||||
: "Enable image generation"
|
||||
}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
|
|
@ -1157,6 +1257,21 @@ export function SharedComposer({
|
|||
<span>Images</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={artifactDisabled}
|
||||
onClick={() => setArtifactsEnabled(!artifactsEnabled)}
|
||||
className="composer-pill-btn"
|
||||
data-active={
|
||||
artifactsEnabled && !artifactDisabled ? "true" : "false"
|
||||
}
|
||||
aria-label={
|
||||
artifactsEnabled ? "Disable artifacts" : "Enable artifacts"
|
||||
}
|
||||
>
|
||||
<FileTextIcon className="size-3.5" />
|
||||
<span>Artifacts</span>
|
||||
</button>
|
||||
{showWebFetchPill && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ export const CHAT_REASONING_ENABLED_KEY = "unsloth_chat_reasoning_enabled";
|
|||
export const CHAT_TOOLS_ENABLED_KEY = "unsloth_chat_tools_enabled";
|
||||
export const CHAT_CODE_TOOLS_ENABLED_KEY = "unsloth_chat_code_tools_enabled";
|
||||
export const CHAT_IMAGE_TOOLS_ENABLED_KEY = "unsloth_chat_image_tools_enabled";
|
||||
export const CHAT_ARTIFACTS_ENABLED_KEY = "unsloth_chat_artifacts_enabled";
|
||||
export const CHAT_COLLAPSE_HTML_ARTIFACTS_KEY =
|
||||
"unsloth_chat_collapse_html_artifacts";
|
||||
export const CHAT_ALLOW_ARTIFACT_NETWORK_ACCESS_KEY =
|
||||
"unsloth_chat_allow_artifact_network_access";
|
||||
export const CHAT_MCP_ENABLED_KEY = "unsloth_chat_mcp_enabled";
|
||||
export const CHAT_WEB_FETCH_TOOLS_ENABLED_KEY =
|
||||
"unsloth_chat_web_fetch_tools_enabled";
|
||||
|
|
@ -300,6 +305,9 @@ type ChatRuntimeStore = {
|
|||
toolsEnabled: boolean;
|
||||
codeToolsEnabled: boolean;
|
||||
imageToolsEnabled: boolean;
|
||||
artifactsEnabled: boolean;
|
||||
collapseHtmlArtifacts: boolean;
|
||||
allowArtifactNetworkAccess: boolean;
|
||||
mcpEnabledForChat: boolean;
|
||||
/**
|
||||
* Fetch pill state, independent of `toolsEnabled` (Search). Only
|
||||
|
|
@ -368,6 +376,12 @@ type ChatRuntimeStore = {
|
|||
setToolsEnabled: (enabled: boolean, options?: { persist?: boolean }) => void;
|
||||
setCodeToolsEnabled: (enabled: boolean) => void;
|
||||
setImageToolsEnabled: (enabled: boolean) => void;
|
||||
setArtifactsEnabled: (
|
||||
enabled: boolean,
|
||||
options?: { persist?: boolean },
|
||||
) => void;
|
||||
setCollapseHtmlArtifacts: (enabled: boolean) => void;
|
||||
setAllowArtifactNetworkAccess: (enabled: boolean) => void;
|
||||
setMcpEnabledForChat: (enabled: boolean) => void;
|
||||
setWebFetchToolsEnabled: (enabled: boolean) => void;
|
||||
setToolStatus: (status: string | null) => void;
|
||||
|
|
@ -400,6 +414,8 @@ type ScalarSettingKey =
|
|||
| "autoTitle"
|
||||
| "reasoningEffort"
|
||||
| "preserveThinking"
|
||||
| "collapseHtmlArtifacts"
|
||||
| "allowArtifactNetworkAccess"
|
||||
| "autoHealToolCalls"
|
||||
| "maxToolCallsPerMessage"
|
||||
| "toolCallTimeout";
|
||||
|
|
@ -434,6 +450,8 @@ const SCALAR_SETTING_KEYS = [
|
|||
"autoTitle",
|
||||
"reasoningEffort",
|
||||
"preserveThinking",
|
||||
"collapseHtmlArtifacts",
|
||||
"allowArtifactNetworkAccess",
|
||||
"autoHealToolCalls",
|
||||
"maxToolCallsPerMessage",
|
||||
"toolCallTimeout",
|
||||
|
|
@ -619,6 +637,12 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
toolsEnabled: loadBool(CHAT_TOOLS_ENABLED_KEY, false),
|
||||
codeToolsEnabled: loadBool(CHAT_CODE_TOOLS_ENABLED_KEY, false),
|
||||
imageToolsEnabled: loadBool(CHAT_IMAGE_TOOLS_ENABLED_KEY, false),
|
||||
artifactsEnabled: loadBool(CHAT_ARTIFACTS_ENABLED_KEY, false),
|
||||
collapseHtmlArtifacts: loadBool(CHAT_COLLAPSE_HTML_ARTIFACTS_KEY, false),
|
||||
allowArtifactNetworkAccess: loadBool(
|
||||
CHAT_ALLOW_ARTIFACT_NETWORK_ACCESS_KEY,
|
||||
false,
|
||||
),
|
||||
mcpEnabledForChat: loadBool(CHAT_MCP_ENABLED_KEY, false),
|
||||
webFetchToolsEnabled: loadBool(CHAT_WEB_FETCH_TOOLS_ENABLED_KEY, false),
|
||||
toolStatus: null,
|
||||
|
|
@ -835,6 +859,8 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
toolsEnabled: false,
|
||||
codeToolsEnabled: false,
|
||||
imageToolsEnabled: false,
|
||||
artifactsEnabled: false,
|
||||
mcpEnabledForChat: false,
|
||||
webFetchToolsEnabled: false,
|
||||
toolStatus: null,
|
||||
kvCacheDtype: null,
|
||||
|
|
@ -896,6 +922,36 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
saveBool(CHAT_IMAGE_TOOLS_ENABLED_KEY, imageToolsEnabled);
|
||||
return { imageToolsEnabled };
|
||||
}),
|
||||
setArtifactsEnabled: (artifactsEnabled, options) =>
|
||||
set(() => {
|
||||
if (options?.persist !== false) {
|
||||
saveBool(CHAT_ARTIFACTS_ENABLED_KEY, artifactsEnabled);
|
||||
}
|
||||
return { artifactsEnabled };
|
||||
}),
|
||||
setCollapseHtmlArtifacts: (collapseHtmlArtifacts) =>
|
||||
set((state) => {
|
||||
saveBool(CHAT_COLLAPSE_HTML_ARTIFACTS_KEY, collapseHtmlArtifacts);
|
||||
setScalarSettingVersion(
|
||||
"collapseHtmlArtifacts",
|
||||
collapseHtmlArtifacts,
|
||||
state.collapseHtmlArtifacts,
|
||||
);
|
||||
return { collapseHtmlArtifacts };
|
||||
}),
|
||||
setAllowArtifactNetworkAccess: (allowArtifactNetworkAccess) =>
|
||||
set((state) => {
|
||||
saveBool(
|
||||
CHAT_ALLOW_ARTIFACT_NETWORK_ACCESS_KEY,
|
||||
allowArtifactNetworkAccess,
|
||||
);
|
||||
setScalarSettingVersion(
|
||||
"allowArtifactNetworkAccess",
|
||||
allowArtifactNetworkAccess,
|
||||
state.allowArtifactNetworkAccess,
|
||||
);
|
||||
return { allowArtifactNetworkAccess };
|
||||
}),
|
||||
setMcpEnabledForChat: (mcpEnabledForChat) =>
|
||||
set(() => {
|
||||
saveBool(CHAT_MCP_ENABLED_KEY, mcpEnabledForChat);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ const CHAT_ACTIVE_PRESET_KEY = "unsloth_chat_active_preset";
|
|||
const CHAT_ACTIVE_PRESET_SOURCE_KEY = "unsloth_chat_active_preset_source";
|
||||
const REASONING_EFFORT_KEY = "unsloth_reasoning_effort";
|
||||
const PRESERVE_THINKING_KEY = "unsloth_preserve_thinking";
|
||||
const COLLAPSE_HTML_ARTIFACTS_KEY = "unsloth_chat_collapse_html_artifacts";
|
||||
const ALLOW_ARTIFACT_NETWORK_ACCESS_KEY =
|
||||
"unsloth_chat_allow_artifact_network_access";
|
||||
const CHAT_PRESETS_KEY = "unsloth_chat_custom_presets";
|
||||
const LEGACY_CHAT_SYSTEM_PROMPTS_KEY = "unsloth_chat_system_prompts";
|
||||
const LEGACY_CHAT_SETTINGS_IMPORT_KEY =
|
||||
|
|
@ -216,6 +219,10 @@ function sanitizeChatSettings(value: unknown): PersistedChatSettings {
|
|||
const reasoningEffort = sanitizeReasoningEffort(value.reasoningEffort);
|
||||
const autoTitle = sanitizeBool(value.autoTitle);
|
||||
const preserveThinking = sanitizeBool(value.preserveThinking);
|
||||
const collapseHtmlArtifacts = sanitizeBool(value.collapseHtmlArtifacts);
|
||||
const allowArtifactNetworkAccess = sanitizeBool(
|
||||
value.allowArtifactNetworkAccess,
|
||||
);
|
||||
const autoHealToolCalls = sanitizeBool(value.autoHealToolCalls);
|
||||
const maxToolCallsPerMessage = sanitizeInt(value.maxToolCallsPerMessage, 1);
|
||||
const toolCallTimeout = sanitizeInt(value.toolCallTimeout, 1);
|
||||
|
|
@ -230,6 +237,12 @@ function sanitizeChatSettings(value: unknown): PersistedChatSettings {
|
|||
if (reasoningEffort) settings.reasoningEffort = reasoningEffort;
|
||||
if (preserveThinking !== undefined)
|
||||
settings.preserveThinking = preserveThinking;
|
||||
if (collapseHtmlArtifacts !== undefined) {
|
||||
settings.collapseHtmlArtifacts = collapseHtmlArtifacts;
|
||||
}
|
||||
if (allowArtifactNetworkAccess !== undefined) {
|
||||
settings.allowArtifactNetworkAccess = allowArtifactNetworkAccess;
|
||||
}
|
||||
if (autoHealToolCalls !== undefined) {
|
||||
settings.autoHealToolCalls = autoHealToolCalls;
|
||||
}
|
||||
|
|
@ -290,6 +303,8 @@ export function isEmptyChatSettings(settings: PersistedChatSettings): boolean {
|
|||
settings.autoTitle === undefined &&
|
||||
settings.reasoningEffort === undefined &&
|
||||
settings.preserveThinking === undefined &&
|
||||
settings.collapseHtmlArtifacts === undefined &&
|
||||
settings.allowArtifactNetworkAccess === undefined &&
|
||||
settings.autoHealToolCalls === undefined &&
|
||||
settings.maxToolCallsPerMessage === undefined &&
|
||||
settings.toolCallTimeout === undefined
|
||||
|
|
@ -318,6 +333,8 @@ export function loadLegacyChatSettings(): PersistedChatSettings {
|
|||
);
|
||||
const autoTitle = loadBool(AUTO_TITLE_KEY);
|
||||
const preserveThinking = loadBool(PRESERVE_THINKING_KEY);
|
||||
const collapseHtmlArtifacts = loadBool(COLLAPSE_HTML_ARTIFACTS_KEY);
|
||||
const allowArtifactNetworkAccess = loadBool(ALLOW_ARTIFACT_NETWORK_ACCESS_KEY);
|
||||
const autoHealToolCalls = loadBool(AUTO_HEAL_TOOL_CALLS_KEY);
|
||||
const maxToolCallsPerMessage = loadInt(MAX_TOOL_CALLS_KEY, 1);
|
||||
const toolCallTimeout = loadInt(TOOL_CALL_TIMEOUT_KEY, 1);
|
||||
|
|
@ -336,6 +353,12 @@ export function loadLegacyChatSettings(): PersistedChatSettings {
|
|||
if (reasoningEffort) settings.reasoningEffort = reasoningEffort;
|
||||
if (preserveThinking !== undefined)
|
||||
settings.preserveThinking = preserveThinking;
|
||||
if (collapseHtmlArtifacts !== undefined) {
|
||||
settings.collapseHtmlArtifacts = collapseHtmlArtifacts;
|
||||
}
|
||||
if (allowArtifactNetworkAccess !== undefined) {
|
||||
settings.allowArtifactNetworkAccess = allowArtifactNetworkAccess;
|
||||
}
|
||||
if (autoHealToolCalls !== undefined) {
|
||||
settings.autoHealToolCalls = autoHealToolCalls;
|
||||
}
|
||||
|
|
|
|||
11
studio/frontend/src/features/native-intents/index.ts
Normal file
11
studio/frontend/src/features/native-intents/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
export { NativeModelChip } from "./components/native-model-chip";
|
||||
export { NativeModelDropOverlay } from "./components/native-model-drop-overlay";
|
||||
export { useNativeIntentStore } from "./store";
|
||||
export type { NativeIntent } from "./types";
|
||||
export { useChooseNativeModel } from "./use-native-dialogs";
|
||||
export { useNativeModelDrop } from "./use-native-drop";
|
||||
export type { NativeModelDropState } from "./use-native-drop";
|
||||
export { useNativePathLeasesSupported } from "./use-native-readiness";
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { toast } from "@/lib/toast";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -15,6 +16,7 @@ import {
|
|||
clearAllChats,
|
||||
countAllChats,
|
||||
downloadChatExport,
|
||||
useChatRuntimeStore,
|
||||
} from "@/features/chat";
|
||||
import { useT } from "@/i18n";
|
||||
import { Delete02Icon, Download02Icon } from "@hugeicons/core-free-icons";
|
||||
|
|
@ -29,10 +31,26 @@ export function ChatTab() {
|
|||
const [count, setCount] = useState<number | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const collapseHtmlArtifacts = useChatRuntimeStore(
|
||||
(state) => state.collapseHtmlArtifacts,
|
||||
);
|
||||
const setCollapseHtmlArtifacts = useChatRuntimeStore(
|
||||
(state) => state.setCollapseHtmlArtifacts,
|
||||
);
|
||||
const allowArtifactNetworkAccess = useChatRuntimeStore(
|
||||
(state) => state.allowArtifactNetworkAccess,
|
||||
);
|
||||
const setAllowArtifactNetworkAccess = useChatRuntimeStore(
|
||||
(state) => state.setAllowArtifactNetworkAccess,
|
||||
);
|
||||
const hydratePersistedSettings = useChatRuntimeStore(
|
||||
(state) => state.hydratePersistedSettings,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void countAllChats().then(setCount);
|
||||
}, []);
|
||||
void hydratePersistedSettings();
|
||||
}, [hydratePersistedSettings]);
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
|
|
@ -111,6 +129,31 @@ export function ChatTab() {
|
|||
</p>
|
||||
</header>
|
||||
|
||||
<SettingsSection title={t("settings.chat.artifacts.title")}>
|
||||
<SettingsRow
|
||||
label={t("settings.chat.artifacts.collapseHtmlBlocks")}
|
||||
description={t(
|
||||
"settings.chat.artifacts.collapseHtmlBlocksDescription",
|
||||
)}
|
||||
>
|
||||
<Switch
|
||||
checked={collapseHtmlArtifacts}
|
||||
onCheckedChange={setCollapseHtmlArtifacts}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t("settings.chat.artifacts.allowNetworkAccess")}
|
||||
description={t(
|
||||
"settings.chat.artifacts.allowNetworkAccessDescription",
|
||||
)}
|
||||
>
|
||||
<Switch
|
||||
checked={allowArtifactNetworkAccess}
|
||||
onCheckedChange={setAllowArtifactNetworkAccess}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("settings.chat.data")}>
|
||||
<SettingsRow
|
||||
label={t("settings.chat.exportHistory")}
|
||||
|
|
|
|||
|
|
@ -165,6 +165,15 @@ export const en = {
|
|||
chat: {
|
||||
title: "Chat",
|
||||
description: "Manage your chat history stored on this device.",
|
||||
artifacts: {
|
||||
title: "Artifacts",
|
||||
collapseHtmlBlocks: "Collapse HTML blocks",
|
||||
collapseHtmlBlocksDescription:
|
||||
"Artifacts mode collapses full HTML fallback automatically. Turn this on to also collapse full fenced HTML documents when Artifacts is off.",
|
||||
allowNetworkAccess: "Allow artifact network access",
|
||||
allowNetworkAccessDescription:
|
||||
"Let artifact previews load scripts, styles, fonts, media, fetch, and WebSocket resources from HTTP(S) CDNs. Keep off for fully offline previews.",
|
||||
},
|
||||
data: "Data",
|
||||
exportHistory: "Export chat history",
|
||||
exportHistoryDescription:
|
||||
|
|
|
|||
|
|
@ -818,6 +818,149 @@
|
|||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.chat-artifact-split[data-artifact-layout-animating="true"] > [data-panel] {
|
||||
transition:
|
||||
flex-basis 260ms var(--ease-out-cubic),
|
||||
flex-grow 260ms var(--ease-out-cubic),
|
||||
flex-shrink 260ms var(--ease-out-cubic);
|
||||
will-change: flex-basis, flex-grow;
|
||||
}
|
||||
|
||||
.chat-artifact-pop-surface {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: scale(0.965) translateY(8px);
|
||||
transform-origin: center center;
|
||||
transition:
|
||||
opacity 180ms var(--ease-out-cubic),
|
||||
transform 220ms var(--ease-out-cubic);
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.chat-artifact-pop-surface[data-artifact-surface-visible="true"] {
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chat-artifact-pop-surface {
|
||||
transition: none;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.artifact-card-shimmer {
|
||||
background: linear-gradient(
|
||||
105deg,
|
||||
transparent 0%,
|
||||
color-mix(in oklch, var(--muted-foreground) 4%, transparent) 40%,
|
||||
color-mix(in oklch, var(--muted-foreground) 8%, transparent) 50%,
|
||||
color-mix(in oklch, var(--muted-foreground) 4%, transparent) 60%,
|
||||
transparent 100%
|
||||
);
|
||||
transform: translateX(-120%);
|
||||
animation: artifact-card-shimmer 1.55s var(--ease-out-cubic) infinite;
|
||||
}
|
||||
|
||||
.artifact-loading-line {
|
||||
width: 48%;
|
||||
background: color-mix(in oklch, var(--primary) 88%, transparent);
|
||||
transform: translate3d(-125%, 0, 0) scaleX(0.78);
|
||||
transform-origin: center center;
|
||||
animation: artifact-loading-line 1.7s linear infinite;
|
||||
}
|
||||
|
||||
.artifact-panel-shell::before,
|
||||
.artifact-panel-shell::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
border-color 150ms var(--ease-out-cubic),
|
||||
background-color 150ms var(--ease-out-cubic),
|
||||
box-shadow 150ms var(--ease-out-cubic),
|
||||
transform 150ms var(--ease-out-cubic);
|
||||
}
|
||||
|
||||
.artifact-panel-shell::before {
|
||||
inset: 0;
|
||||
border: 2px solid transparent;
|
||||
border-radius: inherit;
|
||||
-webkit-mask: linear-gradient(90deg, #000 0 24px, transparent 24px);
|
||||
mask: linear-gradient(90deg, #000 0 24px, transparent 24px);
|
||||
}
|
||||
|
||||
.artifact-panel-shell::after {
|
||||
top: 50%;
|
||||
left: -2px;
|
||||
height: 28px;
|
||||
width: 4px;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
[data-slot="resizable-handle"]:hover
|
||||
+ [data-slot="resizable-panel"]
|
||||
.artifact-panel-shell::before,
|
||||
[data-slot="resizable-handle"]:active
|
||||
+ [data-slot="resizable-panel"]
|
||||
.artifact-panel-shell::before,
|
||||
[data-slot="resizable-handle"][data-resize-handle-state="hover"]
|
||||
+ [data-slot="resizable-panel"]
|
||||
.artifact-panel-shell::before,
|
||||
[data-slot="resizable-handle"][data-resize-handle-state="drag"]
|
||||
+ [data-slot="resizable-panel"]
|
||||
.artifact-panel-shell::before {
|
||||
border-color: color-mix(in oklch, var(--primary) 58%, var(--border));
|
||||
}
|
||||
|
||||
[data-slot="resizable-handle"]:hover
|
||||
+ [data-slot="resizable-panel"]
|
||||
.artifact-panel-shell::after,
|
||||
[data-slot="resizable-handle"]:active
|
||||
+ [data-slot="resizable-panel"]
|
||||
.artifact-panel-shell::after,
|
||||
[data-slot="resizable-handle"][data-resize-handle-state="hover"]
|
||||
+ [data-slot="resizable-panel"]
|
||||
.artifact-panel-shell::after,
|
||||
[data-slot="resizable-handle"][data-resize-handle-state="drag"]
|
||||
+ [data-slot="resizable-panel"]
|
||||
.artifact-panel-shell::after {
|
||||
background: color-mix(in oklch, var(--primary) 58%, var(--border));
|
||||
box-shadow: none;
|
||||
transform: translateY(-50%) scaleY(1.06);
|
||||
}
|
||||
|
||||
@keyframes artifact-card-shimmer {
|
||||
to {
|
||||
transform: translateX(120%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes artifact-loading-line {
|
||||
0% {
|
||||
transform: translate3d(-125%, 0, 0) scaleX(0.78);
|
||||
}
|
||||
35% {
|
||||
transform: translate3d(-18%, 0, 0) scaleX(0.96);
|
||||
}
|
||||
62% {
|
||||
transform: translate3d(72%, 0, 0) scaleX(1.12);
|
||||
}
|
||||
82% {
|
||||
transform: translate3d(132%, 0, 0) scaleX(1.26);
|
||||
}
|
||||
94% {
|
||||
transform: translate3d(188%, 0, 0) scaleX(1.36);
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(220%, 0, 0) scaleX(1.42);
|
||||
}
|
||||
}
|
||||
|
||||
/* Fine-tuning Studio: equal default height, expandable when needed (md+) */
|
||||
.min-h-studio-config-column {
|
||||
@apply md:min-h-[470px];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue