diff --git a/README.md b/README.md index 7046a2af7c..e1b7e448ef 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,43 @@

- - - Unsloth logo + + + Unsloth logo

-Run and train AI models with a unified local interface. +Unsloth Studio lets you run and train models locally.

Features • - Quickstart • + QuickstartNotebooks • - Documentation • - Reddit + Documentation

- -unsloth studio ui homepage +
+ +unsloth studio ui homepage -Unsloth Studio (Beta) lets you run and train text, [audio](https://unsloth.ai/docs/basics/text-to-speech-tts-fine-tuning), [embedding](https://unsloth.ai/docs/new/embedding-finetuning), [vision](https://unsloth.ai/docs/basics/vision-fine-tuning) models on Windows, Linux and macOS. +## ⚡ Get started + +#### macOS, Linux, WSL: +```bash +curl -fsSL https://unsloth.ai/install.sh | sh +``` +#### Windows: +```powershell +irm https://unsloth.ai/install.ps1 | iex +``` +#### Community: + +- [Discord](https://discord.gg/unsloth) +- [𝕏 (Twitter)](https://x.com/UnslothAI) +- [Reddit](https://reddit.com/r/unsloth) ## ⭐ Features -Unsloth provides several key features for both inference and training: +Unsloth Studio (Beta) lets you run and train text, [audio](https://unsloth.ai/docs/basics/text-to-speech-tts-fine-tuning), [embedding](https://unsloth.ai/docs/new/embedding-finetuning), [vision](https://unsloth.ai/docs/basics/vision-fine-tuning) models on Windows, Linux and macOS. + ### Inference * **Search + download + run models** including GGUF, LoRA adapters, safetensors * **Export models**: [Save or export](https://unsloth.ai/docs/new/studio/export) models to GGUF, 16-bit safetensors and other formats. @@ -40,7 +55,7 @@ Unsloth provides several key features for both inference and training: * **Observability**: Monitor training live, track loss and GPU usage and customize graphs. * [Multi-GPU](https://unsloth.ai/docs/basics/multi-gpu-training-with-unsloth) training is supported, with major improvements coming soon. -## ⚡ Quickstart +## 📥 Install Unsloth can be used in two ways: through **[Unsloth Studio](https://unsloth.ai/docs/new/studio/)**, the web UI, or through **Unsloth Core**, the code-based version. Each has different requirements. ### Unsloth Studio (web UI) @@ -133,7 +148,8 @@ Read our [guide](https://unsloth.ai/docs/get-started/fine-tuning-llms-guide). Ad - See detailed documentation for Unsloth [here](https://unsloth.ai/docs) ## 🦥 Unsloth News -- **Gemma 4**: Run and train Google’s new models directly in Unsloth Studio! [Blog](https://unsloth.ai/docs/models/gemma-4) +- **Qwen3.6**: Qwen3.6-35B-A3B can now be trained and run in Unsloth Studio. [Blog](https://unsloth.ai/docs/models/qwen3.6) +- **Gemma 4**: Run and train Google’s new models directly in Unsloth. [Blog](https://unsloth.ai/docs/models/gemma-4) - **Introducing Unsloth Studio**: our new web UI for running and training LLMs. [Blog](https://unsloth.ai/docs/new/studio) - **Qwen3.5** - 0.8B, 2B, 4B, 9B, 27B, 35-A3B, 112B-A10B are now supported. [Guide + notebooks](https://unsloth.ai/docs/models/qwen3.5/fine-tune) - Train **MoE LLMs 12x faster** with 35% less VRAM - DeepSeek, GLM, Qwen and gpt-oss. [Blog](https://unsloth.ai/docs/new/faster-moe) diff --git a/pyproject.toml b/pyproject.toml index b4c0122f4a..815c6ee119 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,7 +88,7 @@ huggingfacenotorch = [ ] huggingface = [ "unsloth[huggingfacenotorch]", - "unsloth_zoo>=2026.4.7", + "unsloth_zoo>=2026.4.8", "torchvision", "unsloth[triton]", ] @@ -578,7 +578,7 @@ colab-ampere-torch220 = [ "flash-attn>=2.6.3 ; ('linux' in sys_platform)", ] colab-new = [ - "unsloth_zoo>=2026.4.7", + "unsloth_zoo>=2026.4.8", "packaging", "tyro", "transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.5.0", diff --git a/scripts/install_qwen3_6_mlx.sh b/scripts/install_qwen3_6_mlx.sh new file mode 100644 index 0000000000..5ce66d29a6 --- /dev/null +++ b/scripts/install_qwen3_6_mlx.sh @@ -0,0 +1,191 @@ +#!/bin/bash +set -e + +# ============================================================ +# Qwen3.6 MLX — One-command setup + inference +# +# Usage: +# bash install_qwen3_6_mlx.sh [--venv-dir DIR] +# +# This script: +# 1. Creates a Python virtual environment +# 2. Installs uv, mlx-vlm, transformers, torch, torchvision +# ============================================================ + +# ── Output style (inspired by unsloth/install.sh) ───────────── +RULE="" +_rule_i=0 +while [ "$_rule_i" -lt 52 ]; do + RULE="${RULE}─" + _rule_i=$((_rule_i + 1)) +done + +if [ -n "${NO_COLOR:-}" ]; then + C_TITLE= C_DIM= C_OK= C_WARN= C_ERR= C_RST= +elif [ -t 1 ] || [ -n "${FORCE_COLOR:-}" ]; then + _ESC="$(printf '\033')" + C_TITLE="${_ESC}[38;5;117m" + C_DIM="${_ESC}[38;5;245m" + C_OK="${_ESC}[38;5;108m" + C_WARN="${_ESC}[38;5;136m" + C_ERR="${_ESC}[91m" + C_RST="${_ESC}[0m" +else + C_TITLE= C_DIM= C_OK= C_WARN= C_ERR= C_RST= +fi + +step() { printf " ${C_DIM}%-18.18s${C_RST}${3:-$C_OK}%s${C_RST}\n" "$1" "$2"; } +substep() { printf " ${C_DIM}%-18s${2:-$C_DIM}%s${C_RST}\n" "" "$1"; } +fail() { step "error" "$1" "$C_ERR"; exit 1; } + +# ── Parse flags ─────────────────────────────────────────────── +VENV_DIR="" +_next_is_venv=false + +for arg in "$@"; do + if [ "$_next_is_venv" = true ]; then + VENV_DIR="$arg" + _next_is_venv=false + continue + fi + case "$arg" in + --venv-dir) _next_is_venv=true ;; + esac +done + +# Default venv location +if [ -z "$VENV_DIR" ]; then + VENV_DIR="$HOME/.unsloth/unsloth_qwen3_6_mlx" +fi + +# ── Banner ──────────────────────────────────────────────────── +echo "" +printf " ${C_TITLE}%s${C_RST}\n" "Qwen3.6 MLX Installer" +printf " ${C_DIM}%s${C_RST}\n" "$RULE" +echo "" + +# ── Platform check ──────────────────────────────────────────── +if [ "$(uname)" != "Darwin" ]; then + fail "MLX requires macOS with Apple Silicon. Detected: $(uname)" +fi + +_ARCH=$(uname -m) +if [ "$_ARCH" != "arm64" ]; then + step "warning" "Apple Silicon recommended (detected: $_ARCH)" "$C_WARN" +fi + +step "platform" "macOS ($_ARCH)" + +# ── Detect Python ───────────────────────────────────────────── +PYTHON="" +for _candidate in python3.12 python3.11 python3.13 python3; do + if command -v "$_candidate" >/dev/null 2>&1; then + PYTHON="$_candidate" + break + fi +done + +if [ -z "$PYTHON" ]; then + fail "Python 3 not found. Install via: brew install python@3.12" +fi + +_PY_VERSION=$("$PYTHON" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')") +step "python" "$PYTHON ($_PY_VERSION)" + +# ── Create virtual environment ──────────────────────────────── +if [ -x "$VENV_DIR/bin/python" ]; then + step "venv" "using existing environment" + substep "$VENV_DIR" +else + step "venv" "creating virtual environment" + substep "$VENV_DIR" + mkdir -p "$(dirname "$VENV_DIR")" + "$PYTHON" -m venv "$VENV_DIR" +fi + +# ── Install uv ─────────────────────────────────────────────── +if ! command -v uv >/dev/null 2>&1; then + step "uv" "installing uv package manager..." + _uv_tmp=$(mktemp) + curl -LsSf "https://astral.sh/uv/install.sh" -o "$_uv_tmp" + sh "$_uv_tmp" /dev/null || echo 'uv')" +fi + +_VENV_PY="$VENV_DIR/bin/python" + +# ── Install dependencies ────────────────────────────────────── +step "install" "installing mlx-vlm..." +uv pip install --python "$_VENV_PY" -q mlx-vlm +substep "done" + +step "install" "installing transformers>=5.2.0..." +if uv pip install --python "$_VENV_PY" -q "transformers>=5.2.0"; then + substep "installed from PyPI" +else + substep "PyPI install failed, trying GitHub..." + if uv pip install --python "$_VENV_PY" -q "git+https://github.com/huggingface/transformers.git"; then + substep "installed from huggingface/transformers main" + else + fail "Could not install transformers>=5.2.0 (required for Qwen3.5/3.6 model support). Please check your Python version (>=3.10 required) and network connection, then try again." + fi +fi + +step "install" "installing torch + torchvision (needed for Qwen3 VL processor)..." +uv pip install --python "$_VENV_PY" -q torch torchvision +substep "done" + +# ── Verify installation ────────────────────────────────────── +if "$_VENV_PY" -c "import mlx_vlm; import torch; import torchvision; import transformers"; then + substep "mlx-vlm + torch + transformers verified" +else + fail "Installation verification failed. Please ensure Python >=3.10 and try again." +fi + +# ── Apply patches for multi-turn image chat ────────────────── +_PATCH_BASE="https://raw.githubusercontent.com/unslothai/unsloth/refs/heads/fix/ui-fix/unsloth/models/patches/mlx_vlm_qwen3_5" +_SITE_PKGS=$("$_VENV_PY" -c "import site; print(site.getsitepackages()[0])") + +step "patch" "fixing multi-turn image chat..." + +if curl -sSLf "${_PATCH_BASE}/qwen3_5.py" -o "${_SITE_PKGS}/mlx_vlm/models/qwen3_5/qwen3_5.py"; then + substep "patched qwen3_5.py (MRoPE position reset)" +else + step "warning" "failed to download qwen3_5.py patch — multi-turn image chat may not work" "$C_WARN" +fi + +if curl -sSLf "${_PATCH_BASE}/generate.py" -o "${_SITE_PKGS}/mlx_vlm/generate.py"; then + substep "patched generate.py (mask trim on cache reuse)" +else + step "warning" "failed to download generate.py patch — multi-turn image chat may not work" "$C_WARN" +fi + +# Clear pycache so patches take effect +find "${_SITE_PKGS}/mlx_vlm" -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true +substep "cleared bytecode cache" + +# ── Done ────────────────────────────────────────────────────── +echo "" +printf " ${C_TITLE}%s${C_RST}\n" "Qwen3.6 MLX installed!" +printf " ${C_DIM}%s${C_RST}\n" "$RULE" +echo "" +step "available models" "unsloth/Qwen3.6-35B-A3B-UD-MLX-3bit" +substep "unsloth/Qwen3.6-35B-A3B-UD-MLX-4bit" +substep "unsloth/Qwen3.6-35B-A3B-MLX-8bit" +echo "" +step "venv activate" "source ${VENV_DIR}/bin/activate" +echo "" +step "vision chat" "python -m mlx_vlm.chat --model unsloth/Qwen3.6-35B-A3B-UD-MLX-4bit" +substep "Use /image path/to/image.jpg to load an image" +echo "" +step "gradio UI" "python -m mlx_vlm.chat_ui --model unsloth/Qwen3.6-35B-A3B-UD-MLX-4bit" +echo "" +printf " ${C_DIM}%s${C_RST}\n" "$RULE" +echo "" diff --git a/studio/backend/assets/configs/inference_defaults.json b/studio/backend/assets/configs/inference_defaults.json index 1b4b5381e8..1b10b557e4 100644 --- a/studio/backend/assets/configs/inference_defaults.json +++ b/studio/backend/assets/configs/inference_defaults.json @@ -1,6 +1,14 @@ { "_comment": "Per-model-family inference parameter defaults. Sources: (1) Ollama params blobs, (2) Existing Unsloth Studio YAML configs. Patterns ordered longest-match-first.", "families": { + "qwen3.6": { + "temperature": 0.7, + "top_p": 0.8, + "top_k": 20, + "min_p": 0.0, + "repetition_penalty": 1.0, + "presence_penalty": 1.5 + }, "qwen3.5": { "temperature": 0.7, "top_p": 0.8, @@ -369,7 +377,7 @@ } }, "patterns": [ - "qwen3.5", + "qwen3.6", "qwen3.5", "qwen3-coder", "qwen3-next", "qwen3-vl", "qwen3", "qwen2.5-coder", "qwen2.5-vl", "qwen2.5-omni", "qwen2.5-math", "qwen2.5", "qwen2-vl", "qwen2", diff --git a/studio/backend/core/inference/anthropic_compat.py b/studio/backend/core/inference/anthropic_compat.py index e7b40a60ce..400e6053e0 100644 --- a/studio/backend/core/inference/anthropic_compat.py +++ b/studio/backend/core/inference/anthropic_compat.py @@ -114,6 +114,39 @@ def anthropic_tools_to_openai(tools: list) -> list[dict]: return result +def anthropic_tool_choice_to_openai(tc: Any) -> Any: + """Translate Anthropic `tool_choice` into OpenAI `tool_choice`. + + Anthropic formats (all dict shapes with a ``type`` discriminator): + + - ``{"type": "auto"}`` → ``"auto"`` + - ``{"type": "any"}`` → ``"required"`` + - ``{"type": "none"}`` → ``"none"`` + - ``{"type": "tool", "name": "get_weather"}`` + → ``{"type": "function", "function": {"name": "get_weather"}}`` + + Returns ``None`` for ``None`` or any unrecognized shape (caller may + then fall back to its own default, typically ``"auto"``). + """ + if tc is None: + return None + if not isinstance(tc, dict): + return None + t = tc.get("type") + if t == "auto": + return "auto" + if t == "any": + return "required" + if t == "none": + return "none" + if t == "tool": + name = tc.get("name") + if not name: + return None + return {"type": "function", "function": {"name": name}} + return None + + def build_anthropic_sse_event(event_type: str, data: dict) -> str: """Format a single Anthropic SSE event.""" return f"event: {event_type}\ndata: {json.dumps(data)}\n\n" diff --git a/studio/backend/core/inference/defaults.py b/studio/backend/core/inference/defaults.py index f3026dddaf..53718c1294 100644 --- a/studio/backend/core/inference/defaults.py +++ b/studio/backend/core/inference/defaults.py @@ -10,6 +10,7 @@ DEFAULT_MODELS_GGUF = [ "unsloth/gemma-4-E4B-it-GGUF", "unsloth/gemma-4-31B-it-GGUF", "unsloth/gemma-4-26B-A4B-it-GGUF", + "unsloth/Qwen3.6-35B-A3B-GGUF", "unsloth/Qwen3.5-4B-GGUF", "unsloth/Qwen3.5-9B-GGUF", "unsloth/Qwen3.5-35B-A3B-GGUF", @@ -27,6 +28,7 @@ DEFAULT_MODELS_STANDARD = [ "unsloth/gemma-4-E4B-it-GGUF", "unsloth/gemma-4-31B-it-GGUF", "unsloth/gemma-4-26B-A4B-it-GGUF", + "unsloth/Qwen3.6-35B-A3B-GGUF", "unsloth/Qwen3.5-4B-GGUF", "unsloth/Qwen3.5-9B-GGUF", "unsloth/Qwen3.5-35B-A3B-GGUF", diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 77b58e22fb..2e26995309 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -1514,12 +1514,12 @@ class LlamaCppBackend: ) # For reasoning models, set default thinking mode. - # Qwen3.5 models below 9B (0.8B, 2B, 4B) disable thinking by default. + # Qwen3.5/3.6 models below 9B (0.8B, 2B, 4B) disable thinking by default. # Only 9B and larger enable thinking. if self._supports_reasoning: thinking_default = True mid = (model_identifier or "").lower() - if "qwen3.5" in mid: + if "qwen3.5" in mid or "qwen3.6" in mid: size_val = _extract_model_size_b(mid) if size_val is not None and size_val < 9: thinking_default = False diff --git a/studio/backend/main.py b/studio/backend/main.py index 8a40791c06..d146a8ef12 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -27,6 +27,7 @@ import mimetypes import shutil import warnings from contextlib import asynccontextmanager +from importlib.metadata import PackageNotFoundError, version as package_version # Fix broken Windows registry MIME types. Some Windows installs map .js to # "text/plain" in the registry (HKCR\.js\Content Type). Python's mimetypes @@ -78,6 +79,27 @@ import utils.hardware.hardware as _hw_module from utils.cache_cleanup import clear_unsloth_compiled_cache +def get_unsloth_version() -> str: + try: + return package_version("unsloth") + except PackageNotFoundError: + pass + + version_file = ( + _Path(__file__).resolve().parents[2] / "unsloth" / "models" / "_utils.py" + ) + try: + for line in version_file.read_text(encoding = "utf-8").splitlines(): + if line.startswith("__version__ = "): + return line.split("=", 1)[1].strip().strip('"').strip("'") + except OSError: + pass + return "dev" + + +UNSLOTH_VERSION = get_unsloth_version() + + @asynccontextmanager async def lifespan(app: FastAPI): """Startup: detect hardware, seed default admin if needed. Shutdown: clean up compiled cache.""" @@ -140,7 +162,7 @@ async def lifespan(app: FastAPI): # Create FastAPI app app = FastAPI( title = "Unsloth UI Backend", - version = "1.0.0", + version = UNSLOTH_VERSION, description = "Backend API for Unsloth UI - Training and Model Management", lifespan = lifespan, ) @@ -198,6 +220,7 @@ async def health_check(): "status": "healthy", "timestamp": datetime.now().isoformat(), "service": "Unsloth UI Backend", + "version": UNSLOTH_VERSION, "device_type": device_type, "chat_only": _hw_module.CHAT_ONLY, } diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index 4917a14579..956b99f75c 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -11,7 +11,7 @@ import time import uuid from typing import Annotated, Any, Dict, Literal, Optional, List, Union -from pydantic import BaseModel, Discriminator, Field, Tag +from pydantic import BaseModel, Discriminator, Field, Tag, model_validator class LoadRequest(BaseModel): @@ -338,14 +338,68 @@ class ChatMessage(BaseModel): ``content`` may be a plain string (text-only) or a list of content parts for multimodal messages (OpenAI vision format). + Assistant messages that only contain tool calls may set ``content`` + to ``None`` with ``tool_calls`` populated. ``role="tool"`` messages + carry the result of a client-executed tool call and require + ``tool_call_id`` per the OpenAI spec. """ - role: Literal["system", "user", "assistant"] = Field( + role: Literal["system", "user", "assistant", "tool"] = Field( ..., description = "Message role" ) - content: Union[str, list[ContentPart]] = Field( - ..., description = "Message content (string or multimodal parts)" + content: Optional[Union[str, list[ContentPart]]] = Field( + None, description = "Message content (string or multimodal parts)" ) + tool_call_id: Optional[str] = Field( + None, + description = "OpenAI tool-result messages: id of the tool call this result belongs to.", + ) + tool_calls: Optional[list[dict]] = Field( + None, + description = "OpenAI assistant messages: structured tool calls the model decided to make.", + ) + name: Optional[str] = Field( + None, + description = "OpenAI tool-result messages: name of the tool whose result this is.", + ) + + @model_validator(mode = "after") + def _validate_role_shape(self) -> "ChatMessage": + # Enforce the per-role OpenAI spec shape at the request boundary. + # Without this, malformed messages (e.g. user entries with no + # content, tool_calls on a user/system role, role="tool" without + # tool_call_id) would be silently forwarded to llama-server via + # the passthrough path, surfacing as opaque upstream errors or + # broken tool-call reconciliation downstream. + + # Tool-call metadata must appear only on the appropriate role. + if self.tool_calls is not None and self.role != "assistant": + raise ValueError('"tool_calls" is only valid on role="assistant" messages.') + if self.tool_call_id is not None and self.role != "tool": + raise ValueError('"tool_call_id" is only valid on role="tool" messages.') + if self.name is not None and self.role != "tool": + raise ValueError('"name" is only valid on role="tool" messages.') + + # Per-role content requirements. + if self.role == "tool": + if not self.tool_call_id: + raise ValueError( + 'role="tool" messages require "tool_call_id" per the OpenAI spec.' + ) + if not self.content: + raise ValueError('role="tool" messages require non-empty "content".') + elif self.role == "assistant": + # Assistant messages may omit content when tool_calls is set. + if not self.content and not self.tool_calls: + raise ValueError( + 'role="assistant" messages require either "content" or "tool_calls".' + ) + else: # "user" | "system" + if not self.content: + raise ValueError( + f'role="{self.role}" messages require non-empty "content".' + ) + return self class ChatCompletionRequest(BaseModel): @@ -355,18 +409,49 @@ class ChatCompletionRequest(BaseModel): Extensions (non-OpenAI fields) are marked with 'x-unsloth'. """ + # Accept unknown fields defensively so future OpenAI fields (seed, + # response_format, logprobs, frequency_penalty, etc.) don't get + # silently dropped by Pydantic before route code runs. Mirrors + # AnthropicMessagesRequest and ResponsesRequest. + model_config = {"extra": "allow"} + model: str = Field( "default", description = "Model identifier (informational; the active model is used)", ) messages: list[ChatMessage] = Field(..., description = "Conversation messages") - stream: bool = Field(True, description = "Whether to stream the response via SSE") + stream: bool = Field( + False, + description = ( + "Whether to stream the response via SSE. Default matches OpenAI's " + "spec (`false`); opt into streaming by sending `stream: true`." + ), + ) temperature: float = Field(0.6, ge = 0.0, le = 2.0) top_p: float = Field(0.95, ge = 0.0, le = 1.0) max_tokens: Optional[int] = Field( None, ge = 1, description = "Maximum tokens to generate (None = until EOS)" ) presence_penalty: float = Field(0.0, ge = 0.0, le = 2.0, description = "Presence penalty") + stop: Optional[Union[str, list[str]]] = Field( + None, + description = "OpenAI stop sequences: a single string or list of strings at which generation halts.", + ) + tools: Optional[list[dict]] = Field( + None, + description = ( + "OpenAI function-tool definitions. When provided without `enable_tools=true`, " + "Studio forwards the tools to the backend so the model returns structured " + "tool_calls for the client to execute (standard OpenAI function calling)." + ), + ) + tool_choice: Optional[Union[str, dict]] = Field( + None, + description = ( + "OpenAI tool choice: 'auto' | 'required' | 'none' | " + "{'type': 'function', 'function': {'name': ...}}" + ), + ) # ── Unsloth extensions (ignored by standard OpenAI clients) ── top_k: int = Field(20, ge = -1, le = 100, description = "[x-unsloth] Top-k sampling") diff --git a/studio/backend/requirements/extras-no-deps.txt b/studio/backend/requirements/extras-no-deps.txt index a16571567d..23c61baa44 100644 --- a/studio/backend/requirements/extras-no-deps.txt +++ b/studio/backend/requirements/extras-no-deps.txt @@ -2,7 +2,7 @@ descript-audio-codec descript-audiotools julius -torchcodec +torchcodec==0.10.0 snac # peft 0.19.0 causes export subprocess shutdown issues in Studio; diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 4246f0056b..6103c90915 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -29,6 +29,14 @@ from utils.models import extract_model_size_b as _extract_model_size_b def _friendly_error(exc: Exception) -> str: """Extract a user-friendly message from known llama-server errors.""" + # httpx transport-layer failures reaching the managed llama-server — + # raised by the async pass-through helpers that talk to llama-server + # directly. Treat any RequestError subclass (ConnectError, ReadError, + # RemoteProtocolError, WriteError, PoolTimeout, ...) as "the upstream + # subprocess is unreachable", which for Studio always means the + # llama-server subprocess crashed or is still coming up. + if isinstance(exc, httpx.RequestError): + return "Lost connection to the model server. It may have crashed -- try reloading the model." msg = str(exc) m = _re.search( r"request \((\d+) tokens?\) exceeds the available context size \((\d+) tokens?\)", @@ -106,6 +114,7 @@ from models.inference import ( from core.inference.anthropic_compat import ( anthropic_messages_to_openai, anthropic_tools_to_openai, + anthropic_tool_choice_to_openai, AnthropicStreamEmitter, AnthropicPassthroughEmitter, ) @@ -1122,6 +1131,56 @@ async def openai_chat_completions( ) return JSONResponse(content = response.model_dump()) + # ── Standard OpenAI function-calling pass-through (GGUF only) ──── + # When a client (opencode / Claude Code via OpenAI compat / Cursor / + # Continue / ...) sends standard OpenAI `tools` without Studio's + # `enable_tools` shorthand, forward the request to llama-server + # verbatim so structured `tool_calls` flow back to the client. This + # branch runs BEFORE `_extract_content_parts` because that helper is + # unaware of `role="tool"` messages and assistant messages that only + # carry `tool_calls` (content=None) — both of which are valid in + # multi-turn client-side tool loops. + _has_tool_messages = any(m.role == "tool" or m.tool_calls for m in payload.messages) + if ( + using_gguf + and llama_backend.supports_tools + and not payload.enable_tools + and ((payload.tools and len(payload.tools) > 0) or _has_tool_messages) + ): + # Preserve the vision guard that would otherwise run in the + # non-passthrough path below: text-only tool-capable GGUFs + # should return a clear 400 here rather than forwarding the + # image to llama-server and surfacing an opaque upstream error. + if not llama_backend.is_vision and ( + payload.image_base64 + or any( + isinstance(m.content, list) + and any(isinstance(p, ImageContentPart) for p in m.content) + for m in payload.messages + ) + ): + raise HTTPException( + status_code = 400, + detail = "Image provided but current GGUF model does not support vision.", + ) + + cancel_event = threading.Event() + completion_id = f"chatcmpl-{uuid.uuid4().hex[:12]}" + if payload.stream: + return await _openai_passthrough_stream( + request, + cancel_event, + llama_backend, + payload, + model_name, + completion_id, + ) + return await _openai_passthrough_non_streaming( + llama_backend, + payload, + model_name, + ) + # ── Parse messages (handles multimodal content parts) ───── system_prompt, chat_messages, extracted_image_b64 = _extract_content_parts( payload.messages @@ -1151,9 +1210,11 @@ async def openai_chat_completions( from PIL import Image as _Image raw = _b64.b64decode(image_b64) - img = _Image.open(_BytesIO(raw)) - if img.mode == "RGBA": - img = img.convert("RGB") + # Normalize to RGB so PNG encoding succeeds regardless of + # source mode (RGBA, P, L, CMYK, I, F, ...). Previously + # we only converted RGBA, which left CMYK/I/F to raise at + # img.save(PNG). + img = _Image.open(_BytesIO(raw)).convert("RGB") buf = _BytesIO() img.save(buf, format = "PNG") image_b64 = _b64.b64encode(buf.getvalue()).decode("ascii") @@ -1933,25 +1994,32 @@ async def openai_completions( if is_stream: async def _stream(): - # Manual httpx client/response lifecycle — see - # _anthropic_passthrough_stream for the full rationale. Briefly: - # `async with` inside an async generator causes - # "Attempted to exit cancel scope in a different task" / - # "async generator ignored GeneratorExit" on Python 3.13 + - # httpcore 1.0.x when the generator is orphaned and finalized - # by GC. Closing via a finally block that catches Exception - # (but not BaseException) suppresses the anyio cleanup noise - # while letting GeneratorExit propagate cleanly. + # Manual httpx client/response lifecycle AND explicit + # aiter_bytes() iterator close — see _anthropic_passthrough_stream + # for the full rationale. Saving `bytes_iter = resp.aiter_bytes()` + # and `await bytes_iter.aclose()` in the finally block is the + # part that matters for avoiding the Python 3.13 + httpcore + # 1.0.x "Exception ignored in: " / anyio + # cancel-scope trace: an anonymous async for leaves the + # iterator unclosed, so Python's asyncgen GC finalizer runs + # cleanup on a later pass in a different asyncio task. client = httpx.AsyncClient(timeout = 600) resp = None + bytes_iter = None try: req = client.build_request("POST", target_url, json = body) resp = await client.send(req, stream = True) - async for chunk in resp.aiter_bytes(): + bytes_iter = resp.aiter_bytes() + async for chunk in bytes_iter: yield chunk except Exception as e: logger.error("openai_completions stream error: %s", e) finally: + if bytes_iter is not None: + try: + await bytes_iter.aclose() + except Exception: + pass if resp is not None: try: await resp.aclose() @@ -2339,22 +2407,12 @@ async def anthropic_messages( ) stop = payload.stop_sequences or None - # tool_choice is declared on AnthropicMessagesRequest for Anthropic SDK - # compatibility (the SDK often sets it by default), but it is not - # currently honored by Unsloth's backend. Warn once per request so the - # silent drop is visible to operators instead of looking like a model - # quality issue to clients. - if payload.tool_choice is not None: - logger.warning( - "anthropic_messages.tool_choice_ignored", - tool_choice = payload.tool_choice, - note = ( - "tool_choice is accepted for Anthropic SDK compatibility but not " - "honored by Unsloth. Use enable_tools / enabled_tools (server-side " - "built-in tools) or restrict the `tools` array (client-side) to " - "control which tools the model sees." - ), - ) + # Translate Anthropic tool_choice to OpenAI format for forwarding to + # llama-server. Falls back to "auto" when unset or unrecognized, which + # matches the prior hardcoded behavior. + openai_tool_choice = anthropic_tool_choice_to_openai(payload.tool_choice) + if openai_tool_choice is None: + openai_tool_choice = "auto" cancel_event = threading.Event() @@ -2392,6 +2450,7 @@ async def anthropic_messages( min_p = min_p, repetition_penalty = repetition_penalty, presence_penalty = presence_penalty, + tool_choice = openai_tool_choice, ) return await _anthropic_passthrough_non_streaming( llama_backend, @@ -2407,6 +2466,7 @@ async def anthropic_messages( min_p = min_p, repetition_penalty = repetition_penalty, presence_penalty = presence_penalty, + tool_choice = openai_tool_choice, ) if server_tools: @@ -2750,11 +2810,12 @@ def _build_passthrough_payload( min_p = None, repetition_penalty = None, presence_penalty = None, + tool_choice = "auto", ): body = { "messages": openai_messages, "tools": openai_tools, - "tool_choice": "auto", + "tool_choice": tool_choice, "temperature": temperature, "top_p": top_p, "top_k": top_k, @@ -2792,6 +2853,7 @@ async def _anthropic_passthrough_stream( min_p = None, repetition_penalty = None, presence_penalty = None, + tool_choice = "auto", ): """Streaming client-side pass-through: forward tools to llama-server and translate its streaming response to Anthropic SSE without executing anything.""" @@ -2808,6 +2870,7 @@ async def _anthropic_passthrough_stream( min_p = min_p, repetition_penalty = repetition_penalty, presence_penalty = presence_penalty, + tool_choice = tool_choice, ) async def _stream(): @@ -2815,33 +2878,42 @@ async def _anthropic_passthrough_stream( for line in emitter.start(message_id, model_name): yield line - # Manage the httpx client and response MANUALLY — no `async with`. + # Manage the httpx client, response, AND the aiter_lines() async + # generator MANUALLY — no `async with`, no anonymous iterator. # - # On Python 3.13 + httpcore 1.0.x, an orphaned async generator (e.g. - # when the client disconnects mid-stream and Starlette drops the - # StreamingResponse iterator without explicitly calling aclose()) - # is finalized by Python's asyncgen GC hook in a DIFFERENT asyncio - # task than the one that originally entered the httpx context - # managers. When `async with` exits run in the wrong task, httpcore's - # internal `HTTP11ConnectionByteStream.aclose()` hits - # `anyio.CancelScope.__exit__` with a mismatched task and raises - # RuntimeError("Attempted to exit cancel scope in a different task"), - # which escapes as "Exception ignored in:" because it happens during - # GC finalization outside any user-owned try/except. + # On Python 3.13 + httpcore 1.0.x, `async for raw_line in + # resp.aiter_lines():` creates an anonymous async generator. When + # the loop exits via `break` (or the generator is orphaned when a + # client disconnects mid-stream), Python's `async for` protocol + # does NOT auto-close the iterator the way a sync `for` loop + # would. The iterator remains reachable only from the current + # coroutine frame; once `_stream()` returns, the frame is GC'd + # and the iterator becomes unreachable. Python's asyncgen + # finalizer hook then runs its aclose() on a LATER GC pass in a + # DIFFERENT asyncio task, where httpcore's + # `HTTP11ConnectionByteStream.aclose()` enters + # `anyio.CancelScope.__exit__` with a mismatched task and prints + # `RuntimeError: Attempted to exit cancel scope in a different + # task` / `RuntimeError: async generator ignored GeneratorExit` + # as "Exception ignored in:" unraisable warnings. # - # The fix: do not use `async with` for the client/response. Close - # them in a finally block wrapped in `try: ... except Exception: pass`. - # This narrowly suppresses RuntimeError / other Exception subclasses - # from the anyio cleanup noise while letting GeneratorExit (a - # BaseException, not Exception) propagate through cleanly so the - # generator terminates as Python expects. + # The fix: save `resp.aiter_lines()` as `lines_iter`, and in the + # finally block explicitly `await lines_iter.aclose()` BEFORE + # `resp.aclose()` / `client.aclose()`. This closes the iterator + # inside our own task's event loop, so the internal httpcore + # byte-stream is cleaned up before Python's asyncgen finalizer + # has anything orphaned to finalize. Each aclose is wrapped in + # `try: ... except Exception: pass` so anyio cleanup noise from + # nested aclose paths can't bubble out. client = httpx.AsyncClient(timeout = 600) resp = None + lines_iter = None try: req = client.build_request("POST", target_url, json = body) resp = await client.send(req, stream = True) - async for raw_line in resp.aiter_lines(): + lines_iter = resp.aiter_lines() + async for raw_line in lines_iter: if await request.is_disconnected(): cancel_event.set() break @@ -2859,6 +2931,11 @@ async def _anthropic_passthrough_stream( except Exception as e: logger.error("anthropic_messages passthrough stream error: %s", e) finally: + if lines_iter is not None: + try: + await lines_iter.aclose() + except Exception: + pass if resp is not None: try: await resp.aclose() @@ -2897,6 +2974,7 @@ async def _anthropic_passthrough_non_streaming( min_p = None, repetition_penalty = None, presence_penalty = None, + tool_choice = "auto", ): """Non-streaming client-side pass-through.""" target_url = f"{llama_backend.base_url}/v1/chat/completions" @@ -2912,6 +2990,7 @@ async def _anthropic_passthrough_non_streaming( min_p = min_p, repetition_penalty = repetition_penalty, presence_penalty = presence_penalty, + tool_choice = tool_choice, ) async with httpx.AsyncClient() as client: @@ -2969,3 +3048,265 @@ async def _anthropic_passthrough_non_streaming( ), ) return JSONResponse(content = resp_obj.model_dump()) + + +# ===================================================================== +# Client-side tool pass-through (OpenAI-native /v1/chat/completions) +# ===================================================================== + + +def _openai_messages_for_passthrough(payload) -> list[dict]: + """Build OpenAI-format message dicts for the /v1/chat/completions + passthrough path. + + Messages from ``payload.messages`` are dumped through Pydantic (dropping + unset optional fields) so they are already in standard OpenAI format + — including ``role="tool"`` tool-result messages and assistant messages + that carry structured ``tool_calls``. Content-parts images already in + the message list are left untouched. + + When a client uses Studio's legacy ``image_base64`` top-level field, the + image is re-encoded to PNG (llama-server's stb_image has limited format + support) and spliced into the last user message as an OpenAI + ``image_url`` content part so vision + function-calling requests work + transparently. + """ + messages = [m.model_dump(exclude_none = True) for m in payload.messages] + + if not payload.image_base64: + return messages + + try: + import base64 as _b64 + from io import BytesIO as _BytesIO + from PIL import Image as _Image + + raw = _b64.b64decode(payload.image_base64) + img = _Image.open(_BytesIO(raw)).convert("RGB") + buf = _BytesIO() + img.save(buf, format = "PNG") + png_b64 = _b64.b64encode(buf.getvalue()).decode("ascii") + except Exception as e: + raise HTTPException( + status_code = 400, + detail = f"Failed to process image: {e}", + ) + + data_url = f"data:image/png;base64,{png_b64}" + image_part = {"type": "image_url", "image_url": {"url": data_url}} + + for msg in reversed(messages): + if msg.get("role") != "user": + continue + existing = msg.get("content") + if isinstance(existing, str): + msg["content"] = [{"type": "text", "text": existing}, image_part] + elif isinstance(existing, list): + existing.append(image_part) + else: + msg["content"] = [image_part] + break + else: + messages.append({"role": "user", "content": [image_part]}) + + return messages + + +def _build_openai_passthrough_body(payload) -> dict: + """Assemble the llama-server request body from a ChatCompletionRequest. + + Only explicitly-known OpenAI / llama-server fields are forwarded so that + Studio-specific extensions (``enable_tools``, ``enabled_tools``, + ``session_id``, ...) never leak to the backend. + """ + messages = _openai_messages_for_passthrough(payload) + tool_choice = payload.tool_choice if payload.tool_choice is not None else "auto" + return _build_passthrough_payload( + messages, + payload.tools, + payload.temperature, + payload.top_p, + payload.top_k, + payload.max_tokens, + payload.stream, + stop = payload.stop, + min_p = payload.min_p, + repetition_penalty = payload.repetition_penalty, + presence_penalty = payload.presence_penalty, + tool_choice = tool_choice, + ) + + +async def _openai_passthrough_stream( + request, + cancel_event, + llama_backend, + payload, + model_name, + completion_id, +): + """Streaming client-side pass-through for /v1/chat/completions. + + Forwards the client's OpenAI function-calling request to llama-server and + relays the SSE stream back verbatim. This preserves llama-server's + native response ``id``, ``finish_reason`` (including ``"tool_calls"``), + ``delta.tool_calls``, and the trailing ``usage`` chunk so the client + observes a standard OpenAI response. + """ + target_url = f"{llama_backend.base_url}/v1/chat/completions" + body = _build_openai_passthrough_body(payload) + + # Dispatch the upstream request BEFORE returning StreamingResponse so + # transport errors and non-200 upstream statuses surface as real HTTP + # errors to the client. OpenAI SDKs rely on status codes to raise + # ``APIError``/``BadRequestError``/...; burying the failure inside a + # 200 SSE ``error`` frame silently breaks their error handling. + client = httpx.AsyncClient(timeout = 600) + resp = None + try: + req = client.build_request("POST", target_url, json = body) + resp = await client.send(req, stream = True) + except httpx.RequestError as e: + # llama-server subprocess crashed / still starting / unreachable. + logger.error("openai passthrough stream: upstream unreachable: %s", e) + if resp is not None: + try: + await resp.aclose() + except Exception: + pass + try: + await client.aclose() + except Exception: + pass + raise HTTPException( + status_code = 502, + detail = _friendly_error(e), + ) + + if resp.status_code != 200: + err_bytes = await resp.aread() + err_text = err_bytes.decode("utf-8", errors = "replace") + logger.error( + "openai passthrough upstream error: status=%s body=%s", + resp.status_code, + err_text[:500], + ) + upstream_status = resp.status_code + try: + await resp.aclose() + except Exception: + pass + try: + await client.aclose() + except Exception: + pass + raise HTTPException( + status_code = upstream_status, + detail = f"llama-server error: {err_text[:500]}", + ) + + async def _stream(): + # Same httpx lifecycle pattern as _anthropic_passthrough_stream: + # avoid `async with` on the client/response AND explicitly save + # resp.aiter_lines() so we can close it ourselves in the finally + # block. See the long comment there for the full rationale on + # why the anonymous `async for raw_line in resp.aiter_lines():` + # pattern leaks an unclosed async generator that Python's + # asyncgen GC hook then finalizes in a different asyncio task, + # producing "Exception ignored in:" / "async generator ignored + # GeneratorExit" / anyio cancel-scope traces on Python 3.13 + + # httpcore 1.0.x. + lines_iter = None + try: + lines_iter = resp.aiter_lines() + async for raw_line in lines_iter: + if await request.is_disconnected(): + cancel_event.set() + break + if not raw_line: + continue + if not raw_line.startswith("data: "): + continue + # Relay the llama-server SSE chunk verbatim so the client + # sees its native `id`, `finish_reason`, `delta.tool_calls`, + # and final `usage` unchanged. + yield raw_line + "\n\n" + if raw_line[6:].strip() == "[DONE]": + break + except Exception as e: + # Mid-stream failures still have to be reported inside the SSE + # body because the 200 response headers have already been + # committed by the time the first chunk flushes. + logger.error("openai passthrough stream error: %s", e) + err = { + "error": { + "message": _friendly_error(e), + "type": "server_error", + }, + } + yield f"data: {json.dumps(err)}\n\n" + finally: + if lines_iter is not None: + try: + await lines_iter.aclose() + except Exception: + pass + try: + await resp.aclose() + except Exception: + pass + try: + await client.aclose() + except Exception: + pass + + return StreamingResponse( + _stream(), + media_type = "text/event-stream", + headers = { + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +async def _openai_passthrough_non_streaming( + llama_backend, + payload, + model_name, +): + """Non-streaming client-side pass-through for /v1/chat/completions. + + Returns llama-server's JSON response verbatim (via JSONResponse) so the + client sees the native response ``id``, ``finish_reason`` (including + ``"tool_calls"``), structured ``tool_calls``, and accurate ``usage`` + token counts. + """ + target_url = f"{llama_backend.base_url}/v1/chat/completions" + body = _build_openai_passthrough_body(payload) + + try: + async with httpx.AsyncClient() as client: + resp = await client.post(target_url, json = body, timeout = 600) + except httpx.RequestError as e: + # llama-server subprocess crashed / still starting / unreachable. + # Surface the same friendly message the sync chat path emits so + # operators don't see a bare 500 with no diagnostic. + logger.error("openai passthrough non-streaming: upstream unreachable: %s", e) + raise HTTPException( + status_code = 502, + detail = _friendly_error(e), + ) + + if resp.status_code != 200: + raise HTTPException( + status_code = resp.status_code, + detail = f"llama-server error: {resp.text[:500]}", + ) + + # Pass the upstream body through as raw bytes — skips a redundant + # parse+re-serialize round-trip and keeps the response truly + # verbatim (matches the docstring). Status is guaranteed 200 by + # the check above. + return Response(content = resp.content, media_type = "application/json") diff --git a/studio/backend/tests/test_openai_tool_passthrough.py b/studio/backend/tests/test_openai_tool_passthrough.py new file mode 100644 index 0000000000..ccb0dba325 --- /dev/null +++ b/studio/backend/tests/test_openai_tool_passthrough.py @@ -0,0 +1,465 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +""" +Tests for the OpenAI /v1/chat/completions client-side tool pass-through. + +Covers: +- ChatCompletionRequest accepts standard OpenAI `tools` / `tool_choice` / `stop`. +- ChatMessage accepts role="tool" with `tool_call_id` and role="assistant" + with `content: None` + `tool_calls`. +- ChatCompletionRequest carries unknown fields via `extra="allow"`. +- anthropic_tool_choice_to_openai() covers all four Anthropic shapes. +- _build_passthrough_payload() honors a caller-supplied tool_choice and + defaults to "auto" when unset. +- _friendly_error() maps httpx transport errors to a "Lost connection" + message so passthrough failures are legible instead of bare 500s. + +No running server or GPU required. +""" + +import os +import sys + +_backend = os.path.join(os.path.dirname(__file__), "..") +sys.path.insert(0, _backend) + +import httpx +import pytest +from pydantic import ValidationError + +from models.inference import ( + ChatCompletionRequest, + ChatMessage, +) +from core.inference.anthropic_compat import ( + anthropic_tool_choice_to_openai, +) +from routes.inference import _build_passthrough_payload, _friendly_error + + +# ===================================================================== +# ChatMessage — tool role, tool_calls, optional content +# ===================================================================== + + +class TestChatMessageToolRoles: + def test_tool_role_with_tool_call_id(self): + msg = ChatMessage( + role = "tool", + tool_call_id = "call_abc123", + content = '{"temperature": 72}', + ) + assert msg.role == "tool" + assert msg.tool_call_id == "call_abc123" + assert msg.content == '{"temperature": 72}' + + def test_tool_role_with_name(self): + msg = ChatMessage( + role = "tool", + tool_call_id = "call_abc123", + name = "get_weather", + content = '{"temperature": 72}', + ) + assert msg.name == "get_weather" + + def test_assistant_with_tool_calls_no_content(self): + msg = ChatMessage( + role = "assistant", + content = None, + tool_calls = [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"city": "Paris"}', + }, + } + ], + ) + assert msg.role == "assistant" + assert msg.content is None + assert msg.tool_calls is not None + assert len(msg.tool_calls) == 1 + assert msg.tool_calls[0]["function"]["name"] == "get_weather" + + def test_assistant_with_content_and_tool_calls(self): + msg = ChatMessage( + role = "assistant", + content = "Let me check the weather.", + tool_calls = [ + { + "id": "call_1", + "type": "function", + "function": {"name": "get_weather", "arguments": "{}"}, + } + ], + ) + assert msg.content == "Let me check the weather." + assert msg.tool_calls[0]["id"] == "call_1" + + def test_plain_user_message_still_works(self): + msg = ChatMessage(role = "user", content = "Hello") + assert msg.role == "user" + assert msg.tool_call_id is None + assert msg.tool_calls is None + assert msg.name is None + + def test_invalid_role_rejected(self): + with pytest.raises(ValidationError): + ChatMessage(role = "function", content = "x") + + def test_content_absent_on_assistant_tool_call_defaults_to_none(self): + # Assistant messages that carry only tool_calls are the one + # documented case where `content=None` is permitted. + msg = ChatMessage( + role = "assistant", + tool_calls = [ + { + "id": "call_1", + "type": "function", + "function": {"name": "f", "arguments": "{}"}, + } + ], + ) + assert msg.content is None + + def test_tool_role_missing_tool_call_id_rejected(self): + # Per OpenAI spec, role="tool" messages must carry tool_call_id so + # upstream backends can associate the result with its prior call. + # Pin the boundary-level rejection so a malformed tool-result + # message never reaches the passthrough path. + with pytest.raises(ValidationError) as exc_info: + ChatMessage(role = "tool", content = '{"temperature": 72}') + assert "tool_call_id" in str(exc_info.value) + + def test_tool_role_empty_tool_call_id_rejected(self): + with pytest.raises(ValidationError): + ChatMessage( + role = "tool", + tool_call_id = "", + content = '{"temperature": 72}', + ) + + # ── Role-aware content requirements ──────────────────────────── + + def test_user_empty_content_rejected(self): + with pytest.raises(ValidationError): + ChatMessage(role = "user", content = "") + + def test_system_empty_content_rejected(self): + with pytest.raises(ValidationError): + ChatMessage(role = "system", content = "") + + def test_user_empty_list_content_rejected(self): + with pytest.raises(ValidationError): + ChatMessage(role = "user", content = []) + + def test_tool_empty_content_rejected(self): + with pytest.raises(ValidationError) as exc_info: + ChatMessage(role = "tool", tool_call_id = "call_1", content = "") + assert "content" in str(exc_info.value) + + def test_assistant_without_content_or_tool_calls_rejected(self): + with pytest.raises(ValidationError) as exc_info: + ChatMessage(role = "assistant") + assert "content" in str(exc_info.value) or "tool_calls" in str(exc_info.value) + + # ── Role-constrained tool-call metadata ──────────────────────── + + def test_tool_calls_on_user_rejected(self): + with pytest.raises(ValidationError) as exc_info: + ChatMessage( + role = "user", + content = "Hi", + tool_calls = [ + { + "id": "c1", + "type": "function", + "function": {"name": "f", "arguments": "{}"}, + } + ], + ) + assert "tool_calls" in str(exc_info.value) + + def test_tool_call_id_on_user_rejected(self): + with pytest.raises(ValidationError) as exc_info: + ChatMessage(role = "user", content = "Hi", tool_call_id = "call_1") + assert "tool_call_id" in str(exc_info.value) + + def test_name_on_user_rejected(self): + with pytest.raises(ValidationError) as exc_info: + ChatMessage(role = "user", content = "Hi", name = "get_weather") + assert "name" in str(exc_info.value) + + +# ===================================================================== +# ChatCompletionRequest — standard OpenAI tool fields +# ===================================================================== + + +class TestChatCompletionRequestToolFields: + def _make(self, **kwargs): + base = {"messages": [{"role": "user", "content": "Hi"}]} + base.update(kwargs) + return ChatCompletionRequest(**base) + + def test_tools_parses(self): + req = self._make( + tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Return the weather in a city", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + }, + } + ], + ) + assert req.tools is not None + assert len(req.tools) == 1 + assert req.tools[0]["function"]["name"] == "get_weather" + + def test_tool_choice_string_auto(self): + assert self._make(tool_choice = "auto").tool_choice == "auto" + + def test_tool_choice_string_required(self): + assert self._make(tool_choice = "required").tool_choice == "required" + + def test_tool_choice_string_none(self): + assert self._make(tool_choice = "none").tool_choice == "none" + + def test_tool_choice_named_function(self): + tc = {"type": "function", "function": {"name": "get_weather"}} + assert self._make(tool_choice = tc).tool_choice == tc + + def test_stop_string(self): + assert self._make(stop = "\nUser:").stop == "\nUser:" + + def test_stop_list(self): + assert self._make(stop = ["\nUser:", "\nAssistant:"]).stop == [ + "\nUser:", + "\nAssistant:", + ] + + def test_tools_default_none(self): + req = self._make() + assert req.tools is None + assert req.tool_choice is None + assert req.stop is None + + def test_extra_fields_accepted(self): + # `frequency_penalty`, `seed`, `response_format` are not yet + # explicitly declared but must survive Pydantic parsing now that + # extra="allow" is set. + req = self._make( + frequency_penalty = 0.5, + seed = 42, + response_format = {"type": "json_object"}, + ) + # Extras land in model_extra + assert req.model_extra is not None + assert req.model_extra.get("frequency_penalty") == 0.5 + assert req.model_extra.get("seed") == 42 + assert req.model_extra.get("response_format") == {"type": "json_object"} + + def test_unsloth_extensions_still_work(self): + req = self._make( + enable_tools = True, + enabled_tools = ["web_search", "python"], + session_id = "abc", + ) + assert req.enable_tools is True + assert req.enabled_tools == ["web_search", "python"] + assert req.session_id == "abc" + + def test_stream_defaults_false_matching_openai_spec(self): + # OpenAI's /v1/chat/completions spec defaults `stream` to false. + # Studio previously defaulted to true, which broke naive curl + # clients that omit `stream` (they expect a JSON blob, got SSE). + # Pin the corrected default so it can't silently regress. + req = self._make() + assert req.stream is False + + def test_multiturn_tool_loop_messages(self): + req = ChatCompletionRequest( + messages = [ + {"role": "user", "content": "What's the weather in Paris?"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"city": "Paris"}', + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_1", + "content": '{"temperature": 14, "unit": "celsius"}', + }, + ], + tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "parameters": {"type": "object"}, + }, + } + ], + ) + assert len(req.messages) == 3 + assert req.messages[1].role == "assistant" + assert req.messages[1].content is None + assert req.messages[1].tool_calls[0]["id"] == "call_1" + assert req.messages[2].role == "tool" + assert req.messages[2].tool_call_id == "call_1" + + +# ===================================================================== +# anthropic_tool_choice_to_openai — pure translation helper +# ===================================================================== + + +class TestAnthropicToolChoiceToOpenAI: + def test_auto(self): + assert anthropic_tool_choice_to_openai({"type": "auto"}) == "auto" + + def test_any_becomes_required(self): + assert anthropic_tool_choice_to_openai({"type": "any"}) == "required" + + def test_none(self): + assert anthropic_tool_choice_to_openai({"type": "none"}) == "none" + + def test_tool_named(self): + result = anthropic_tool_choice_to_openai( + {"type": "tool", "name": "get_weather"} + ) + assert result == { + "type": "function", + "function": {"name": "get_weather"}, + } + + def test_tool_missing_name_returns_none(self): + assert anthropic_tool_choice_to_openai({"type": "tool"}) is None + + def test_none_input_returns_none(self): + assert anthropic_tool_choice_to_openai(None) is None + + def test_unrecognized_shape_returns_none(self): + assert anthropic_tool_choice_to_openai({"type": "wibble"}) is None + assert anthropic_tool_choice_to_openai("auto") is None + assert anthropic_tool_choice_to_openai(42) is None + + +# ===================================================================== +# _build_passthrough_payload — tool_choice propagation +# ===================================================================== + + +class TestBuildPassthroughPayloadToolChoice: + def _args(self): + return dict( + openai_messages = [{"role": "user", "content": "Hi"}], + openai_tools = [ + { + "type": "function", + "function": {"name": "f", "parameters": {"type": "object"}}, + } + ], + temperature = 0.6, + top_p = 0.95, + top_k = 20, + max_tokens = 128, + stream = False, + ) + + def test_default_tool_choice_is_auto(self): + body = _build_passthrough_payload(**self._args()) + assert body["tool_choice"] == "auto" + + def test_override_tool_choice_required(self): + body = _build_passthrough_payload(**self._args(), tool_choice = "required") + assert body["tool_choice"] == "required" + + def test_override_tool_choice_none(self): + body = _build_passthrough_payload(**self._args(), tool_choice = "none") + assert body["tool_choice"] == "none" + + def test_override_tool_choice_named_function(self): + tc = {"type": "function", "function": {"name": "f"}} + body = _build_passthrough_payload(**self._args(), tool_choice = tc) + assert body["tool_choice"] == tc + + def test_stream_adds_include_usage(self): + args = self._args() + args["stream"] = True + body = _build_passthrough_payload(**args) + assert body.get("stream_options") == {"include_usage": True} + + def test_repetition_penalty_renamed(self): + body = _build_passthrough_payload(**self._args(), repetition_penalty = 1.1) + assert body.get("repeat_penalty") == 1.1 + assert "repetition_penalty" not in body + + +# ===================================================================== +# _friendly_error — httpx transport failures +# ===================================================================== + + +class TestFriendlyErrorHttpx: + """The async pass-through helpers talk to llama-server via httpx. + When the subprocess is down, httpx raises RequestError subclasses + whose string form (``"All connection attempts failed"``, ``"[Errno 111] + Connection refused"``, ...) does NOT contain the substring + ``"Lost connection to llama-server"`` the sync path uses, so the + previous substring-only `_friendly_error` returned a useless generic + message. These tests pin the new isinstance-based mapping. + """ + + def _req(self): + return httpx.Request("POST", "http://127.0.0.1:65535/v1/chat/completions") + + def test_connect_error_mapped(self): + exc = httpx.ConnectError("All connection attempts failed", request = self._req()) + assert "Lost connection" in _friendly_error(exc) + + def test_read_error_mapped(self): + exc = httpx.ReadError("EOF", request = self._req()) + assert "Lost connection" in _friendly_error(exc) + + def test_remote_protocol_error_mapped(self): + exc = httpx.RemoteProtocolError("peer closed", request = self._req()) + assert "Lost connection" in _friendly_error(exc) + + def test_read_timeout_mapped(self): + exc = httpx.ReadTimeout("timed out", request = self._req()) + assert "Lost connection" in _friendly_error(exc) + + def test_non_httpx_unchanged(self): + # Non-httpx exceptions still fall through to the existing substring + # heuristics — a context-size message must still produce the + # "Message too long" path. + ctx_msg = ( + "request (4096 tokens) exceeds the available context size (2048 tokens)" + ) + assert "Message too long" in _friendly_error(ValueError(ctx_msg)) + + def test_generic_exception_returns_generic_message(self): + assert ( + _friendly_error(RuntimeError("unrelated")) == "An internal error occurred" + ) diff --git a/studio/backend/tests/test_studio_api.py b/studio/backend/tests/test_studio_api.py index 9cc17c89fb..521c99e126 100644 --- a/studio/backend/tests/test_studio_api.py +++ b/studio/backend/tests/test_studio_api.py @@ -11,11 +11,16 @@ authentication and the CLI's ``--help`` output: 1. curl -- basic chat completions (non-streaming) 2. curl -- streaming chat completions 3. Python OpenAI SDK -- streaming completions - 4. curl -- with tools (web_search + python) - 5. Anthropic Messages API -- basic non-streaming - 6. Anthropic Messages API -- streaming SSE - 7. Anthropic Python SDK -- non-streaming - 8. Anthropic Messages API -- streaming with tools + 4. curl -- Studio server-side tools (enable_tools=true) + 5. curl -- Standard OpenAI function calling (non-streaming) + 6. curl -- Standard OpenAI function calling (streaming) + 7. curl -- Standard OpenAI function calling (multi-turn tool loop) + 8. OpenAI Python SDK -- Standard function calling + 9. Anthropic Messages API -- basic non-streaming + 10. Anthropic Messages API -- streaming SSE + 11. Anthropic Python SDK -- non-streaming + 12. Anthropic Messages API -- streaming with tools + 13. Anthropic Messages API -- tool_choice={"type":"any"} honored Training, export, fine-tuning, and chat-UI concerns are out of scope — see the unit suites elsewhere under ``studio/backend/tests/`` for those. @@ -266,6 +271,250 @@ def test_curl_with_tools(base_url: str, api_key: str): print(f" PASS curl with tools: {len(chunks)} chunks, {len(full)} chars content") +# ── Standard OpenAI function-calling pass-through tests ───────────── +# +# Regression coverage for unslothai/unsloth#4999: Studio's +# /v1/chat/completions used to silently strip standard OpenAI `tools` +# and `tool_choice` fields, so clients (opencode, Claude Code, Cursor, +# Continue, ...) could never get structured tool_calls back. These +# tests exercise the client-side pass-through path that forwards those +# fields to llama-server verbatim. +# +# They require a tool-capable GGUF (``supports_tools=True`` — e.g. +# Qwen3, Qwen2.5-Coder, Llama-3.1-Instruct). The default test model +# ``unsloth/Qwen3-1.7B-GGUF`` advertises tool support via its chat +# template metadata. + +_WEATHER_TOOL = { + "type": "function", + "function": { + "name": "get_weather", + "description": "Look up the current weather for a given city.", + "parameters": { + "type": "object", + "properties": { + "city": { + "type": "string", + "description": "The name of the city, e.g. 'Paris'.", + }, + }, + "required": ["city"], + }, + }, +} + + +def _collect_streamed_tool_calls(chunks: list[dict]) -> list[dict]: + """Reassemble OpenAI streaming delta.tool_calls into full tool calls. + + OpenAI streams partial tool calls across chunks — the first chunk for + a given index carries ``id`` + ``function.name``, and subsequent + chunks append fragments to ``function.arguments``. + """ + by_index: dict[int, dict] = {} + for c in chunks: + choices = c.get("choices") or [] + if not choices: + continue + delta = choices[0].get("delta") or {} + tool_calls = delta.get("tool_calls") or [] + for tc in tool_calls: + idx = tc.get("index", 0) + slot = by_index.setdefault( + idx, + { + "id": None, + "type": "function", + "function": {"name": None, "arguments": ""}, + }, + ) + if tc.get("id"): + slot["id"] = tc["id"] + fn = tc.get("function") or {} + if fn.get("name"): + slot["function"]["name"] = fn["name"] + if fn.get("arguments"): + slot["function"]["arguments"] += fn["arguments"] + return [by_index[i] for i in sorted(by_index)] + + +def _final_finish_reason(chunks: list[dict]) -> str | None: + for c in reversed(chunks): + choices = c.get("choices") or [] + if not choices: + continue + fr = choices[0].get("finish_reason") + if fr is not None: + return fr + return None + + +def test_openai_tools_nonstream(base_url: str, api_key: str): + """Standard OpenAI function calling, non-streaming, tool_choice='required'. + + Regression: before the fix, Studio silently stripped `tools` and the + model returned plain text with finish_reason='stop'. After the fix, + llama-server's response is forwarded verbatim so the client sees + finish_reason='tool_calls' with a structured tool_calls array and + non-zero usage.prompt_tokens. + """ + status, text = _http( + "POST", + f"{base_url}/v1/chat/completions", + body = { + "messages": [{"role": "user", "content": "What is the weather in Paris?"}], + "tools": [_WEATHER_TOOL], + "tool_choice": "required", + "stream": False, + }, + headers = {"Authorization": f"Bearer {api_key}"}, + timeout = 120, + ) + assert status == 200, f"Expected 200, got {status}: {text[:500]}" + data = json.loads(text) + assert "choices" in data, f"Missing 'choices': {text[:300]}" + choice = data["choices"][0] + assert ( + choice["finish_reason"] == "tool_calls" + ), f"Expected finish_reason='tool_calls', got {choice['finish_reason']!r}" + msg = choice["message"] + tool_calls = msg.get("tool_calls") or [] + assert len(tool_calls) >= 1, f"No tool_calls in response: {msg}" + first = tool_calls[0] + assert first["type"] == "function" + assert ( + first["function"]["name"] == "get_weather" + ), f"Wrong tool name: {first['function']['name']!r}" + # arguments must be valid JSON + parsed = json.loads(first["function"]["arguments"]) + assert "city" in parsed, f"Tool call missing required 'city' arg: {parsed}" + # Usage must be non-zero (was 0 before the fix) + usage = data.get("usage") or {} + assert ( + usage.get("prompt_tokens", 0) > 0 + ), f"Expected non-zero prompt_tokens; got {usage}" + assert data.get("id"), "Missing response id" + print( + f" PASS openai tools non-stream: " + f"tool={first['function']['name']}, args={parsed}, " + f"prompt_tokens={usage['prompt_tokens']}" + ) + + +def test_openai_tools_stream(base_url: str, api_key: str): + """Standard OpenAI function calling, streaming, tool_choice='required'.""" + status, chunks = _stream_http( + f"{base_url}/v1/chat/completions", + body = { + "messages": [{"role": "user", "content": "What is the weather in Tokyo?"}], + "tools": [_WEATHER_TOOL], + "tool_choice": "required", + "stream": True, + }, + headers = {"Authorization": f"Bearer {api_key}"}, + timeout = 120, + ) + assert status == 200, f"Expected 200, got {status}" + assert len(chunks) > 0, "No SSE chunks received" + assert _final_finish_reason(chunks) == "tool_calls", ( + f"Expected final finish_reason='tool_calls', got " + f"{_final_finish_reason(chunks)!r}" + ) + assembled = _collect_streamed_tool_calls(chunks) + assert len(assembled) >= 1, "No tool_calls reassembled from stream" + first = assembled[0] + assert first["function"]["name"] == "get_weather" + parsed = json.loads(first["function"]["arguments"]) + assert "city" in parsed + print( + f" PASS openai tools stream: {len(chunks)} chunks, " + f"tool={first['function']['name']}, args={parsed}" + ) + + +def test_openai_tools_multiturn(base_url: str, api_key: str): + """Multi-turn client-side tool loop: validates that role='tool' result + messages and assistant messages carrying tool_calls are accepted. + + Regression: before the fix, ChatMessage.role was restricted to + {system,user,assistant} and rejected role='tool' at the Pydantic + validation stage. This test sends a full round trip so the model + receives the simulated tool result and responds with final text. + """ + status, text = _http( + "POST", + f"{base_url}/v1/chat/completions", + body = { + "messages": [ + {"role": "user", "content": "What is the weather in Paris?"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_test_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"city": "Paris"}', + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_test_1", + "content": '{"temperature_c": 14, "condition": "cloudy"}', + }, + ], + "tools": [_WEATHER_TOOL], + "stream": False, + }, + headers = {"Authorization": f"Bearer {api_key}"}, + timeout = 120, + ) + assert status == 200, f"Expected 200, got {status}: {text[:500]}" + data = json.loads(text) + msg = data["choices"][0]["message"] + # The model should respond with text now that it has the tool result + content = msg.get("content") or "" + assert len(content) > 0 or msg.get( + "tool_calls" + ), f"Expected text or follow-up tool call, got empty message: {msg}" + print(f" PASS openai tools multiturn: {content[:80]!r}") + + +def test_openai_sdk_tool_calling(base_url: str, api_key: str): + """OpenAI Python SDK round trip — the real client shape opencode et al. use.""" + try: + from openai import OpenAI + except ImportError: + print(" SKIP openai SDK not installed") + return + + client = OpenAI(base_url = f"{base_url}/v1", api_key = api_key) + resp = client.chat.completions.create( + model = "current", + messages = [{"role": "user", "content": "What's the weather in Berlin?"}], + tools = [_WEATHER_TOOL], + tool_choice = "required", + stream = False, + ) + assert resp.choices[0].finish_reason == "tool_calls", ( + f"Expected finish_reason='tool_calls', got " + f"{resp.choices[0].finish_reason!r}" + ) + tool_calls = resp.choices[0].message.tool_calls + assert tool_calls and len(tool_calls) >= 1, "No tool_calls from SDK" + tc = tool_calls[0] + assert tc.function.name == "get_weather" + parsed = json.loads(tc.function.arguments) + assert "city" in parsed + print( + f" PASS openai SDK tool calling: " f"tool={tc.function.name}, args={parsed}" + ) + + def test_invalid_key_rejected(base_url: str): """Requests with a bad API key should be rejected.""" status, _text = _http( @@ -464,6 +713,73 @@ def test_anthropic_with_tools(base_url: str, api_key: str): ) +def test_anthropic_tool_choice_any(base_url: str, api_key: str): + """Anthropic Messages API: ``tool_choice: {"type": "any"}`` must be + honored (forwarded as OpenAI ``tool_choice: "required"`` to + llama-server). Regression for the secondary fix bundled with #4999 — + previously this field was accepted on the request model but silently + dropped with a warning log, so the model was free to answer from + memory instead of using the tool. + """ + status, events = _stream_anthropic_http( + f"{base_url}/v1/messages", + body = { + "model": "default", + "max_tokens": 256, + "messages": [ + # A question the model could easily answer from memory if + # tool_choice were not enforced. + { + "role": "user", + "content": "What is the weather in London right now?", + } + ], + "tools": [ + { + "name": "get_weather", + "description": "Look up current weather for a city.", + "input_schema": { + "type": "object", + "properties": { + "city": {"type": "string"}, + }, + "required": ["city"], + }, + } + ], + "tool_choice": {"type": "any"}, + "stream": True, + }, + headers = {"Authorization": f"Bearer {api_key}"}, + timeout = 120, + ) + assert status == 200, f"Expected 200, got {status}" + assert len(events) > 0, "No SSE events received" + + # With tool_choice=any, stop_reason must be tool_use (not end_turn) + stop_reason = None + for etype, data in events: + if etype == "message_delta": + stop_reason = data.get("delta", {}).get("stop_reason") or stop_reason + assert stop_reason == "tool_use", ( + f"Expected stop_reason='tool_use' with tool_choice=any, got " + f"{stop_reason!r} — tool_choice may not be forwarded to llama-server." + ) + + # And at least one tool_use content block must be emitted + tool_use_starts = [ + e + for e in events + if e[0] == "content_block_start" + and e[1].get("content_block", {}).get("type") == "tool_use" + ] + assert len(tool_use_starts) >= 1, "No tool_use content block emitted" + print( + f" PASS anthropic tool_choice=any honored: " + f"{len(tool_use_starts)} tool_use blocks, stop_reason={stop_reason}" + ) + + # ── Server lifecycle ───────────────────────────────────────────────── @@ -578,10 +894,10 @@ def main(): print(f" ERROR {fn.__name__}: {type(exc).__name__}: {exc}") # ── 1. Test --help (no server needed) ──────────────────────────── - print("\n[1/11] Testing --help output") + print("\n[1/16] Testing --help output") run_test(test_help_output) - # ── 2-11. Start server and run API tests ───────────────────────── + # ── 2-16. Start server and run API tests ───────────────────────── print( f"\nStarting server: {args.model} (variant={args.gguf_variant}) on port {PORT}..." ) @@ -591,39 +907,54 @@ def main(): base_url = f"http://{HOST}:{PORT}" print(f"Server ready. API Key: {api_key[:20]}...\n") - print("[2/11] Testing curl basic (non-streaming)") + print("[2/16] Testing curl basic (non-streaming)") run_test(test_curl_basic, base_url, api_key) - print("[3/11] Testing curl streaming") + print("[3/16] Testing curl streaming") run_test(test_curl_streaming, base_url, api_key) - print("[4/11] Testing OpenAI Python SDK (streaming)") + print("[4/16] Testing OpenAI Python SDK (streaming)") run_test(test_openai_sdk, base_url, api_key) - print("[5/11] Testing curl with tools") + print("[5/16] Testing curl with tools (server-side enable_tools)") run_test(test_curl_with_tools, base_url, api_key) - print("[6/11] Testing invalid API key rejection") + print("[6/16] Testing OpenAI standard tools (non-streaming)") + run_test(test_openai_tools_nonstream, base_url, api_key) + + print("[7/16] Testing OpenAI standard tools (streaming)") + run_test(test_openai_tools_stream, base_url, api_key) + + print("[8/16] Testing OpenAI standard tools (multi-turn)") + run_test(test_openai_tools_multiturn, base_url, api_key) + + print("[9/16] Testing OpenAI SDK tool calling") + run_test(test_openai_sdk_tool_calling, base_url, api_key) + + print("[10/16] Testing invalid API key rejection") run_test(test_invalid_key_rejected, base_url) - print("[7/11] Testing no API key rejection") + print("[11/16] Testing no API key rejection") run_test(test_no_key_rejected, base_url) - print("[8/11] Testing Anthropic basic (non-streaming)") + print("[12/16] Testing Anthropic basic (non-streaming)") run_test(test_anthropic_basic, base_url, api_key) - print("[9/11] Testing Anthropic streaming") + print("[13/16] Testing Anthropic streaming") run_test(test_anthropic_streaming, base_url, api_key) - print("[10/11] Testing Anthropic Python SDK") + print("[14/16] Testing Anthropic Python SDK") run_test(test_anthropic_sdk, base_url, api_key) - print("[11/11] Testing Anthropic with tools") + print("[15/16] Testing Anthropic with tools") run_test(test_anthropic_with_tools, base_url, api_key) + print("[16/16] Testing Anthropic tool_choice=any honored") + run_test(test_anthropic_tool_choice_any, base_url, api_key) + except RuntimeError as exc: print(f"\nFATAL: Server failed to start: {exc}") - failed += 11 # count remaining tests as failed + failed += 16 # count remaining tests as failed finally: if proc: print("\nStopping server...") diff --git a/studio/backend/utils/datasets/llm_assist.py b/studio/backend/utils/datasets/llm_assist.py index fdc4f374ab..4c66d2ebf6 100644 --- a/studio/backend/utils/datasets/llm_assist.py +++ b/studio/backend/utils/datasets/llm_assist.py @@ -26,7 +26,7 @@ from loggers import get_logger logger = get_logger(__name__) -DEFAULT_HELPER_MODEL_REPO = "unsloth/Qwen3.5-4B-GGUF" +DEFAULT_HELPER_MODEL_REPO = "unsloth/gemma-4-E2B-it-GGUF" DEFAULT_HELPER_MODEL_VARIANT = "UD-Q4_K_XL" README_MAX_CHARS = 1500 diff --git a/studio/frontend/package.json b/studio/frontend/package.json index ffb3c65719..a2eebd5cb5 100644 --- a/studio/frontend/package.json +++ b/studio/frontend/package.json @@ -87,6 +87,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", + "playwright": "^1.59.1", "typescript": "~5.9.3", "typescript-eslint": "^8.55.0", "vite": "^8.0.1" diff --git a/studio/frontend/public/blacklogo-c.png b/studio/frontend/public/blacklogo-c.png new file mode 100644 index 0000000000..7ab9959536 Binary files /dev/null and b/studio/frontend/public/blacklogo-c.png differ diff --git a/studio/frontend/public/circle-logo-small.png b/studio/frontend/public/circle-logo-small.png new file mode 100644 index 0000000000..8fc411695d Binary files /dev/null and b/studio/frontend/public/circle-logo-small.png differ diff --git a/studio/frontend/public/fonts/FiraCode-VariableFont_wght.ttf b/studio/frontend/public/fonts/FiraCode-VariableFont_wght.ttf new file mode 100644 index 0000000000..d7077f1d64 Binary files /dev/null and b/studio/frontend/public/fonts/FiraCode-VariableFont_wght.ttf differ diff --git a/studio/frontend/public/fonts/Hellix-Medium.woff b/studio/frontend/public/fonts/Hellix-Medium.woff new file mode 100644 index 0000000000..86e46d94de Binary files /dev/null and b/studio/frontend/public/fonts/Hellix-Medium.woff differ diff --git a/studio/frontend/public/fonts/Hellix-Regular.woff b/studio/frontend/public/fonts/Hellix-Regular.woff new file mode 100644 index 0000000000..683aa71fa8 Binary files /dev/null and b/studio/frontend/public/fonts/Hellix-Regular.woff differ diff --git a/studio/frontend/public/sidebar-logo-black.png b/studio/frontend/public/sidebar-logo-black.png new file mode 100644 index 0000000000..3db8fea46a Binary files /dev/null and b/studio/frontend/public/sidebar-logo-black.png differ diff --git a/studio/frontend/public/sidebar-logo-white.png b/studio/frontend/public/sidebar-logo-white.png new file mode 100644 index 0000000000..f76b2ea396 Binary files /dev/null and b/studio/frontend/public/sidebar-logo-white.png differ diff --git a/studio/frontend/public/sticker.png b/studio/frontend/public/sticker.png new file mode 100644 index 0000000000..d04573c080 Binary files /dev/null and b/studio/frontend/public/sticker.png differ diff --git a/studio/frontend/public/unsloth-beta-black.png b/studio/frontend/public/unsloth-beta-black.png new file mode 100644 index 0000000000..beb3f6e82f Binary files /dev/null and b/studio/frontend/public/unsloth-beta-black.png differ diff --git a/studio/frontend/public/unsloth-beta-white.png b/studio/frontend/public/unsloth-beta-white.png new file mode 100644 index 0000000000..be689ff874 Binary files /dev/null and b/studio/frontend/public/unsloth-beta-white.png differ diff --git a/studio/frontend/public/whitelogo-c.png b/studio/frontend/public/whitelogo-c.png new file mode 100644 index 0000000000..ee15955092 Binary files /dev/null and b/studio/frontend/public/whitelogo-c.png differ diff --git a/studio/frontend/src/app/router.tsx b/studio/frontend/src/app/router.tsx index d507929758..13ff8a5cbe 100644 --- a/studio/frontend/src/app/router.tsx +++ b/studio/frontend/src/app/router.tsx @@ -13,7 +13,6 @@ import { Route as loginRoute } from "./routes/login"; import { Route as onboardingRoute } from "./routes/onboarding"; import { Route as changePasswordRoute } from "./routes/change-password"; import { Route as studioRoute } from "./routes/studio"; -import { Route as apiKeysRoute } from "./routes/api-keys"; const routeTree = rootRoute.addChildren([ indexRoute, @@ -26,7 +25,6 @@ const routeTree = rootRoute.addChildren([ exportRoute, dataRecipesRoute, dataRecipeRoute, - apiKeysRoute, ]); export const router = createRouter({ routeTree }); diff --git a/studio/frontend/src/app/routes/__root.tsx b/studio/frontend/src/app/routes/__root.tsx index e1bbdc03f7..69aeb11748 100644 --- a/studio/frontend/src/app/routes/__root.tsx +++ b/studio/frontend/src/app/routes/__root.tsx @@ -1,8 +1,13 @@ // 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 { AppSidebar } from "@/components/app-sidebar"; import { Navbar } from "@/components/navbar"; +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { usePlatformStore } from "@/config/env"; +import { SettingsDialog, useSettingsDialogStore } from "@/features/settings"; +import { useTrainingUnloadGuard } from "@/features/training/hooks/use-training-unload-guard"; +import { useSidebarPin } from "@/hooks/use-sidebar-pin"; import { Outlet, createRootRoute, @@ -10,7 +15,7 @@ import { useRouterState, } from "@tanstack/react-router"; import { AnimatePresence, motion } from "motion/react"; -import { Suspense } from "react"; +import { Suspense, useEffect } from "react"; import { AppProvider } from "../provider"; const CHAT_ONLY_ALLOWED = new Set([ @@ -19,7 +24,6 @@ const CHAT_ONLY_ALLOWED = new Set([ "/login", "/signup", "/change-password", - "/api-keys", ]); function isChatOnlyAllowed(pathname: string): boolean { @@ -43,24 +47,63 @@ const HIDDEN_NAVBAR_ROUTES = ["/onboarding", "/login", "/change-password"]; function RootLayout() { const pathname = useRouterState({ select: (s) => s.location.pathname }); const hideNavbar = HIDDEN_NAVBAR_ROUTES.includes(pathname); + const isChatRoute = pathname.startsWith("/chat"); + const { pinned, setPinned, togglePinned } = useSidebarPin(); + + useTrainingUnloadGuard(); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.defaultPrevented) return; + if ((e.metaKey || e.ctrlKey) && e.key === ",") { + e.preventDefault(); + useSettingsDialogStore.getState().openDialog(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); return ( - {!hideNavbar && } - - + + {hideNavbar ? ( +
- - +
+ ) : ( + + + + +
+ + + + + + + +
+
+
+ )}
); } diff --git a/studio/frontend/src/app/routes/api-keys.tsx b/studio/frontend/src/app/routes/api-keys.tsx deleted file mode 100644 index 5846690d7b..0000000000 --- a/studio/frontend/src/app/routes/api-keys.tsx +++ /dev/null @@ -1,18 +0,0 @@ -// 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 { createRoute } from "@tanstack/react-router"; -import { lazy } from "react"; -import { requireAuth } from "../auth-guards"; -import { Route as rootRoute } from "./__root"; - -const ApiKeysPage = lazy(() => - import("@/features/auth/api-keys-page").then((m) => ({ default: m.ApiKeysPage })), -); - -export const Route = createRoute({ - getParentRoute: () => rootRoute, - path: "/api-keys", - beforeLoad: () => requireAuth(), - component: ApiKeysPage, -}); diff --git a/studio/frontend/src/app/routes/chat.tsx b/studio/frontend/src/app/routes/chat.tsx index e435f090bd..49c05ce219 100644 --- a/studio/frontend/src/app/routes/chat.tsx +++ b/studio/frontend/src/app/routes/chat.tsx @@ -1,18 +1,25 @@ // 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 { ChatPage } from "@/features/chat/chat-page"; import { createRoute } from "@tanstack/react-router"; -import { lazy } from "react"; import { requireAuth } from "../auth-guards"; import { Route as rootRoute } from "./__root"; -const ChatPage = lazy(() => - import("@/features/chat/chat-page").then((m) => ({ default: m.ChatPage })), -); +export type ChatSearch = { + thread?: string; + compare?: string; + new?: string; +}; export const Route = createRoute({ getParentRoute: () => rootRoute, path: "/chat", beforeLoad: () => requireAuth(), + validateSearch: (search: Record): ChatSearch => ({ + thread: typeof search.thread === "string" ? search.thread : undefined, + compare: typeof search.compare === "string" ? search.compare : undefined, + new: typeof search.new === "string" ? search.new : undefined, + }), component: ChatPage, }); diff --git a/studio/frontend/src/app/routes/onboarding.tsx b/studio/frontend/src/app/routes/onboarding.tsx index dcc3593b1a..8d1cd6ff5f 100644 --- a/studio/frontend/src/app/routes/onboarding.tsx +++ b/studio/frontend/src/app/routes/onboarding.tsx @@ -6,6 +6,8 @@ import { lazy } from "react"; import { requireAuth } from "../auth-guards"; import { Route as rootRoute } from "./__root"; +export type OnboardingSearch = { redirectTo?: string }; + const WizardLayout = lazy(() => import("@/features/onboarding/components/wizard-layout").then((m) => ({ default: m.WizardLayout, @@ -16,5 +18,8 @@ export const Route = createRoute({ getParentRoute: () => rootRoute, path: "/onboarding", beforeLoad: () => requireAuth(), + validateSearch: (search: Record): OnboardingSearch => ({ + redirectTo: typeof search.redirectTo === "string" ? search.redirectTo : undefined, + }), component: WizardLayout, }); diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx new file mode 100644 index 0000000000..959c2a6b31 --- /dev/null +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -0,0 +1,641 @@ +// 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 { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useAnimatedThemeToggle } from "@/components/ui/animated-theme-toggler"; +import { cn } from "@/lib/utils"; +import { + Book03Icon, + ChefHatIcon, + ColumnInsertIcon, + CursorInfo02Icon, + Delete02Icon, + Download03Icon, + GemIcon, + MessageSearch01Icon, + Search01Icon, + NewReleasesIcon, + PowerIcon, + PencilEdit02Icon, + LayoutAlignLeftIcon, + Settings02Icon, + ZapIcon, +} from "@hugeicons/core-free-icons"; +import { + Tooltip, + TooltipContent, +} from "@/components/ui/tooltip"; +import { Tooltip as TooltipPrimitive } from "radix-ui"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { ChevronDown, ChevronsUpDown, Moon, Sun } from "lucide-react"; +import { Link, useNavigate, useRouterState } from "@tanstack/react-router"; +import { useTrainingRuntimeStore } from "@/features/training"; +import { useSettingsDialogStore } from "@/features/settings"; +import { useEffectiveProfile, UserAvatar } from "@/features/profile"; +import { usePlatformStore } from "@/config/env"; +import { TOUR_OPEN_EVENT } from "@/features/tour"; +import { + useChatSidebarItems, + deleteChatItem, +} from "@/features/chat/hooks/use-chat-sidebar-items"; +import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store"; +import { useChatSearchStore } from "@/features/chat/stores/chat-search-store"; +import { ChatSearchDialog } from "@/features/chat/components/chat-search-dialog"; +import { useTrainingHistorySidebarItems, deleteTrainingRun } from "@/features/training"; +import type { TrainingRunSummary } from "@/features/training"; +import { useEffect, useState } from "react"; +import { ShutdownDialog } from "@/components/shutdown-dialog"; +import { removeTrainingUnloadGuard } from "@/features/training/hooks/use-training-unload-guard"; + +function getTourId(pathname: string): string | null { + if (pathname.startsWith("/studio")) return "studio"; + if (pathname.startsWith("/export")) return "export"; + if (pathname.startsWith("/chat")) return "chat"; + return null; +} + +function runStatusDotClass(status: TrainingRunSummary["status"]): string { + switch (status) { + case "running": + return "bg-blue-500 animate-pulse"; + case "completed": + return "bg-emerald-500"; + case "stopped": + return "bg-amber-500"; + case "error": + return "bg-red-500"; + default: + return "bg-muted-foreground"; + } +} + +function formatRelativeShort(iso: string): string { + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return ""; + const diffMs = Date.now() - then; + const s = Math.max(0, Math.floor(diffMs / 1000)); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h`; + const d = Math.floor(h / 24); + return `${d}d`; +} + +function createNavigationNonce(): string { + if (typeof globalThis.crypto?.randomUUID === "function") { + return globalThis.crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +function NavItem({ + icon, + label, + active, + disabled, + onClick, + children, + variant = "nav", + dataTour, +}: { + icon: typeof ZapIcon; + label: string; + active: boolean; + disabled?: boolean; + onClick: () => void; + children?: React.ReactNode; + variant?: "nav" | "menu"; + dataTour?: string; +}) { + const isNav = variant === "nav"; + return ( + +
+ + + {label} + +
+ {children} +
+ ); +} + +export function AppSidebar() { + const { isDark, toggleTheme, anchorRef } = useAnimatedThemeToggle(); + const { pathname, search } = useRouterState({ + select: (s) => ({ + pathname: s.location.pathname, + search: s.location.search as Record, + }), + }); + const { togglePinned, isMobile, setOpenMobile } = useSidebar(); + const navigate = useNavigate(); + + // Auto-close mobile Sheet after navigation + const closeMobileIfOpen = () => { + if (isMobile) setOpenMobile(false); + }; + + const isTrainingRunning = useTrainingRuntimeStore((s) => s.isTrainingRunning); + const chatOnly = usePlatformStore((s) => s.isChatOnly()); + const [shutdownOpen, setShutdownOpen] = useState(false); + + // Chat collapsible state — open by default, auto-expand on route entry + const isChatRoute = pathname.startsWith("/chat"); + const isStudioRoute = pathname === "/studio" || pathname.startsWith("/studio/"); + const [chatOpen, setChatOpen] = useState(true); + const [runsOpen, setRunsOpen] = useState(true); + + useEffect(() => { if (isChatRoute) setChatOpen(true); }, [isChatRoute]); + useEffect(() => { if (isStudioRoute) setRunsOpen(true); }, [isStudioRoute]); + + const isRecipesRoute = pathname.startsWith("/data-recipes"); + const { displayTitle, avatarDataUrl } = useEffectiveProfile(); + + const { items: chatItems } = useChatSidebarItems(); + const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId); + const setActiveThreadId = useChatRuntimeStore((s) => s.setActiveThreadId); + const activeThreadId = isChatRoute + ? (search.thread as string | undefined) ?? + (search.compare as string | undefined) ?? + storeThreadId ?? + undefined + : undefined; + + // Training runs + const { items: runItems, refresh: refreshRuns } = useTrainingHistorySidebarItems( + !chatOnly && isStudioRoute, + ); + const activeJobId = useTrainingRuntimeStore((s) => s.jobId); + const selectedHistoryRunId = useTrainingRuntimeStore((s) => s.selectedHistoryRunId); + const setSelectedHistoryRunId = useTrainingRuntimeStore((s) => s.setSelectedHistoryRunId); + + const chatDisabled = isTrainingRunning; + + async function handleDeleteThread(item: Parameters[0]) { + await deleteChatItem(item, activeThreadId, (view) => { + navigate({ + to: "/chat", + search: { new: view.newThreadNonce }, + }); + }); + } + + return ( + <> + + + {/* Expanded: compact logo + close toggle */} +
+ { + event.preventDefault(); + if (chatDisabled) return; + setActiveThreadId(null); + closeMobileIfOpen(); + void navigate({ + to: "/chat", + search: { new: createNavigationNonce() }, + }); + }} + className="flex items-center gap-[6px] select-none" + aria-label="Unsloth home" + > + Unsloth + + unsloth + + + BETA + + + {!isMobile && ( + + + + + + Close sidebar + + + )} +
+ + {/* Collapsed: panel icon doubles as expand trigger */} + {!isMobile && ( +
+ + + + + + Open sidebar + + +
+ )} +
+ + + + + { + if (chatDisabled) return; + setActiveThreadId(null); + navigate({ to: "/chat", search: { new: createNavigationNonce() } }); + closeMobileIfOpen(); + }} + /> + i.id === search.compare)} + disabled={chatDisabled} + dataTour="chat-compare" + onClick={() => { + if (chatDisabled) return; + setActiveThreadId(null); + navigate({ to: "/chat", search: { compare: createNavigationNonce() } }); + closeMobileIfOpen(); + }} + /> + { + if (chatDisabled) return; + useChatSearchStore.getState().open(); + closeMobileIfOpen(); + }} + /> + + + + + + {/* Navigate (no header) */} + + + + { + if (chatOnly) return; + navigate({ to: "/studio" }); + closeMobileIfOpen(); + }} + /> + + { + navigate({ to: "/data-recipes" }); + closeMobileIfOpen(); + }} + /> + + { + if (chatOnly) return; + navigate({ to: "/export" }); + closeMobileIfOpen(); + }} + /> + + + + + {/* Recent Chats — hide on Studio only (Eyera fac13); chatOpen = ec695 clickability */} + {!isStudioRoute && chatItems.length > 0 && ( + + + + + Recents + + + + + + + {chatItems.map((item) => ( + + { + navigate({ + to: "/chat", + search: + item.type === "single" + ? { thread: item.id } + : { compare: item.id }, + }); + closeMobileIfOpen(); + }} + > + {item.title} + + + + ))} + + + + + + )} + + {/* Recent Runs */} + {isStudioRoute && runItems.length > 0 && !chatOnly && ( + + + + + Recents + + + + + + + {runItems.map((run) => { + const isActiveRun = + selectedHistoryRunId === run.id || activeJobId === run.id; + return ( + + { + setSelectedHistoryRunId(run.id); + closeMobileIfOpen(); + }} + > +
+ + + {run.model_name} + + + {formatRelativeShort(run.started_at)} + +
+ + {run.dataset_name} + +
+ +
+ ); + })} +
+
+
+
+
+ )} +
+ + + + + + + +
+ +
+
+ {displayTitle} + Studio +
+ +
+
+ + + useSettingsDialogStore.getState().openDialog()} + > + + Settings + ⌘, + + + + + } + onSelect={(e) => { e.preventDefault(); toggleTheme(); }} + > + {isDark ? : } + {isDark ? "Light Mode" : "Dark Mode"} + + { + const tourId = getTourId(pathname); + if (!tourId) return; + window.dispatchEvent( + new CustomEvent(TOUR_OPEN_EVENT, { + detail: { id: tourId }, + }), + ); + }} + > + + Guided Tour + + + + + + + + Learn More + + + + + + What's New + + + + + + Feedback + + + + + setShutdownOpen(true)}> + + Shutdown + + +
+
+
+
+
+ + + + ); +} diff --git a/studio/frontend/src/components/assistant-ui/code-plugin.ts b/studio/frontend/src/components/assistant-ui/code-plugin.ts new file mode 100644 index 0000000000..5df7ac4f95 --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/code-plugin.ts @@ -0,0 +1,66 @@ +// 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 { + createCodePlugin as createShikiCodePlugin, + type CodeHighlighterPlugin, + type CodePluginOptions, + type HighlightOptions, + type HighlightResult, +} from "@streamdown/code"; +import type { BundledLanguage } from "shiki"; + +// Fence tags LLMs/users commonly write that shiki doesn't expose as aliases. +// Keys are lower-cased input; values are canonical shiki language ids. +const LANGUAGE_ALIAS_OVERRIDES: Record = { + objectivec: "objective-c", + "obj-c": "objective-c", + objectivecpp: "objective-cpp", + "objective-cplusplus": "objective-cpp", + objcpp: "objective-cpp", + "c++": "cpp", + cplusplus: "cpp", + "c#": "csharp", + cs: "csharp", + "f#": "fsharp", + "c-sharp": "csharp", + "f-sharp": "fsharp", + golang: "go", + rs: "rust", + rb: "ruby", + py: "python", + sh: "shellscript", + bash: "shellscript", + zsh: "shellscript", + shell: "shellscript", + yml: "yaml", + ts: "typescript", + js: "javascript", + kt: "kotlin", + rsx: "rust", + "vue-html": "vue", +}; + +const normalizeLanguage = (language: string): BundledLanguage => { + const key = language.trim().toLowerCase(); + const override = LANGUAGE_ALIAS_OVERRIDES[key]; + return (override ?? (key as BundledLanguage)); +}; + +export function createCodePlugin( + options: CodePluginOptions = {}, +): CodeHighlighterPlugin { + const inner = createShikiCodePlugin(options); + return { + ...inner, + supportsLanguage: (language) => inner.supportsLanguage(normalizeLanguage(language)), + highlight: ( + opts: HighlightOptions, + callback?: (result: HighlightResult) => void, + ) => + inner.highlight( + { ...opts, language: normalizeLanguage(opts.language) }, + callback, + ), + }; +} diff --git a/studio/frontend/src/components/assistant-ui/code-themes.ts b/studio/frontend/src/components/assistant-ui/code-themes.ts new file mode 100644 index 0000000000..2557b45ef0 --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/code-themes.ts @@ -0,0 +1,30 @@ +// 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 oneDarkPro from "@shikijs/themes/one-dark-pro"; +import oneLight from "@shikijs/themes/one-light"; +import type { ThemeRegistrationAny } from "shiki"; + +// Canonical Atom One Dark / One Light themes, shipped by `@shikijs/themes`. +// We only override the background so the code block blends into the app's +// `--code-block` surface instead of painting its own. Every token color and +// scope mapping is left intact — that's what gives consistent multi-language +// highlighting (including Objective-C, Go, Rust, etc.) out of the box. +const withTransparentBg = (theme: ThemeRegistrationAny): ThemeRegistrationAny => ({ + ...theme, + bg: "transparent", + colors: { + ...theme.colors, + "editor.background": "transparent", + }, +}); + +export const unslothLightTheme: ThemeRegistrationAny = { + ...withTransparentBg(oneLight), + name: "unsloth-light", +}; + +export const unslothDarkTheme: ThemeRegistrationAny = { + ...withTransparentBg(oneDarkPro), + name: "unsloth-dark", +}; diff --git a/studio/frontend/src/components/assistant-ui/code-toggle-icon.tsx b/studio/frontend/src/components/assistant-ui/code-toggle-icon.tsx new file mode 100644 index 0000000000..6d7abefad5 --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/code-toggle-icon.tsx @@ -0,0 +1,22 @@ +// 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 { FC } from "react"; + +export const CodeToggleIcon: FC<{ className?: string }> = ({ className }) => { + return ( + + ); +}; diff --git a/studio/frontend/src/components/assistant-ui/markdown-text.tsx b/studio/frontend/src/components/assistant-ui/markdown-text.tsx index c7974db365..2a0517a44a 100644 --- a/studio/frontend/src/components/assistant-ui/markdown-text.tsx +++ b/studio/frontend/src/components/assistant-ui/markdown-text.tsx @@ -8,7 +8,7 @@ import { preprocessLaTeX } from "@/lib/latex"; import { INTERNAL, useMessagePartText } from "@assistant-ui/react"; import { Copy02Icon, Tick02Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; -import { code } from "@streamdown/code"; +import { createCodePlugin } from "./code-plugin"; import { createMathPlugin } from "@streamdown/math"; import { mermaid } from "@streamdown/mermaid"; import { DownloadIcon, Maximize2Icon, Minimize2Icon } from "lucide-react"; @@ -16,8 +16,12 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Block, type BlockProps, Streamdown } from "streamdown"; import "katex/dist/katex.min.css"; import { AudioPlayer } from "./audio-player"; +import { unslothDarkTheme, unslothLightTheme } from "./code-themes"; const math = createMathPlugin({ singleDollarTextMath: true }); +const code = createCodePlugin({ + themes: [unslothLightTheme, unslothDarkTheme], +}); const { withSmoothContextProvider } = INTERNAL; const STREAMDOWN_COMPONENTS = { @@ -272,8 +276,8 @@ function MermaidCopyButton({ source }: { source: string }) { type="button" className="absolute top-3.5 right-20 z-20 cursor-pointer text-muted-foreground transition-all hover:text-foreground" title="Copy Mermaid source" - onClick={() => { - if (!copyToClipboard(source)) { + onClick={async () => { + if (!(await copyToClipboard(source))) { return; } showCopied(); @@ -306,8 +310,8 @@ function CodeBlockActions({ className={ACTION_BUTTON_CLASS} title="Copy code" disabled={disabled} - onClick={() => { - if (!copyToClipboard(source)) { + onClick={async () => { + if (!(await copyToClipboard(source))) { return; } showCopied(); @@ -425,7 +429,7 @@ const MarkdownTextImpl = () => { panZoom: true, }, }} - shikiTheme={["github-light", "github-dark"]} + shikiTheme={[unslothLightTheme, unslothDarkTheme]} BlockComponent={StreamdownBlock} > {processedText} diff --git a/studio/frontend/src/components/assistant-ui/model-selector.tsx b/studio/frontend/src/components/assistant-ui/model-selector.tsx index 08e69bbf93..3628252177 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector.tsx @@ -68,9 +68,9 @@ function ModelSelectorTrigger({ className={cn( "flex items-center gap-2 transition-colors", variant === "outline" && - "rounded-full border border-border/60 hover:bg-accent", - variant === "ghost" && "rounded-md hover:bg-accent", - variant === "muted" && "rounded-md bg-muted hover:bg-muted/80", + "rounded-[8px] border border-border/60 hover:bg-[#ececec] dark:hover:bg-[#2e3035]", + variant === "ghost" && "rounded-[8px] hover:bg-[#ececec] dark:hover:bg-[#2e3035]", + variant === "muted" && "rounded-[8px] bg-muted hover:bg-muted/80", size === "sm" && "h-8 px-3 text-xs", size === "default" && "h-9 px-3.5 text-sm", size === "lg" && "h-10 px-4 text-sm", @@ -80,15 +80,16 @@ function ModelSelectorTrigger({ {isLoaded && ( )} - - {currentModel?.name ?? "Select model..."} + + {currentModel?.name ?? "Select model"} {currentModel?.description && ( {currentModel.description} )} diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx index 8f318a8292..30b8fdecb8 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -152,8 +152,8 @@ function ModelRow({ type="button" onClick={onClick} className={cn( - "flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-accent", - selected && "bg-accent/60", + "flex w-full items-center gap-2 rounded-[6px] px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-[#ececec] dark:hover:bg-[#2e3035]", + selected && "bg-[#ececec] dark:bg-[#2e3035]", )} > diff --git a/studio/frontend/src/components/assistant-ui/reasoning.tsx b/studio/frontend/src/components/assistant-ui/reasoning.tsx index 6b2c7a05e7..387f8cd458 100644 --- a/studio/frontend/src/components/assistant-ui/reasoning.tsx +++ b/studio/frontend/src/components/assistant-ui/reasoning.tsx @@ -6,12 +6,12 @@ /* eslint-disable react-refresh/only-export-components */ import { MarkdownText } from "@/components/assistant-ui/markdown-text"; -import { AnimatedShinyText } from "@/components/ui/animated-shiny-text"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; +import { useCollapseScrollLock } from "@/hooks/use-collapse-scroll-lock"; import { cn } from "@/lib/utils"; import { type ReasoningGroupComponent, @@ -68,49 +68,8 @@ function ReasoningRoot({ ...props }: ReasoningRootProps) { const collapsibleRef = useRef(null); - const lockCleanupRef = useRef<(() => void) | null>(null); const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); - - useEffect(() => { - return () => { - lockCleanupRef.current?.(); - }; - }, []); - - const lockScroll = useCallback(() => { - lockCleanupRef.current?.(); - - const animatedElement = collapsibleRef.current; - if (!animatedElement) return; - - let scrollContainer: HTMLElement | null = animatedElement; - while (scrollContainer) { - const { overflowY } = getComputedStyle(scrollContainer); - if (overflowY === "scroll" || overflowY === "auto") { - break; - } - scrollContainer = scrollContainer.parentElement; - } - if (!scrollContainer) return; - - const scrollPosition = scrollContainer.scrollTop; - const resetPosition = () => { - scrollContainer.scrollTop = scrollPosition; - }; - - scrollContainer.addEventListener("scroll", resetPosition); - let timeoutId: ReturnType | null = null; - const cleanup = () => { - if (timeoutId !== null) { - clearTimeout(timeoutId); - timeoutId = null; - } - scrollContainer.removeEventListener("scroll", resetPosition); - lockCleanupRef.current = null; - }; - timeoutId = setTimeout(cleanup, ANIMATION_DURATION); - lockCleanupRef.current = cleanup; - }, []); + const lockScroll = useCollapseScrollLock(collapsibleRef, ANIMATION_DURATION); const isControlled = controlledOpen !== undefined; const isOpen = isControlled ? controlledOpen : uncontrolledOpen; @@ -151,34 +110,6 @@ function ReasoningRoot({ ); } -function ReasoningFade({ className, ...props }: ComponentProps<"div">) { - return ( -
- ); -} - -function ReasoningFadeTop({ className, ...props }: ComponentProps<"div">) { - return ( -
- ); -} - function ReasoningTrigger({ active, duration, @@ -206,7 +137,7 @@ function ReasoningTrigger({ className="aui-reasoning-trigger-label-wrapper relative inline-block leading-none" > {active ? ( - Thinking... + Thinking... ) : ( Thought for {duration ?? 0} seconds )} @@ -234,7 +165,7 @@ function ReasoningContent({ - {streaming && } {children} - ); } @@ -353,8 +282,8 @@ function ReasoningCopyButton({ startIndex, endIndex }: { startIndex: number; end .join("\n"); }); - const handleCopy = useCallback(() => { - if (copyToClipboard(reasoningText)) { + const handleCopy = useCallback(async () => { + if (await copyToClipboard(reasoningText)) { setCopied(true); if (resetRef.current) clearTimeout(resetRef.current); resetRef.current = setTimeout(() => setCopied(false), COPY_RESET_MS); @@ -481,8 +410,6 @@ const Reasoning = memo( Trigger: typeof ReasoningTrigger; Content: typeof ReasoningContent; Text: typeof ReasoningText; - Fade: typeof ReasoningFade; - FadeTop: typeof ReasoningFadeTop; }; Reasoning.displayName = "Reasoning"; @@ -490,8 +417,6 @@ Reasoning.Root = ReasoningRoot; Reasoning.Trigger = ReasoningTrigger; Reasoning.Content = ReasoningContent; Reasoning.Text = ReasoningText; -Reasoning.Fade = ReasoningFade; -Reasoning.FadeTop = ReasoningFadeTop; const ReasoningGroup = memo(ReasoningGroupImpl); ReasoningGroup.displayName = "ReasoningGroup"; @@ -503,6 +428,4 @@ export { ReasoningTrigger, ReasoningContent, ReasoningText, - ReasoningFade, - ReasoningFadeTop, }; diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 8f41987fbf..54f64220e8 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -16,7 +16,7 @@ import { WebSearchToolUI } from "@/components/assistant-ui/tool-ui-web-search"; import { PythonToolUI } from "@/components/assistant-ui/tool-ui-python"; import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; -import { AnimatedShinyText } from "@/components/ui/animated-shiny-text"; +import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon"; import { Button } from "@/components/ui/button"; import { sentAudioNames } from "@/features/chat/api/chat-adapter"; import { AUDIO_ACCEPT, MAX_AUDIO_SIZE, fileToBase64 } from "@/lib/audio-utils"; @@ -35,6 +35,7 @@ import { useAui, useAuiEvent, useAuiState, + useThreadViewport, } from "@assistant-ui/react"; import { motion } from "motion/react"; import { @@ -70,13 +71,18 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({ }) => { return ( {!hideWelcome && ( thread.isEmpty}> @@ -92,43 +98,70 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({ }} /> - - {!hideComposer && ( -
- )} + {/* Bottom slack so the last message has breathing room above the + sticky scroll-to-bottom button (and the floating composer in + single mode). Without this, content would butt against the + sticky footer and feel cramped. */} + !thread.isEmpty}>
+ + + !thread.isEmpty}> + -
- !thread.isEmpty}> - {!hideComposer && } - - + +
+ + {!hideComposer && ( + !thread.isEmpty}> +
+
+
+
+ +
+

+ LLMs can make mistakes. Double-check all responses. +

+
+
+ + )} ); }; const ThreadScrollToBottom: FC = () => { + // Scoped to the nearest ThreadPrimitive.Root via context, so in compare + // mode each pane reads its own viewport state. + // + // The button stays mounted and toggles visibility via CSS. Conditionally + // rendering (return null) unmounts a DOM node inside the viewport, which + // the assistant-ui autoscroll hook's MutationObserver sees as a content + // change — during streaming that triggered spurious scroll-to-bottom + // calls, especially in the narrower mobile stacked layout. + const isAtBottom = useThreadViewport((vp) => vp.isAtBottom); return ( @@ -199,7 +232,7 @@ const SuggestionItem: FC = () => { const ThreadWelcome: FC<{ hideComposer?: boolean }> = ({ hideComposer }) => { return (
-
+
= ({ hideComposer }) => { alt="Sloth mascot" className="size-20" /> -

+

Chat with your model

-

- Run GGUFs, safetensors, vision and audio models! +

+ Run GGUFs, safetensors, vision and audio models

-
- -
{!hideComposer && }
@@ -243,10 +271,6 @@ const GeneratingSpinner: FC = () => { const ComposerAnimated: FC = () => { return (
-
{ const Composer: FC = () => { return ( - + @@ -457,7 +482,7 @@ const CodeToolsToggle: FC = () => { )} aria-label={codeToolsEnabled ? "Disable code execution" : "Enable code execution"} > - + Code ); @@ -594,15 +619,13 @@ const GeneratingIndicator: FC = () => { message.content.length === 0 && message.status?.type === "running", ); if (!show) return null; - return ( - Generating... - ); + return Generating...; }; const AssistantMessage: FC = () => { return (
@@ -678,9 +701,9 @@ const CopyButton: FC = () => { const [copied, setCopied] = useState(false); const resetTimeoutRef = useRef | null>(null); - const handleCopy = () => { + const handleCopy = async () => { const text = aui.message().getCopyText(); - if (copyToClipboard(text)) { + if (await copyToClipboard(text)) { setCopied(true); if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current); resetTimeoutRef.current = setTimeout(() => { @@ -701,9 +724,9 @@ const AssistantActionBar: FC = () => { return ( @@ -755,22 +778,22 @@ const UserMessageAudio: FC = () => { const UserMessage: FC = () => { return ( -
-
+
+
-
+
- + ); }; @@ -778,8 +801,8 @@ const UserMessage: FC = () => { const UserActionBar: FC = () => { return ( @@ -805,10 +828,10 @@ const EditComposer: FC = () => { }); return ( - +
diff --git a/studio/frontend/src/components/assistant-ui/tool-fallback.tsx b/studio/frontend/src/components/assistant-ui/tool-fallback.tsx index 82a5b17e04..e407163045 100644 --- a/studio/frontend/src/components/assistant-ui/tool-fallback.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-fallback.tsx @@ -8,11 +8,11 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; +import { useCollapseScrollLock } from "@/hooks/use-collapse-scroll-lock"; import { cn } from "@/lib/utils"; import { type ToolCallMessagePartComponent, type ToolCallMessagePartStatus, - useScrollLock, } from "@assistant-ui/react"; import { AlertCircleIcon, @@ -52,7 +52,7 @@ function ToolFallbackRoot({ }: ToolFallbackRootProps) { const collapsibleRef = useRef(null); const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); - const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION); + const lockScroll = useCollapseScrollLock(collapsibleRef, ANIMATION_DURATION); const isControlled = controlledOpen !== undefined; const isOpen = isControlled ? controlledOpen : uncontrolledOpen; diff --git a/studio/frontend/src/components/assistant-ui/tool-group.tsx b/studio/frontend/src/components/assistant-ui/tool-group.tsx index bf7a6a9a25..e91da4cee2 100644 --- a/studio/frontend/src/components/assistant-ui/tool-group.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-group.tsx @@ -12,12 +12,12 @@ import { ChevronDownIcon, LoaderIcon } from "lucide-react"; import { Wrench01Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { cva, type VariantProps } from "class-variance-authority"; -import { useScrollLock } from "@assistant-ui/react"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; +import { useCollapseScrollLock } from "@/hooks/use-collapse-scroll-lock"; import { cn } from "@/lib/utils"; const ANIMATION_DURATION = 200; @@ -54,7 +54,7 @@ function ToolGroupRoot({ }: ToolGroupRootProps) { const collapsibleRef = useRef(null); const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); - const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION); + const lockScroll = useCollapseScrollLock(collapsibleRef, ANIMATION_DURATION); const isControlled = controlledOpen !== undefined; const isOpen = isControlled ? controlledOpen : uncontrolledOpen; diff --git a/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx b/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx index 6aa590ae11..bab735104c 100644 --- a/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-ui-python.tsx @@ -44,8 +44,8 @@ function CopyBtn({ text }: { text: string }) { }; }, []); - const copy = useCallback(() => { - if (copyToClipboard(text)) { + const copy = useCallback(async () => { + if (await copyToClipboard(text)) { setCopied(true); if (timer.current) { clearTimeout(timer.current); diff --git a/studio/frontend/src/components/assistant-ui/tool-ui-terminal.tsx b/studio/frontend/src/components/assistant-ui/tool-ui-terminal.tsx index f233f951d3..1b1357ab3c 100644 --- a/studio/frontend/src/components/assistant-ui/tool-ui-terminal.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-ui-terminal.tsx @@ -34,8 +34,8 @@ function CopyBtn({ text }: { text: string }) { }; }, []); - const copy = useCallback(() => { - if (copyToClipboard(text)) { + const copy = useCallback(async () => { + if (await copyToClipboard(text)) { setCopied(true); if (timer.current) { clearTimeout(timer.current); diff --git a/studio/frontend/src/components/navbar.tsx b/studio/frontend/src/components/navbar.tsx index 2d310a81c3..716c9d791f 100644 --- a/studio/frontend/src/components/navbar.tsx +++ b/studio/frontend/src/components/navbar.tsx @@ -1,647 +1,20 @@ // 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 { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from "@/components/ui/hover-card"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler"; -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetTrigger, -} from "@/components/ui/sheet"; -import { cn } from "@/lib/utils"; -import { - ArrowReloadHorizontalIcon, - ArrowRight01Icon, - Cancel01Icon, - Book03Icon, - BubbleChatIcon, - ChefHatIcon, - Copy01Icon, - CursorInfo02Icon, - Key01Icon, - PackageIcon, - Tick02Icon, - ZapIcon, -} from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { copyToClipboard } from "@/lib/copy-to-clipboard"; -import { useTrainingRuntimeStore } from "@/features/training"; -import { usePlatformStore } from "@/config/env"; -import { Link, useRouterState } from "@tanstack/react-router"; -import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import type { ReactElement } from "react"; -import { useEffect, useRef, useState } from "react"; -import { TOUR_OPEN_EVENT } from "@/features/tour"; -import { ShutdownDialog } from "@/components/shutdown-dialog"; - -const NAV_ITEMS = [ - { label: "Studio", href: "/studio", icon: ZapIcon, enabled: true }, - { label: "Recipes", href: "/data-recipes", icon: ChefHatIcon, enabled: true }, - { label: "Export", href: "/export", icon: PackageIcon, enabled: true }, - { label: "Chat", href: "/chat", icon: BubbleChatIcon, enabled: true }, -]; - -const STUDIO_UPDATE_CMD = "unsloth studio update"; -const STUDIO_UPDATE_FALLBACK_UNIX_CMD = - "curl -fsSL https://unsloth.ai/install.sh | sh"; -const STUDIO_UPDATE_FALLBACK_WINDOWS_CMD = - "irm https://unsloth.ai/install.ps1 | iex"; - -type UpdateShell = "windows" | "unix"; - -function getDefaultUpdateShell(deviceType: string): UpdateShell { - return deviceType === "windows" ? "windows" : "unix"; -} - -function getStudioUpdateInstructionLine(shell: UpdateShell): string { - return shell === "windows" ? "Open PowerShell and run:" : "Open Terminal and run:"; -} - -function CopyableCommand({ - command, - copyLabel, -}: { - command: string; - copyLabel: string; -}): ReactElement { - const [copied, setCopied] = useState(false); - const timerRef = useRef | null>(null); - - useEffect(() => { - return () => { - if (timerRef.current) { - clearTimeout(timerRef.current); - } - }; - }, []); - - const handleCopy = () => { - if (!copyToClipboard(command)) { - return; - } - setCopied(true); - if (timerRef.current) { - clearTimeout(timerRef.current); - } - timerRef.current = setTimeout(() => setCopied(false), 2000); - }; - - return ( -
- - -
- ); -} - -function UpdateStudioInstructions({ - className, - defaultShell, - showTitle = true, -}: { - className?: string; - defaultShell: UpdateShell; - showTitle?: boolean; -}): ReactElement { - const [shell, setShell] = useState(defaultShell); - const prefersReducedMotion = useReducedMotion(); - const windows = shell === "windows"; - const fadeTransition = prefersReducedMotion - ? { duration: 0 } - : { duration: 0.16, ease: [0.165, 0.84, 0.44, 1] as const }; - const fadeInitial = prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: 2 }; - const fadeAnimate = { opacity: 1, y: 0 }; - const fadeExit = prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: -2 }; - - useEffect(() => { - setShell(defaultShell); - }, [defaultShell]); - - return ( -
-
- {showTitle ? ( -

- Update Unsloth Studio -

- ) : null} -
- - / - -
-
- - - {getStudioUpdateInstructionLine(shell)} - - - -

- If that fails or unsloth studio update is unavailable, run: -

- - - - - -

- Restart Studio after updating for changes to take effect. -

-
- ); -} - -function getTourId(pathname: string): "studio" | "chat" | "export" | null { - if (pathname === "/studio") return "studio"; - if (pathname === "/chat") return "chat"; - if (pathname === "/export") return "export"; - return null; -} +import { SidebarTrigger, useSidebar } from "@/components/ui/sidebar"; export function Navbar() { - const pathname = useRouterState({ select: (s) => s.location.pathname }); - const isTrainingRunning = useTrainingRuntimeStore((s) => s.isTrainingRunning); - const [mobileOpen, setMobileOpen] = useState(false); - const [mobileUpdateOpen, setMobileUpdateOpen] = useState(false); - const [shutdownOpen, setShutdownOpen] = useState(false); - - const deviceType = usePlatformStore((s) => s.deviceType); - const chatOnly = usePlatformStore((s) => s.isChatOnly()); - const defaultUpdateShell = getDefaultUpdateShell(deviceType); - - // Warn before closing the tab only when training is running (data loss risk). - // We store the handler in a ref so removeUnloadHandler() can clean it up - // before the "Server stopped" page renders. - const unloadHandlerRef = useRef<((e: BeforeUnloadEvent) => void) | null>(null); - - useEffect(() => { - const handler = (e: BeforeUnloadEvent) => { - if (!useTrainingRuntimeStore.getState().isTrainingRunning) return; - e.preventDefault(); - e.returnValue = ""; - }; - unloadHandlerRef.current = handler; - window.addEventListener("beforeunload", handler); - return () => { - window.removeEventListener("beforeunload", handler); - }; - }, []); - - const removeUnloadHandler = () => { - if (unloadHandlerRef.current) { - window.removeEventListener("beforeunload", unloadHandlerRef.current); - unloadHandlerRef.current = null; - } - }; - - const tourId = getTourId(pathname); - - const openTour = () => { - if (!tourId) return; - window.dispatchEvent( - new CustomEvent(TOUR_OPEN_EVENT, { detail: { id: tourId } }), + const { isMobile } = useSidebar(); + if (!isMobile) { + return ( +
); - }; - + } return ( - <> -
-
- {/* Left: logo */} - - Unsloth - Unsloth - - BETA - - - - {/* Center: pill nav */} - - - {/* Right: docs/tour desktop — one wrapper per control so flex gap is even (HoverCard roots can confuse flex spacing). */} -
-
- -
- -
- - - API Keys - -
- {tourId ? ( -
- -
- ) : null} -
- - - - - - - - -
-
- -
-
- - {/* Right: mobile */} -
- {tourId ? ( - - ) : null} - { - setMobileOpen(open); - if (!open) setMobileUpdateOpen(false); - }} - > - - - - - - Navigate - -
- {NAV_ITEMS.filter((item) => item.enabled).map((item) => { - const active = pathname === item.href; - const disabledByTraining = - isTrainingRunning && item.href !== "/studio"; - const disabledByDevice = - chatOnly && item.href !== "/chat" && item.href !== "/data-recipes"; - if (disabledByTraining || disabledByDevice) { - return ( - - - {item.label} - - ); - } - return ( - setMobileOpen(false)} - className={cn( - "flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium", - active - ? "border-foreground bg-foreground text-background" - : "border-border text-foreground hover:bg-accent", - )} - > - - {item.label} - - ); - })} - setMobileOpen(false)} - className={cn( - "mt-3 flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium", - pathname === "/api-keys" - ? "border-foreground bg-foreground text-background" - : "border-border text-foreground hover:bg-accent", - )} - > - - API Keys - - setMobileOpen(false)} - > - - Learn more (Docs) - - {tourId ? ( - - ) : null} - - - - - - - - - -
- Theme - -
-
-
-
-
+
+
+
- - - ); } diff --git a/studio/frontend/src/components/shutdown-dialog.tsx b/studio/frontend/src/components/shutdown-dialog.tsx index dea738bf6b..dfeafb33eb 100644 --- a/studio/frontend/src/components/shutdown-dialog.tsx +++ b/studio/frontend/src/components/shutdown-dialog.tsx @@ -18,16 +18,17 @@ import { interface ShutdownDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - /** Called right before the shutdown API request so callers can remove the - * beforeunload listener — otherwise the "Server stopped" page would still - * trigger a "Leave site?" prompt when the user tries to close it. */ - onBeforeShutdown?: () => void; + /** Called after the shutdown API returns success, right before we replace + * document.body with the "Server stopped" page. Callers use this to remove + * their beforeunload listener — otherwise the browser would prompt + * "Leave site?" when the user tries to close the final tab. */ + onAfterShutdown?: () => void; } export function ShutdownDialog({ open, onOpenChange, - onBeforeShutdown, + onAfterShutdown, }: ShutdownDialogProps) { const [stopping, setStopping] = useState(false); @@ -49,7 +50,7 @@ export function ShutdownDialog({ return; } - onBeforeShutdown?.(); + onAfterShutdown?.(); document.body.innerHTML = `

Unsloth Studio has stopped.

diff --git a/studio/frontend/src/components/ui/animated-theme-toggler.tsx b/studio/frontend/src/components/ui/animated-theme-toggler.tsx index 24f3c68ec9..d83e278401 100644 --- a/studio/frontend/src/components/ui/animated-theme-toggler.tsx +++ b/studio/frontend/src/components/ui/animated-theme-toggler.tsx @@ -6,11 +6,73 @@ import { Moon, Sun } from "lucide-react" import { flushSync } from "react-dom" import { cn } from "@/lib/utils" +import { setTheme } from "@/features/settings/stores/theme-store" interface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<"button"> { duration?: number } +export function useAnimatedThemeToggle(duration = 400) { + const [isDark, setIsDark] = useState(false) + const anchorRef = useRef(null) + + useEffect(() => { + const updateTheme = () => { + setIsDark(document.documentElement.classList.contains("dark")) + } + updateTheme() + const observer = new MutationObserver(updateTheme) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }) + return () => observer.disconnect() + }, []) + + const toggleTheme = useCallback(async () => { + const anchor = anchorRef.current + const applyTheme = () => { + flushSync(() => { + const newTheme = !isDark + setIsDark(newTheme) + setTheme(newTheme ? "dark" : "light") + }) + } + + if (!document.startViewTransition) { + applyTheme() + return + } + + await document.startViewTransition(applyTheme).ready + + if (anchor) { + const { top, left, width, height } = anchor.getBoundingClientRect() + const x = left + width / 2 + const y = top + height / 2 + const maxRadius = Math.hypot( + Math.max(left, window.innerWidth - left), + Math.max(top, window.innerHeight - top) + ) + document.documentElement.animate( + { + clipPath: [ + `circle(0px at ${x}px ${y}px)`, + `circle(${maxRadius}px at ${x}px ${y}px)`, + ], + }, + { + duration, + easing: "ease-in-out", + pseudoElement: "::view-transition-new(root)", + } + ) + } + }, [isDark, duration]) + + return { isDark, toggleTheme, anchorRef } +} + export const AnimatedThemeToggler = ({ className, duration = 400, @@ -38,14 +100,20 @@ export const AnimatedThemeToggler = ({ const toggleTheme = useCallback(async () => { if (!buttonRef.current) return - await document.startViewTransition(() => { + const apply = () => { flushSync(() => { const newTheme = !isDark setIsDark(newTheme) - document.documentElement.classList.toggle("dark") - localStorage.setItem("theme", newTheme ? "dark" : "light") + setTheme(newTheme ? "dark" : "light") }) - }).ready + } + + if (!document.startViewTransition) { + apply() + return + } + + await document.startViewTransition(apply).ready const { top, left, width, height } = buttonRef.current.getBoundingClientRect() diff --git a/studio/frontend/src/components/ui/command.tsx b/studio/frontend/src/components/ui/command.tsx index 6181d55ea1..a2340b62ed 100644 --- a/studio/frontend/src/components/ui/command.tsx +++ b/studio/frontend/src/components/ui/command.tsx @@ -1,6 +1,6 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 - +// 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 { Command as CommandPrimitive } from "cmdk"; @@ -39,12 +39,14 @@ function CommandDialog({ description = "Search for a command to run...", children, className, + overlayClassName, showCloseButton = false, ...props }: React.ComponentProps & { title?: string; description?: string; className?: string; + overlayClassName?: string; showCloseButton?: boolean; }) { return ( @@ -55,9 +57,10 @@ function CommandDialog({ {children} diff --git a/studio/frontend/src/components/ui/sidebar.tsx b/studio/frontend/src/components/ui/sidebar.tsx index 898972f1ee..967b0dcaac 100644 --- a/studio/frontend/src/components/ui/sidebar.tsx +++ b/studio/frontend/src/components/ui/sidebar.tsx @@ -1,6 +1,6 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 - +// 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 * as React from "react" @@ -26,12 +26,11 @@ import { } from "@/components/ui/tooltip" import { useIsMobile } from "@/hooks/use-mobile" import { HugeiconsIcon } from "@hugeicons/react" -import { SidebarLeftIcon } from "@hugeicons/core-free-icons" +import { LayoutAlignLeftIcon } from "@hugeicons/core-free-icons" + +const noop = () => {} -const SIDEBAR_COOKIE_NAME = "sidebar_state" -const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 const SIDEBAR_WIDTH = "16rem" -const SIDEBAR_WIDTH_MOBILE = "18rem" const SIDEBAR_WIDTH_ICON = "3rem" const SIDEBAR_KEYBOARD_SHORTCUT = "b" @@ -43,6 +42,10 @@ type SidebarContextProps = { setOpenMobile: (open: boolean) => void isMobile: boolean toggleSidebar: () => void + hasPinMode: boolean + pinned: boolean + setPinned: (value: boolean) => void + togglePinned: () => void } const SidebarContext = React.createContext(null) @@ -60,6 +63,9 @@ function SidebarProvider({ defaultOpen = true, open: openProp, onOpenChange: setOpenProp, + pinned: pinnedProp, + setPinned: setPinnedProp, + togglePinned: togglePinnedProp, className, style, children, @@ -68,33 +74,57 @@ function SidebarProvider({ defaultOpen?: boolean open?: boolean onOpenChange?: (open: boolean) => void + pinned?: boolean + setPinned?: (value: boolean) => void + togglePinned?: () => void }) { const isMobile = useIsMobile() const [openMobile, setOpenMobile] = React.useState(false) + const prevIsMobileRef = React.useRef(isMobile) + React.useEffect(() => { + if (prevIsMobileRef.current && !isMobile) { + setOpenMobile(false) + } + prevIsMobileRef.current = isMobile + }, [isMobile]) + + // Whether pin mode is active (caller provides pinned + setPinned + togglePinned). + const hasPinMode = pinnedProp !== undefined && setPinnedProp !== undefined && togglePinnedProp !== undefined + // This is the internal state of the sidebar. // We use openProp and setOpenProp for control from outside the component. const [_open, _setOpen] = React.useState(defaultOpen) - const open = openProp ?? _open + + // When pin mode is active, open is driven entirely by `pinned` (explicit + // user toggle). Otherwise fall back to the controlled/uncontrolled pattern. + const open = hasPinMode ? !!pinnedProp : (openProp ?? _open) + const setOpen = React.useCallback( (value: boolean | ((value: boolean) => boolean)) => { const openState = typeof value === "function" ? value(open) : value + + if (hasPinMode) { + // In pin mode, setOpen controls pinned state. + setPinnedProp?.(openState) + return + } + if (setOpenProp) { setOpenProp(openState) } else { _setOpen(openState) } - - // This sets the cookie to keep the sidebar state. - document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` }, - [setOpenProp, open] + [setOpenProp, open, hasPinMode, setPinnedProp] ) // Helper to toggle the sidebar. const toggleSidebar = React.useCallback(() => { - return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) - }, [isMobile, setOpen, setOpenMobile]) + if (isMobile) return setOpenMobile((open) => !open) + if (hasPinMode && togglePinnedProp) return togglePinnedProp() + return setOpen((open) => !open) + }, [isMobile, setOpen, setOpenMobile, hasPinMode, togglePinnedProp]) // Adds a keyboard shortcut to toggle the sidebar. React.useEffect(() => { @@ -116,6 +146,10 @@ function SidebarProvider({ // This makes it easier to style the sidebar with Tailwind classes. const state = open ? "expanded" : "collapsed" + const pinned = pinnedProp ?? false + const setPinned = setPinnedProp ?? noop + const togglePinned = togglePinnedProp ?? noop + const contextValue = React.useMemo( () => ({ state, @@ -125,8 +159,12 @@ function SidebarProvider({ openMobile, setOpenMobile, toggleSidebar, + hasPinMode, + pinned, + setPinned, + togglePinned, }), - [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, hasPinMode, pinned, setPinned, togglePinned] ) return ( @@ -165,7 +203,7 @@ function Sidebar({ variant?: "sidebar" | "floating" | "inset" collapsible?: "offcanvas" | "icon" | "none" }) { - const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + const { isMobile, state, openMobile, setOpenMobile, hasPinMode, pinned } = useSidebar() if (collapsible === "none") { return ( @@ -190,12 +228,7 @@ function Sidebar({ data-sidebar="sidebar" data-slot="sidebar" data-mobile="true" - className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden" - style={ - { - "--sidebar-width": SIDEBAR_WIDTH_MOBILE, - } as React.CSSProperties - } + className="bg-sidebar text-sidebar-foreground w-2/3 max-w-[18rem] p-0 [&>button]:hidden" side={side} > @@ -210,7 +243,11 @@ function Sidebar({ return (
@@ -274,7 +337,7 @@ function SidebarTrigger({ }} {...props} > - + Toggle Sidebar ) @@ -310,7 +373,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
) { data-slot="sidebar-content" data-sidebar="content" className={cn( - "no-scrollbar gap-2 flex min-h-0 flex-1 flex-col overflow-auto group-data-[collapsible=icon]:overflow-hidden", + "gap-2 flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden group-data-[collapsible=icon]:overflow-hidden [&>*]:shrink-0", className )} {...props} @@ -408,7 +471,7 @@ function SidebarGroupLabel({ data-slot="sidebar-group-label" data-sidebar="group-label" className={cn( - "text-sidebar-foreground/70 ring-sidebar-ring h-8 rounded-md px-2 text-xs font-medium transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 flex shrink-0 items-center outline-hidden [&>svg]:shrink-0", + "text-[#94a3b8] dark:text-[#666] ring-sidebar-ring h-auto pt-3 pb-2 px-4 rounded-md text-[10px] font-semibold uppercase tracking-[0.08em] group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-3 flex shrink-0 items-center outline-hidden [&>svg]:shrink-0", className )} {...props} @@ -455,7 +518,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
    ) @@ -473,7 +536,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { } const sidebarMenuButtonVariants = cva( - "ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground gap-2 rounded-lg corner-squircle p-2 text-left text-sm transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! focus-visible:ring-2 data-active:font-medium peer/menu-button flex w-full items-center overflow-hidden outline-hidden group/menu-button disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&_svg]:size-4 [&_svg]:shrink-0", + "ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground gap-2 rounded-md p-2 text-left text-sm cursor-pointer transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:w-full! group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:p-2! data-active:font-medium peer/menu-button flex w-full items-center overflow-hidden outline-hidden group/menu-button disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate group-data-[collapsible=icon]:[&>span]:hidden [&_svg]:size-4 [&_svg]:shrink-0 group-data-[collapsible=icon]:[&_svg]:size-[18px]", { variants: { variant: { diff --git a/studio/frontend/src/components/ui/slider.tsx b/studio/frontend/src/components/ui/slider.tsx index 573dea835b..705182e5ae 100644 --- a/studio/frontend/src/components/ui/slider.tsx +++ b/studio/frontend/src/components/ui/slider.tsx @@ -79,7 +79,7 @@ function Slider({ > ))} diff --git a/studio/frontend/src/components/ui/textarea.tsx b/studio/frontend/src/components/ui/textarea.tsx index b71e593958..36c372bdf5 100644 --- a/studio/frontend/src/components/ui/textarea.tsx +++ b/studio/frontend/src/components/ui/textarea.tsx @@ -1,19 +1,28 @@ // 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 { cn } from "@/lib/utils"; - -function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { - return ( -