Merge remote-tracking branch 'origin/main'

This commit is contained in:
Daniel Han 2026-04-21 00:34:27 +00:00
commit f11e520d06
118 changed files with 6818 additions and 2496 deletions

View file

@ -1,28 +1,43 @@
<h1 align="center" style="margin:0;">
<a href="https://unsloth.ai/docs"><picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/unslothai/unsloth/main/images/STUDIO%20WHITE%20LOGO.png">
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/unslothai/unsloth/main/images/STUDIO%20BLACK%20LOGO.png">
<img alt="Unsloth logo" src="https://raw.githubusercontent.com/unslothai/unsloth/main/images/STUDIO%20BLACK%20LOGO.png" height="60" style="max-width:100%;">
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/unslothai/unsloth/main/images/unsloth%20logo%20white%20text.png">
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/unslothai/unsloth/main/images/unsloth%20logo%20black%20text.png">
<img alt="Unsloth logo" src="https://raw.githubusercontent.com/unslothai/unsloth/main/images/unsloth%20logo%20black%20text.png" height="80" style="max-width:100%;">
</picture></a>
</h1>
<h3 align="center" style="margin: 0; margin-top: 0;">
Run and train AI models with a unified local interface.
Unsloth Studio lets you run and train models locally.
</h3>
<p align="center">
<a href="#-features">Features</a> •
<a href="#-quickstart">Quickstart</a> •
<a href="#-install">Quickstart</a> •
<a href="#-free-notebooks">Notebooks</a> •
<a href="https://unsloth.ai/docs">Documentation</a> •
<a href="https://www.reddit.com/r/unsloth/">Reddit</a>
<a href="https://unsloth.ai/docs">Documentation</a>
</p>
<a href="https://unsloth.ai/docs/new/studio">
<img alt="unsloth studio ui homepage" src="https://raw.githubusercontent.com/unslothai/unsloth/main/studio/frontend/public/studio%20github%20landscape%20colab%20display.png" style="max-width: 100%; margin-bottom: 0;"></a>
<br>
<a href="https://unsloth.ai/docs/new/studio">
<img alt="unsloth studio ui homepage" src="https://github.com/user-attachments/assets/53ae17a9-d975-44ef-9686-efb4ebd0454d" style="max-width: 100%; margin-bottom: 0;"></a>
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 Googles 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 Googles 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)

View file

@ -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",

View file

@ -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
rm -f "$_uv_tmp"
if [ -f "$HOME/.local/bin/env" ]; then
. "$HOME/.local/bin/env"
fi
export PATH="$HOME/.local/bin:$PATH"
substep "done"
else
step "uv" "found $(uv --version 2>/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 ""

View file

@ -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",

View file

@ -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"

View file

@ -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",

View file

@ -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

View file

@ -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,
}

View file

@ -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")

View file

@ -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;

View file

@ -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: <async_generator>" / 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")

View file

@ -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"
)

View file

@ -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...")

View file

@ -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

View file

@ -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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 990 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

View file

@ -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 });

View file

@ -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 (
<AppProvider>
{!hideNavbar && <Navbar />}
<AnimatePresence initial={false} mode="wait">
<motion.div
key={pathname}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="flex-1"
>
<SettingsDialog />
{hideNavbar ? (
<main className="flex-1">
<Suspense fallback={null}>
<Outlet />
</Suspense>
</motion.div>
</AnimatePresence>
</main>
) : (
<SidebarProvider
pinned={pinned}
setPinned={setPinned}
togglePinned={togglePinned}
className="!min-h-0 h-dvh overflow-hidden"
>
<AppSidebar />
<SidebarInset className={isChatRoute ? "overflow-hidden" : "overflow-y-auto"}>
<Navbar />
<div
className={`flex min-h-0 min-w-0 flex-1 basis-0 flex-col ${isChatRoute ? "overflow-hidden" : "overflow-visible"} ${isChatRoute ? "" : "pt-14 md:pt-0"}`}
>
<AnimatePresence initial={false} mode="wait">
<motion.div
key={pathname}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className={`flex min-h-0 min-w-0 flex-1 basis-0 flex-col ${isChatRoute ? "overflow-hidden" : "overflow-visible"}`}
>
<Suspense fallback={null}>
<Outlet />
</Suspense>
</motion.div>
</AnimatePresence>
</div>
</SidebarInset>
</SidebarProvider>
)}
</AppProvider>
);
}

View file

@ -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,
});

View file

@ -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<string, unknown>): 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,
});

View file

@ -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<string, unknown>): OnboardingSearch => ({
redirectTo: typeof search.redirectTo === "string" ? search.redirectTo : undefined,
}),
component: WizardLayout,
});

View file

@ -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 (
<SidebarMenuItem>
<div className="relative">
<SidebarMenuButton
tooltip={label}
disabled={disabled}
onClick={onClick}
isActive={active}
data-tour={dataTour}
className={
isNav
? "h-[30px] rounded-[8px] gap-2.5 px-2.5 font-medium text-[#383835] dark:text-[#c7c7c4] hover:bg-[#ececec]! dark:hover:bg-[#2e3035]! hover:text-black! dark:hover:text-white! data-active:bg-[#ececec]! dark:data-active:bg-[#2e3035]! data-active:text-black! dark:data-active:text-white! group-data-[collapsible=icon]:!w-[30px] group-data-[collapsible=icon]:!rounded-[9px] group-data-[collapsible=icon]:mx-auto"
: "h-[30px] rounded-[8px] gap-2.5 px-2.5 font-medium text-[#383835] dark:text-[#c7c7c4] hover:bg-[#ececec]! dark:hover:bg-[#2e3035]! hover:text-black! dark:hover:text-white! data-active:bg-[#ececec]! dark:data-active:bg-[#2e3035]! data-active:text-black! dark:data-active:text-white! group-data-[collapsible=icon]:!w-[30px] group-data-[collapsible=icon]:!rounded-[9px] group-data-[collapsible=icon]:mx-auto"
}
>
<HugeiconsIcon icon={icon} strokeWidth={1.5} className="size-[18px]!" />
<span className="text-sm">{label}</span>
</SidebarMenuButton>
</div>
{children}
</SidebarMenuItem>
);
}
export function AppSidebar() {
const { isDark, toggleTheme, anchorRef } = useAnimatedThemeToggle();
const { pathname, search } = useRouterState({
select: (s) => ({
pathname: s.location.pathname,
search: s.location.search as Record<string, string | undefined>,
}),
});
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<typeof deleteChatItem>[0]) {
await deleteChatItem(item, activeThreadId, (view) => {
navigate({
to: "/chat",
search: { new: view.newThreadNonce },
});
});
}
return (
<>
<Sidebar
collapsible="icon"
variant="sidebar"
className="font-heading group-data-[collapsible=icon]:[&_[data-sidebar=sidebar]]:bg-white dark:group-data-[collapsible=icon]:[&_[data-sidebar=sidebar]]:bg-background"
>
<SidebarHeader className="pl-[17px] pr-3 pt-[12px] pb-[12px] group-data-[collapsible=icon]:px-0">
{/* Expanded: compact logo + close toggle */}
<div className="flex items-center justify-between gap-2 group-data-[collapsible=icon]:hidden">
<Link
to="/chat"
onClick={(event) => {
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"
>
<img
src="/circle-logo-small.png"
alt="Unsloth"
className="h-[34px] w-[34px] rounded-full object-cover"
/>
<span className="font-heading text-[21px] font-semibold tracking-[-0.01em] dark:tracking-[0.02em] leading-none text-black dark:text-white">
unsloth
</span>
<span
style={{ fontFamily: '"Inter Variable", ui-sans-serif, system-ui, sans-serif' }}
className="ml-0.5 inline-flex items-center justify-center rounded-full border border-[#e0ded6] px-[5px] py-[2px] text-[8px] font-medium leading-none tracking-[0.04em] text-[#62605a] antialiased subpixel-antialiased shadow-[0_1px_2px_rgba(0,0,0,0.06)] dark:border-[#3a3c3f] dark:text-[#9d9fa5] dark:shadow-[0_1px_2px_rgba(0,0,0,0.35)]"
>
BETA
</span>
</Link>
{!isMobile && (
<Tooltip>
<TooltipPrimitive.Trigger asChild>
<button
type="button"
onClick={togglePinned}
className="inline-flex h-7 w-7 items-center justify-center rounded-[8px] text-[#8f8f8f] dark:text-[#5c5c5c] transition-colors hover:bg-[#ececec] dark:hover:bg-[#2e3035] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Close sidebar"
>
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-[18px]" />
</button>
</TooltipPrimitive.Trigger>
<TooltipContent side="bottom" sideOffset={6}>
Close sidebar
</TooltipContent>
</Tooltip>
)}
</div>
{/* Collapsed: panel icon doubles as expand trigger */}
{!isMobile && (
<div className="hidden group-data-[collapsible=icon]:flex h-[34px] items-center justify-center w-full">
<Tooltip>
<TooltipPrimitive.Trigger asChild>
<button
type="button"
onClick={togglePinned}
className="inline-flex h-7 w-7 items-center justify-center rounded-[8px] text-[#383835] dark:text-[#c7c7c4] transition-colors hover:bg-[#ececec] dark:hover:bg-[#2e3035] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Open sidebar"
>
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-[18px]" />
</button>
</TooltipPrimitive.Trigger>
<TooltipContent side="right" sideOffset={8}>
Open sidebar
</TooltipContent>
</Tooltip>
</div>
)}
</SidebarHeader>
<SidebarGroup className="group-data-[collapsible=icon]:px-0 px-2 pt-[8px] pb-[12px] shrink-0">
<SidebarGroupContent>
<SidebarMenu>
<NavItem
icon={PencilEdit02Icon}
label="New Chat"
active={false}
disabled={chatDisabled}
onClick={() => {
if (chatDisabled) return;
setActiveThreadId(null);
navigate({ to: "/chat", search: { new: createNavigationNonce() } });
closeMobileIfOpen();
}}
/>
<NavItem
icon={ColumnInsertIcon}
label="Compare"
active={!!search.compare && !chatItems.some((i) => i.id === search.compare)}
disabled={chatDisabled}
dataTour="chat-compare"
onClick={() => {
if (chatDisabled) return;
setActiveThreadId(null);
navigate({ to: "/chat", search: { compare: createNavigationNonce() } });
closeMobileIfOpen();
}}
/>
<NavItem
icon={Search01Icon}
label="Search"
active={false}
disabled={chatDisabled}
onClick={() => {
if (chatDisabled) return;
useChatSearchStore.getState().open();
closeMobileIfOpen();
}}
/>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarContent className="gap-0 overflow-y-auto overscroll-contain min-h-0">
{/* Navigate (no header) */}
<SidebarGroup data-tour="navbar" className="group-data-[collapsible=icon]:px-0 px-2 pt-[8px] pb-[12px]">
<SidebarGroupContent>
<SidebarMenu>
<NavItem
icon={GemIcon}
label="Train"
active={pathname === "/studio" || pathname.startsWith("/studio/")}
disabled={chatOnly}
onClick={() => {
if (chatOnly) return;
navigate({ to: "/studio" });
closeMobileIfOpen();
}}
/>
<NavItem
icon={ChefHatIcon}
label="Recipes"
active={isRecipesRoute}
onClick={() => {
navigate({ to: "/data-recipes" });
closeMobileIfOpen();
}}
/>
<NavItem
icon={Download03Icon}
label="Export"
active={pathname === "/export" || pathname.startsWith("/export/")}
disabled={chatOnly}
onClick={() => {
if (chatOnly) return;
navigate({ to: "/export" });
closeMobileIfOpen();
}}
/>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{/* Recent Chats — hide on Studio only (Eyera fac13); chatOpen = ec695 clickability */}
{!isStudioRoute && chatItems.length > 0 && (
<Collapsible open={chatOpen} onOpenChange={setChatOpen} asChild>
<SidebarGroup className="group-data-[collapsible=icon]:hidden overflow-hidden px-2 py-0">
<SidebarGroupLabel className="pt-2 pb-1.5 pl-2.5 pr-2 text-[12.5px]! font-normal normal-case tracking-normal text-[#62605a] dark:text-[#9d9fa5] focus-visible:ring-0! focus-visible:outline-none" asChild>
<CollapsibleTrigger className="cursor-pointer flex w-full items-center justify-between">
Recents
<ChevronDown className="size-3.5 transition-transform duration-200 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>
{chatItems.map((item) => (
<SidebarMenuItem key={item.id} className="group/recent-item relative">
<SidebarMenuButton
isActive={activeThreadId === item.id}
className="h-[30px] rounded-[8px] pl-2.5 pr-7 text-sm font-medium text-[#383835] dark:text-[#c7c7c4] hover:bg-[#ececec]! dark:hover:bg-[#2e3035]! hover:text-black! dark:hover:text-white! data-active:bg-[#ececec]! dark:data-active:bg-[#2e3035]! data-active:text-black! dark:data-active:text-white!"
onClick={() => {
navigate({
to: "/chat",
search:
item.type === "single"
? { thread: item.id }
: { compare: item.id },
});
closeMobileIfOpen();
}}
>
<span className="truncate">{item.title}</span>
</SidebarMenuButton>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDeleteThread(item);
}}
title="Delete"
className="absolute right-1 top-1/2 -translate-y-1/2 flex size-5 scale-90 items-center justify-center rounded-[8px] text-sidebar-foreground/55 opacity-0 transition-all duration-150 hover:bg-destructive/12 hover:text-destructive group-hover/recent-item:scale-100 group-hover/recent-item:opacity-100"
>
<HugeiconsIcon icon={Delete02Icon} strokeWidth={2} className="size-3.5" />
</button>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
)}
{/* Recent Runs */}
{isStudioRoute && runItems.length > 0 && !chatOnly && (
<Collapsible open={runsOpen} onOpenChange={setRunsOpen} asChild>
<SidebarGroup className="group-data-[collapsible=icon]:hidden overflow-hidden px-2 py-0">
<SidebarGroupLabel className="pt-2 pb-1.5 pl-2.5 pr-2 text-[12.5px]! font-normal normal-case tracking-normal text-[#62605a] dark:text-[#9d9fa5] focus-visible:ring-0! focus-visible:outline-none" asChild>
<CollapsibleTrigger className="cursor-pointer flex w-full items-center justify-between">
Recents
<ChevronDown className="size-3.5 transition-transform duration-200 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>
{runItems.map((run) => {
const isActiveRun =
selectedHistoryRunId === run.id || activeJobId === run.id;
return (
<SidebarMenuItem
key={run.id}
className="group/run-item relative"
>
<SidebarMenuButton
isActive={isActiveRun}
className="h-auto flex-col items-start gap-0.5 py-1.5 rounded-[8px] pl-2.5 pr-7 text-sm font-medium text-[#383835] dark:text-[#c7c7c4] hover:bg-[#ececec]! dark:hover:bg-[#2e3035]! hover:text-black! dark:hover:text-white! data-active:bg-[#ececec]! dark:data-active:bg-[#2e3035]! data-active:text-black! dark:data-active:text-white!"
onClick={() => {
setSelectedHistoryRunId(run.id);
closeMobileIfOpen();
}}
>
<div className="flex w-full items-center gap-2">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
runStatusDotClass(run.status),
)}
aria-hidden
/>
<span className="truncate text-sm">
{run.model_name}
</span>
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground">
{formatRelativeShort(run.started_at)}
</span>
</div>
<span className="w-full truncate pl-3.5 text-xs text-muted-foreground">
{run.dataset_name}
</span>
</SidebarMenuButton>
<button
type="button"
onClick={async (e) => {
e.stopPropagation();
try {
await deleteTrainingRun(run.id);
if (selectedHistoryRunId === run.id) {
setSelectedHistoryRunId(null);
}
await refreshRuns();
} catch {
// ignore — next refresh will reconcile
}
}}
title="Delete"
className="absolute right-1 top-1/2 -translate-y-1/2 flex size-5 scale-90 items-center justify-center rounded-[8px] text-sidebar-foreground/55 opacity-0 transition-all duration-150 hover:bg-destructive/12 hover:text-destructive group-hover/run-item:scale-100 group-hover/run-item:opacity-100"
>
<HugeiconsIcon icon={Delete02Icon} strokeWidth={2} className="size-3.5" />
</button>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
)}
</SidebarContent>
<SidebarFooter className="border-t border-sidebar-border group-data-[collapsible=icon]:border-t-0 group-data-[collapsible=icon]:px-0">
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
aria-label={`${displayTitle} account menu`}
className="!h-[50px] gap-[8px] rounded-[8px] text-[#383835] dark:text-[#c7c7c4] hover:bg-[#ececec]! dark:hover:bg-[#2e3035]! hover:text-black! dark:hover:text-white! data-[state=open]:bg-[#ececec]! dark:data-[state=open]:bg-[#2e3035]! data-[state=open]:text-black! dark:data-[state=open]:text-white!"
>
<div className="shrink-0">
<UserAvatar
name={displayTitle}
imageUrl={avatarDataUrl}
size="sm"
className="!size-8"
/>
</div>
<div className="flex flex-col gap-0.5 leading-none group-data-[collapsible=icon]:hidden">
<span className="truncate font-heading text-[13px] font-semibold text-[#383835] dark:text-[#c7c7c4]">{displayTitle}</span>
<span className="truncate text-[11px] text-muted-foreground">Studio</span>
</div>
<ChevronsUpDown strokeWidth={1.25} className="ml-auto size-4 text-muted-foreground group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
className="w-[15rem] font-heading [&_[data-slot=dropdown-menu-item]]:rounded-[8px] [&_[data-slot=dropdown-menu-item]]:font-medium [&_[data-slot=dropdown-menu-item]]:text-[#383835] dark:[&_[data-slot=dropdown-menu-item]]:text-[#c7c7c4] [&_[data-slot=dropdown-menu-item]:focus]:bg-[#ececec] dark:[&_[data-slot=dropdown-menu-item]:focus]:bg-[#2e3035] [&_[data-slot=dropdown-menu-item]:focus]:text-black dark:[&_[data-slot=dropdown-menu-item]:focus]:text-white [&_[data-slot=dropdown-menu-item]:focus_*]:text-black! dark:[&_[data-slot=dropdown-menu-item]:focus_*]:text-white!"
>
<DropdownMenuGroup>
<DropdownMenuItem
onSelect={() => useSettingsDialogStore.getState().openDialog()}
>
<HugeiconsIcon icon={Settings02Icon} className="size-4" />
<span>Settings</span>
<DropdownMenuShortcut>,</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
ref={anchorRef as React.Ref<HTMLDivElement>}
onSelect={(e) => { e.preventDefault(); toggleTheme(); }}
>
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
<span>{isDark ? "Light Mode" : "Dark Mode"}</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!getTourId(pathname)}
onSelect={() => {
const tourId = getTourId(pathname);
if (!tourId) return;
window.dispatchEvent(
new CustomEvent(TOUR_OPEN_EVENT, {
detail: { id: tourId },
}),
);
}}
>
<HugeiconsIcon icon={CursorInfo02Icon} className="size-4" />
<span>Guided Tour</span>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<a
href="https://unsloth.ai/docs"
target="_blank"
rel="noopener noreferrer"
>
<HugeiconsIcon icon={Book03Icon} className="size-4" />
<span>Learn More</span>
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a
href="https://unsloth.ai/docs/new/changelog"
target="_blank"
rel="noopener noreferrer"
>
<HugeiconsIcon
icon={NewReleasesIcon}
className="size-4"
/>
<span>What's New</span>
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a
href="https://github.com/unslothai/unsloth/issues"
target="_blank"
rel="noopener noreferrer"
>
<HugeiconsIcon
icon={MessageSearch01Icon}
className="size-4"
/>
<span>Feedback</span>
</a>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setShutdownOpen(true)}>
<HugeiconsIcon icon={PowerIcon} className="size-4" />
<span>Shutdown</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
<ChatSearchDialog />
<ShutdownDialog
open={shutdownOpen}
onOpenChange={setShutdownOpen}
onAfterShutdown={removeTrainingUnloadGuard}
/>
</>
);
}

View file

@ -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<string, BundledLanguage> = {
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,
),
};
}

View file

@ -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",
};

View file

@ -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 (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
);
};

View file

@ -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}

View file

@ -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 && (
<span className="size-2 shrink-0 rounded-full bg-emerald-500" />
)}
<span className={isLoaded ? "text-foreground" : "text-muted-foreground"}>
{currentModel?.name ?? "Select model..."}
<span className="font-heading font-medium text-[16px] text-black dark:text-white">
{currentModel?.name ?? "Select model"}
</span>
{currentModel?.description && (
<span className="text-muted-foreground text-xs">{currentModel.description}</span>
)}
<HugeiconsIcon
icon={ArrowDown01Icon}
className="size-3 shrink-0 text-muted-foreground"
strokeWidth={1.75}
className="size-3.5 shrink-0 text-muted-foreground"
/>
</button>
</PopoverTrigger>

View file

@ -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]",
)}
>
<span
@ -376,7 +376,7 @@ function GgufVariantExpander({
handleVariantClick(v.quant, v.downloaded, v.size_bytes)
}
className={cn(
"flex min-w-0 flex-1 items-center justify-between gap-2 rounded-md px-2.5 py-1 text-left text-sm transition-colors hover:bg-accent",
"flex min-w-0 flex-1 items-center justify-between gap-2 rounded-[6px] px-2.5 py-1 text-left text-sm transition-colors hover:bg-[#ececec] dark:hover:bg-[#2e3035]",
)}
>
<span className="min-w-0 flex-1 truncate font-mono text-xs">

View file

@ -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<HTMLDivElement>(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<typeof setTimeout> | 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 (
<div
data-slot="reasoning-fade"
className={cn(
"aui-reasoning-fade pointer-events-none absolute inset-x-0 bottom-0 z-10 h-8",
"bg-gradient-to-t from-background to-transparent",
className,
)}
{...props}
/>
);
}
function ReasoningFadeTop({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="reasoning-fade-top"
className={cn(
"aui-reasoning-fade-top pointer-events-none absolute inset-x-0 top-0 z-10 h-8",
"bg-gradient-to-b from-background to-transparent",
className,
)}
{...props}
/>
);
}
function ReasoningTrigger({
active,
duration,
@ -206,7 +137,7 @@ function ReasoningTrigger({
className="aui-reasoning-trigger-label-wrapper relative inline-block leading-none"
>
{active ? (
<AnimatedShinyText className="text-sm">Thinking...</AnimatedShinyText>
<span className="text-sm">Thinking...</span>
) : (
<span>Thought for {duration ?? 0} seconds</span>
)}
@ -234,7 +165,7 @@ function ReasoningContent({
<CollapsibleContent
data-slot="reasoning-content"
className={cn(
"aui-reasoning-content relative overflow-hidden text-muted-foreground text-sm outline-none",
"aui-reasoning-content relative overflow-hidden text-foreground/85 text-[13.5px] outline-none",
"group/collapsible-content ease-out",
"data-[state=closed]:animate-collapsible-up",
"data-[state=open]:animate-collapsible-down",
@ -246,9 +177,7 @@ function ReasoningContent({
)}
{...props}
>
{streaming && <ReasoningFadeTop />}
{children}
<ReasoningFade />
</CollapsibleContent>
);
}
@ -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,
};

View file

@ -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 (
<ThreadPrimitive.Root
className="aui-root aui-thread-root @container flex h-full flex-col "
className="aui-root aui-thread-root @container relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden"
style={{
["--thread-max-width" as string]: "44rem",
["--thread-content-max-width" as string]:
"calc(var(--thread-max-width) - 2.5rem)",
}}
>
<ThreadPrimitive.Viewport
className="aui-thread-viewport relative flex min-w-0 flex-1 flex-col overflow-x-auto overflow-y-scroll scroll-smooth px-4 pt-4"
className={cn(
"aui-thread-viewport relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-x-auto overflow-y-auto scroll-smooth px-5",
hideComposer ? "pt-4" : "pt-[48px]",
)}
>
{!hideWelcome && (
<AuiIf condition={({ thread }) => thread.isEmpty}>
@ -92,43 +98,70 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({
}}
/>
<ThreadPrimitive.ViewportFooter
className={cn(
"aui-thread-viewport-footer sticky bottom-0 z-20 mt-auto flex w-full flex-col overflow-visible bg-transparent",
hideComposer ? "gap-2" : "gap-4",
// Compare: pointer-events pass-through so messages behind footer stay clickable
hideComposer
? "pointer-events-none pb-3"
: "relative pb-4",
)}
>
{!hideComposer && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-4 bg-background" aria-hidden />
)}
{/* 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. */}
<AuiIf condition={({ thread }) => !thread.isEmpty}>
<div
className={cn("shrink-0", hideComposer ? "h-16" : "h-40")}
aria-hidden
/>
</AuiIf>
<AuiIf condition={({ thread }) => !thread.isEmpty}>
<ThreadPrimitive.ViewportFooter
className={cn(
"flex justify-center",
hideComposer && "pointer-events-auto",
"aui-thread-viewport-footer pointer-events-none sticky z-20 flex w-full justify-center bg-transparent",
hideComposer ? "bottom-3" : "bottom-[140px]",
)}
>
<ThreadScrollToBottom />
</div>
<AuiIf condition={({ thread }) => !thread.isEmpty}>
{!hideComposer && <ComposerAnimated />}
</AuiIf>
</ThreadPrimitive.ViewportFooter>
</ThreadPrimitive.ViewportFooter>
</AuiIf>
</ThreadPrimitive.Viewport>
{!hideComposer && (
<AuiIf condition={({ thread }) => !thread.isEmpty}>
<div className="aui-thread-composer-dock pointer-events-none absolute bottom-0 left-0 right-0 md:right-2 z-20">
<div
aria-hidden
className="absolute inset-x-0 bottom-0 top-[10px] bg-background"
/>
<div className="relative px-5 pb-2">
<div className="pointer-events-auto mx-auto w-full max-w-(--thread-max-width)">
<ComposerAnimated />
</div>
<p className="mt-1.5 text-center text-[11px] text-muted-foreground">
LLMs can make mistakes. Double-check all responses.
</p>
</div>
</div>
</AuiIf>
)}
</ThreadPrimitive.Root>
);
};
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 (
<ThreadPrimitive.ScrollToBottom asChild={true}>
<TooltipIconButton
tooltip="Scroll to bottom"
variant="outline"
className="aui-thread-scroll-to-bottom absolute -top-6 z-10 self-center rounded-full p-4 disabled:invisible dark:bg-background dark:hover:bg-accent"
className={cn(
"aui-thread-scroll-to-bottom pointer-events-auto rounded-full p-4 bg-background hover:bg-accent dark:bg-background dark:hover:bg-accent",
isAtBottom && "invisible pointer-events-none",
)}
>
<ArrowDownIcon />
</TooltipIconButton>
@ -199,7 +232,7 @@ const SuggestionItem: FC = () => {
const ThreadWelcome: FC<{ hideComposer?: boolean }> = ({ hideComposer }) => {
return (
<div className="aui-thread-welcome-root mx-auto my-auto flex w-full max-w-(--thread-max-width) grow flex-col">
<div className="aui-thread-welcome-center flex w-full grow flex-col items-center justify-center">
<div className="aui-thread-welcome-center flex w-full grow flex-col items-center justify-center pb-[48px]">
<div className="aui-thread-welcome-message flex w-full flex-col justify-center gap-6 px-4">
<div className="flex flex-col items-center gap-2 text-center">
<img
@ -207,18 +240,13 @@ const ThreadWelcome: FC<{ hideComposer?: boolean }> = ({ hideComposer }) => {
alt="Sloth mascot"
className="size-20"
/>
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in font-semibold text-2xl duration-200">
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in font-heading font-semibold text-2xl tracking-[-0.02em] duration-200">
Chat with your model
</h1>
<p className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in text-muted-foreground text-base delay-75 duration-200">
Run GGUFs, safetensors, vision and audio models!
<p className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 -mt-1 animate-in font-heading font-normal text-muted-foreground text-sm delay-75 duration-200">
Run GGUFs, safetensors, vision and audio models
</p>
</div>
<div className="grid grid-cols-2 gap-2">
<ThreadPrimitive.Suggestions
components={{ Suggestion: SuggestionItem }}
/>
</div>
<GeneratingSpinner />
{!hideComposer && <ComposerAnimated />}
</div>
@ -243,10 +271,6 @@ const GeneratingSpinner: FC = () => {
const ComposerAnimated: FC = () => {
return (
<div className="relative mx-auto min-w-0 w-full max-w-(--thread-max-width)">
<div
className="pointer-events-none absolute inset-x-0 top-1/2 bottom-0 z-0 bg-background"
aria-hidden
/>
<motion.div
layout={true}
layoutId="composer"
@ -284,14 +308,15 @@ const PendingAudioChip: FC = () => {
const Composer: FC = () => {
return (
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone shadow-border ring-1 ring-border flex w-full flex-col rounded-2xl bg-background px-1 pt-2 outline-none transition-shadow data-[dragging=true]:ring-ring data-[dragging=true]:bg-accent/50">
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone chat-composer-surface flex w-full flex-col rounded-3xl bg-background dark:bg-card px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:bg-accent/50">
<ComposerAttachments />
<PendingAudioChip />
<ToolStatusDisplay />
<ComposerPrimitive.Input
placeholder="Send a message..."
className="aui-composer-input mb-1 max-h-32 min-h-12 w-full resize-none bg-transparent pl-5 pr-4 pt-2 pb-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-0"
rows={1}
className="aui-composer-input mb-1 min-h-12 w-full resize-none overflow-y-auto bg-transparent pl-5 pr-4 pt-2 pb-3 text-sm font-[450] outline-none placeholder:text-muted-foreground focus-visible:ring-0"
minRows={1}
maxRows={6}
autoFocus={true}
aria-label="Message input"
/>
@ -457,7 +482,7 @@ const CodeToolsToggle: FC = () => {
)}
aria-label={codeToolsEnabled ? "Disable code execution" : "Enable code execution"}
>
<TerminalIcon className="size-3.5" />
<CodeToggleIcon className="size-3.5" />
<span>Code</span>
</button>
);
@ -594,15 +619,13 @@ const GeneratingIndicator: FC = () => {
message.content.length === 0 && message.status?.type === "running",
);
if (!show) return null;
return (
<AnimatedShinyText className="text-sm">Generating...</AnimatedShinyText>
);
return <span className="text-sm text-muted-foreground">Generating...</span>;
};
const AssistantMessage: FC = () => {
return (
<MessagePrimitive.Root
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto min-w-0 w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto min-w-0 w-full max-w-(--thread-content-max-width) animate-in py-0.5 text-[15.5px] font-[450] duration-150"
data-role="assistant"
>
<div className="aui-assistant-message-content wrap-break-word min-w-0 text-foreground leading-relaxed">
@ -678,9 +701,9 @@ const CopyButton: FC = () => {
const [copied, setCopied] = useState(false);
const resetTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 (
<ActionBarPrimitive.Root
hideWhenRunning={true}
autohide="not-last"
autohide="always"
autohideFloat="single-branch"
className="aui-assistant-action-bar-root col-start-3 row-start-2 -ml-1 flex gap-1 text-muted-foreground data-floating:absolute data-floating:rounded-md data-floating:border data-floating:bg-background data-floating:p-1 data-floating:shadow-sm"
className="aui-assistant-action-bar-root col-start-3 row-start-2 -ml-1 flex gap-1 text-muted-foreground data-floating:absolute"
>
<CopyButton />
<ActionBarPrimitive.Reload asChild={true}>
@ -755,22 +778,22 @@ const UserMessageAudio: FC = () => {
const UserMessage: FC = () => {
return (
<MessagePrimitive.Root
className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto grid w-full max-w-(--thread-max-width) animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2"
className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto flex w-full max-w-(--thread-content-max-width) animate-in flex-col items-end gap-y-2 pt-6 pb-0.5 text-[15.5px] font-[450] duration-150"
data-role="user"
>
<UserMessageAttachments />
<UserMessageAudio />
<div className="aui-user-message-content-wrapper relative col-start-2 min-w-0">
<div className="aui-user-message-content wrap-break-word rounded-2xl bg-muted px-4 py-2.5 text-foreground">
<div className="aui-user-message-content-wrapper flex max-w-[80%] min-w-0 flex-col items-end">
<div className="aui-user-message-content wrap-break-word w-fit rounded-[16px] rounded-tr-[4px] bg-[#f5f5f5] px-4 py-2.5 text-foreground dark:bg-card">
<MessagePrimitive.Parts />
</div>
<div className="aui-user-action-bar-wrapper absolute top-1/2 left-0 -translate-x-full -translate-y-1/2 pr-2">
<div className="mt-1 flex min-h-6">
<UserActionBar />
</div>
</div>
<BranchPicker className="aui-user-branch-picker col-span-full col-start-1 row-start-3 -mr-1 justify-end" />
<BranchPicker className="aui-user-branch-picker -mr-1 justify-end" />
</MessagePrimitive.Root>
);
};
@ -778,8 +801,8 @@ const UserMessage: FC = () => {
const UserActionBar: FC = () => {
return (
<ActionBarPrimitive.Root
autohide="not-last"
className="aui-user-action-bar-root flex items-center"
autohide="always"
className="aui-user-action-bar-root -mr-1 flex gap-1 text-muted-foreground"
>
<CopyButton />
<ActionBarPrimitive.Edit asChild={true}>
@ -805,10 +828,10 @@ const EditComposer: FC = () => {
});
return (
<MessagePrimitive.Root className="aui-edit-composer-wrapper mx-auto flex w-full max-w-(--thread-max-width) flex-col px-2 py-3">
<MessagePrimitive.Root className="aui-edit-composer-wrapper mx-auto flex w-full max-w-(--thread-content-max-width) flex-col py-3">
<ComposerPrimitive.Root className="aui-edit-composer-root ml-auto flex w-full max-w-[85%] flex-col rounded-2xl bg-muted">
<ComposerPrimitive.Input
className="aui-edit-composer-input min-h-14 w-full resize-none bg-transparent p-4 text-foreground text-sm outline-none"
className="aui-edit-composer-input min-h-14 w-full resize-none bg-transparent p-4 text-foreground text-sm font-[450] outline-none"
autoFocus={true}
/>
<div className="aui-edit-composer-footer mx-3 mb-3 flex items-center gap-2 self-end">

View file

@ -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<HTMLDivElement>(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;

View file

@ -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<HTMLDivElement>(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;

View file

@ -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);

View file

@ -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);

View file

@ -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<ReturnType<typeof setTimeout> | 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 (
<div className="flex min-w-0 items-stretch overflow-hidden rounded-md border border-border bg-muted/40">
<input
type="text"
readOnly
value={command}
className="min-w-0 flex-1 bg-transparent px-2 py-1.5 font-mono text-[11px] text-foreground outline-none"
title={command}
aria-label={`${copyLabel} text`}
/>
<button
type="button"
onClick={handleCopy}
className="flex shrink-0 items-center justify-center border-l border-border px-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title={copied ? "Copied" : "Copy command"}
aria-label={copied ? `${copyLabel} copied` : `Copy ${copyLabel}`}
>
{copied ? (
<HugeiconsIcon icon={Tick02Icon} className="size-4 text-emerald-600" />
) : (
<HugeiconsIcon icon={Copy01Icon} className="size-4" />
)}
</button>
</div>
);
}
function UpdateStudioInstructions({
className,
defaultShell,
showTitle = true,
}: {
className?: string;
defaultShell: UpdateShell;
showTitle?: boolean;
}): ReactElement {
const [shell, setShell] = useState<UpdateShell>(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 (
<div className={cn("flex flex-col gap-3", className)}>
<div
className={cn(
"flex items-center gap-3",
showTitle ? "justify-between" : "justify-start",
)}
>
{showTitle ? (
<p className="shrink-0 whitespace-nowrap text-sm font-semibold font-heading">
Update Unsloth Studio
</p>
) : null}
<div className="flex shrink-0 items-center gap-0.5 text-[11px]">
<button
type="button"
onClick={() => setShell("windows")}
className={cn(
"px-0.5 py-0.5 font-medium transition-colors",
windows
? "text-foreground"
: "text-muted-foreground hover:text-emerald-600",
)}
aria-pressed={windows}
>
Windows
</button>
<span className="text-border">/</span>
<button
type="button"
onClick={() => setShell("unix")}
className={cn(
"px-0.5 py-0.5 font-medium transition-colors",
!windows
? "text-foreground"
: "text-muted-foreground hover:text-emerald-600",
)}
aria-pressed={!windows}
>
macOS/Linux
</button>
</div>
</div>
<AnimatePresence mode="wait" initial={false}>
<motion.p
key={`instruction-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
className="text-xs text-muted-foreground leading-relaxed"
>
{getStudioUpdateInstructionLine(shell)}
</motion.p>
</AnimatePresence>
<CopyableCommand command={STUDIO_UPDATE_CMD} copyLabel="update command" />
<p className="text-xs text-muted-foreground leading-relaxed">
If that fails or unsloth studio update is unavailable, run:
</p>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`fallback-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
>
<CopyableCommand
command={
windows
? STUDIO_UPDATE_FALLBACK_WINDOWS_CMD
: STUDIO_UPDATE_FALLBACK_UNIX_CMD
}
copyLabel="fallback command"
/>
</motion.div>
</AnimatePresence>
<p className="text-xs text-muted-foreground leading-relaxed">
Restart Studio after updating for changes to take effect.
</p>
</div>
);
}
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 (
<header className="absolute top-0 inset-x-0 z-40 h-[48px] pointer-events-none" />
);
};
}
return (
<>
<header className="relative top-0 z-40 h-16 w-full">
<div className="mx-auto grid h-full max-w-7xl grid-cols-[1fr_auto_1fr] items-center px-4 sm:px-6">
{/* Left: logo */}
<Link to={chatOnly ? "/chat" : "/studio"} className="flex items-center gap-1.5 justify-self-start select-none">
<img
src="/blacklogo.png"
alt="Unsloth"
className="h-9 w-auto dark:hidden"
/>
<img
src="/whitelogo.png"
alt="Unsloth"
className="hidden h-9 w-auto dark:block"
/>
<span className="relative -top-[1px] inline-flex items-center text-[10px] font-extrabold leading-none tracking-[0.12em] text-primary">
BETA
</span>
</Link>
{/* Center: pill nav */}
<nav
data-tour="navbar"
className="hidden items-center rounded-full border border-border bg-card p-1 ring-1 ring-foreground/5 md:flex"
>
{NAV_ITEMS.map((item) => {
const active =
pathname === item.href || pathname.startsWith(`${item.href}/`);
const disabledByTraining =
isTrainingRunning && item.href !== "/studio";
const disabledByDevice =
chatOnly && item.href !== "/chat" && item.href !== "/data-recipes";
if (!item.enabled || disabledByTraining || disabledByDevice) {
return (
<span
key={item.href}
className="relative rounded-full px-3 py-1.5 text-sm font-medium text-muted-foreground/40 cursor-not-allowed"
>
{item.label}
</span>
);
}
return (
<Link
key={item.href}
to={item.href}
className={cn(
"relative rounded-full px-3 py-1.5 text-sm font-medium transition-colors",
active
? "text-background"
: "text-muted-foreground hover:text-foreground",
)}
>
{active && (
<motion.span
layoutId="nav-pill"
className="absolute inset-0 rounded-full bg-foreground"
transition={{
type: "spring",
stiffness: 500,
damping: 35,
mass: 0.5,
}}
/>
)}
<span className="relative z-10 flex items-center">
<motion.span
initial={false}
animate={{
width: active ? 14 : 0,
marginLeft: active ? -4 : 0,
marginRight: active ? 4 : 0,
opacity: active ? 1 : 0,
}}
transition={{ duration: 0.2, ease: [0.165, 0.84, 0.44, 1] }}
className="inline-flex shrink-0 items-center justify-center overflow-hidden"
>
<HugeiconsIcon
icon={item.icon}
className="size-3.5 -mt-px shrink-0"
/>
</motion.span>
{item.label}
</span>
</Link>
);
})}
</nav>
{/* Right: docs/tour desktop — one wrapper per control so flex gap is even (HoverCard roots can confuse flex spacing). */}
<div className="hidden items-center justify-self-end gap-0 md:flex">
<div className="flex shrink-0 items-center">
<AnimatedThemeToggler
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground [&_svg]:size-4"
title="Toggle theme"
aria-label="Toggle theme"
/>
</div>
<div className="flex shrink-0 items-center">
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild={true}>
<a
href="https://unsloth.ai/docs"
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-9 items-center gap-1.5 rounded-md px-3 text-sm font-medium text-emerald-600 transition-colors hover:bg-accent hover:text-emerald-700 dark:hover:text-emerald-400"
>
<HugeiconsIcon icon={Book03Icon} className="size-4" />
Learn more
</a>
</HoverCardTrigger>
<HoverCardContent align="end" className="w-80 p-0">
<a
href="https://unsloth.ai/docs"
target="_blank"
rel="noopener noreferrer"
className="group/card flex flex-col gap-1 p-4 no-underline"
>
<p className="text-sm font-semibold font-heading">
Unsloth Documentation
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
Guides on fine-tuning LLMs 2x faster with 70% less memory.
Covers LoRA, QLoRA, data formatting, and deployment.
</p>
<span className="mt-1 flex items-center gap-1 text-xs font-medium text-emerald-600 group-hover/card:underline">
Visit docs
<HugeiconsIcon icon={ArrowRight01Icon} className="size-3" />
</span>
</a>
</HoverCardContent>
</HoverCard>
</div>
<div className="flex shrink-0 items-center">
<Link
to="/api-keys"
className={cn(
"flex h-9 items-center gap-1.5 rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent",
pathname === "/api-keys"
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<HugeiconsIcon icon={Key01Icon} className="size-4" />
API Keys
</Link>
</div>
{tourId ? (
<div className="flex shrink-0 items-center">
<button
type="button"
onClick={openTour}
className="flex h-9 items-center gap-1.5 rounded-md px-3 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Tour"
>
<HugeiconsIcon icon={CursorInfo02Icon} className="size-4" />
<span className="text-sm font-medium">Tour</span>
</button>
</div>
) : null}
<div className="flex shrink-0 items-center">
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild={true}>
<button
type="button"
className="flex h-9 items-center gap-1.5 rounded-md px-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label="How to update Unsloth Studio"
>
<HugeiconsIcon icon={ArrowReloadHorizontalIcon} className="size-4" />
Update
</button>
</HoverCardTrigger>
<HoverCardContent align="end" className="w-[22.5rem] p-0">
<UpdateStudioInstructions
className="p-4"
defaultShell={defaultUpdateShell}
/>
</HoverCardContent>
</HoverCard>
</div>
<div className="flex shrink-0 items-center">
<button
type="button"
onClick={() => setShutdownOpen(true)}
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Shut down Unsloth Studio server"
aria-label="Shut down Unsloth Studio server"
>
<HugeiconsIcon icon={Cancel01Icon} className="size-5" />
</button>
</div>
</div>
{/* Right: mobile */}
<div className="col-start-3 flex items-center gap-2 justify-self-end md:hidden">
{tourId ? (
<button
type="button"
onClick={openTour}
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Tour"
>
<HugeiconsIcon icon={CursorInfo02Icon} className="size-4" />
</button>
) : null}
<Sheet
open={mobileOpen}
onOpenChange={(open) => {
setMobileOpen(open);
if (!open) setMobileUpdateOpen(false);
}}
>
<SheetTrigger asChild={true}>
<button
type="button"
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground"
aria-label="Open navigation menu"
>
Menu
</button>
</SheetTrigger>
<SheetContent side="right" className="w-[300px] p-4">
<SheetHeader>
<SheetTitle>Navigate</SheetTitle>
</SheetHeader>
<div className="mt-6 flex max-h-[calc(100dvh-8rem)] flex-col gap-2 overflow-y-auto pr-1">
{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 (
<span
key={item.href}
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground/40 cursor-not-allowed"
>
<HugeiconsIcon icon={item.icon} className="size-4" />
{item.label}
</span>
);
}
return (
<Link
key={item.href}
to={item.href}
onClick={() => 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",
)}
>
<HugeiconsIcon icon={item.icon} className="size-4" />
{item.label}
</Link>
);
})}
<Link
to="/api-keys"
onClick={() => 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",
)}
>
<HugeiconsIcon icon={Key01Icon} className="size-4" />
API Keys
</Link>
<a
href="https://unsloth.ai/docs"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-foreground hover:bg-accent"
onClick={() => setMobileOpen(false)}
>
<HugeiconsIcon icon={Book03Icon} className="size-4" />
Learn more (Docs)
</a>
{tourId ? (
<button
type="button"
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-left text-sm font-medium text-foreground hover:bg-accent"
onClick={() => {
openTour();
setMobileOpen(false);
}}
>
<HugeiconsIcon icon={CursorInfo02Icon} className="size-4" />
Start tour
</button>
) : null}
<Collapsible
open={mobileUpdateOpen}
onOpenChange={setMobileUpdateOpen}
className="rounded-md border border-border"
>
<CollapsibleTrigger asChild={true}>
<button
type="button"
className="flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm font-medium text-foreground transition-colors hover:bg-accent"
aria-label="Toggle update instructions"
>
<span className="flex items-center gap-2">
<HugeiconsIcon icon={ArrowReloadHorizontalIcon} className="size-4" />
Update Unsloth Studio
</span>
<HugeiconsIcon
icon={ArrowRight01Icon}
className={cn(
"size-4 text-muted-foreground transition-transform",
mobileUpdateOpen && "rotate-90",
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-t border-border p-3 pt-2">
<UpdateStudioInstructions
defaultShell={defaultUpdateShell}
showTitle={false}
/>
</CollapsibleContent>
</Collapsible>
<button
type="button"
className="mt-3 flex items-center gap-2 rounded-md border border-border px-3 py-2 text-left text-sm font-medium text-foreground hover:bg-accent"
onClick={() => {
setMobileOpen(false);
setShutdownOpen(true);
}}
>
<HugeiconsIcon icon={Cancel01Icon} className="size-5" />
Quit Unsloth Studio
</button>
<div className="mt-2 flex items-center justify-between rounded-md border border-border px-3 py-2">
<span className="text-sm font-medium text-foreground">Theme</span>
<AnimatedThemeToggler
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground [&_svg]:size-4"
title="Toggle theme"
aria-label="Toggle theme"
/>
</div>
</div>
</SheetContent>
</Sheet>
</div>
<header className="absolute top-0 inset-x-0 z-40 h-[48px] pointer-events-none">
<div className="flex h-full items-start pt-[11px] pl-2">
<SidebarTrigger className="pointer-events-auto !size-[34px]" />
</div>
</header>
<ShutdownDialog
open={shutdownOpen}
onOpenChange={setShutdownOpen}
onBeforeShutdown={removeUnloadHandler}
/>
</>
);
}

View file

@ -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 = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;gap:12px">
<p style="font-size:1.1rem;font-weight:600;margin:0">Unsloth Studio has stopped.</p>

View file

@ -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<HTMLElement | null>(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()

View file

@ -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<typeof Dialog> & {
title?: string;
description?: string;
className?: string;
overlayClassName?: string;
showCloseButton?: boolean;
}) {
return (
@ -55,9 +57,10 @@ function CommandDialog({
</DialogHeader>
<DialogContent
className={cn(
"rounded-4xl! p-0 top-1/3 translate-y-0 overflow-hidden p-0",
"rounded-4xl! top-1/3 translate-y-0 overflow-hidden p-0",
className,
)}
overlayClassName={overlayClassName}
showCloseButton={showCloseButton}
>
{children}

View file

@ -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<SidebarContextProps | null>(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<SidebarContextProps>(
() => ({
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}
>
<SheetHeader className="sr-only">
@ -210,7 +243,11 @@ function Sidebar({
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
className={cn(
"group peer text-sidebar-foreground relative shrink-0",
hasPinMode && pinned && "w-(--sidebar-width)",
hasPinMode && !pinned && "w-(--sidebar-width-icon)",
)}
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
@ -221,23 +258,45 @@ function Sidebar({
<div
data-slot="sidebar-gap"
className={cn(
"transition-[width] duration-200 ease-linear relative w-(--sidebar-width) bg-transparent",
"group-data-[collapsible=offcanvas]:w-0",
"relative bg-transparent shrink-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
hasPinMode
? cn(
// Pin mode: always push content. Expanded when pinned.
pinned
? "w-(--sidebar-width)"
: (variant === "floating" || variant === "inset"
? "w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "w-(--sidebar-width-icon)"),
)
: cn(
// Legacy mode: original shadcn behavior.
"w-(--sidebar-width)",
"group-data-[collapsible=offcanvas]:w-0",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
),
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
hasPinMode
? cn(
// Pin mode: always push content, full height.
"absolute top-0 bottom-0 flex w-(--sidebar-width) data-[side=left]:left-0",
"group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)
: cn(
// Legacy mode: fixed to viewport (original shadcn behavior).
"fixed inset-y-0 z-10 flex h-svh w-(--sidebar-width) data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
),
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
: !hasPinMode && "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
@ -245,7 +304,11 @@ function Sidebar({
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:ring-sidebar-border group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 flex size-full flex-col"
className={cn(
"bg-sidebar flex size-full flex-col overflow-hidden border-r border-sidebar-border",
"group-data-[variant=floating]:ring-sidebar-border group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1",
hasPinMode && "ring-1 ring-sidebar-border/60",
)}
>
{children}
</div>
@ -274,7 +337,7 @@ function SidebarTrigger({
}}
{...props}
>
<HugeiconsIcon icon={SidebarLeftIcon} strokeWidth={2} />
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-[18px]" />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
@ -310,7 +373,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
<main
data-slot="sidebar-inset"
className={cn(
"bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 relative flex w-full flex-1 flex-col",
"bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 relative flex min-h-0 w-full flex-1 flex-col",
className
)}
{...props}
@ -374,7 +437,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
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">) {
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("gap-1 flex w-full min-w-0 flex-col", className)}
className={cn("gap-0.5 flex w-full min-w-0 flex-col", className)}
{...props}
/>
)
@ -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: {

View file

@ -79,7 +79,7 @@ function Slider({
>
<SliderPrimitive.Track
data-slot="slider-track"
className="bg-muted rounded-4xl data-horizontal:h-3 data-horizontal:w-full data-vertical:h-full data-vertical:w-3 bg-muted relative grow overflow-hidden data-horizontal:w-full data-vertical:h-full cursor-pointer"
className="bg-black/10 dark:bg-black/12 rounded-4xl data-horizontal:h-2 data-horizontal:w-full data-vertical:h-full data-vertical:w-2 relative grow overflow-hidden cursor-pointer"
>
<SliderPrimitive.Range
data-slot="slider-range"
@ -103,7 +103,7 @@ function Slider({
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 relative z-10 size-4 rounded-4xl border bg-white shadow-sm block shrink-0 select-none cursor-pointer disabled:pointer-events-none disabled:opacity-50 transition-transform duration-100 ease-out hover:scale-110 hover:ring-4 active:scale-95 focus-visible:ring-4 focus-visible:outline-hidden"
className="ring-ring/50 relative z-10 size-4 rounded-4xl bg-white shadow-sm block shrink-0 select-none cursor-pointer disabled:pointer-events-none disabled:opacity-50 transition-transform duration-100 ease-out hover:scale-110 hover:ring-4 active:scale-95 focus-visible:ring-4 focus-visible:outline-hidden"
/>
))}
</SliderPrimitive.Root>

View file

@ -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 (
<textarea
data-slot="textarea"
className={cn(
"border-input bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 resize-none rounded-xl border px-3 py-3 text-base transition-colors focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm placeholder:text-muted-foreground flex field-sizing-content min-h-16 w-full outline-none disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
import type * as React from "react";
import { cn } from "@/lib/utils";
type TextareaProps = React.ComponentProps<"textarea"> & {
fieldSizing?: "content" | "fixed";
};
function Textarea({
className,
fieldSizing = "content",
...props
}: TextareaProps) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 resize-none rounded-xl border px-3 py-3 text-base transition-colors focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm placeholder:text-muted-foreground flex min-h-16 min-w-0 max-w-full w-full whitespace-pre-wrap break-words [overflow-wrap:anywhere] outline-none disabled:cursor-not-allowed disabled:opacity-50",
fieldSizing === "content" ? "field-sizing-content" : "[field-sizing:fixed]",
className,
)}
{...props}
/>
);
}

View file

@ -1,426 +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 { DashboardLayout } from "@/components/layout/dashboard-layout";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { copyToClipboardAsync } from "@/lib/copy-to-clipboard";
import {
AlertCircleIcon,
Copy01Icon,
Delete02Icon,
Key01Icon,
Tick02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { authFetch } from "./api";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface ApiKey {
id: number;
name: string;
key_prefix: string;
created_at: string;
last_used_at: string | null;
expires_at: string | null;
is_active: boolean;
}
// ---------------------------------------------------------------------------
// API helpers
// ---------------------------------------------------------------------------
async function fetchApiKeys(): Promise<ApiKey[]> {
const res = await authFetch("/api/auth/api-keys");
if (!res.ok) throw new Error("Failed to load API keys");
const data = (await res.json()) as { api_keys: ApiKey[] };
return data.api_keys;
}
async function createApiKey(
name: string,
expiresInDays: number | null,
): Promise<{ key: string; api_key: ApiKey }> {
const res = await authFetch("/api/auth/api-keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
expires_in_days: expiresInDays,
}),
});
if (!res.ok) throw new Error("Failed to create API key");
return res.json();
}
async function revokeApiKey(keyId: number): Promise<void> {
const res = await authFetch(`/api/auth/api-keys/${keyId}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("Failed to revoke API key");
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatDate(iso: string | null): string {
if (!iso) return "--";
const d = new Date(iso);
return d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
// ---------------------------------------------------------------------------
// Components
// ---------------------------------------------------------------------------
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const handleCopy = async () => {
if (!(await copyToClipboardAsync(text))) return;
setCopied(true);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 2000);
};
return (
<Button
variant="ghost"
size="icon-sm"
onClick={handleCopy}
className={cn(
"shrink-0 rounded-md text-muted-foreground hover:text-foreground",
copied && "text-emerald-600 hover:text-emerald-600",
)}
aria-label={copied ? "Copied API key" : "Copy API key"}
title={copied ? "Copied" : "Copy"}
>
<HugeiconsIcon
icon={copied ? Tick02Icon : Copy01Icon}
className="size-4"
/>
</Button>
);
}
function RevealKeyDialog({
open,
rawKey,
onClose,
}: {
open: boolean;
rawKey: string;
onClose: () => void;
}) {
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>API Key Created</DialogTitle>
<DialogDescription>
Copy this key now. It will not be shown again.
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 p-3">
<code className="min-w-0 flex-1 break-all font-mono text-sm">
{rawKey}
</code>
<CopyButton text={rawKey} />
</div>
<div className="flex items-start gap-2 rounded-md border border-amber-500/20 bg-amber-50 p-3 text-amber-800 dark:border-amber-400/20 dark:bg-amber-950/30 dark:text-amber-300">
<HugeiconsIcon icon={AlertCircleIcon} className="mt-0.5 size-4 shrink-0" />
<p className="text-xs leading-relaxed">
Store this key securely. You will not be able to see it again after closing this dialog.
</p>
</div>
<DialogFooter>
<Button onClick={onClose}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function CreateKeyForm({ onCreated }: { onCreated: (rawKey: string) => void }) {
const [name, setName] = useState("");
const [expiresInDays, setExpiresInDays] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
setLoading(true);
try {
const days = expiresInDays ? parseInt(expiresInDays, 10) : null;
const result = await createApiKey(name.trim(), days);
onCreated(result.key);
setName("");
setExpiresInDays("");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4 rounded-lg border border-border p-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="key-name">Key name</Label>
<Input
id="key-name"
placeholder="e.g. My application"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="key-expiry">Expires in (days)</Label>
<Input
id="key-expiry"
type="number"
min={1}
placeholder="Leave blank for no expiry"
value={expiresInDays}
onChange={(e) => setExpiresInDays(e.target.value)}
/>
</div>
<Button type="submit" disabled={loading || !name.trim()} className="self-start">
{loading ? "Creating..." : "Create API key"}
</Button>
</form>
);
}
function KeysTable({
keys,
onRevoke,
}: {
keys: ApiKey[];
onRevoke: (id: number) => void;
}) {
if (keys.length === 0) {
return (
<p className="py-8 text-center text-sm text-muted-foreground">
No API keys yet. Create one above.
</p>
);
}
return (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/40">
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Name</th>
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Key</th>
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Created</th>
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Last used</th>
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Expires</th>
<th className="px-4 py-2.5 text-right font-medium text-muted-foreground" />
</tr>
</thead>
<tbody>
{keys.map((k) => (
<tr
key={k.id}
className={cn(
"border-b border-border last:border-b-0",
!k.is_active && "opacity-50",
)}
>
<td className="px-4 py-2.5 font-medium">{k.name}</td>
<td className="px-4 py-2.5">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
sk-unsloth-{k.key_prefix}...
</code>
</td>
<td className="px-4 py-2.5 text-muted-foreground">{formatDate(k.created_at)}</td>
<td className="px-4 py-2.5 text-muted-foreground">{formatDate(k.last_used_at)}</td>
<td className="px-4 py-2.5 text-muted-foreground">{formatDate(k.expires_at)}</td>
<td className="px-4 py-2.5 text-right">
{k.is_active ? (
<Button
variant="ghost"
size="sm"
onClick={() => onRevoke(k.id)}
className="text-destructive hover:text-destructive"
>
<HugeiconsIcon icon={Delete02Icon} className="size-3.5 mr-1" />
Revoke
</Button>
) : (
<span className="text-xs text-muted-foreground">Revoked</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function UsageExamples() {
const base = window.location.origin;
const curlExample = `curl ${base}/v1/chat/completions \\
-H "Authorization: Bearer sk-unsloth-YOUR_KEY" \\
-H "Content-Type: application/json" \\
-d '{
"messages": [{"role": "user", "content": "Hello"}],
"stream": true
}'`;
const pythonExample = `from openai import OpenAI
client = OpenAI(
base_url="${base}/v1",
api_key="sk-unsloth-YOUR_KEY",
)
response = client.chat.completions.create(
model="current",
messages=[{"role": "user", "content": "Hello"}],
stream=True,
)
for chunk in response:
print(chunk.choices[0].delta.content or "", end="")`;
const toolsExample = `curl ${base}/v1/chat/completions \\
-H "Authorization: Bearer sk-unsloth-YOUR_KEY" \\
-H "Content-Type: application/json" \\
-d '{
"messages": [{"role": "user", "content": "Search for Python 3.13 features"}],
"stream": true,
"enable_tools": true,
"enabled_tools": ["web_search", "python"],
"session_id": "my-session"
}'`;
return (
<div className="flex flex-col gap-4">
<h3 className="text-sm font-semibold">Usage examples</h3>
<div className="flex flex-col gap-3">
<div>
<p className="mb-1.5 text-xs font-medium text-muted-foreground">curl</p>
<pre className="overflow-x-auto rounded-md border border-border bg-muted/40 p-3 font-mono text-xs leading-relaxed">
{curlExample}
</pre>
</div>
<div>
<p className="mb-1.5 text-xs font-medium text-muted-foreground">Python (OpenAI SDK)</p>
<pre className="overflow-x-auto rounded-md border border-border bg-muted/40 p-3 font-mono text-xs leading-relaxed">
{pythonExample}
</pre>
</div>
<div>
<p className="mb-1.5 text-xs font-medium text-muted-foreground">With tools (web search + code execution)</p>
<pre className="overflow-x-auto rounded-md border border-border bg-muted/40 p-3 font-mono text-xs leading-relaxed">
{toolsExample}
</pre>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export function ApiKeysPage() {
const [keys, setKeys] = useState<ApiKey[]>([]);
const [revealedKey, setRevealedKey] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const loadKeys = useCallback(async () => {
try {
setError(null);
const loaded = await fetchApiKeys();
setKeys(loaded);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load API keys");
}
}, []);
useEffect(() => {
void loadKeys();
}, [loadKeys]);
const handleCreated = (rawKey: string) => {
setRevealedKey(rawKey);
void loadKeys();
};
const handleRevoke = async (keyId: number) => {
try {
await revokeApiKey(keyId);
void loadKeys();
} catch {
setError("Failed to revoke key");
}
};
return (
<DashboardLayout>
<div className="flex flex-col gap-8">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-border bg-muted/40">
<HugeiconsIcon icon={Key01Icon} className="size-5" />
</div>
<div>
<h1 className="text-xl font-bold font-heading">API Keys</h1>
<p className="text-sm text-muted-foreground">
Create keys to access Unsloth Studio programmatically via the OpenAI-compatible API.
</p>
</div>
</div>
{error && (
<div className="flex items-center gap-2 rounded-md border border-destructive/20 bg-destructive/5 p-3 text-sm text-destructive">
<HugeiconsIcon icon={AlertCircleIcon} className="size-4 shrink-0" />
{error}
</div>
)}
<CreateKeyForm onCreated={handleCreated} />
<KeysTable keys={keys} onRevoke={handleRevoke} />
<UsageExamples />
</div>
<RevealKeyDialog
open={revealedKey !== null}
rawKey={revealedKey ?? ""}
onClose={() => setRevealedKey(null)}
/>
</DashboardLayout>
);
}

View file

@ -1,11 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export { ApiKeysPage } from "./api-keys-page";
export { LoginPage } from "./login-page";
export { ChangePasswordPage } from "./change-password-page";
export { authFetch, refreshSession } from "./api";
export {
getAuthToken,
getPostAuthRoute,
hasAuthToken,
hasRefreshToken,

View file

@ -8,7 +8,7 @@ export const AUTH_REFRESH_TOKEN_KEY = "unsloth_auth_refresh_token";
export const ONBOARDING_DONE_KEY = "unsloth_onboarding_done";
export const AUTH_MUST_CHANGE_PASSWORD_KEY = "unsloth_auth_must_change_password";
type PostAuthRoute = "/onboarding" | "/studio" | "/change-password" | "/chat";
type PostAuthRoute = "/change-password" | "/chat";
function canUseStorage(): boolean {
return typeof window !== "undefined";
@ -80,5 +80,5 @@ export function resetOnboardingDone(): void {
export function getPostAuthRoute(): PostAuthRoute {
if (mustChangePassword()) return "/change-password";
if (usePlatformStore.getState().isChatOnly()) return "/chat";
return isOnboardingDone() ? "/studio" : "/onboarding";
return "/chat";
}

View file

@ -455,13 +455,13 @@ async function autoLoadSmallestModel(): Promise<{
// No cached models found — try downloading a small default GGUF
toast("Downloading a small model…", {
id: toastId,
description: "No downloaded models found. Fetching Qwen3.5-4B (UD-Q4_K_XL).",
description: "No downloaded models found. Fetching Gemma-4-E2B-it (UD-Q4_K_XL).",
duration: 30000,
});
try {
if (
!(await canAutoLoad({
model_path: "unsloth/Qwen3.5-4B-GGUF",
model_path: "unsloth/gemma-4-E2B-it-GGUF",
max_seq_length: 0,
is_lora: false,
gguf_variant: "UD-Q4_K_XL",
@ -471,7 +471,7 @@ async function autoLoadSmallestModel(): Promise<{
return { loaded: false, blockedByTrustRemoteCode };
}
const loadResp = await loadModel({
model_path: "unsloth/Qwen3.5-4B-GGUF",
model_path: "unsloth/gemma-4-E2B-it-GGUF",
hf_token: hfToken,
max_seq_length: 0,
load_in_4bit: true,
@ -479,20 +479,20 @@ async function autoLoadSmallestModel(): Promise<{
gguf_variant: "UD-Q4_K_XL",
trust_remote_code: trustRemoteCode,
});
useChatRuntimeStore.getState().setCheckpoint("unsloth/Qwen3.5-4B-GGUF", "UD-Q4_K_XL");
useChatRuntimeStore.getState().setCheckpoint("unsloth/gemma-4-E2B-it-GGUF", "UD-Q4_K_XL");
const store = useChatRuntimeStore.getState();
store.setModelRequiresTrustRemoteCode(
loadResp.requires_trust_remote_code ?? false,
);
store.setParams({ ...store.params, maxTokens: loadResp.context_length ?? 131072 });
const defaultModel: ChatModelSummary = {
id: "unsloth/Qwen3.5-4B-GGUF",
name: loadResp.display_name ?? "Qwen3.5-4B-GGUF",
id: "unsloth/gemma-4-E2B-it-GGUF",
name: loadResp.display_name ?? "gemma-4-E2B-it-GGUF",
isVision: loadResp.is_vision ?? false,
isLora: false,
isGguf: true,
};
if (!store.models.some((m) => m.id === "unsloth/Qwen3.5-4B-GGUF")) {
if (!store.models.some((m) => m.id === "unsloth/gemma-4-E2B-it-GGUF")) {
store.setModels([...store.models, defaultModel]);
}
useChatRuntimeStore.setState({
@ -509,7 +509,7 @@ async function autoLoadSmallestModel(): Promise<{
defaultChatTemplate: loadResp.chat_template ?? null,
chatTemplateOverride: null,
});
toast.success("Loaded Qwen3.5-4B (UD-Q4_K_XL)", { id: toastId });
toast.success("Loaded Gemma-4-E2B-it (UD-Q4_K_XL)", { id: toastId });
return { loaded: true, blockedByTrustRemoteCode: false };
} catch {
toast.dismiss(toastId);

View file

@ -7,36 +7,21 @@ import {
ModelSelector,
} from "@/components/assistant-ui/model-selector";
import { Thread } from "@/components/assistant-ui/thread";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
SidebarProvider,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { cn } from "@/lib/utils";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { useSidebar } from "@/components/ui/sidebar";
import {
ColumnInsertIcon,
PencilEdit02Icon,
Settings04Icon,
Settings05Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
type CSSProperties,
Tooltip,
TooltipContent,
} from "@/components/ui/tooltip";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { useNavigate, useSearch } from "@tanstack/react-router";
import {
type ReactElement,
type ReactNode,
memo,
useCallback,
useEffect,
@ -45,6 +30,7 @@ import {
useState,
} from "react";
import { toast } from "sonner";
import type { ChatSearch } from "@/app/routes/chat";
import { listLocalModels } from "./api/chat-api";
import { ChatSettingsPanel } from "./chat-settings-sheet";
import { ContextUsageBar } from "./components/context-usage-bar";
@ -58,12 +44,12 @@ import {
import { ChatRuntimeProvider } from "./runtime-provider";
import {
type CompareHandle,
type CompareHandles,
CompareHandlesProvider,
RegisterCompareHandle,
SharedComposer,
} from "./shared-composer";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import { ThreadSidebar } from "./thread-sidebar";
import { buildChatTourSteps } from "./tour";
import type { ChatView, MessageRecord } from "./types";
@ -135,7 +121,7 @@ const SingleContent = memo(function SingleContent({
initialThreadId={threadId}
newThreadNonce={newThreadNonce}
>
<div className="min-h-0 flex-1">
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
<Thread />
</div>
</ChatRuntimeProvider>
@ -186,6 +172,96 @@ const CompareContent = memo(function CompareContent({
);
});
/**
* A single column in the compare layout. Hosts one ChatRuntimeProvider
* and one Thread rendered with hideComposer the composer is shared
* across both panes and rendered outside the pane flex.
*
* Each pane is a flex item with `flex-1 basis-0 min-h-0 min-w-0` so on
* mobile (flex-col) they share height equally, and on desktop (flex-row)
* they share width equally. The `min-*` constraints are required for
* the inner viewport to scroll internally instead of spilling into the
* page.
*/
function ComparePane({
modelType,
pairId,
initialThreadId,
handleName,
header,
borderClassName,
}: {
modelType: "base" | "lora" | "model1" | "model2";
pairId: string;
initialThreadId: string | undefined;
handleName: string;
header: ReactElement;
borderClassName?: string;
}): ReactElement {
return (
<div
className={cn(
"flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden",
borderClassName,
)}
>
{header}
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden [&_.aui-thread-viewport]:px-6 lg:[&_.aui-thread-viewport]:px-10">
<ChatRuntimeProvider
modelType={modelType}
pairId={pairId}
initialThreadId={initialThreadId}
syncActiveThreadId={false}
>
<RegisterCompareHandle name={handleName} />
<Thread hideComposer={true} hideWelcome={true} />
</ChatRuntimeProvider>
</div>
</div>
);
}
/**
* Shared shell for both compare variants. A vertical flex column with
* the two panes as siblings and the shared composer docked at the
* bottom. On mobile the panes stack (flex-col); on desktop they sit
* side by side (md:flex-row).
*
* Flex is used rather than CSS grid for the pane container so that
* viewport sizing stays stable across viewport-size transitions. Grid
* rows with 1fr were triggering resize thrash in assistant-ui's
* autoscroll hook on breakpoint crossings, leaving it stuck in a
* scroll-to-bottom loop.
*/
function CompareShell({
handlesRef,
children,
composer,
}: {
handlesRef: CompareHandles;
children: ReactElement;
composer: ReactElement;
}): ReactElement {
return (
<CompareHandlesProvider handlesRef={handlesRef}>
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col">
<div
data-tour="chat-compare-view"
className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col md:flex-row"
>
{children}
</div>
<div className="shrink-0 bg-background px-5 pb-2 pt-1">
<div className="mx-auto w-full max-w-[44rem]">{composer}</div>
<p className="mt-1.5 text-center text-[11px] text-muted-foreground">
LLMs can make mistakes. Double-check all responses.
</p>
</div>
</div>
</CompareHandlesProvider>
);
}
/** Fast path: same model, adapter on/off, simultaneous generation. */
const LoraCompareContent = memo(function LoraCompareContent({
pairId,
@ -211,57 +287,87 @@ const LoraCompareContent = memo(function LoraCompareContent({
}, [pairId]);
return (
<CompareHandlesProvider handlesRef={handlesRef}>
<div className="flex min-h-0 flex-1 flex-col">
<div
data-tour="chat-compare-view"
className="grid min-h-0 flex-1 grid-cols-1 px-0 md:grid-cols-2"
>
<div className="flex min-h-0 flex-col">
<div className="px-3 py-1.5">
<CompareShell
handlesRef={handlesRef}
composer={<SharedComposer handlesRef={handlesRef} />}
>
<>
<ComparePane
modelType="base"
pairId={pairId}
initialThreadId={baseThreadId}
handleName="base"
header={
<div className="shrink-0 px-3 py-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Base Model
</span>
</div>
<div className="min-h-0 flex-1">
<ChatRuntimeProvider
modelType="base"
pairId={pairId}
initialThreadId={baseThreadId}
syncActiveThreadId={false}
>
<RegisterCompareHandle name="base" />
<Thread hideComposer={true} hideWelcome={true} />
</ChatRuntimeProvider>
</div>
</div>
<div className="flex min-h-0 flex-col border-t border-border/60 md:border-t-0 md:border-l">
<div className="px-3 py-1.5 text-start md:text-end">
}
/>
<ComparePane
modelType="lora"
pairId={pairId}
initialThreadId={loraThreadId}
handleName="lora"
borderClassName="border-t border-border/60 md:border-t-0 md:border-l"
header={
<div className="shrink-0 px-3 py-1.5 text-start md:text-end">
<span className="text-[10px] font-semibold uppercase tracking-wider text-primary">
Fine-tuned
</span>
</div>
<div className="min-h-0 flex-1">
<ChatRuntimeProvider
modelType="lora"
pairId={pairId}
initialThreadId={loraThreadId}
syncActiveThreadId={false}
>
<RegisterCompareHandle name="lora" />
<Thread hideComposer={true} hideWelcome={true} />
</ChatRuntimeProvider>
</div>
</div>
</div>
<div className="z-20 mx-auto w-full max-w-4xl shrink-0 border-t border-border/60 bg-background px-4 pt-2 pb-4">
<SharedComposer handlesRef={handlesRef} />
</div>
</div>
</CompareHandlesProvider>
}
/>
</>
</CompareShell>
);
});
/**
* Per-pane header rendered inside GeneralCompareContent. Contains the
* model selector aligned with the global topbar height. The left pane
* reserves room for the mobile sidebar trigger; the right pane reserves
* room for the global settings button.
*/
function GeneralCompareHeader({
models,
loraModels,
value,
onValueChange,
onFoldersChange,
side,
}: {
models: ModelOption[];
loraModels: LoraModelOption[];
value: string;
onValueChange: (
id: string,
meta: { isLora: boolean; ggufVariant?: string },
) => void;
onFoldersChange?: () => void;
side: "left" | "right";
}): ReactElement {
return (
<div
className={cn(
"flex h-[48px] shrink-0 items-center gap-2 bg-background",
side === "left" ? "pl-12 pr-3 md:pl-2" : "pl-3 pr-12",
)}
>
<ModelSelector
models={models}
loraModels={loraModels}
value={value}
onValueChange={onValueChange}
onFoldersChange={onFoldersChange}
variant="ghost"
className="max-w-[80%] !h-[34px]"
/>
</div>
);
}
/** General path: any two models, sequential load → generate. */
const GeneralCompareContent = memo(function GeneralCompareContent({
pairId,
@ -315,197 +421,80 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
}, [pairId]);
return (
<CompareHandlesProvider handlesRef={handlesRef}>
<div className="flex min-h-0 flex-1 flex-col">
<div
data-tour="chat-compare-view"
className="grid min-h-0 flex-1 grid-cols-1 px-0 md:grid-cols-2"
>
<div className="flex min-h-0 flex-col">
<div className="flex items-center gap-2 px-3 py-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Model 1
</span>
<ModelSelector
models={models}
loraModels={loraModels}
value={model1.id}
onValueChange={(id, meta) =>
setModel1({
id,
isLora: meta.isLora,
ggufVariant: meta.ggufVariant,
})
}
onFoldersChange={onFoldersChange}
variant="ghost"
size="sm"
className="max-w-[50%]"
/>
</div>
<div className="min-h-0 flex-1">
<ChatRuntimeProvider
modelType="model1"
pairId={pairId}
initialThreadId={model1ThreadId}
syncActiveThreadId={false}
>
<RegisterCompareHandle name="model1" />
<Thread hideComposer={true} hideWelcome={true} />
</ChatRuntimeProvider>
</div>
</div>
<div className="flex min-h-0 flex-col border-t border-border/60 md:border-t-0 md:border-l">
<div className="flex items-center gap-2 px-3 py-1.5 md:justify-end">
<span className="text-[10px] font-semibold uppercase tracking-wider text-primary">
Model 2
</span>
<ModelSelector
models={models}
loraModels={loraModels}
value={model2.id}
onValueChange={(id, meta) =>
setModel2({
id,
isLora: meta.isLora,
ggufVariant: meta.ggufVariant,
})
}
onFoldersChange={onFoldersChange}
variant="ghost"
size="sm"
className="max-w-[50%]"
/>
</div>
<div className="min-h-0 flex-1">
<ChatRuntimeProvider
modelType="model2"
pairId={pairId}
initialThreadId={model2ThreadId}
syncActiveThreadId={false}
>
<RegisterCompareHandle name="model2" />
<Thread hideComposer={true} hideWelcome={true} />
</ChatRuntimeProvider>
</div>
</div>
</div>
<div className="z-20 mx-auto w-full max-w-4xl shrink-0 border-t border-border/60 bg-background px-4 pt-2 pb-4">
<SharedComposer
handlesRef={handlesRef}
model1={model1}
model2={model2}
/>
</div>
</div>
</CompareHandlesProvider>
<CompareShell
handlesRef={handlesRef}
composer={
<SharedComposer
handlesRef={handlesRef}
model1={model1}
model2={model2}
/>
}
>
<>
<ComparePane
modelType="model1"
pairId={pairId}
initialThreadId={model1ThreadId}
handleName="model1"
header={
<GeneralCompareHeader
side="left"
models={models}
loraModels={loraModels}
value={model1.id}
onValueChange={(id, meta) =>
setModel1({
id,
isLora: meta.isLora,
ggufVariant: meta.ggufVariant,
})
}
onFoldersChange={onFoldersChange}
/>
}
/>
<ComparePane
modelType="model2"
pairId={pairId}
initialThreadId={model2ThreadId}
handleName="model2"
borderClassName="border-t border-sidebar-border md:border-t-0 md:border-l"
header={
<GeneralCompareHeader
side="right"
models={models}
loraModels={loraModels}
value={model2.id}
onValueChange={(id, meta) =>
setModel2({
id,
isLora: meta.isLora,
ggufVariant: meta.ggufVariant,
})
}
onFoldersChange={onFoldersChange}
/>
}
/>
</>
</CompareShell>
);
});
function InlineSidebar({
children,
side = "left",
}: {
children: ReactNode;
side?: "left" | "right";
}) {
const { state, isMobile, openMobile, setOpenMobile } = useSidebar();
const collapsed = state === "collapsed";
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile}>
<SheetContent side={side} className="w-[18rem] p-0">
<SheetHeader className="sr-only">
<SheetTitle>Chat sidebar</SheetTitle>
<SheetDescription>Chat threads and actions</SheetDescription>
</SheetHeader>
<div className="h-full overflow-auto">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group shrink-0 h-full pb-3.5"
data-state={state}
data-collapsible={collapsed ? "offcanvas" : ""}
data-side={side}
>
<aside
data-sidebar="sidebar"
className={cn(
"bg-muted/70 text-sidebar-foreground h-full overflow-hidden rounded-2xl corner-squircle transition-[width] duration-200 ease-linear",
!collapsed && side === "right" && "border-l border-sidebar-border/70",
collapsed ? "w-0" : "w-(--sidebar-width)",
)}
>
<div className="flex h-full w-(--sidebar-width) flex-col">
{children}
</div>
</aside>
</div>
);
}
function TopBarActions({
onNewThread,
onNewCompare,
showCompare,
}: {
onNewThread: () => void;
onNewCompare: () => void;
showCompare: boolean;
}) {
const { state } = useSidebar();
if (state !== "collapsed") {
return null;
}
return (
<>
<Tooltip>
<TooltipTrigger asChild={true}>
<Button variant="ghost" size="icon-sm" onClick={onNewThread}>
<HugeiconsIcon icon={PencilEdit02Icon} strokeWidth={2} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">New Chat</TooltipContent>
</Tooltip>
{showCompare ? (
<Tooltip>
<TooltipTrigger asChild={true}>
<Button variant="ghost" size="icon-sm" onClick={onNewCompare}>
<HugeiconsIcon icon={ColumnInsertIcon} strokeWidth={2} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Compare</TooltipContent>
</Tooltip>
) : null}
</>
);
}
function getInitialSingleChatView(): ChatView {
const id = useChatRuntimeStore.getState().activeThreadId;
if (typeof id === "string" && id.length > 0 && !id.startsWith("__LOCALID_")) {
return { mode: "single", threadId: id };
}
return { mode: "single" };
}
export function ChatPage(): ReactElement {
// Do not set newThreadNonce here: each /chat mount would run ThreadNewChatSwitch
// and create spurious threads when navigating (e.g. Recipes / Export). New Chat
// explicitly sets a nonce in handleNewThread.
const [view, setView] = useState<ChatView>(getInitialSingleChatView);
const [settingsOpen, setSettingsOpen] = useState(false);
const search = useSearch({ from: "/chat" });
const navigate = useNavigate();
const settingsOpen = useChatRuntimeStore((s) => s.settingsPanelOpen);
const setSettingsOpen = useChatRuntimeStore((s) => s.setSettingsPanelOpen);
useEffect(() => {
return () => setSettingsOpen(false);
}, [setSettingsOpen]);
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
const [modelSelectorLocked, setModelSelectorLocked] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [viewBeforeCompare, setViewBeforeCompare] = useState<ChatView | null>(
null,
);
const viewBeforeCompareRef = useRef<ChatSearch | null>(null);
const inferenceParams = useChatRuntimeStore((state) => state.params);
const setInferenceParams = useChatRuntimeStore((state) => state.setParams);
const activeGgufVariant = useChatRuntimeStore(
@ -515,8 +504,6 @@ export function ChatPage(): ReactElement {
(state) => state.ggufContextLength,
);
const contextUsage = useChatRuntimeStore((state) => state.contextUsage);
const autoTitle = useChatRuntimeStore((state) => state.autoTitle);
const setAutoTitle = useChatRuntimeStore((state) => state.setAutoTitle);
const modelsFromStore = useChatRuntimeStore((state) => state.models);
const lorasFromStore = useChatRuntimeStore((state) => state.loras);
const modelsError = useChatRuntimeStore((state) => state.modelsError);
@ -541,6 +528,27 @@ export function ChatPage(): ReactElement {
return Boolean(inferenceParams.checkpoint);
}, [inferenceParams.checkpoint]);
// Derive view from URL search params
const view = useMemo<ChatView>(() => {
if (search.compare) {
return {
mode: "compare",
pairId:
search.compare,
};
}
if (search.thread) {
return { mode: "single", threadId: search.thread };
}
if (activeThreadId && !activeThreadId.startsWith("__LOCALID_")) {
return { mode: "single", threadId: activeThreadId };
}
if (search.new) {
return { mode: "single", newThreadNonce: search.new };
}
return { mode: "single" };
}, [search.thread, search.compare, search.new, activeThreadId]);
const handleCheckpointChange = useCallback(
(
value: string,
@ -601,28 +609,6 @@ export function ChatPage(): ReactElement {
const handleEject = useCallback(() => {
void ejectModel();
}, [ejectModel]);
const handleNewThread = useCallback(() => {
// Skip if we are already on a fresh unsaved draft with no messages sent.
// Once the user sends a message, append() sets activeThreadId in the store,
// so we check the store to know whether the current draft has been sent.
if (
view.mode === "single" &&
!view.threadId &&
!useChatRuntimeStore.getState().activeThreadId
) {
return;
}
useChatRuntimeStore.getState().setActiveThreadId(null);
setView({ mode: "single", newThreadNonce: crypto.randomUUID() });
}, [view]);
const handleNewCompare = useCallback(() => {
setView({ mode: "compare", pairId: crypto.randomUUID() });
// Clear activeThreadId so compare panes do not inherit the single-chat
// thread ID as a fallback for session_id routing.
useChatRuntimeStore.getState().setActiveThreadId(null);
useChatRuntimeStore.getState().setContextUsage(null);
}, []);
const openModelSelector = useCallback(() => {
setModelSelectorLocked(true);
@ -641,30 +627,26 @@ export function ChatPage(): ReactElement {
},
[modelSelectorLocked],
);
const openSettings = useCallback(() => setSettingsOpen(true), []);
const closeSettings = useCallback(() => setSettingsOpen(false), []);
const openSidebar = useCallback(() => setSidebarOpen(true), []);
const openSettings = useCallback(() => setSettingsOpen(true), [setSettingsOpen]);
const closeSettings = useCallback(() => setSettingsOpen(false), [setSettingsOpen]);
const { setPinned, isMobile } = useSidebar();
const openSidebar = useCallback(() => setPinned(true), [setPinned]);
const enterCompare = useCallback(() => {
setViewBeforeCompare((prev) => prev ?? view);
setView({ mode: "compare", pairId: crypto.randomUUID() });
// Clear activeThreadId so compare panes do not inherit the single-chat
// thread ID as a fallback for session_id routing.
viewBeforeCompareRef.current = { ...search };
useChatRuntimeStore.getState().setActiveThreadId(null);
useChatRuntimeStore.getState().setContextUsage(null);
}, [view]);
navigate({ to: "/chat", search: { compare: crypto.randomUUID() } });
}, [navigate, search]);
const exitCompare = useCallback(() => {
if (!viewBeforeCompare) return;
setView(viewBeforeCompare);
setViewBeforeCompare(null);
const saved = viewBeforeCompareRef.current;
if (!saved) return;
viewBeforeCompareRef.current = null;
navigate({ to: "/chat", search: saved });
// Restore context usage from the active thread's last assistant message.
// Use the thread ID from the saved view rather than the store, because
// activeThreadId may have been cleared on compare entry.
const store = useChatRuntimeStore.getState();
const threadId =
("threadId" in viewBeforeCompare ? viewBeforeCompare.threadId : null) ??
store.activeThreadId;
saved.thread ?? useChatRuntimeStore.getState().activeThreadId;
if (threadId) {
void db.messages
.where("threadId")
@ -672,18 +654,12 @@ export function ChatPage(): ReactElement {
.reverse()
.first()
.then((msg) => {
const saved = msg?.metadata as Record<string, unknown> | undefined;
const usage = saved?.contextUsage as
| typeof store.contextUsage
| undefined;
if (usage) store.setContextUsage(usage);
const metadata = msg?.metadata as Record<string, unknown> | undefined;
const usage = metadata?.contextUsage as ReturnType<typeof useChatRuntimeStore.getState>["contextUsage"];
if (usage) useChatRuntimeStore.getState().setContextUsage(usage);
});
}
}, [viewBeforeCompare]);
const handleThreadSelect = useCallback((nextView: ChatView) => {
setView(nextView);
}, []);
}, [navigate]);
const models = useMemo<ModelOption[]>(
() =>
@ -727,7 +703,7 @@ export function ChatPage(): ReactElement {
);
})
.catch(() => {});
}, []);
}, [navigate]);
const loraModels = useMemo<LoraModelOption[]>(() => {
const fromLoras = lorasFromStore.map((lora) => ({
@ -771,9 +747,9 @@ export function ChatPage(): ReactElement {
});
await selectModelRef.current({ id: targetLora.id, isLora: true });
if (canceled) return;
setView({ mode: "compare", pairId: crypto.randomUUID() });
useChatRuntimeStore.getState().setActiveThreadId(null);
useChatRuntimeStore.getState().setContextUsage(null);
navigate({ to: "/chat", search: { compare: crypto.randomUUID() } });
clearHandoff();
console.info("[chat-handoff] loaded lora + opened compare");
return;
@ -851,39 +827,18 @@ export function ChatPage(): ReactElement {
}, [modelSelectorLocked, tour.open]);
return (
<div className="h-[calc(100dvh-4rem)] bg-background overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-1 basis-0 bg-background overflow-hidden">
<GuidedTour {...tour.tourProps} />
<SidebarProvider
defaultOpen={true}
open={sidebarOpen}
onOpenChange={setSidebarOpen}
className="!min-h-0 h-full w-full max-w-7xl mx-auto px-2 sm:px-4"
style={
{
"--sidebar-width": "14rem",
"--sidebar-width-icon": "3rem",
} as CSSProperties
}
>
<InlineSidebar>
<ThreadSidebar
view={view}
onSelect={handleThreadSelect}
onNewThread={handleNewThread}
onNewCompare={handleNewCompare}
showCompare={canCompare}
/>
</InlineSidebar>
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div className="flex h-11 shrink-0 items-center px-1.5 sm:px-2">
<div className="flex items-center gap-1">
<SidebarTrigger />
<TopBarActions
onNewThread={handleNewThread}
onNewCompare={handleNewCompare}
showCompare={canCompare}
/>
<div className="relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
<div
className={cn(
"absolute top-0 left-0 right-[10px] z-30 flex h-[48px] shrink-0 items-start pt-[11px] pr-2 bg-background",
isMobile ? "pl-12 pr-1.5" : "pl-2",
view.mode === "compare" && "right-[10px] left-auto w-auto bg-transparent pl-0 pr-2",
)}
>
<div className="flex items-center gap-1">
{view.mode !== "compare" && (
<ModelSelector
models={models}
loraModels={loraModels}
@ -897,36 +852,37 @@ export function ChatPage(): ReactElement {
onOpenChange={handleModelSelectorOpenChange}
triggerDataTour="chat-model-selector"
contentDataTour="chat-model-selector-popover"
className="max-w-[62vw] sm:max-w-none"
className="max-w-[62vw] sm:max-w-none !h-[34px]"
/>
{loadingModel && loadToastDismissed ? (
<ModelLoadInlineStatus
label={
loadProgress?.phase === "starting"
? "Starting model…"
: loadingModel.isDownloaded || loadingModel.isCachedLora
? "Loading model…"
: "Downloading model…"
}
title={
loadingModel.isDownloaded
? `Loading ${loadingModel.displayName} from cache.`
: loadingModel.isCachedLora
? `Loading ${loadingModel.displayName} into memory.`
: `Loading ${loadingModel.displayName}. This may include downloading.`
}
progressPercent={loadProgress?.percent}
progressLabel={loadProgress?.label}
onStop={cancelLoading}
/>
) : null}
</div>
{modelsError && (
<div className="ml-2 text-xs text-destructive truncate max-w-[28rem]">
{modelsError}
</div>
)}
<div className="flex-1" />
{loadingModel && loadToastDismissed ? (
<ModelLoadInlineStatus
label={
loadProgress?.phase === "starting"
? "Starting model…"
: loadingModel.isDownloaded || loadingModel.isCachedLora
? "Loading model…"
: "Downloading model…"
}
title={
loadingModel.isDownloaded
? `Loading ${loadingModel.displayName} from cache.`
: loadingModel.isCachedLora
? `Loading ${loadingModel.displayName} into memory.`
: `Loading ${loadingModel.displayName}. This may include downloading.`
}
progressPercent={loadProgress?.percent}
progressLabel={loadProgress?.label}
onStop={cancelLoading}
/>
) : null}
</div>
{modelsError && (
<div className="ml-2 text-xs text-destructive truncate max-w-[28rem]">
{modelsError}
</div>
)}
<div className="ml-auto flex items-center gap-2">
{view.mode === "single" && ggufContextLength && contextUsage ? (
<ContextUsageBar
used={contextUsage.totalTokens}
@ -934,57 +890,65 @@ export function ChatPage(): ReactElement {
cached={contextUsage.cachedTokens}
promptTokens={contextUsage.promptTokens}
completionTokens={contextUsage.completionTokens}
className="h-[34px]"
/>
) : null}
<button
type="button"
onClick={() => setSettingsOpen((o) => !o)}
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Inference settings"
data-tour="chat-settings"
>
<HugeiconsIcon icon={Settings04Icon} className="size-5" />
</button>
{!settingsOpen && (
<Tooltip>
<TooltipPrimitive.Trigger asChild>
<button
type="button"
onClick={() => setSettingsOpen(true)}
className="flex h-[34px] w-[34px] items-center justify-center rounded-[8px] text-[#383835] dark:text-[#c7c7c4] transition-colors hover:bg-[#ececec] dark:hover:bg-[#2e3035] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Open configuration"
data-tour="chat-settings"
>
<HugeiconsIcon icon={Settings05Icon} className="size-5" />
</button>
</TooltipPrimitive.Trigger>
<TooltipContent side="bottom" sideOffset={6}>
Open configuration
</TooltipContent>
</Tooltip>
)}
</div>
{view.mode === "single" ? (
<SingleContent
key={view.threadId ?? "single"}
threadId={view.threadId}
newThreadNonce={view.newThreadNonce}
/>
) : (
<CompareContent
key={view.pairId}
pairId={view.pairId}
models={models}
loraModels={loraModels}
onFoldersChange={refreshLocalModels}
/>
)}
</div>
<ChatSettingsPanel
open={settingsOpen}
onOpenChange={setSettingsOpen}
params={inferenceParams}
onParamsChange={setInferenceParams}
autoTitle={autoTitle}
onAutoTitleChange={setAutoTitle}
onReloadModel={() => {
const state = useChatRuntimeStore.getState();
if (state.params.checkpoint) {
selectModel({
id: state.params.checkpoint,
ggufVariant: state.activeGgufVariant ?? undefined,
forceReload: true,
isDownloaded: true,
loadingDescription: "Reloading with updated chat template.",
});
}
}}
/>
</SidebarProvider>
{view.mode === "single" ? (
<SingleContent
key={view.threadId ?? "single"}
threadId={view.threadId}
newThreadNonce={view.newThreadNonce}
/>
) : (
<CompareContent
key={view.pairId}
pairId={view.pairId}
models={models}
loraModels={loraModels}
onFoldersChange={refreshLocalModels}
/>
)}
</div>
<ChatSettingsPanel
open={settingsOpen}
onOpenChange={setSettingsOpen}
params={inferenceParams}
onParamsChange={setInferenceParams}
onReloadModel={() => {
const state = useChatRuntimeStore.getState();
if (state.params.checkpoint) {
selectModel({
id: state.params.checkpoint,
ggufVariant: state.activeGgufVariant ?? undefined,
forceReload: true,
isDownloaded: true,
loadingDescription: "Reloading with updated chat template.",
});
}
}}
/>
</div>
);
}

View file

@ -52,13 +52,17 @@ import {
CodeIcon,
Delete02Icon,
FloppyDiskIcon,
PencilEdit01Icon,
Settings02Icon,
Settings05Icon,
SlidersHorizontalIcon,
UserSettings01Icon,
Wrench01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
Tooltip,
TooltipContent,
} from "@/components/ui/tooltip";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { AnimatePresence, motion } from "motion/react";
import type { ReactNode } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
@ -476,8 +480,6 @@ interface ChatSettingsPanelProps {
onOpenChange?: (open: boolean) => void;
params: InferenceParams;
onParamsChange: (params: InferenceParams) => void;
autoTitle: boolean;
onAutoTitleChange: (enabled: boolean) => void;
onReloadModel?: () => void;
}
@ -486,8 +488,6 @@ export function ChatSettingsPanel({
onOpenChange,
params,
onParamsChange,
autoTitle,
onAutoTitleChange,
onReloadModel,
}: ChatSettingsPanelProps) {
const isMobile = useIsMobile();
@ -743,17 +743,37 @@ export function ChatSettingsPanel({
const settingsContent = (
<>
<div className="flex items-center gap-2 px-4 py-3">
<HugeiconsIcon
icon={PencilEdit01Icon}
className="size-4 text-muted-foreground/70"
/>
<span className="flex-1 text-base font-semibold tracking-tight">
Configuration
</span>
<div className="aui-thread-viewport relative h-full overflow-y-auto">
<div className="sticky top-0 z-10 flex h-[48px] items-start gap-2 pl-2 pr-2 pt-[11px] backdrop-blur">
{isMobile ? (
<span className="flex h-[34px] flex-1 items-center pl-1 text-base font-semibold tracking-tight">
Configuration
</span>
) : (
<>
<Tooltip>
<TooltipPrimitive.Trigger asChild>
<button
type="button"
onClick={() => onOpenChange?.(false)}
className="flex h-[34px] w-[34px] items-center justify-center rounded-[8px] text-[#383835] dark:text-[#c7c7c4] transition-colors hover:bg-[#ececec] dark:hover:bg-[#2e3035] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Close configuration"
>
<HugeiconsIcon icon={Settings05Icon} className="size-5" />
</button>
</TooltipPrimitive.Trigger>
<TooltipContent side="bottom" sideOffset={6}>
Close configuration
</TooltipContent>
</Tooltip>
<span className="flex h-[34px] flex-1 items-center text-base font-semibold tracking-tight">
Configuration
</span>
</>
)}
</div>
<div className="flex-1 overflow-y-auto px-1.5">
<div className="px-1.5">
{/* mt-4 matches the Playground sidebar gap (SidebarHeader py-3 + SidebarGroup pt-1) */}
<div className="mt-4 px-2 pb-3">
<div className="space-y-1.5">
@ -893,7 +913,7 @@ export function ChatSettingsPanel({
value={params.systemPrompt}
onChange={(e) => set("systemPrompt")(e.target.value)}
placeholder="You are a helpful assistant..."
className="min-h-20 text-xs corner-squircle"
className="min-h-20 max-h-48 overflow-y-auto text-xs corner-squircle focus-visible:ring-[1px]"
rows={3}
/>
</div>
@ -1174,27 +1194,9 @@ export function ChatSettingsPanel({
</div>
</CollapsibleSection>
<CollapsibleSection
icon={UserSettings01Icon}
label="Preferences"
defaultOpen={true}
>
<div className="flex flex-col gap-3 py-1">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-medium">Auto title</div>
<div className="text-[11px] text-muted-foreground">
Generate short title after reply.
</div>
</div>
<Switch checked={autoTitle} onCheckedChange={onAutoTitleChange} />
</div>
<HfTokenField />
</div>
</CollapsibleSection>
<ChatTemplateSection onReloadModel={onReloadModel} />
</div>
</div>
<Dialog
open={systemPromptEditorOpen}
onOpenChange={(nextOpen) => {
@ -1224,7 +1226,8 @@ export function ChatSettingsPanel({
value={systemPromptDraft}
onChange={(event) => setSystemPromptDraft(event.target.value)}
placeholder="You are a helpful assistant..."
className="min-h-[24rem] text-sm leading-6 corner-squircle"
fieldSizing="fixed"
className="min-h-[24rem] max-h-[50vh] overflow-y-auto text-sm leading-6 corner-squircle"
rows={14}
/>
</div>
@ -1268,9 +1271,9 @@ export function ChatSettingsPanel({
return (
<aside
className={`shrink-0 self-start h-[calc(100%-0.875rem)] overflow-hidden bg-muted/70 rounded-2xl corner-squircle transition-[width] duration-200 ease-linear ${open ? "w-[17rem] border-l border-sidebar-border/70" : "w-0"}`}
className={`relative z-50 shrink-0 h-full overflow-hidden bg-muted/70 transition-[width] duration-200 ease-linear ${open ? "w-[17rem]" : "w-0"}`}
>
<div className="flex h-full w-[17rem] flex-col">{settingsContent}</div>
<div className="h-full w-[17rem]">{settingsContent}</div>
</aside>
);
}
@ -1348,29 +1351,6 @@ function AutoHealToolCallsToggle() {
);
}
function HfTokenField() {
const hfToken = useChatRuntimeStore((s) => s.hfToken);
const setHfToken = useChatRuntimeStore((s) => s.setHfToken);
return (
<div className="flex flex-col gap-1.5">
<div className="min-w-0">
<div className="text-xs font-medium">Hugging Face Token</div>
<div className="text-[11px] text-muted-foreground">
For downloading gated or private models.
</div>
</div>
<Input
type="password"
value={hfToken}
placeholder="hf_..."
className="h-7 text-xs font-mono"
onChange={(e) => setHfToken(e.target.value)}
/>
</div>
);
}
function ChatTemplateSection({
onReloadModel,
}: {
@ -1391,7 +1371,7 @@ function ChatTemplateSection({
<Textarea
value={displayValue}
onChange={(e) => setOverride(e.target.value)}
className="min-h-32 font-mono text-[10px] leading-relaxed md:text-[10px] corner-squircle"
className="min-h-32 max-h-64 overflow-y-auto font-mono text-[10px] leading-relaxed md:text-[10px] corner-squircle"
rows={6}
spellCheck={false}
/>

View file

@ -0,0 +1,120 @@
// 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 {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandList,
} from "@/components/ui/command";
import { useTrainingRuntimeStore } from "@/features/training";
import { Cancel01Icon, Message01Icon, SearchIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useNavigate } from "@tanstack/react-router";
import { Command as CommandPrimitive } from "cmdk";
import { useEffect } from "react";
import { useChatSearchIndex } from "../hooks/use-chat-search-index";
import { useChatSearchStore } from "../stores/chat-search-store";
function formatRelative(createdAt: number): string {
const diff = Date.now() - createdAt;
const day = 86_400_000;
if (diff < day) return "Today";
if (diff < 7 * day) return "Past week";
if (diff < 30 * day) return "Past month";
return "Older";
}
export function ChatSearchDialog() {
const isOpen = useChatSearchStore((s) => s.isOpen);
const setOpen = useChatSearchStore((s) => s.setOpen);
const close = useChatSearchStore((s) => s.close);
const navigate = useNavigate();
const { items, loading } = useChatSearchIndex(isOpen);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== "k") return;
if (useTrainingRuntimeStore.getState().isTrainingRunning) return;
const el = document.activeElement as HTMLElement | null;
const tag = el?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || el?.isContentEditable) return;
e.preventDefault();
useChatSearchStore.getState().open();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
return (
<CommandDialog
open={isOpen}
onOpenChange={setOpen}
className="shadow-border corner-squircle w-[635px] max-w-[calc(100%-2rem)] gap-0 p-0 sm:max-w-[635px]"
overlayClassName="bg-transparent"
>
<Command className="rounded-none p-0">
<div className="flex items-center gap-3 border-b border-border/40 px-4 py-3">
<HugeiconsIcon
icon={SearchIcon}
strokeWidth={2}
className="size-4 shrink-0 text-muted-foreground"
/>
<CommandPrimitive.Input
placeholder="Search chats..."
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
<button
type="button"
onClick={close}
className="flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Close"
>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} className="size-4" />
</button>
</div>
<CommandList className="max-h-[420px] p-1">
<CommandEmpty className="py-6 text-center text-xs text-muted-foreground">
{loading
? "Loading…"
: items.length === 0
? "No chats yet."
: "No chats match."}
</CommandEmpty>
<CommandGroup className="p-0">
{items.map((item) => (
<CommandPrimitive.Item
key={item.id}
value={`${item.title} ${item.preview}`}
onSelect={() => {
navigate({
to: "/chat",
search:
item.type === "single"
? { thread: item.id }
: { compare: item.id },
});
close();
}}
className="relative flex cursor-default select-none items-center gap-3 rounded-lg px-3 py-2.5 text-sm outline-hidden data-selected:bg-muted data-selected:text-foreground"
>
<HugeiconsIcon
icon={Message01Icon}
strokeWidth={2}
className="size-4 shrink-0 text-muted-foreground"
/>
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">
{item.title || "Untitled chat"}
</span>
<span className="shrink-0 text-[11px] text-muted-foreground">
{formatRelative(item.createdAt)}
</span>
</CommandPrimitive.Item>
))}
</CommandGroup>
</CommandList>
</Command>
</CommandDialog>
);
}

View file

@ -289,11 +289,11 @@ export function useChatModelRuntime() {
loadedSpeculativeType: currentSpecType,
});
// Set reasoning default for Qwen3.5 small models
// Set reasoning default for Qwen3.5/3.6 small models
if (supportsReasoning) {
let reasoningDefault = true;
const mid = statusRes.active_model.toLowerCase();
if (mid.includes("qwen3.5")) {
if (mid.includes("qwen3.5") || mid.includes("qwen3.6")) {
const sizeMatch = mid.match(/(\d+\.?\d*)\s*b/);
if (sizeMatch && parseFloat(sizeMatch[1]) < 9) {
reasoningDefault = false;
@ -462,11 +462,11 @@ export function useChatModelRuntime() {
setParams(
mergeRecommendedInference(currentParams, loadResponse, modelId),
);
// Qwen3.5 small models (0.8B, 2B, 4B, 9B) disable thinking by default
// Qwen3.5/3.6 small models (0.8B, 2B, 4B, 9B) disable thinking by default
let reasoningDefault = loadResponse.supports_reasoning ?? false;
if (reasoningDefault) {
const mid = modelId.toLowerCase();
if (mid.includes("qwen3.5")) {
if (mid.includes("qwen3.5") || mid.includes("qwen3.6")) {
const sizeMatch = mid.match(/(\d+\.?\d*)\s*b/);
if (sizeMatch && parseFloat(sizeMatch[1]) < 9) {
reasoningDefault = false;
@ -509,12 +509,14 @@ export function useChatModelRuntime() {
defaultChatTemplate: loadResponse.chat_template ?? null,
chatTemplateOverride: null,
});
// Qwen3/3.5: apply thinking-mode-specific params after load
// Qwen3/3.5/3.6: apply thinking-mode-specific params after load
if (modelId.toLowerCase().includes("qwen3") && (loadResponse.supports_reasoning ?? false)) {
const store = useChatRuntimeStore.getState();
const mid = modelId.toLowerCase();
const needsPresencePenalty = mid.includes("qwen3.5") || mid.includes("qwen3.6");
const p = reasoningDefault
? { temperature: 0.6, topP: 0.95, topK: 20, minP: 0.0 }
: { temperature: 0.7, topP: 0.8, topK: 20, minP: 0.0 };
? { temperature: 0.6, topP: 0.95, topK: 20, minP: 0.0, ...(needsPresencePenalty ? { presencePenalty: 1.5 } : {}) }
: { temperature: 0.7, topP: 0.8, topK: 20, minP: 0.0, ...(needsPresencePenalty ? { presencePenalty: 1.5 } : {}) };
store.setParams({ ...store.params, ...p });
}
await refresh();

View file

@ -0,0 +1,156 @@
// 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 { useEffect, useState } from "react";
import { db } from "../db";
import type { MessageRecord, ThreadRecord } from "../types";
export interface ChatSearchItem {
type: "single" | "compare";
id: string;
title: string;
preview: string;
createdAt: number;
}
const THREAD_LIMIT = 200;
const PREVIEW_MAX = 120;
function extractText(message: MessageRecord): string {
const content = message.content;
if (!Array.isArray(content)) return "";
const parts: string[] = [];
for (const part of content) {
if (!part || typeof part !== "object") continue;
const p = part as { type?: string; text?: unknown };
if ((p.type === "text" || p.type === "reasoning") && typeof p.text === "string") {
parts.push(p.text);
}
}
return parts.join(" ").replace(/\s+/g, " ").trim();
}
function truncate(text: string, max: number): string {
if (text.length <= max) return text;
return text.slice(0, max).trimEnd() + "…";
}
async function buildIndex(): Promise<ChatSearchItem[]> {
// Fetch all threads newest-first, filter archived in JS, then take top N.
// `archived` is a boolean which Dexie does not index reliably, so we filter
// after the sort instead of using `.where("archived")`.
const all = (await db.threads
.orderBy("createdAt")
.reverse()
.toArray()) as ThreadRecord[];
const active = all.filter((t) => !t.archived).slice(0, THREAD_LIMIT);
const itemThreadIds = new Map<
string,
{ item: Omit<ChatSearchItem, "preview">; threadIds: string[] }
>();
const seenPairs = new Set<string>();
for (const t of active) {
if (t.pairId) {
if (seenPairs.has(t.pairId)) {
const existing = itemThreadIds.get(t.pairId);
if (existing) existing.threadIds.push(t.id);
continue;
}
seenPairs.add(t.pairId);
itemThreadIds.set(t.pairId, {
item: {
type: "compare",
id: t.pairId,
title: t.title,
createdAt: t.createdAt,
},
threadIds: [t.id],
});
} else {
itemThreadIds.set(t.id, {
item: {
type: "single",
id: t.id,
title: t.title,
createdAt: t.createdAt,
},
threadIds: [t.id],
});
}
}
// One query for all messages across all relevant threads, then group by
// threadId in memory. Avoids N sequential awaits.
const allThreadIds = Array.from(itemThreadIds.values()).flatMap(
(e) => e.threadIds,
);
const messages = (await db.messages
.where("threadId")
.anyOf(allThreadIds)
.toArray()) as MessageRecord[];
const byThreadId = new Map<string, MessageRecord[]>();
for (const m of messages) {
const arr = byThreadId.get(m.threadId);
if (arr) arr.push(m);
else byThreadId.set(m.threadId, [m]);
}
const results: ChatSearchItem[] = [];
for (const { item, threadIds } of itemThreadIds.values()) {
const merged: MessageRecord[] = [];
for (const tid of threadIds) {
const arr = byThreadId.get(tid);
if (arr) merged.push(...arr);
}
merged.sort((a, b) => b.createdAt - a.createdAt);
let preview = "";
for (const m of merged) {
const text = extractText(m);
if (text) {
preview = truncate(text, PREVIEW_MAX);
break;
}
}
results.push({ ...item, preview });
}
results.sort((a, b) => b.createdAt - a.createdAt);
return results;
}
export function useChatSearchIndex(enabled: boolean): {
items: ChatSearchItem[];
loading: boolean;
} {
const [items, setItems] = useState<ChatSearchItem[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!enabled) {
// Clear stale results so the next open doesn't flash old items.
setItems([]);
return;
}
let cancelled = false;
setLoading(true);
buildIndex()
.then((result) => {
if (!cancelled) setItems(result);
})
.catch(() => {
if (!cancelled) setItems([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [enabled]);
return { items, loading };
}

View file

@ -0,0 +1,95 @@
// 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 { db, useLiveQuery } from "../db";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
import type { ThreadRecord } from "../types";
export interface SidebarItem {
type: "single" | "compare";
id: string;
title: string;
createdAt: number;
}
export function groupThreads(threads: ThreadRecord[]): SidebarItem[] {
const items: SidebarItem[] = [];
const seenPairs = new Set<string>();
for (const t of threads) {
if (t.archived) {
continue;
}
if (t.pairId) {
if (seenPairs.has(t.pairId)) {
continue;
}
seenPairs.add(t.pairId);
items.push({
type: "compare",
id: t.pairId,
title: t.title,
createdAt: t.createdAt,
});
} else if (!t.pairId) {
items.push({
type: "single",
id: t.id,
title: t.title,
createdAt: t.createdAt,
});
}
}
return items.sort((a, b) => b.createdAt - a.createdAt);
}
export function useChatSidebarItems() {
const allThreads = useLiveQuery(async () => {
const threadIdsWithMessage = new Set(
(await db.messages.orderBy("threadId").uniqueKeys()) as string[],
);
const rows = await db.threads.orderBy("createdAt").reverse().toArray();
return rows.filter((t) => !t.archived && threadIdsWithMessage.has(t.id));
}, []);
const items = groupThreads(allThreads ?? []);
const canCompare = useChatRuntimeStore((s) => Boolean(s.params.checkpoint));
return { items, canCompare };
}
function cancelIfRunning(threadId: string): void {
const { runningByThreadId, cancelByThreadId } =
useChatRuntimeStore.getState();
if (!runningByThreadId[threadId]) return;
cancelByThreadId[threadId]?.();
}
export async function deleteChatItem(
item: SidebarItem,
activeId: string | undefined,
onSelect: (view: { mode: "single"; newThreadNonce: string }) => void,
) {
const threadIds: string[] =
item.type === "single"
? [item.id]
: (await db.threads.where("pairId").equals(item.id).toArray()).map(
(t) => t.id,
);
// Stop any in-flight streams before deleting, so the model doesn't keep
// generating against a thread that no longer exists.
for (const id of threadIds) cancelIfRunning(id);
await db.transaction("rw", db.threads, db.messages, async () => {
for (const id of threadIds) {
await db.messages.where("threadId").equals(id).delete();
await db.threads.delete(id);
}
});
if (activeId === item.id) {
useChatRuntimeStore.getState().setActiveThreadId(null);
onSelect({ mode: "single", newThreadNonce: crypto.randomUUID() });
}
}

View file

@ -730,6 +730,34 @@ function ActiveThreadSync({
return null;
}
// Exposes the current thread's cancelRun() via the shared store so external
// surfaces (e.g. the sidebar trash button) can stop an in-flight stream
// before deleting the thread — mirroring the Stop → Trash sequence.
function CancelRegistrar(): ReactElement | null {
const aui = useAui();
const mainThreadId = useAuiState(({ threads }) => threads.mainThreadId);
const isRunning = useChatRuntimeStore((s) =>
mainThreadId ? Boolean(s.runningByThreadId[mainThreadId]) : false,
);
useEffect(() => {
if (!mainThreadId || !isRunning) return;
const cancel = () => {
try {
aui.thread().cancelRun();
} catch {
// Run may have already ended between the caller's read and this call.
}
};
useChatRuntimeStore.getState().registerThreadCancel(mainThreadId, cancel);
return () => {
useChatRuntimeStore.getState().clearThreadCancel(mainThreadId);
};
}, [aui, mainThreadId, isRunning]);
return null;
}
export function ChatRuntimeProvider({
children,
modelType = "base",
@ -762,6 +790,7 @@ export function ChatRuntimeProvider({
<ActiveThreadSync
enabled={modelType === "base" && !pairId && !newThreadNonce && !initialThreadId}
/>
<CancelRegistrar />
{initialThreadId && (
<ThreadAutoSwitch
threadId={initialThreadId}

View file

@ -2,11 +2,12 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon";
import { Button } from "@/components/ui/button";
import { AUDIO_ACCEPT, MAX_AUDIO_SIZE, fileToBase64 } from "@/lib/audio-utils";
import { useAui } from "@assistant-ui/react";
import { cn } from "@/lib/utils";
import { ArrowUpIcon, GlobeIcon, HeadphonesIcon, LightbulbIcon, LightbulbOffIcon, MicIcon, PlusIcon, SquareIcon, TerminalIcon, XIcon } from "lucide-react";
import { ArrowUpIcon, GlobeIcon, HeadphonesIcon, LightbulbIcon, LightbulbOffIcon, MicIcon, PlusIcon, SquareIcon, XIcon } from "lucide-react";
import { toast } from "sonner";
import { loadModel, validateModel } from "./api/chat-api";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
@ -267,6 +268,21 @@ export function SharedComposer({
return () => clearInterval(id);
}, [handlesRef]);
// Auto-expand textarea up to 6 rows, then scroll (matches regular chat composer).
useEffect(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.style.height = "auto";
const styles = window.getComputedStyle(ta);
const lineHeight = parseFloat(styles.lineHeight) || 20;
const paddingY = parseFloat(styles.paddingTop) + parseFloat(styles.paddingBottom);
const borderY = parseFloat(styles.borderTopWidth) + parseFloat(styles.borderBottomWidth);
const maxHeight = lineHeight * 6 + paddingY + borderY;
const next = Math.min(ta.scrollHeight, maxHeight);
ta.style.height = `${next}px`;
ta.style.overflowY = ta.scrollHeight > maxHeight ? "auto" : "hidden";
}, [text]);
const addFiles = useCallback((files: FileList | null) => {
if (!files?.length) return;
const next: PendingImage[] = [];
@ -455,7 +471,7 @@ export function SharedComposer({
return (
<div
className={`shadow-border ring-1 ring-border relative flex w-full flex-col rounded-2xl bg-background px-1 pt-2 transition-shadow outline-none ${dragging ? "ring-ring bg-accent/50" : ""}`}
className={`chat-composer-surface relative flex w-full flex-col rounded-3xl bg-background dark:bg-card px-1 pt-2 transition-shadow outline-none ${dragging ? "border-ring bg-accent/50" : ""}`}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
@ -498,7 +514,7 @@ export function SharedComposer({
onChange={(e) => setText(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Send to both models..."
className="mb-1 max-h-32 min-h-14 w-full resize-none bg-transparent pl-5 pr-4 pt-2 pb-3 text-sm outline-none placeholder:text-muted-foreground"
className="mb-1 min-h-12 w-full resize-none overflow-y-hidden bg-transparent pl-5 pr-4 pt-2 pb-3 text-sm font-[450] outline-none placeholder:text-muted-foreground focus-visible:ring-0"
rows={1}
/>
<div className="relative mx-2 mb-2 flex items-center justify-between">
@ -515,13 +531,13 @@ export function SharedComposer({
}}
/>
<TooltipIconButton
tooltip="Add attachment"
tooltip="Add Attachment"
side="bottom"
variant="ghost"
size="icon"
className="size-8 rounded-full text-muted-foreground hover:bg-muted-foreground/15"
className="size-8.5 rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
onClick={() => fileInputRef.current?.click()}
aria-label="Add attachment"
aria-label="Add Attachment"
>
<PlusIcon className="size-5 stroke-[1.5px]" />
</TooltipIconButton>
@ -542,11 +558,11 @@ export function SharedComposer({
side="bottom"
variant="ghost"
size="icon"
className="size-8 rounded-full text-muted-foreground hover:bg-muted-foreground/15"
className="size-8.5 rounded-full p-1 text-muted-foreground hover:bg-muted-foreground/15"
onClick={() => audioInputRef.current?.click()}
aria-label="Upload audio"
>
<HeadphonesIcon className="size-4 stroke-[1.5px]" />
<HeadphonesIcon className="size-4.5 stroke-[1.5px]" />
</TooltipIconButton>
</>
)}
@ -557,18 +573,19 @@ export function SharedComposer({
if (reasoningAlwaysOn) return;
const next = !reasoningEnabled;
setReasoningEnabled(next);
// Qwen3/3.5: adjust params for thinking on/off
// Qwen3/3.5/3.6: adjust params for thinking on/off
const store = useChatRuntimeStore.getState();
const cp = store.params.checkpoint?.toLowerCase() ?? "";
if (cp.includes("qwen3")) {
const needsPresencePenalty = cp.includes("qwen3.5") || cp.includes("qwen3.6");
const p = next
? { temperature: 0.6, topP: 0.95, topK: 20, minP: 0.0 }
: { temperature: 0.7, topP: 0.8, topK: 20, minP: 0.0 };
? { temperature: 0.6, topP: 0.95, topK: 20, minP: 0.0, ...(needsPresencePenalty ? { presencePenalty: 1.5 } : {}) }
: { temperature: 0.7, topP: 0.8, topK: 20, minP: 0.0, ...(needsPresencePenalty ? { presencePenalty: 1.5 } : {}) };
store.setParams({ ...store.params, ...p });
}
}}
className={cn(
"flex items-center gap-0.5 rounded-full px-2 py-0.5 text-xs font-medium transition-colors",
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
reasoningDisabled
? "cursor-not-allowed opacity-40"
: (reasoningEnabled || reasoningAlwaysOn)
@ -578,9 +595,9 @@ export function SharedComposer({
aria-label={reasoningEnabled ? "Disable thinking" : "Enable thinking"}
>
{(reasoningEnabled || reasoningAlwaysOn) && !reasoningDisabled ? (
<LightbulbIcon className="size-3" />
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3" />
<LightbulbOffIcon className="size-3.5" />
)}
<span>Think</span>
</button>
@ -615,7 +632,7 @@ export function SharedComposer({
)}
aria-label={codeToolsEnabled ? "Disable code execution" : "Enable code execution"}
>
<TerminalIcon className="size-3.5" />
<CodeToggleIcon className="size-3.5" />
<span>Code</span>
</button>
</div>
@ -628,7 +645,7 @@ export function SharedComposer({
side="bottom"
variant="ghost"
size="icon"
className="size-8 rounded-full text-muted-foreground hover:bg-muted-foreground/15"
className="size-8 rounded-full text-muted-foreground"
onClick={startDictation}
aria-label="Dictate"
>

View file

@ -146,6 +146,7 @@ type ChatRuntimeStore = {
models: ChatModelSummary[];
loras: ChatLoraSummary[];
runningByThreadId: Record<string, boolean>;
cancelByThreadId: Record<string, () => void>;
autoTitle: boolean;
hfToken: string;
modelsError: string | null;
@ -173,6 +174,7 @@ type ChatRuntimeStore = {
defaultChatTemplate: string | null;
chatTemplateOverride: string | null;
activeThreadId: string | null;
settingsPanelOpen: boolean;
pendingAudioBase64: string | null;
pendingAudioName: string | null;
contextUsage: {
@ -188,11 +190,14 @@ type ChatRuntimeStore = {
setModels: (models: ChatModelSummary[]) => void;
setLoras: (loras: ChatLoraSummary[]) => void;
setThreadRunning: (threadId: string, running: boolean) => void;
registerThreadCancel: (threadId: string, cancel: () => void) => void;
clearThreadCancel: (threadId: string) => void;
setAutoTitle: (enabled: boolean) => void;
setHfToken: (token: string) => void;
setModelsError: (error: string | null) => void;
setCheckpoint: (modelId: string, ggufVariant?: string | null) => void;
setActiveThreadId: (threadId: string | null) => void;
setSettingsPanelOpen: (open: boolean) => void;
clearCheckpoint: () => void;
setReasoningEnabled: (enabled: boolean) => void;
setToolsEnabled: (enabled: boolean) => void;
@ -216,6 +221,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
models: [],
loras: [],
runningByThreadId: {},
cancelByThreadId: {},
autoTitle: loadBool(AUTO_TITLE_KEY, false),
hfToken: loadString(HF_TOKEN_KEY, ""),
modelsError: null,
@ -243,6 +249,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
defaultChatTemplate: null,
chatTemplateOverride: null,
activeThreadId: null,
settingsPanelOpen: false,
pendingAudioBase64: null,
pendingAudioName: null,
contextUsage: null,
@ -274,6 +281,19 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
}
return { runningByThreadId: next };
}),
registerThreadCancel: (threadId, cancel) =>
set((state) => {
const next = { ...state.cancelByThreadId };
next[threadId] = cancel;
return { cancelByThreadId: next };
}),
clearThreadCancel: (threadId) =>
set((state) => {
if (!(threadId in state.cancelByThreadId)) return state;
const next = { ...state.cancelByThreadId };
delete next[threadId];
return { cancelByThreadId: next };
}),
setAutoTitle: (autoTitle) =>
set(() => {
saveBool(AUTO_TITLE_KEY, autoTitle);
@ -294,6 +314,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
activeGgufVariant: ggufVariant ?? null,
})),
setActiveThreadId: (activeThreadId) => set({ activeThreadId, contextUsage: null }),
setSettingsPanelOpen: (settingsPanelOpen) => set({ settingsPanelOpen }),
clearCheckpoint: () =>
set((state) => ({
params: {

View file

@ -0,0 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { create } from "zustand";
interface ChatSearchStore {
isOpen: boolean;
open: () => void;
close: () => void;
setOpen: (open: boolean) => void;
}
export const useChatSearchStore = create<ChatSearchStore>((set) => ({
isOpen: false,
open: () => set({ isOpen: true }),
close: () => set({ isOpen: false }),
setOpen: (isOpen) => set({ isOpen }),
}));

View file

@ -21,48 +21,10 @@ import {
PencilEdit02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { db, useLiveQuery } from "./db";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import type { ChatView, ThreadRecord } from "./types";
interface SidebarItem {
type: "single" | "compare";
id: string;
title: string;
createdAt: number;
}
function groupThreads(threads: ThreadRecord[]): SidebarItem[] {
const items: SidebarItem[] = [];
const seenPairs = new Set<string>();
for (const t of threads) {
if (t.archived) {
continue;
}
if (t.pairId) {
if (seenPairs.has(t.pairId)) {
continue;
}
seenPairs.add(t.pairId);
items.push({
type: "compare",
id: t.pairId,
title: t.title,
createdAt: t.createdAt,
});
} else if (!t.pairId) {
items.push({
type: "single",
id: t.id,
title: t.title,
createdAt: t.createdAt,
});
}
}
return items.sort((a, b) => b.createdAt - a.createdAt);
}
import type { ChatView } from "./types";
import { deleteChatItem, useChatSidebarItems } from "./hooks/use-chat-sidebar-items";
import type { SidebarItem } from "./hooks/use-chat-sidebar-items";
export function ThreadSidebar({
view,
@ -77,14 +39,7 @@ export function ThreadSidebar({
onNewCompare: () => void;
showCompare: boolean;
}) {
const allThreads = useLiveQuery(async () => {
const threadIdsWithMessage = new Set(
(await db.messages.orderBy("threadId").uniqueKeys()) as string[],
);
const rows = await db.threads.orderBy("createdAt").reverse().toArray();
return rows.filter((t) => !t.archived && threadIdsWithMessage.has(t.id));
}, []);
const items = groupThreads(allThreads ?? []);
const { items } = useChatSidebarItems();
const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
const activeId =
view.mode === "single" ? (view.threadId ?? storeThreadId) : view.pairId;
@ -96,23 +51,10 @@ export function ThreadSidebar({
}
async function handleDelete(item: SidebarItem) {
if (item.type === "single") {
await db.messages.where("threadId").equals(item.id).delete();
await db.threads.delete(item.id);
} else {
const paired = await db.threads.where("pairId").equals(item.id).toArray();
for (const t of paired) {
await db.messages.where("threadId").equals(t.id).delete();
await db.threads.delete(t.id);
}
}
if (activeId === item.id) {
// Directly set a new view with a nonce rather than going through
// onNewThread(), which may return early if the guard sees no
// threadId and no activeThreadId (after we just cleared it).
useChatRuntimeStore.getState().setActiveThreadId(null);
onSelect({ mode: "single", newThreadNonce: crypto.randomUUID() });
}
// Directly set a new view with a nonce rather than going through
// onNewThread(), which may return early if the guard sees no
// threadId and no activeThreadId (after we just cleared it).
await deleteChatItem(item, activeId ?? undefined, onSelect);
}
return (

View file

@ -0,0 +1,15 @@
// 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 { db } from "../db";
export async function countAllChats(): Promise<number> {
return db.threads.count();
}
export async function clearAllChats(): Promise<void> {
await db.transaction("rw", db.threads, db.messages, async () => {
await db.messages.clear();
await db.threads.clear();
});
}

View file

@ -0,0 +1,41 @@
// 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 { db } from "../db";
interface ExportedChat {
exportedAt: string;
version: 1;
threadCount: number;
threads: unknown[];
messages: unknown[];
}
export async function buildChatExport(): Promise<ExportedChat> {
const [threads, messages] = await Promise.all([
db.threads.toArray(),
db.messages.toArray(),
]);
return {
exportedAt: new Date().toISOString(),
version: 1,
threadCount: threads.length,
threads,
messages,
};
}
export async function downloadChatExport(): Promise<void> {
const data = await buildChatExport();
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `unsloth-chats-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}

View file

@ -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 { useEffect, useState } from "react";
import { liveQuery } from "dexie";
import { listRecipes } from "../data/recipes-db";
import type { RecipeRecord } from "../types";
export function useRecipeSidebarItems(enabled: boolean) {
const [recipes, setRecipes] = useState<RecipeRecord[]>([]);
useEffect(() => {
if (!enabled) return;
const sub = liveQuery(() => listRecipes()).subscribe({
next: (value) => setRecipes(value),
error: (err) => console.error("recipe sidebar liveQuery:", err),
});
return () => sub.unsubscribe();
}, [enabled]);
return recipes;
}

View file

@ -272,12 +272,13 @@ export function ExportDialog({
exportSuccess,
exportOutputPath,
}: ExportDialogProps) {
// Live log capture is only meaningful for export methods that run
// a slow subprocess operation with interesting stdout: merged and
// gguf. LoRA adapter export is a fast disk write and would just
// show a blank panel, so we hide it there.
// Live log capture is useful for any export path executed by the
// backend worker, including LoRA adapter-only export.
const showLogPanel =
exportMethod === "merged" || exportMethod === "gguf";
exportMethod === "merged" ||
exportMethod === "gguf" ||
exportMethod === "lora";
const showCompletionScreen = exportSuccess && !showLogPanel;
const { lines: logLines, connected: logConnected, error: logError } =
useExportLogs(exporting && showLogPanel, exportMethod, open);
@ -314,7 +315,7 @@ export function ExportDialog({
className={showLogPanel ? "sm:max-w-2xl" : "sm:max-w-lg"}
onInteractOutside={(e) => { if (exporting) e.preventDefault(); }}
>
{exportSuccess ? (
{showCompletionScreen ? (
<>
<div className="flex flex-col items-center gap-3 py-6">
<div className="flex size-12 items-center justify-center rounded-full bg-emerald-500/10">
@ -460,6 +461,27 @@ export function ExportDialog({
)}
</AnimatePresence>
{/* Success banner for log-driven exports.
Keep users on the log screen after completion so they can
inspect conversion output before closing. */}
{exportSuccess && showLogPanel && (
<div className="flex items-start gap-2 rounded-lg bg-emerald-500/10 p-3 text-sm text-emerald-700 dark:text-emerald-300">
<HugeiconsIcon icon={CheckmarkCircle02Icon} className="mt-0.5 size-4 shrink-0" />
<div className="flex min-w-0 flex-col gap-1">
<span>
{destination === "hub"
? "Export finished and pushed to Hugging Face Hub."
: "Export finished successfully."}
</span>
{exportOutputPath ? (
<code className="select-all break-all font-mono text-[12px] text-foreground/90" title={exportOutputPath}>
{exportOutputPath}
</code>
) : null}
</div>
</div>
)}
{/* Error banner */}
{exportError && (
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
@ -577,14 +599,16 @@ export function ExportDialog({
onClick={() => onOpenChange(false)}
disabled={exporting}
>
Cancel
{exportSuccess ? "Done" : "Cancel"}
</Button>
<Button onClick={onExport} disabled={exporting}>
<Button onClick={onExport} disabled={exporting || exportSuccess}>
{exporting ? (
<span className="flex items-center gap-2">
<Spinner className="size-4" />
Exporting
</span>
) : exportSuccess ? (
"Export Complete"
) : (
"Start Export"
)}

View file

@ -36,14 +36,14 @@ export const EXPORT_METHODS: {
];
export const QUANT_OPTIONS = [
{ value: "q2_k_l", label: "Q2_K_L", size: "~2.9 GB" },
{ value: "q3_k_m", label: "Q3_K_M", size: "~3.5 GB" },
{ value: "q4_0", label: "Q4_0", size: "~4.1 GB" },
{ value: "q4_k_m", label: "Q4_K_M", size: "~4.8 GB", recommended: true },
{ value: "q5_0", label: "Q5_0", size: "~5.0 GB" },
{ value: "q5_k_m", label: "Q5_K_M", size: "~5.6 GB" },
{ value: "q6_k", label: "Q6_K", size: "~6.6 GB" },
{ value: "q8_0", label: "Q8_0", size: "~8.2 GB" },
{ value: "bf16", label: "BF16", size: "~14.2 GB" },
{ value: "f16", label: "F16", size: "~14.2 GB" },
{ value: "f32", label: "F32", size: "~28.4 GB" },
];
export function getEstimatedSize(

View file

@ -7,12 +7,12 @@ import { motion } from "motion/react";
interface SplashScreenProps {
onStartOnboarding: () => void;
onGoToStudio: () => void;
onSkipOnboarding: () => void;
}
export function SplashScreen({
onStartOnboarding,
onGoToStudio,
onSkipOnboarding,
}: SplashScreenProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gradient-to-b from-background via-background to-primary/5 p-6">
@ -65,7 +65,7 @@ export function SplashScreen({
<Button size="lg" onClick={onStartOnboarding}>
Start Onboarding
</Button>
<Button size="lg" variant="outline" onClick={onGoToStudio}>
<Button size="lg" variant="outline" onClick={onSkipOnboarding}>
Skip Onboarding
</Button>
</motion.div>

View file

@ -7,10 +7,15 @@ import { markOnboardingDone } from "@/features/auth";
import { useTrainingConfigStore } from "@/features/training";
import { ArrowLeft02Icon, ArrowRight02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useNavigate } from "@tanstack/react-router";
import { useShallow } from "zustand/react/shallow";
export function WizardFooter({ onBackToSplash }: { onBackToSplash: () => void }) {
export function WizardFooter({
returnTo,
onBackToSplash,
}: {
returnTo: string;
onBackToSplash: () => void;
}) {
const { currentStep, prevStep, nextStep, canProceed } = useTrainingConfigStore(
useShallow((s) => ({
currentStep: s.currentStep,
@ -19,7 +24,6 @@ export function WizardFooter({ onBackToSplash }: { onBackToSplash: () => void })
canProceed: s.canProceed(),
})),
);
const navigate = useNavigate();
const isFirst = currentStep === 1;
const isLast = currentStep === STEPS.length;
@ -41,7 +45,7 @@ export function WizardFooter({ onBackToSplash }: { onBackToSplash: () => void })
className="px-4"
onClick={() => {
markOnboardingDone();
navigate({ to: "/studio" });
window.location.assign(returnTo);
}}
>
Skip
@ -51,12 +55,12 @@ export function WizardFooter({ onBackToSplash }: { onBackToSplash: () => void })
<Button
onClick={() => {
markOnboardingDone();
navigate({ to: "/studio" });
window.location.assign(returnTo);
}}
disabled={!canProceed}
className="px-4 !pr-4"
>
Go to Studio
Finish onboarding
<HugeiconsIcon icon={ArrowRight02Icon} data-icon="inline-end" />
</Button>
) : (
@ -65,7 +69,7 @@ export function WizardFooter({ onBackToSplash }: { onBackToSplash: () => void })
if (currentStep === 1 && sessionStorage.getItem("unsloth_chat_only") === "1") {
sessionStorage.removeItem("unsloth_chat_only");
markOnboardingDone();
window.location.href = "/chat";
window.location.assign("/chat");
} else {
nextStep();
}

View file

@ -2,7 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Card } from "@/components/ui/card";
import { useNavigate } from "@tanstack/react-router";
import { Route as OnboardingRoute } from "@/app/routes/onboarding";
import { motion } from "motion/react";
import { Suspense, lazy, useEffect, useRef, useState } from "react";
@ -19,13 +19,23 @@ const Confetti = lazy(() =>
import("@/components/ui/confetti").then((m) => ({ default: m.Confetti })),
);
function sanitizeRedirectTarget(value: string | undefined): string {
if (!value) return "/chat";
if (!value.startsWith("/")) return "/chat";
if (value.startsWith("//")) return "/chat";
if (value.includes("\\")) return "/chat";
return value;
}
export function WizardLayout() {
const navigate = useNavigate();
const search = OnboardingRoute.useSearch();
const [showSplash, setShowSplash] = useState(true);
const currentStep = useTrainingConfigStore((s) => s.currentStep);
const confettiRef = useRef<ConfettiRef>(null);
const hasFiredRef = useRef(false);
const isFinalStep = currentStep === STEPS.length;
const returnTo = sanitizeRedirectTarget(search.redirectTo);
const exitToReturnTo = () => window.location.assign(returnTo);
// Only redirect on initial mount — not on re-renders after markOnboardingDone()
// which would override explicit /chat navigation from skip buttons.
@ -34,10 +44,10 @@ export function WizardLayout() {
if (!checkedRef.current) {
checkedRef.current = true;
if (isOnboardingDone()) {
navigate({ to: "/studio" });
exitToReturnTo();
}
}
}, [navigate]);
}, [returnTo]);
useEffect(() => {
if (isFinalStep && !hasFiredRef.current) {
@ -67,9 +77,9 @@ export function WizardLayout() {
{showSplash && (
<SplashScreen
onStartOnboarding={() => setShowSplash(false)}
onGoToStudio={() => {
onSkipOnboarding={() => {
markOnboardingDone();
window.location.href = "/studio";
exitToReturnTo();
}}
/>
)}
@ -91,10 +101,10 @@ export function WizardLayout() {
}}
>
<Card className="relative z-10 w-full !gap-0 !m-0 !p-0 flex min-h-[560px] flex-col overflow-hidden shadow-border ring-1 ring-border md:min-h-[620px] md:flex-row lg:h-[660px]">
<WizardSidebar />
<WizardSidebar returnTo={returnTo} />
<div className="flex-1 flex flex-col">
<WizardContent />
<WizardFooter onBackToSplash={() => setShowSplash(true)} />
<WizardFooter returnTo={returnTo} onBackToSplash={() => setShowSplash(true)} />
</div>
</Card>
</motion.div>

View file

@ -10,7 +10,7 @@ import { ArrowRight02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { WizardStepItem } from "./wizard-step-item";
export function WizardSidebar() {
export function WizardSidebar({ returnTo }: { returnTo: string }) {
const currentStep = useTrainingConfigStore((s) => s.currentStep);
const progress = ((currentStep - 1) / (STEPS.length - 1)) * 100;
@ -38,10 +38,10 @@ export function WizardSidebar() {
className="mt-2 w-full md:hidden"
onClick={() => {
markOnboardingDone();
window.location.href = "/chat";
window.location.assign(returnTo);
}}
>
Skip to Chat
Skip onboarding
<HugeiconsIcon icon={ArrowRight02Icon} data-icon="inline-end" />
</Button>
<nav className="mt-3 hidden flex-col gap-1 md:flex">
@ -54,10 +54,10 @@ export function WizardSidebar() {
className="mt-3 hidden w-full md:flex"
onClick={() => {
markOnboardingDone();
window.location.href = "/chat";
window.location.assign(returnTo);
}}
>
Skip to Chat
Skip onboarding
<HugeiconsIcon icon={ArrowRight02Icon} data-icon="inline-end" />
</Button>
</aside>

View file

@ -0,0 +1,157 @@
// 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getAuthToken } from "@/features/auth";
import { toastError, toastSuccess } from "@/shared/toast";
import { Camera } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { decodeJwtSubject } from "../utils/jwt-subject";
import { resizeImageFileToDataUrl } from "../utils/resize-image-file";
import { useUserProfileStore } from "../stores/user-profile-store";
import { UserAvatar } from "./user-avatar";
const PROFILE_STORAGE_KEY = "unsloth_user_profile";
function readPersistedProfile(): { displayName: string; avatarDataUrl: string | null } | null {
try {
const raw = window.localStorage.getItem(PROFILE_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object") return null;
// Zustand persist shape: { state: {...}, version }
const maybeState = "state" in parsed ? (parsed as { state?: unknown }).state : parsed;
if (!maybeState || typeof maybeState !== "object") return null;
const state = maybeState as { displayName?: unknown; avatarDataUrl?: unknown };
return {
displayName: typeof state.displayName === "string" ? state.displayName : "",
avatarDataUrl: typeof state.avatarDataUrl === "string" ? state.avatarDataUrl : null,
};
} catch {
return null;
}
}
export function ProfilePersonalizationPanel() {
const displayName = useUserProfileStore((s) => s.displayName);
const avatarDataUrl = useUserProfileStore((s) => s.avatarDataUrl);
const setDisplayName = useUserProfileStore((s) => s.setDisplayName);
const setAvatarDataUrl = useUserProfileStore((s) => s.setAvatarDataUrl);
const [imageError, setImageError] = useState<string | null>(null);
const [draftName, setDraftName] = useState(displayName);
const fileInputRef = useRef<HTMLInputElement>(null);
const sessionSub = decodeJwtSubject(getAuthToken()) ?? "";
const previewName = draftName.trim() || sessionSub || "Unsloth";
const hasNameChanges = useMemo(
() => draftName.trim() !== displayName.trim(),
[draftName, displayName],
);
const saveName = () => {
const trimmed = draftName.trim();
if (trimmed !== draftName) setDraftName(trimmed);
if (trimmed !== displayName) {
setDisplayName(trimmed);
const persisted = readPersistedProfile();
if (persisted && persisted.displayName === trimmed) {
toastSuccess("Profile name saved");
} else {
toastError(
"Could not persist profile name",
"Name updated for this session, but may not persist after reload.",
);
}
}
};
const onPickFile = async (file: File | undefined) => {
if (!file) return;
setImageError(null);
try {
const dataUrl = await resizeImageFileToDataUrl(file);
setAvatarDataUrl(dataUrl);
const persisted = readPersistedProfile();
if (persisted && persisted.avatarDataUrl === dataUrl) {
toastSuccess("Profile photo updated");
} else {
toastError(
"Could not persist profile photo",
"Photo updated for this session, but may not persist after reload.",
);
}
} catch (e) {
const message = e instanceof Error ? e.message : "Could not use this image.";
setImageError(message);
toastError("Could not update profile photo", message);
}
};
return (
<div className="mx-auto flex w-full max-w-[640px] flex-col items-center gap-6 rounded-2xl border border-border/70 bg-muted/10 px-8 py-7">
<div className="relative">
<UserAvatar
name={previewName}
imageUrl={avatarDataUrl}
size="lg"
className="size-[124px] text-[3.15rem]"
/>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
className="sr-only"
onChange={(e) => {
void onPickFile(e.target.files?.[0]);
e.target.value = "";
}}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="absolute right-0 bottom-0 -translate-x-[15.625%] -translate-y-[15.625%] flex size-8 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-sm transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
aria-label="Change profile picture"
>
<Camera className="size-3.5" strokeWidth={2} />
</button>
</div>
<div className="flex w-full max-w-[560px] flex-col gap-2">
<Label htmlFor="profile-display-name" className="text-xs font-medium text-muted-foreground">
Display name
</Label>
<div className="flex items-center gap-2">
<Input
id="profile-display-name"
type="text"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
saveName();
}
}}
autoComplete="off"
placeholder={sessionSub || "Unsloth"}
className="h-10 min-w-0 flex-1 rounded-lg text-sm"
/>
<Button type="button" size="sm" className="h-10 px-5" onClick={saveName} disabled={!hasNameChanges}>
Save
</Button>
</div>
</div>
{imageError ? (
<p className="w-full text-xs text-destructive" role="alert">
{imageError}
</p>
) : null}
</div>
);
}

View file

@ -0,0 +1,45 @@
// 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 { cn } from "@/lib/utils";
import { avatarBgStyle, initialsFromName } from "../utils/avatar-initials";
type UserAvatarProps = {
name: string;
imageUrl: string | null;
size: "sm" | "md" | "lg";
className?: string;
};
const SIZE: Record<"sm" | "md" | "lg", string> = {
sm: "size-9 text-xs",
md: "size-11 text-sm",
/** ~10% larger than `size-24` / `text-2xl` for the edit-profile dialog. */
lg: "size-[106px] text-[1.65rem]",
};
export function UserAvatar({ name, imageUrl, size, className }: UserAvatarProps) {
const label = initialsFromName(name);
if (imageUrl) {
return (
<span className={cn("relative inline-flex shrink-0 overflow-hidden rounded-full", SIZE[size], className)}>
<img src={imageUrl} alt="" className="size-full object-cover" />
</span>
);
}
return (
<span
style={avatarBgStyle()}
className={cn(
"inline-flex shrink-0 items-center justify-center rounded-full font-semibold text-white",
SIZE[size],
className,
)}
aria-hidden
>
{label}
</span>
);
}

View file

@ -0,0 +1,19 @@
// 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 { getAuthToken } from "@/features/auth";
import { decodeJwtSubject } from "../utils/jwt-subject";
import { useUserProfileStore } from "../stores/user-profile-store";
export function useEffectiveProfile() {
const displayName = useUserProfileStore((s) => s.displayName);
const avatarDataUrl = useUserProfileStore((s) => s.avatarDataUrl);
const sessionSub = decodeJwtSubject(getAuthToken());
const dn = displayName.trim();
return {
sessionSub,
displayTitle: dn || "Unsloth",
avatarDataUrl,
};
}

View file

@ -0,0 +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
export { ProfilePersonalizationPanel } from "./components/profile-personalization-panel";
export { UserAvatar } from "./components/user-avatar";
export { useEffectiveProfile } from "./hooks/use-effective-profile";

View file

@ -0,0 +1,24 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { create } from "zustand";
import { persist } from "zustand/middleware";
export interface UserProfileState {
displayName: string;
avatarDataUrl: string | null;
setDisplayName: (displayName: string) => void;
setAvatarDataUrl: (avatarDataUrl: string | null) => void;
}
export const useUserProfileStore = create<UserProfileState>()(
persist(
(set) => ({
displayName: "",
avatarDataUrl: null,
setDisplayName: (displayName) => set({ displayName }),
setAvatarDataUrl: (avatarDataUrl) => set({ avatarDataUrl }),
}),
{ name: "unsloth_user_profile" },
),
);

View file

@ -0,0 +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
export function initialsFromName(name: string): string {
const trimmed = name.trim();
if (!trimmed) return "?";
return trimmed[0]!.toUpperCase();
}
/** Default blue background for avatar fallback (readable white text). */
export function avatarBgStyle(): { backgroundColor: string } {
return { backgroundColor: "hsl(217 58% 48%)" };
}

View file

@ -0,0 +1,21 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
/**
* Read the JWT `sub` claim for display purposes only (not verified).
*/
export function decodeJwtSubject(token: string | null): string | null {
if (!token) return null;
try {
const parts = token.split(".");
if (parts.length < 2) return null;
const payload = parts[1];
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
const json = atob(padded);
const parsed = JSON.parse(json) as { sub?: unknown };
return typeof parsed.sub === "string" ? parsed.sub : null;
} catch {
return null;
}
}

View file

@ -0,0 +1,53 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
const MAX_EDGE = 256;
const MAX_BYTES = 380_000;
function loadImage(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("Could not load image"));
};
img.src = url;
});
}
/**
* Downscale and re-encode as JPEG so localStorage stays within reasonable size.
*/
export async function resizeImageFileToDataUrl(file: File): Promise<string> {
const img = await loadImage(file);
const w = img.naturalWidth;
const h = img.naturalHeight;
if (!w || !h) throw new Error("Invalid image dimensions");
const scale = Math.min(1, MAX_EDGE / Math.max(w, h));
const cw = Math.max(1, Math.round(w * scale));
const ch = Math.max(1, Math.round(h * scale));
const canvas = document.createElement("canvas");
canvas.width = cw;
canvas.height = ch;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas not available");
ctx.drawImage(img, 0, 0, cw, ch);
let quality = 0.88;
let dataUrl = canvas.toDataURL("image/jpeg", quality);
while (dataUrl.length > MAX_BYTES * 1.35 && quality > 0.45) {
quality -= 0.08;
dataUrl = canvas.toDataURL("image/jpeg", quality);
}
if (dataUrl.length > MAX_BYTES * 1.35) {
throw new Error("Image is still too large after compression. Try a smaller file.");
}
return dataUrl;
}

View file

@ -132,7 +132,7 @@ function AuxNodeBase({
const hasInvalidRefs =
findInvalidJinjaReferences(value, availableRefs).length > 0;
return (
<BaseNode className="corner-squircle w-full min-w-0 rounded-lg border-border/60 bg-card shadow-sm">
<BaseNode className="corner-squircle w-full min-w-0 rounded-4xl border-border/60 bg-card shadow-sm">
<BaseNodeHeader className="border-b border-border/50 px-3 py-2">
<BaseNodeHeaderTitle className="text-xs">{data.title}</BaseNodeHeaderTitle>
</BaseNodeHeader>
@ -195,7 +195,7 @@ function AuxNodeBase({
};
return (
<BaseNode className="corner-squircle w-full min-w-0 rounded-lg border-border/60 bg-card shadow-sm">
<BaseNode className="corner-squircle w-full min-w-0 rounded-4xl border-border/60 bg-card shadow-sm">
<BaseNodeHeader className="border-b border-border/50 px-3 py-2">
<BaseNodeHeaderTitle className="text-xs">
{score.name.trim() || `Scorer ${data.scoreIndex + 1}`}

View file

@ -399,7 +399,7 @@ function RecipeGraphNodeBase({
return (
<BaseNode
className="corner-squircle relative w-full min-w-0 overflow-visible rounded-lg border-border/60 shadow-sm"
className="corner-squircle relative w-full min-w-0 overflow-visible rounded-4xl border-border/60 shadow-sm"
style={noteStyle}
>
<NodeResizer
@ -465,7 +465,7 @@ function RecipeGraphNodeBase({
return (
<BaseNode
className={cn(
"corner-squircle relative w-full min-w-0 overflow-visible rounded-lg border-border/60 shadow-sm",
"corner-squircle relative w-full min-w-0 overflow-visible rounded-4xl border-border/60 shadow-sm",
runtimeNodeTone,
hasConnectionIssue &&
runtimeState === "idle" &&

View file

@ -0,0 +1,41 @@
// 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 { authFetch } from "@/features/auth/api";
export interface ApiKey {
id: number;
name: string;
key_prefix: string;
created_at: string;
last_used_at: string | null;
expires_at: string | null;
is_active: boolean;
}
export async function fetchApiKeys(): Promise<ApiKey[]> {
const res = await authFetch("/api/auth/api-keys");
if (!res.ok) throw new Error("Failed to load API keys");
const data = (await res.json()) as { api_keys: ApiKey[] };
return data.api_keys.filter((k) => k.is_active);
}
export async function createApiKey(
name: string,
expiresInDays: number | null,
): Promise<{ key: string; api_key: ApiKey }> {
const res = await authFetch("/api/auth/api-keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, expires_in_days: expiresInDays }),
});
if (!res.ok) throw new Error("Failed to create API key");
return res.json();
}
export async function revokeApiKey(keyId: number): Promise<void> {
const res = await authFetch(`/api/auth/api-keys/${keyId}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("Failed to revoke API key");
}

View file

@ -0,0 +1,101 @@
// 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 { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Delete02Icon,
Copy01Icon,
MoreHorizontalIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import type { ApiKey } from "../api/api-keys";
function relative(iso: string | null): string {
if (!iso) return "never";
const diff = Date.now() - new Date(iso).getTime();
const days = Math.floor(diff / 86400000);
if (days < 1) {
const hours = Math.floor(diff / 3600000);
if (hours < 1) return "just now";
return `${hours}h ago`;
}
if (days < 30) return `${days}d ago`;
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
return `${Math.floor(days / 365)}y ago`;
}
function expiresText(iso: string | null): string {
if (!iso) return "never";
const diff = new Date(iso).getTime() - Date.now();
if (diff < 0) return "expired";
const days = Math.floor(diff / 86400000);
if (days < 1) return "today";
return `in ${days}d`;
}
export function ApiKeyRow({
apiKey,
onRevoke,
}: {
apiKey: ApiKey;
onRevoke: (key: ApiKey) => void;
}) {
const prefix = `sk-unsloth-${apiKey.key_prefix}`;
return (
<div className="group flex items-center gap-3 border-b border-border/60 px-1 py-3 last:border-b-0 transition-colors hover:bg-accent/40">
<span
className="size-1.5 shrink-0 rounded-full bg-emerald-500"
aria-hidden="true"
/>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex min-w-0 items-center justify-between gap-3">
<span className="truncate text-sm font-medium text-foreground" title={apiKey.name}>
{apiKey.name}
</span>
<code className="shrink-0 font-mono text-[11px] text-muted-foreground">
{prefix}
</code>
</div>
<div className="flex flex-wrap gap-x-1.5 text-[11px] text-muted-foreground">
<span>Created {relative(apiKey.created_at)}</span>
<span>·</span>
<span>Used {relative(apiKey.last_used_at)}</span>
<span>·</span>
<span>Expires {expiresText(apiKey.expires_at)}</span>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="size-7 p-0 opacity-0 transition-opacity group-hover:opacity-100 data-[state=open]:opacity-100 max-sm:!opacity-100 max-sm:size-9"
aria-label={`Actions for ${apiKey.name}`}
>
<HugeiconsIcon icon={MoreHorizontalIcon} className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={async () => { await copyToClipboard(prefix); }}>
<HugeiconsIcon icon={Copy01Icon} className="size-3.5 mr-2" />
Copy prefix
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onRevoke(apiKey)}
className="text-destructive focus:text-destructive"
>
<HugeiconsIcon icon={Delete02Icon} className="size-3.5 mr-2" />
Revoke key
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View file

@ -0,0 +1,83 @@
// 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { useState } from "react";
import { createApiKey } from "../api/api-keys";
const EXPIRY_PRESETS = [
{ label: "Never", value: null as number | null },
{ label: "7d", value: 7 },
{ label: "30d", value: 30 },
{ label: "90d", value: 90 },
];
export function CreateKeyForm({
onCreated,
onError,
}: {
onCreated: (rawKey: string) => void;
onError: (message: string) => void;
}) {
const [name, setName] = useState("");
const [expiry, setExpiry] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || loading) return;
setLoading(true);
try {
const result = await createApiKey(name.trim(), expiry);
onCreated(result.key);
setName("");
} catch (err) {
onError(err instanceof Error ? err.message : "Couldn't create key.");
} finally {
setLoading(false);
}
};
return (
<form
onSubmit={handleSubmit}
className="flex flex-col gap-2 rounded-lg border border-border bg-muted/20 p-3"
>
<div className="flex flex-wrap items-center gap-2">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Key name (e.g. production)"
className="h-8 min-w-[180px] flex-1 text-sm"
aria-label="New key name"
/>
<div className="inline-flex items-center rounded-md border border-border bg-background p-0.5">
{EXPIRY_PRESETS.map((p) => {
const active = expiry === p.value;
return (
<button
key={p.label}
type="button"
onClick={() => setExpiry(p.value)}
aria-pressed={active}
className={cn(
"rounded px-2 py-1 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active
? "bg-accent text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{p.label}
</button>
);
})}
</div>
<Button type="submit" size="sm" disabled={loading || !name.trim()}>
{loading ? "Creating…" : "Create key"}
</Button>
</div>
</form>
);
}

View file

@ -0,0 +1,71 @@
// 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 { Button } from "@/components/ui/button";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { cn } from "@/lib/utils";
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useState } from "react";
export function KeyRevealCard({
rawKey,
onDone,
}: {
rawKey: string;
onDone: () => void;
}) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
if (await copyToClipboard(rawKey)) {
setCopied(true);
setTimeout(() => setCopied(false), 1800);
}
};
return (
<div className="flex flex-col gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-3">
<div className="flex items-center gap-1.5">
<HugeiconsIcon
icon={Tick02Icon}
className="size-3.5 text-emerald-600 dark:text-emerald-500"
/>
<span className="text-xs font-medium text-emerald-700 dark:text-emerald-500">
New key created
</span>
</div>
<button
type="button"
onClick={handleCopy}
className={cn(
"flex w-full items-center justify-between gap-3 rounded-md border border-border bg-muted/40 px-3 py-2.5 font-mono text-sm transition-colors hover:bg-muted/60",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
copied && "border-emerald-500/40 bg-emerald-500/10",
)}
aria-label={copied ? "Key copied" : "Copy key"}
>
<code className="min-w-0 flex-1 break-all text-left text-foreground">
{rawKey}
</code>
<HugeiconsIcon
icon={copied ? Tick02Icon : Copy01Icon}
className={cn("size-4 shrink-0", copied && "text-emerald-600")}
/>
</button>
<div className="flex items-center justify-between gap-3 pt-0.5">
<p className="text-[11px] text-muted-foreground">
Copy now this won't be shown again.
</p>
<Button
type="button"
size="sm"
onClick={onDone}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
>
Done
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,39 @@
// 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 { cn } from "@/lib/utils";
import type { ReactNode } from "react";
export function SettingsRow({
label,
description,
children,
destructive,
className,
}: {
label: string;
description?: string;
children?: ReactNode;
destructive?: boolean;
className?: string;
}) {
return (
<div
className={cn(
"flex items-center justify-between gap-6 py-3",
destructive && "border-t border-border/60 mt-2 pt-4",
className,
)}
>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-sm font-medium text-foreground">{label}</span>
{description ? (
<span className="text-xs text-muted-foreground leading-snug">
{description}
</span>
) : null}
</div>
{children ? <div className="flex shrink-0 items-center">{children}</div> : null}
</div>
);
}

View file

@ -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 type { ReactNode } from "react";
export function SettingsSection({
title,
description,
children,
}: {
title: string;
description?: string;
children: ReactNode;
}) {
return (
<section className="flex flex-col">
<div className="mb-1 flex flex-col gap-0.5">
<h2 className="text-base font-semibold font-heading text-foreground">
{title}
</h2>
{description ? (
<p className="text-xs text-muted-foreground leading-relaxed">
{description}
</p>
) : null}
</div>
<div className="flex flex-col divide-y divide-border/60">{children}</div>
</section>
);
}

View file

@ -0,0 +1,58 @@
// 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 { cn } from "@/lib/utils";
import {
LaptopIcon,
Moon02Icon,
Sun02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { motion, useReducedMotion } from "motion/react";
import { useTheme, type Theme } from "../stores/theme-store";
const OPTIONS: { value: Theme; label: string; icon: typeof Sun02Icon }[] = [
{ value: "light", label: "Light", icon: Sun02Icon },
{ value: "dark", label: "Dark", icon: Moon02Icon },
{ value: "system", label: "System", icon: LaptopIcon },
];
export function ThemeSegmented() {
const { theme, setTheme } = useTheme();
const reduced = useReducedMotion();
return (
<div className="inline-flex items-center rounded-md border border-border bg-muted/30 p-0.5">
{OPTIONS.map((opt) => {
const active = theme === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => setTheme(opt.value)}
aria-pressed={active}
className={cn(
"relative flex h-7 items-center gap-1.5 rounded px-2.5 text-xs font-medium transition-colors",
active
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{active && (
<motion.span
layoutId="theme-pill"
className="absolute inset-0 rounded bg-background shadow-border"
transition={
reduced
? { duration: 0 }
: { type: "spring", stiffness: 500, damping: 35, mass: 0.5 }
}
/>
)}
<HugeiconsIcon icon={opt.icon} className="relative z-10 size-3.5" />
<span className="relative z-10">{opt.label}</span>
</button>
);
})}
</div>
);
}

View file

@ -0,0 +1,185 @@
// 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 { cn } from "@/lib/utils";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { ReactElement } from "react";
import { useEffect, useRef, useState } from "react";
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";
export type UpdateShell = "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<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, []);
const handleCopy = async () => {
if (!(await copyToClipboard(command))) {
return;
}
setCopied(true);
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => setCopied(false), 2000);
};
return (
<div className="flex min-w-0 items-stretch overflow-hidden rounded-md border border-border bg-muted/40">
<input
type="text"
readOnly
value={command}
className="min-w-0 flex-1 bg-transparent px-2 py-1.5 font-mono text-[11px] text-foreground outline-none"
title={command}
aria-label={`${copyLabel} text`}
/>
<button
type="button"
onClick={handleCopy}
className="flex shrink-0 items-center justify-center border-l border-border px-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title={copied ? "Copied" : "Copy command"}
aria-label={copied ? `${copyLabel} copied` : `Copy ${copyLabel}`}
>
{copied ? (
<HugeiconsIcon icon={Tick02Icon} className="size-4 text-emerald-600" />
) : (
<HugeiconsIcon icon={Copy01Icon} className="size-4" />
)}
</button>
</div>
);
}
export function UpdateStudioInstructions({
className,
defaultShell,
showTitle = true,
}: {
className?: string;
defaultShell: UpdateShell;
showTitle?: boolean;
}): ReactElement {
const [shell, setShell] = useState<UpdateShell>(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 (
<div className={cn("flex flex-col gap-3", className)}>
<div
className={cn(
"flex items-center gap-3",
showTitle ? "justify-between" : "justify-start",
)}
>
{showTitle ? (
<p className="shrink-0 whitespace-nowrap text-sm font-semibold font-heading">
Update Unsloth Studio
</p>
) : null}
<div className="flex shrink-0 items-center gap-0.5 text-[11px]">
<button
type="button"
onClick={() => setShell("windows")}
className={cn(
"px-0.5 py-0.5 font-medium transition-colors",
windows
? "text-foreground"
: "text-muted-foreground hover:text-emerald-600",
)}
aria-pressed={windows}
>
Windows
</button>
<span className="text-border">/</span>
<button
type="button"
onClick={() => setShell("unix")}
className={cn(
"px-0.5 py-0.5 font-medium transition-colors",
!windows
? "text-foreground"
: "text-muted-foreground hover:text-emerald-600",
)}
aria-pressed={!windows}
>
macOS/Linux
</button>
</div>
</div>
<AnimatePresence mode="wait" initial={false}>
<motion.p
key={`instruction-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
className="text-xs text-muted-foreground leading-relaxed"
>
{getStudioUpdateInstructionLine(shell)}
</motion.p>
</AnimatePresence>
<CopyableCommand command={STUDIO_UPDATE_CMD} copyLabel="update command" />
<p className="text-xs text-muted-foreground leading-relaxed">
If that fails or unsloth studio update is unavailable, run:
</p>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`fallback-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
>
<CopyableCommand
command={
windows
? STUDIO_UPDATE_FALLBACK_WINDOWS_CMD
: STUDIO_UPDATE_FALLBACK_UNIX_CMD
}
copyLabel="fallback command"
/>
</motion.div>
</AnimatePresence>
<p className="text-xs text-muted-foreground leading-relaxed">
Restart Studio after updating for changes to take effect.
</p>
</div>
);
}

View file

@ -0,0 +1,146 @@
// 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 { cn } from "@/lib/utils";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import {
ArrowDown01Icon,
Copy01Icon,
Tick02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { AnimatePresence, motion } from "motion/react";
import { useMemo, useState } from "react";
type Lang = "curl" | "python" | "tools";
const TABS: { id: Lang; label: string }[] = [
{ id: "curl", label: "curl" },
{ id: "python", label: "Python" },
{ id: "tools", label: "Tools" },
];
function buildSnippets(base: string) {
return {
curl: `curl ${base}/v1/chat/completions \\
-H "Authorization: Bearer sk-unsloth-YOUR_KEY" \\
-H "Content-Type: application/json" \\
-d '{
"messages": [{"role": "user", "content": "Hello"}],
"stream": true
}'`,
python: `from openai import OpenAI
client = OpenAI(
base_url="${base}/v1",
api_key="sk-unsloth-YOUR_KEY",
)
response = client.chat.completions.create(
model="current",
messages=[{"role": "user", "content": "Hello"}],
stream=True,
)
for chunk in response:
print(chunk.choices[0].delta.content or "", end="")`,
tools: `curl ${base}/v1/chat/completions \\
-H "Authorization: Bearer sk-unsloth-YOUR_KEY" \\
-H "Content-Type: application/json" \\
-d '{
"messages": [{"role": "user", "content": "Search Python 3.13 features"}],
"enable_tools": true,
"enabled_tools": ["web_search", "python"],
"stream": true
}'`,
};
}
export function UsageExamples() {
const [open, setOpen] = useState(false);
const [lang, setLang] = useState<Lang>("curl");
const [copied, setCopied] = useState(false);
const snippets = useMemo(
() =>
buildSnippets(
typeof window !== "undefined" ? window.location.origin : "",
),
[],
);
const handleCopy = async () => {
if (await copyToClipboard(snippets[lang])) {
setCopied(true);
setTimeout(() => setCopied(false), 1800);
}
};
return (
<section className="flex flex-col">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-fit items-center gap-1.5 rounded text-xs font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-expanded={open}
>
<HugeiconsIcon
icon={ArrowDown01Icon}
className={cn("size-3.5 transition-transform", open && "rotate-180")}
/>
{open ? "Hide usage examples" : "Show usage examples"}
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: [0.165, 0.84, 0.44, 1] }}
className="overflow-hidden"
>
<div className="mt-3 overflow-hidden rounded-lg border border-border bg-muted/20">
<div className="flex items-center justify-between border-b border-border px-2 py-1.5">
<div className="flex items-center gap-0.5">
{TABS.map((t) => {
const active = lang === t.id;
return (
<button
key={t.id}
type="button"
onClick={() => setLang(t.id)}
aria-pressed={active}
className={cn(
"rounded px-2 py-1 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active
? "bg-background text-foreground shadow-border"
: "text-muted-foreground hover:text-foreground",
)}
>
{t.label}
</button>
);
})}
</div>
<button
type="button"
onClick={handleCopy}
className="flex items-center gap-1 rounded px-1.5 py-1 text-[11px] text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Copy snippet"
>
<HugeiconsIcon
icon={copied ? Tick02Icon : Copy01Icon}
className={cn("size-3.5", copied && "text-emerald-600")}
/>
{copied ? "Copied" : "Copy"}
</button>
</div>
<pre className="overflow-x-auto p-3 font-mono text-[11px] leading-relaxed text-foreground">
{snippets[lang]}
</pre>
</div>
</motion.div>
)}
</AnimatePresence>
</section>
);
}

View file

@ -0,0 +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
export { SettingsDialog } from "./settings-dialog";
export { useSettingsDialogStore } from "./stores/settings-dialog-store";
export type { SettingsTab } from "./stores/settings-dialog-store";

View file

@ -0,0 +1,148 @@
// 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 {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import {
Cancel01Icon,
Key01Icon,
Message01Icon,
PaintBrush02Icon,
Settings02Icon,
SparklesIcon,
UserIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { motion, useReducedMotion } from "motion/react";
import { useSettingsDialogStore, type SettingsTab } from "./stores/settings-dialog-store";
import { AboutTab } from "./tabs/about-tab";
import { ApiKeysTab } from "./tabs/api-keys-tab";
import { AppearanceTab } from "./tabs/appearance-tab";
import { ChatTab } from "./tabs/chat-tab";
import { GeneralTab } from "./tabs/general-tab";
import { ProfileTab } from "./tabs/profile-tab";
interface TabDef {
id: SettingsTab;
label: string;
icon: typeof Settings02Icon;
}
const TABS: TabDef[] = [
{ id: "general", label: "General", icon: Settings02Icon },
{ id: "profile", label: "Profile", icon: UserIcon },
{ id: "appearance", label: "Appearance", icon: PaintBrush02Icon },
{ id: "chat", label: "Chat", icon: Message01Icon },
{ id: "api-keys", label: "API Keys", icon: Key01Icon },
{ id: "about", label: "About", icon: SparklesIcon },
];
function renderTab(tab: SettingsTab) {
switch (tab) {
case "general":
return <GeneralTab />;
case "profile":
return <ProfileTab />;
case "appearance":
return <AppearanceTab />;
case "chat":
return <ChatTab />;
case "api-keys":
return <ApiKeysTab />;
case "about":
return <AboutTab />;
}
}
export function SettingsDialog() {
const open = useSettingsDialogStore((s) => s.open);
const activeTab = useSettingsDialogStore((s) => s.activeTab);
const setActiveTab = useSettingsDialogStore((s) => s.setActiveTab);
const closeDialog = useSettingsDialogStore((s) => s.closeDialog);
const reduced = useReducedMotion();
return (
<Dialog open={open} onOpenChange={(o) => !o && closeDialog()}>
<DialogContent
showCloseButton={false}
overlayClassName="bg-background/40"
className={cn(
"!max-w-none h-[560px] w-[820px] p-0 overflow-hidden",
"shadow-border rounded-xl border-border",
"sm:h-[560px] sm:w-[820px]",
"max-sm:h-dvh max-sm:w-dvw max-sm:rounded-none",
)}
>
<DialogTitle className="sr-only">Settings</DialogTitle>
<DialogDescription className="sr-only">
Manage your Unsloth Studio preferences.
</DialogDescription>
<div className="flex h-full min-h-0">
<aside className="font-heading flex w-[200px] shrink-0 flex-col border-r border-border bg-muted/20 p-2">
<nav className="flex flex-col gap-0.5">
{TABS.map((tab) => {
const active = activeTab === tab.id;
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={cn(
"relative flex h-[30px] items-center gap-2.5 rounded-[8px] px-2.5 text-sm font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
active
? "text-black dark:text-white"
: "text-[#383835] dark:text-[#c7c7c4] hover:bg-[#ececec] dark:hover:bg-[#2e3035] hover:text-black dark:hover:text-white",
)}
>
{active && (
<motion.span
layoutId="settings-active-pill"
className="absolute inset-0 rounded-[8px] bg-[#ececec] dark:bg-[#2e3035]"
transition={
reduced
? { duration: 0 }
: {
type: "spring",
stiffness: 500,
damping: 35,
mass: 0.5,
}
}
/>
)}
<HugeiconsIcon
icon={tab.icon}
strokeWidth={1.5}
className="relative z-10 size-[18px]"
/>
<span className="relative z-10">{tab.label}</span>
</button>
);
})}
</nav>
</aside>
<main className="relative flex min-w-0 flex-1 flex-col">
<button
type="button"
onClick={closeDialog}
className="absolute top-3 right-3 z-10 flex size-7 items-center justify-center rounded-[8px] text-[#383835] dark:text-[#c7c7c4] transition-colors hover:bg-[#ececec] dark:hover:bg-[#2e3035] hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Close settings"
>
<HugeiconsIcon icon={Cancel01Icon} className="size-4" />
</button>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-6">
{renderTab(activeTab)}
</div>
</main>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,53 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { create } from "zustand";
export type SettingsTab =
| "general"
| "profile"
| "appearance"
| "chat"
| "api-keys"
| "about";
interface SettingsDialogState {
open: boolean;
activeTab: SettingsTab;
openDialog: (tab?: SettingsTab) => void;
closeDialog: () => void;
setActiveTab: (tab: SettingsTab) => void;
}
const ACTIVE_TAB_KEY = "unsloth_settings_active_tab";
function loadInitialTab(): SettingsTab {
if (typeof window === "undefined") return "general";
let stored: string | null = null;
try {
stored = window.localStorage.getItem(ACTIVE_TAB_KEY);
} catch {
return "general";
}
const valid: SettingsTab[] = ["general", "profile", "appearance", "chat", "api-keys", "about"];
return valid.includes(stored as SettingsTab) ? (stored as SettingsTab) : "general";
}
export const useSettingsDialogStore = create<SettingsDialogState>((set) => ({
open: false,
activeTab: loadInitialTab(),
openDialog: (tab) =>
set((state) => ({
open: true,
activeTab: tab ?? state.activeTab,
})),
closeDialog: () => set({ open: false }),
setActiveTab: (tab) => {
try {
window.localStorage.setItem(ACTIVE_TAB_KEY, tab);
} catch {
// ignore storage failures
}
set({ activeTab: tab });
},
}));

View file

@ -0,0 +1,96 @@
// 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 { useSyncExternalStore } from "react";
export type Theme = "light" | "dark" | "system";
export type ResolvedTheme = "light" | "dark";
const STORAGE_KEY = "theme";
function readStoredTheme(): Theme {
if (typeof window === "undefined") return "system";
let stored: string | null = null;
try {
stored = window.localStorage.getItem(STORAGE_KEY);
} catch {
return "system";
}
if (stored === "light" || stored === "dark" || stored === "system") return stored;
return "system";
}
function systemPrefersDark(): boolean {
if (typeof window === "undefined") return false;
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
function resolveTheme(theme: Theme): ResolvedTheme {
if (theme === "system") return systemPrefersDark() ? "dark" : "light";
return theme;
}
function applyToDocument(resolved: ResolvedTheme) {
if (typeof document === "undefined") return;
document.documentElement.classList.toggle("dark", resolved === "dark");
}
const listeners = new Set<() => void>();
function subscribe(cb: () => void) {
listeners.add(cb);
if (typeof window === "undefined") {
return () => listeners.delete(cb);
}
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const syncTheme = () => {
applyToDocument(resolveTheme(readStoredTheme()));
cb();
};
const onStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY || e.key === null) syncTheme();
};
mq.addEventListener("change", syncTheme);
window.addEventListener("storage", onStorage);
return () => {
listeners.delete(cb);
mq.removeEventListener("change", syncTheme);
window.removeEventListener("storage", onStorage);
};
}
function getSnapshot(): Theme {
return readStoredTheme();
}
function getServerSnapshot(): Theme {
return "system";
}
/**
* Single source of truth for setting the theme. All writers (the Settings
* dialog's segmented control AND the sidebar dropdown's animated toggler)
* must route through this so the DOM class, localStorage, and React
* subscribers stay in sync.
*/
export function setTheme(next: Theme): void {
if (typeof window === "undefined") return;
// Persist "system" explicitly so next-themes (mounted with
// defaultTheme="light") doesn't clobber the choice on reload.
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// ignore storage failures
}
applyToDocument(resolveTheme(next));
listeners.forEach((cb) => cb());
}
export function useTheme(): {
theme: Theme;
resolved: ResolvedTheme;
setTheme: (next: Theme) => void;
} {
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const resolved = resolveTheme(theme);
return { theme, resolved, setTheme };
}

View file

@ -0,0 +1,120 @@
// 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 { Button } from "@/components/ui/button";
import { ShutdownDialog } from "@/components/shutdown-dialog";
import { UpdateStudioInstructions } from "../components/update-studio-instructions";
import { usePlatformStore } from "@/config/env";
import { removeTrainingUnloadGuard } from "@/features/training/hooks/use-training-unload-guard";
import {
ArrowUpRight01Icon,
Book03Icon,
Cancel01Icon,
MessageNotification01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useState } from "react";
import { SettingsRow } from "../components/settings-row";
import { SettingsSection } from "../components/settings-section";
export function AboutTab() {
const deviceType = usePlatformStore((s) => s.deviceType);
const defaultShell = deviceType === "windows" ? "windows" : "unix";
const [shutdownOpen, setShutdownOpen] = useState(false);
const [version, setVersion] = useState("dev");
useEffect(() => {
let canceled = false;
(async () => {
try {
const res = await fetch("/api/health");
if (!res.ok) return;
const data = (await res.json()) as { version?: string };
if (!canceled && data.version) {
setVersion(data.version);
}
} catch {
// fall back to dev label
}
})();
return () => {
canceled = true;
};
}, []);
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-semibold font-heading">About</h1>
<p className="text-xs text-muted-foreground">
Unsloth Studio build info and support.
</p>
</header>
<SettingsSection title="Studio">
<SettingsRow label="Version">
<code className="font-mono text-xs text-muted-foreground">{version}</code>
</SettingsRow>
</SettingsSection>
<SettingsSection title="Updates">
<div className="py-2">
<UpdateStudioInstructions defaultShell={defaultShell} showTitle={false} />
</div>
</SettingsSection>
<SettingsSection title="Help">
<SettingsRow label="Documentation">
<a
href="https://unsloth.ai/docs"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
>
<HugeiconsIcon icon={Book03Icon} className="size-3.5" />
unsloth.ai/docs
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
</a>
</SettingsRow>
<SettingsRow label="Feedback">
<a
href="https://github.com/unslothai/unsloth/issues"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
>
<HugeiconsIcon icon={MessageNotification01Icon} className="size-3.5" />
Report an issue
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
</a>
</SettingsRow>
</SettingsSection>
<SettingsSection title="Danger zone">
<SettingsRow
destructive
label="Shut down Unsloth Studio"
description="Stops the Studio server process and ends your session."
>
<Button
variant="outline"
size="sm"
onClick={() => setShutdownOpen(true)}
className="text-destructive hover:text-destructive hover:border-destructive/60"
>
<HugeiconsIcon icon={Cancel01Icon} className="size-3.5 mr-1.5" />
Shut down
</Button>
</SettingsRow>
</SettingsSection>
<ShutdownDialog
open={shutdownOpen}
onOpenChange={setShutdownOpen}
onAfterShutdown={removeTrainingUnloadGuard}
/>
</div>
);
}

View file

@ -0,0 +1,159 @@
// 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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useState } from "react";
import { fetchApiKeys, revokeApiKey, type ApiKey } from "../api/api-keys";
import { ApiKeyRow } from "../components/api-key-row";
import { CreateKeyForm } from "../components/create-key-form";
import { KeyRevealCard } from "../components/key-reveal-card";
import { UsageExamples } from "../components/usage-examples";
export function ApiKeysTab() {
const [keys, setKeys] = useState<ApiKey[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [revokeTarget, setRevokeTarget] = useState<ApiKey | null>(null);
const [revoking, setRevoking] = useState(false);
const [revealed, setRevealed] = useState<string | null>(null);
const reduced = useReducedMotion();
const t = reduced
? { duration: 0 }
: { duration: 0.18, ease: [0.165, 0.84, 0.44, 1] as const };
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
setKeys(await fetchApiKeys());
} catch (e) {
setError(e instanceof Error ? e.message : "Couldn't load API keys.");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const confirmRevoke = async () => {
if (!revokeTarget) return;
setRevoking(true);
try {
await revokeApiKey(revokeTarget.id);
await load();
setRevokeTarget(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Couldn't revoke key.");
} finally {
setRevoking(false);
}
};
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-semibold font-heading">API Keys</h1>
<p className="text-xs text-muted-foreground">
Access Unsloth Studio programmatically via the OpenAI-compatible API.
</p>
</header>
<AnimatePresence mode="wait" initial={false}>
{revealed !== null ? (
<motion.div
key="reveal"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={t}
>
<KeyRevealCard
rawKey={revealed}
onDone={() => setRevealed(null)}
/>
</motion.div>
) : (
<motion.div
key="form"
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={t}
>
<CreateKeyForm
onCreated={(raw) => {
setRevealed(raw);
void load();
}}
onError={setError}
/>
</motion.div>
)}
</AnimatePresence>
<section className="flex flex-col">
<h2 className="mb-2 text-sm font-semibold text-foreground">Your keys</h2>
{error ? (
<div className="rounded-md border border-destructive/20 bg-destructive/5 p-3 text-xs text-destructive">
{error}
</div>
) : loading ? (
<div className="flex flex-col gap-2 py-2">
{[0, 1].map((i) => (
<div
key={i}
className="h-12 animate-pulse rounded-md bg-muted/40"
/>
))}
</div>
) : keys.length === 0 ? (
<p className="py-6 text-center text-xs text-muted-foreground">
No API keys yet.
</p>
) : (
<div className="flex flex-col">
{keys.map((k) => (
<ApiKeyRow key={k.id} apiKey={k} onRevoke={setRevokeTarget} />
))}
</div>
)}
</section>
<UsageExamples />
<Dialog open={revokeTarget !== null} onOpenChange={(o) => !o && setRevokeTarget(null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Revoke key {revokeTarget?.name}?</DialogTitle>
<DialogDescription>
Applications using this key will immediately lose access. This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setRevokeTarget(null)}>
Cancel
</Button>
<Button
onClick={confirmRevoke}
disabled={revoking}
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
>
{revoking ? "Revoking…" : `Revoke “${revokeTarget?.name}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show more