Merge remote-tracking branch 'origin/main' into feature/rag

# Conflicts:
#	studio/backend/core/inference/tools.py
#	studio/frontend/src/components/assistant-ui/sources.tsx
#	studio/frontend/src/components/assistant-ui/thread.tsx
#	studio/frontend/src/features/chat/api/chat-adapter.ts
#	studio/frontend/src/features/chat/chat-settings-sheet.tsx
#	studio/frontend/src/features/chat/shared-composer.tsx
#	studio/frontend/src/features/chat/stores/chat-runtime-store.ts
This commit is contained in:
Roland Tannous 2026-06-02 07:27:59 +04:00
commit 66a9ee258d
94 changed files with 9424 additions and 1277 deletions

View file

@ -72,6 +72,31 @@ concurrency:
permissions:
contents: read
# ──────────────────────────────────────────────────────────────────────
# Network-resilience knobs, applied to every job/step. These add retries
# and backoff ONLY; they do not relax a single integrity check. cargo
# still resolves against Cargo.lock (--locked), pip still verifies the
# wheels it downloads, npm still enforces package-lock integrity, the
# harden-runner egress allowlists below are unchanged, and every action
# stays SHA-pinned. The advisory-audit run on 2026-05-29 red-failed when
# one crates.io tarball fetch hit "Recv failure: Connection reset by
# peer" (curl 56); cargo's default of 3 retries over an HTTP/2-multiplexed
# connection did not recover. The settings below make that class of
# transient fault self-heal instead of failing the whole run.
env:
# pip: raise the built-in retry count and per-connection timeout.
PIP_RETRIES: "10"
PIP_DEFAULT_TIMEOUT: "60"
# cargo: retry network ops and disable HTTP/2 multiplexing -- the
# documented mitigation for the curl-56 connection resets above.
CARGO_NET_RETRY: "10"
CARGO_HTTP_MULTIPLEXING: "false"
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
# npm: retry registry fetches with capped exponential backoff.
NPM_CONFIG_FETCH_RETRIES: "5"
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: "2000"
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: "60000"
jobs:
# ─────────────────────────────────────────────────────────────────────
# Combined advisory-DB audit: pip-audit + npm audit + cargo audit
@ -140,7 +165,7 @@ jobs:
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable @ 2026-03-27
- uses: swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1
- uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: studio/src-tauri -> target
@ -153,8 +178,23 @@ jobs:
# crashes with a TOML parse error on that file.
# npm audit is bundled with the node toolchain, no install.
run: |
python -m pip install --upgrade pip 'pip-audit>=2.7'
cargo install --locked --version '^0.22' cargo-audit
retry() { # retry <max-attempts> <command...> with exponential backoff
local max="$1"; shift
local n=1 delay=5
until "$@"; do
if [ "$n" -ge "$max" ]; then
echo "::error::command failed after ${n} attempts: $*" >&2
return 1
fi
echo "attempt ${n}/${max} failed; retrying in ${delay}s: $*" >&2
sleep "$delay"; n=$((n + 1)); delay=$((delay * 2))
done
}
retry 5 python -m pip install --upgrade pip 'pip-audit>=2.7'
# --locked keeps the resolved tree identical to Cargo.lock; the
# CARGO_NET_* env above plus this outer loop survive transient
# crates.io connection resets without weakening that guarantee.
retry 5 cargo install --locked --version '^0.22' cargo-audit
# ─────────────────────────────────────────────────────────────
# Python: pip-audit
@ -330,32 +370,60 @@ jobs:
# ─────────────────────────────────────────────────────────────
# OSV-Scanner: cross-ecosystem advisory DB (PyPI + npm + cargo)
# ─────────────────────────────────────────────────────────────
- name: Download + verify OSV-Scanner
# Split out from the scan below so binary integrity is a HARD gate:
# a checksum mismatch (swapped release asset, the Trivy-style pivot
# this workflow refuses) fails the job instead of being swallowed by
# the scan step's continue-on-error. A download still failing after
# retries is transient, so we skip the scan rather than red-fail.
# SHA-256 verified BEFORE chmod +x / exec. Bump OSV_SHA256 in lockstep
# with OSV_VERSION (value from the release's osv-scanner_SHA256SUMS).
run: |
set -euo pipefail
OSV_VERSION="v2.0.2"
OSV_SHA256="3abcfd7126c453a00421487e721b296e0cb68085bd431d6cef60872774170fc8"
if ! curl --proto '=https' --tlsv1.2 -fsSL \
--retry 5 --retry-delay 3 --retry-connrefused --retry-all-errors \
-o /tmp/osv-scanner \
"https://github.com/google/osv-scanner/releases/download/${OSV_VERSION}/osv-scanner_linux_amd64"; then
echo "::warning::osv-scanner download failed after retries; skipping scan" >&2
rm -f /tmp/osv-scanner
exit 0 # transient availability: do not red-fail the job
fi
if ! echo "${OSV_SHA256} /tmp/osv-scanner" | sha256sum -c -; then
echo "::error::osv-scanner checksum mismatch; refusing to execute" >&2
rm -f /tmp/osv-scanner
exit 1 # integrity failure: hard-fail
fi
chmod +x /tmp/osv-scanner
/tmp/osv-scanner --version
- name: OSV-Scanner (PyPI + npm + cargo, cross-ecosystem advisories)
# OSV's advisory feed is a superset of GitHub-Advisory + RustSec
# + npm advisories; running it alongside the per-ecosystem audit
# tools catches CVEs that haven't propagated to the per-ecosystem
# DBs yet (e.g. langchain-core CVE-2025-68664 was on OSV before
# GitHub Advisory). Single binary, one transitive resolver, all
# three lockfile types in one pass. Non-blocking until baselines
# close.
# three lockfile types in one pass. Binary is checksum-verified in
# the step above; only the advisory scan stays non-blocking until
# baselines close.
continue-on-error: true
run: |
set +e
# OSV-Scanner ships a raw binary (no tarball) in v2.x.
curl -fsSL -o /tmp/osv-scanner \
https://github.com/google/osv-scanner/releases/download/v2.0.2/osv-scanner_linux_amd64
chmod +x /tmp/osv-scanner
/tmp/osv-scanner --version
/tmp/osv-scanner scan source \
--lockfile=studio/frontend/package-lock.json \
--lockfile=studio/src-tauri/Cargo.lock \
--lockfile=requirements.txt:audit-reqs/unsloth-deps.txt \
--lockfile=requirements.txt:audit-reqs/studio.txt \
--lockfile=requirements.txt:audit-reqs/no-torch-runtime.txt \
--lockfile=requirements.txt:audit-reqs/overrides.txt \
--lockfile=requirements.txt:audit-reqs/extras.txt \
--lockfile=requirements.txt:audit-reqs/extras-no-deps.txt \
--format=table 2>&1 | tee logs-osv-scanner.txt
if [ ! -x /tmp/osv-scanner ]; then
echo "osv-scanner unavailable this run; skipping scan" | tee logs-osv-scanner.txt
else
/tmp/osv-scanner scan source \
--lockfile=studio/frontend/package-lock.json \
--lockfile=studio/src-tauri/Cargo.lock \
--lockfile=requirements.txt:audit-reqs/unsloth-deps.txt \
--lockfile=requirements.txt:audit-reqs/studio.txt \
--lockfile=requirements.txt:audit-reqs/no-torch-runtime.txt \
--lockfile=requirements.txt:audit-reqs/overrides.txt \
--lockfile=requirements.txt:audit-reqs/extras.txt \
--lockfile=requirements.txt:audit-reqs/extras-no-deps.txt \
--format=table 2>&1 | tee logs-osv-scanner.txt
fi
{
echo "## OSV-Scanner (cross-ecosystem)"
echo
@ -1075,7 +1143,23 @@ jobs:
# new-install-script gate below protects against, and we must
# not run any third-party hook to set up the audit.
working-directory: studio/frontend
run: npm ci --ignore-scripts
run: |
retry() { # retry <max-attempts> <command...> with exponential backoff
local max="$1"; shift
local n=1 delay=5
until "$@"; do
if [ "$n" -ge "$max" ]; then
echo "::error::command failed after ${n} attempts: $*" >&2
return 1
fi
echo "attempt ${n}/${max} failed; retrying in ${delay}s: $*" >&2
sleep "$delay"; n=$((n + 1)); delay=$((delay * 2))
done
}
# --ignore-scripts is mandatory here (no third-party hook runs);
# the retry only re-attempts the registry fetch, it never relaxes
# that flag or the package-lock integrity check npm ci enforces.
retry 5 npm ci --ignore-scripts
- name: npm audit signatures (informational)
# Surfaces unsigned / mis-signed packages from the npm

View file

@ -296,6 +296,8 @@ jobs:
- name: Upload logs
# Always upload so green runs are still reviewable.
if: always()
# Diagnostic only: a transient artifact-service drop must not fail a green job.
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: openai-anthropic-log
@ -771,6 +773,8 @@ jobs:
- name: Upload logs
# Always upload so green runs are still reviewable.
if: always()
# Diagnostic only: a transient artifact-service drop must not fail a green job.
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: tool-calling-log
@ -1043,6 +1047,8 @@ jobs:
- name: Upload logs
# Always upload so green runs are still reviewable.
if: always()
# Diagnostic only: a transient artifact-service drop must not fail a green job.
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: json-images-log

View file

@ -289,6 +289,8 @@ jobs:
- name: Upload logs
# Always upload so green runs are still reviewable.
if: always()
# Diagnostic only: a transient artifact-service drop must not fail a green job.
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: openai-anthropic-log
@ -649,6 +651,8 @@ jobs:
- name: Upload logs
# Always upload so green runs are still reviewable.
if: always()
# Diagnostic only: a transient artifact-service drop must not fail a green job.
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: tool-calling-log
@ -1025,6 +1029,8 @@ jobs:
- name: Upload logs
# Always upload so green runs are still reviewable.
if: always()
# Diagnostic only: a transient artifact-service drop must not fail a green job.
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: json-images-log

View file

@ -65,6 +65,18 @@ jobs:
with:
persist-credentials: false
# Fast GPU-free gate: parse setup.ps1 and run the Resolve-CudaToolkit unit
# test (deferred Windows CUDA Toolkit check) before the heavy GGUF smoke.
- name: setup.ps1 unit test (Resolve-CudaToolkit)
shell: pwsh
run: |
$errs = $null
[void][System.Management.Automation.Language.Parser]::ParseFile(
(Resolve-Path studio/setup.ps1).Path, [ref]$null, [ref]$errs)
if ($errs) { $errs | ForEach-Object { $_.ToString() }; exit 1 }
Write-Host "setup.ps1 parsed with no errors"
pwsh -NoProfile -File tests/studio/test_resolve_cuda_toolkit.ps1
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
@ -360,6 +372,9 @@ jobs:
- name: Collect llama-server logs
if: always()
# A transient Windows DLL-init crash (0xC0000142) in this diagnostic
# copy must not fail an otherwise-green job.
continue-on-error: true
shell: bash
# Copy llama-server's own stdout/stderr (teed by Studio under
# ~/.unsloth/studio/logs/llama-server/) into the workspace so
@ -373,6 +388,8 @@ jobs:
- name: Upload logs
if: always()
# Diagnostic only: a transient artifact-service drop must not fail a green job.
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: windows-openai-anthropic-log
@ -788,6 +805,9 @@ jobs:
- name: Collect llama-server logs
if: always()
# A transient Windows DLL-init crash (0xC0000142) in this diagnostic
# copy must not fail an otherwise-green job.
continue-on-error: true
shell: bash
# Copy llama-server's own stdout/stderr (teed by Studio under
# ~/.unsloth/studio/logs/llama-server/) into the workspace so
@ -801,6 +821,8 @@ jobs:
- name: Upload logs
if: always()
# Diagnostic only: a transient artifact-service drop must not fail a green job.
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: windows-tool-calling-log
@ -1186,6 +1208,9 @@ jobs:
- name: Collect llama-server logs
if: always()
# A transient Windows DLL-init crash (0xC0000142) in this diagnostic
# copy must not fail an otherwise-green job.
continue-on-error: true
shell: bash
# Copy llama-server's own stdout/stderr (teed by Studio under
# ~/.unsloth/studio/logs/llama-server/) into the workspace so
@ -1199,6 +1224,8 @@ jobs:
- name: Upload logs
if: always()
# Diagnostic only: a transient artifact-service drop must not fail a green job.
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: windows-json-images-log

View file

@ -72,10 +72,13 @@ Unsloth Studio (Beta) works on **Windows, Linux, WSL** and **macOS**.
```bash
curl -fsSL https://unsloth.ai/install.sh | sh
```
Use the same command to update.
#### Windows:
```powershell
irm https://unsloth.ai/install.ps1 | iex
```
Use the same command to update.
#### Launch
```bash
@ -83,9 +86,6 @@ unsloth studio -p 8888
```
For cloud or global access, add `-H 0.0.0.0`. By default, Unsloth is accessible only locally.
#### Update
To update, use the same install commands above or use `unsloth studio update`.
#### Docker
Use our [Docker image](https://hub.docker.com/r/unsloth/unsloth) ```unsloth/unsloth``` container. Run:
```bash
@ -171,7 +171,9 @@ unsloth studio -p 8888
```
Then to update :
```bash
unsloth studio update
cd unsloth && git pull
./install.sh --local
unsloth studio -p 8888
```
#### Developer installs: Windows PowerShell:
@ -184,7 +186,9 @@ unsloth studio -p 8888
```
Then to update :
```bash
unsloth studio update
cd unsloth && git pull
./install.sh --local
unsloth studio -p 8888
```
#### Nightly: MacOS, Linux, WSL:

View file

@ -1566,7 +1566,7 @@ shell.Run cmd, 0, False
if ($SkipTorch) {
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.8" unsloth-zoo }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.10" unsloth-zoo }
if ($baseInstallExit -eq 0) {
# Resolve pydantic WITH deps so pip pins pydantic-core
# to the matching version (no-torch-runtime.txt below
@ -1580,7 +1580,7 @@ shell.Run cmd, 0, False
}
}
} else {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.8" unsloth-zoo }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.10" unsloth-zoo }
}
if ($baseInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
@ -1627,7 +1627,7 @@ shell.Run cmd, 0, False
if ($SkipTorch) {
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.5.8" unsloth-zoo }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.5.10" unsloth-zoo }
if ($baseInstallExit -eq 0) {
# Same pydantic-with-deps trick as the migrated branch.
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython pydantic }
@ -1639,7 +1639,7 @@ shell.Run cmd, 0, False
}
}
} elseif ($StudioLocalInstall) {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.5.8" unsloth-zoo }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.5.10" unsloth-zoo }
} else {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" }
}
@ -1667,7 +1667,7 @@ shell.Run cmd, 0, False
Write-TauriLog "STEP" "Installing unsloth"
substep "installing unsloth (this may take a few minutes)..."
if ($StudioLocalInstall) {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.5.8" --torch-backend=auto }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.5.10" --torch-backend=auto }
if ($baseInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)

View file

@ -1530,17 +1530,51 @@ if [ -x "$VENV_DIR/bin/python" ]; then
: > "$VENV_DIR/.unsloth-studio-owned" 2>/dev/null || true
fi
# Guard against Python 3.13.8 torch import bug on Apple Silicon
# (skip when the user explicitly chose a version via --python)
# Guard against two independent Apple Silicon venv problems, in order:
# 1. uv may create the venv from a cached x86_64 (Rosetta) Python when a
# same-version x86_64 build is already cached (often because uv itself
# is an x86_64 build). That venv reports x86_64 to wheel resolvers, and
# PyTorch ships no macOS wheels on the CPU index for any architecture,
# so the torch install can never resolve. Recreate it with an
# arch-explicit arm64 CPython.
# 2. Python 3.13.8 has a known torch import bug.
# The two are independent: a venv may be x86_64 and, once recreated, still
# land on 3.13.8. So we re-inspect the interpreter between the checks instead
# of chaining them with elif, guaranteeing both invariants hold on whatever
# venv we end up with. Skip both when the user explicitly chose an interpreter
# via --python.
if [ -z "$_USER_PYTHON" ] && [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then
_PY_VER=$("$VENV_DIR/bin/python" -c \
"import sys; print('{}.{}.{}'.format(*sys.version_info[:3]))" 2>/dev/null || echo "")
_inspect_venv() {
"$VENV_DIR/bin/python" -c \
"import platform, sys; print(platform.machine(), '{}.{}.{}'.format(*sys.version_info[:3]))" \
2>/dev/null || echo " "
}
_info=$(_inspect_venv)
_VENV_ARCH=${_info%% *}
_PY_VER=${_info##* }
if [ "$_VENV_ARCH" = "x86_64" ]; then
echo " WARNING: venv was created with an x86_64 (Rosetta) Python on Apple Silicon."
echo " Recreating venv with native arm64 Python ${PYTHON_VERSION}..."
rm -rf "$VENV_DIR"
run_install_cmd "recreate venv (arm64)" uv venv "$VENV_DIR" \
--python "cpython-${PYTHON_VERSION}-macos-aarch64-none"
if [ -x "$VENV_DIR/bin/python" ]; then
: > "$VENV_DIR/.unsloth-studio-owned" 2>/dev/null || true
fi
# Re-inspect: the recreated arm64 venv may still be 3.13.8.
_info=$(_inspect_venv)
_VENV_ARCH=${_info%% *}
_PY_VER=${_info##* }
fi
if [ "$_PY_VER" = "3.13.8" ]; then
echo " WARNING: Python 3.13.8 has a known torch import bug."
echo " Recreating venv with Python 3.12..."
rm -rf "$VENV_DIR"
PYTHON_VERSION="3.12"
run_install_cmd "recreate venv" uv venv "$VENV_DIR" --python "$PYTHON_VERSION"
run_install_cmd "recreate venv" uv venv "$VENV_DIR" \
--python "cpython-${PYTHON_VERSION}-macos-aarch64-none"
if [ -x "$VENV_DIR/bin/python" ]; then
: > "$VENV_DIR/.unsloth-studio-owned" 2>/dev/null || true
fi
@ -2049,7 +2083,7 @@ if [ "$_MIGRATED" = true ]; then
# to prevent transitive torch resolution.
run_install_cmd "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
--reinstall-package unsloth --reinstall-package unsloth-zoo \
"unsloth>=2026.5.8" unsloth-zoo
"unsloth>=2026.5.10" unsloth-zoo
# Resolve pydantic WITH deps so pip pins pydantic-core to the
# matching version (no-torch-runtime.txt below is --no-deps).
# All transitive deps are torch-free.
@ -2062,7 +2096,7 @@ if [ "$_MIGRATED" = true ]; then
else
run_install_cmd "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \
--reinstall-package unsloth --reinstall-package unsloth-zoo \
"unsloth>=2026.5.8" unsloth-zoo
"unsloth>=2026.5.10" unsloth-zoo
fi
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
substep "overlaying local repo (editable)..."
@ -2266,7 +2300,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
run_install_cmd "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
--upgrade-package unsloth --upgrade-package unsloth-zoo \
"unsloth>=2026.5.8" unsloth-zoo
"unsloth>=2026.5.10" unsloth-zoo
# Same pydantic-with-deps trick as the migrated branch.
run_install_cmd "install pydantic (with deps for compatible core)" \
uv pip install --python "$_VENV_PY" pydantic
@ -2284,7 +2318,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
fi
elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then
run_install_cmd "install unsloth (local)" uv pip install --python "$_VENV_PY" \
--upgrade-package unsloth "unsloth>=2026.5.8" unsloth-zoo
--upgrade-package unsloth "unsloth>=2026.5.10" unsloth-zoo
substep "overlaying local repo (editable)..."
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
substep "overlaying unsloth-zoo from git main..."
@ -2316,7 +2350,7 @@ else
tauri_log "STEP" "Installing Unsloth"
substep "installing unsloth (this may take a few minutes)..."
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.5.8" --torch-backend=auto
run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.5.10" --torch-backend=auto
substep "overlaying local repo (editable)..."
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
substep "overlaying unsloth-zoo from git main..."

View file

@ -69,7 +69,7 @@ triton = [
]
huggingfacenotorch = [
"unsloth_zoo>=2026.5.4",
"unsloth_zoo>=2026.5.5",
"wheel>=0.42.0",
"packaging",
"numpy",
@ -90,7 +90,7 @@ huggingfacenotorch = [
]
huggingface = [
"unsloth[huggingfacenotorch]",
"unsloth_zoo>=2026.5.4",
"unsloth_zoo>=2026.5.5",
"torchvision",
"unsloth[triton]",
]
@ -580,7 +580,7 @@ colab-ampere-torch220 = [
"flash-attn>=2.6.3 ; ('linux' in sys_platform)",
]
colab-new = [
"unsloth_zoo>=2026.5.4",
"unsloth_zoo>=2026.5.5",
"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

@ -176,12 +176,21 @@ def build_mcp_providers(
) -> list:
from data_designer.config.mcp import LocalStdioMCPProvider, MCPProvider # pyright: ignore[reportMissingImports]
# Same gate as the chat MCP path: stdio providers spawn a local subprocess,
# so only build them when this host allows it (desktop / explicit opt-in).
# Skip them otherwise so a recipe carried onto a hosted host cannot spawn.
from core.inference.mcp_client import stdio_mcp_enabled
stdio_allowed = stdio_mcp_enabled()
providers: list[MCPProvider | LocalStdioMCPProvider] = []
for provider in recipe.get("mcp_providers", []):
if not isinstance(provider, dict):
continue
provider_type = provider.get("provider_type")
if provider_type == "stdio":
if not stdio_allowed:
continue
env = provider.get("env")
if not isinstance(env, dict):
env = {}

View file

@ -218,6 +218,8 @@ class AnthropicStreamEmitter:
def __init__(self) -> None:
self.block_index: int = 0
self._text_block_open: bool = False
self._open_tool_call_id: Optional[str] = None
self._open_tool_args_sent: bool = False
self._prev_text: str = ""
self._usage: dict = {}
@ -263,8 +265,10 @@ class AnthropicStreamEmitter:
def finish(self, stop_reason: str = "end_turn") -> list[str]:
"""Close any open block and emit message_delta + message_stop."""
events = []
if self._text_block_open:
if self._text_block_open or self._open_tool_call_id is not None:
events.append(self._close_block())
self._open_tool_call_id = None
self._open_tool_args_sent = False
events.append(
build_anthropic_sse_event(
"message_delta",
@ -310,12 +314,26 @@ class AnthropicStreamEmitter:
return events
def _handle_tool_start(self, event: dict) -> list[str]:
tool_call_id = event.get("tool_call_id", "")
args = event.get("arguments", {})
if tool_call_id and self._open_tool_call_id == tool_call_id:
return self._tool_arguments_delta(args)
events = []
# Close current text block if open
# Close current text block if open.
if self._text_block_open:
events.append(self._close_block())
# Open a tool_use block
# Defensive: if a replacement/different tool_start arrives while a
# tool_use block is open, close the stale block before starting another.
elif self._open_tool_call_id is not None:
events.append(self._close_block())
self._open_tool_call_id = None
self._open_tool_args_sent = False
# Open a tool_use block.
self.block_index += 1
self._open_tool_call_id = tool_call_id
self._open_tool_args_sent = False
events.append(
build_anthropic_sse_event(
"content_block_start",
@ -324,35 +342,43 @@ class AnthropicStreamEmitter:
"index": self.block_index,
"content_block": {
"type": "tool_use",
"id": event.get("tool_call_id", ""),
"id": tool_call_id,
"name": event.get("tool_name", ""),
"input": {},
},
},
)
)
# Emit the arguments as input_json_delta
args = event.get("arguments", {})
if args:
events.append(
build_anthropic_sse_event(
"content_block_delta",
{
"type": "content_block_delta",
"index": self.block_index,
"delta": {
"type": "input_json_delta",
"partial_json": json.dumps(args),
},
},
)
)
events.extend(self._tool_arguments_delta(args))
return events
def _tool_arguments_delta(self, args: dict) -> list[str]:
if not args:
return []
if self._open_tool_args_sent:
return []
self._open_tool_args_sent = True
return [
build_anthropic_sse_event(
"content_block_delta",
{
"type": "content_block_delta",
"index": self.block_index,
"delta": {
"type": "input_json_delta",
"partial_json": json.dumps(args),
},
},
)
]
def _handle_tool_end(self, event: dict) -> list[str]:
events = []
# Close the tool_use block
events.append(self._close_block())
# Close the tool_use block.
if self._open_tool_call_id is not None or self._text_block_open:
events.append(self._close_block())
self._open_tool_call_id = None
self._open_tool_args_sent = False
# Emit custom tool_result event (non-standard, ignored by SDKs)
events.append(
build_anthropic_sse_event(

View file

@ -52,6 +52,7 @@ from utils.subprocess_compat import (
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
)
from core.inference.tool_call_parser import (
RENDER_HTML_REPEAT_NUDGE,
parse_tool_calls_from_text as _shared_parse_tool_calls_from_text,
)
@ -2604,6 +2605,105 @@ class LlamaCppBackend:
# ── Lifecycle ─────────────────────────────────────────────────
# GGUF ``general.architecture`` values for diffusion / image models.
# llama.cpp proper has no such architectures, so loading one as a chat
# model dies with "unknown model architecture: '<arch>'". These match
# the patched stable-diffusion.cpp / ComfyUI-GGUF enums (LLM_ARCH_FLUX,
# LLM_ARCH_QWEN_IMAGE, ...). Unsloth publishes FLUX and Qwen-Image GGUFs
# under https://huggingface.co/collections/unsloth/unsloth-diffusion-ggufs.
# Matched exactly (not as a substring) so a chat arch merely containing a
# short token like "wan"/"sd1" (e.g. "taiwan") is not misrouted to Images.
_DIFFUSION_ARCHES = frozenset(
(
"qwen_image",
"flux",
"sd1",
"sdxl",
"sd3",
"aura",
"hidream",
"cosmos",
"ltxv",
"hyvid",
"wan",
"lumina2",
)
)
@staticmethod
def _classify_llama_start_failure(
output: str,
gguf_path: Optional[str],
model_identifier: Optional[str],
) -> str:
"""Explain *why* llama-server failed to start, from its output.
Several distinct failures all otherwise collapse into the same
opaque "invalid GGUF or out of memory" message. The worst case is
a diffusion / image GGUF (FLUX, Qwen-Image, ...) loaded as a chat
model: the file is perfectly valid and there is plenty of memory,
but llama.cpp has no such architecture, so the user is told to free
memory that was never the problem (issue #5842). Pick the most
specific message the captured output supports.
"""
lowered = (output or "").lower()
# Detect Ollama source up front so the arch branch can keep the
# Ollama hint instead of the generic "unsupported arch" message.
gguf = gguf_path or ""
is_ollama = (
".studio_links" in gguf
or os.sep + "ollama_links" + os.sep in gguf
or os.sep + ".cache" + os.sep + "ollama" + os.sep in gguf
or (model_identifier or "").startswith("ollama/")
)
# "unknown model architecture: '<arch>'": diffusion -> Images page,
# Ollama -> Ollama hint, else a precise "unsupported" message. Exact
# match so chat archs are never misrouted.
arch_match = re.search(r"unknown model architecture:\s*'([^']+)'", lowered)
if arch_match:
arch = arch_match.group(1)
if arch in LlamaCppBackend._DIFFUSION_ARCHES:
return (
f"'{arch}' is a diffusion (image-generation) GGUF, which "
"llama-server cannot run as a chat/completion model. Use "
"Studio's Images page to generate with local diffusion "
"GGUFs such as FLUX and Qwen-Image."
)
if is_ollama:
return (
"Some Ollama models do not work with llama.cpp. Try a "
"different model, or use this model directly through "
"Ollama instead."
)
return (
f"llama.cpp does not support this GGUF's model architecture "
f"('{arch}'). The file is valid, but this model type cannot "
"be run with llama-server."
)
# Other Ollama compat failures that do not name an arch. Only when
# the output shows a GGUF compat issue, not OOM / missing binaries.
if is_ollama:
gguf_compat_hints = (
"key not found",
"unknown model architecture",
"failed to load model",
)
if any(h in lowered for h in gguf_compat_hints):
return (
"Some Ollama models do not work with llama.cpp. Try a "
"different model, or use this model directly through "
"Ollama instead."
)
# Fallback: genuinely unknown failure (OOM, missing binary, ...).
return (
"llama-server failed to start. "
"Check that the GGUF file is valid and you have enough memory."
)
def load_model(
self,
*,
@ -3396,31 +3496,12 @@ class LlamaCppBackend:
# Wait for llama-server to become healthy
if not self._wait_for_health(timeout = 600.0):
self._kill_process()
_gguf = gguf_path or ""
_is_ollama = (
".studio_links" in _gguf
or os.sep + "ollama_links" + os.sep in _gguf
or os.sep + ".cache" + os.sep + "ollama" + os.sep in _gguf
or (self._model_identifier or "").startswith("ollama/")
)
# Only show the Ollama-specific message when the server
# output indicates a GGUF compatibility issue, not for
# unrelated failures like OOM or missing binaries.
if _is_ollama:
_output = "\n".join(self._stdout_lines[-50:]).lower()
_gguf_compat_hints = (
"key not found",
"unknown model architecture",
"failed to load model",
)
if any(h in _output for h in _gguf_compat_hints):
raise RuntimeError(
"Some Ollama models do not work with llama.cpp. "
"Try a different model, or use this model directly through Ollama instead."
)
raise RuntimeError(
"llama-server failed to start. "
"Check that the GGUF file is valid and you have enough memory."
self._classify_llama_start_failure(
"\n".join(self._stdout_lines[-50:]),
gguf_path,
self._model_identifier,
)
)
self._healthy = True
@ -4553,6 +4634,7 @@ class LlamaCppBackend:
# a transient failure are allowed (only block when the previous
# identical call succeeded).
_tool_call_history: list[tuple[str, bool]] = [] # (key, failed)
_render_html_succeeded = False
# ── Re-prompt on plan-without-action ─────────────────
# When the model describes what it intends to do (forward-looking
@ -4627,6 +4709,7 @@ class LlamaCppBackend:
_iter_timings = None
_stream_done = False
_last_emitted = ""
provisional_render_html_tool_call_ids = set()
stream_timeout = httpx.Timeout(
connect = 10,
@ -4736,6 +4819,33 @@ class LlamaCppBackend:
tool_calls_acc[idx]["function"][
"arguments"
] += func["arguments"]
current_name = tool_calls_acc[idx][
"function"
].get("name", "")
fallback_id = f"call_{idx}"
current_id = tool_calls_acc[idx].get(
"id", fallback_id
)
already_started = (
current_id
in provisional_render_html_tool_call_ids
)
has_real_id = current_id != fallback_id
if (
current_name == "render_html"
and not _render_html_succeeded
and not already_started
and has_real_id
):
provisional_render_html_tool_call_ids.add(
current_id
)
yield {
"type": "tool_start",
"tool_name": "render_html",
"tool_call_id": current_id,
"arguments": {},
}
continue
# ── Reasoning tokens ──
@ -4917,13 +5027,25 @@ class LlamaCppBackend:
"content": _stripped,
}
)
available_tool_names = [
tool.get("function", {}).get("name")
for tool in tools
if isinstance(tool, dict)
and isinstance(tool.get("function"), dict)
]
available_tool_names = [
name for name in available_tool_names if name
]
tool_hint = (
" or ".join(available_tool_names) or "an available tool"
)
conversation.append(
{
"role": "user",
"content": (
"STOP. Do NOT write code or explain. "
"You MUST call a tool NOW. "
"Call web_search or python immediately."
f"Call {tool_hint} immediately."
),
}
)
@ -5095,7 +5217,12 @@ class LlamaCppBackend:
arguments = json.loads(raw_args)
except (json.JSONDecodeError, ValueError):
if auto_heal_tool_calls:
arguments = {"query": raw_args}
heal_key = {
"python": "code",
"terminal": "command",
"render_html": "code",
}.get(tool_name, "query")
arguments = {heal_key: raw_args}
else:
arguments = {"raw": raw_args}
else:
@ -5132,14 +5259,18 @@ class LlamaCppBackend:
)
else:
status_text = f"Calling: {tool_name}"
yield {"type": "status", "text": status_text}
_repeat_render_html = (
tool_name == "render_html" and _render_html_succeeded
)
if not _repeat_render_html:
yield {"type": "status", "text": status_text}
yield {
"type": "tool_start",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"arguments": arguments,
}
yield {
"type": "tool_start",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"arguments": arguments,
}
# ── Duplicate call detection ──────────────
# str(dict) is stable here: arguments always comes from
@ -5147,7 +5278,9 @@ class LlamaCppBackend:
# so insertion order is deterministic (Python 3.7+).
_tc_key = tool_name + str(arguments)
_prev = _tool_call_history[-1] if _tool_call_history else None
if _prev and _prev[0] == _tc_key and not _prev[1]:
if _repeat_render_html:
result = RENDER_HTML_REPEAT_NUDGE
elif _prev and _prev[0] == _tc_key and not _prev[1]:
result = (
"You already made this exact call. "
"Do not repeat the same tool call. "
@ -5186,12 +5319,13 @@ class LlamaCppBackend:
tool_context = tool_context,
)
yield {
"type": "tool_end",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"result": result,
}
if not _repeat_render_html:
yield {
"type": "tool_end",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"result": result,
}
# Nudge model to try a different approach on errors
_error_prefixes = (
@ -5207,6 +5341,8 @@ class LlamaCppBackend:
_is_error = isinstance(result, str) and result.lstrip().startswith(
_error_prefixes
)
if tool_name == "render_html" and not _is_error:
_render_html_succeeded = True
_tool_call_history.append((_tc_key, _is_error))
# Strip image sentinel before feeding result to the LLM
# (the full result with sentinel is still yielded via

View file

@ -5,6 +5,9 @@ from __future__ import annotations
import asyncio
import json
import os
import shlex
import sys
from typing import Any, Optional
from loggers import get_logger
@ -16,7 +19,55 @@ MCP_TOOL_PREFIX = "mcp__"
_oauth_token_store = None
def is_stdio(address: str) -> bool:
"""A non-HTTP address is a local stdio command, e.g.
'npx -y @modelcontextprotocol/server-filesystem /path'."""
return not address.strip().lower().startswith(("http://", "https://"))
def parse_stdio_command(address: str) -> list[str]:
"""Split a stdio command line into argv. Shared by route validation and the
transport so both agree on quoting (notably Windows backslash paths)."""
posix = sys.platform != "win32"
parts = shlex.split(address, posix = posix)
if not posix:
# posix=False keeps backslash paths intact but also keeps the surrounding
# quotes on a token. Strip a matched pair so the argv reaches the
# subprocess clean ('"C:\\Program Files\\node"' -> C:\\Program Files\\node).
parts = [
p[1:-1] if len(p) >= 2 and p[0] == p[-1] and p[0] in "\"'" else p
for p in parts
]
return parts
def stdio_mcp_enabled() -> bool:
"""stdio MCP servers spawn local processes as the backend user (and bypass
the python/terminal sandbox), so they are only allowed when the backend
host is the user's own machine. The Tauri desktop app sets
UNSLOTH_STUDIO_ALLOW_STDIO_MCP=1 (see main.py); advanced localhost /
self-hosted users can opt in with the same variable. It stays off for
Colab and any network (0.0.0.0) bind."""
return os.environ.get("UNSLOTH_STUDIO_ALLOW_STDIO_MCP") == "1"
# Probe timeouts for discovering a server's tool list. OAuth needs minutes for
# first-connect/expired-token browser sign-in; stdio allows for first-run
# package download (e.g. `npx -y ...`); HTTP fails fast.
_HTTP_PROBE_TIMEOUT = 8.0
_OAUTH_PROBE_TIMEOUT = 305.0
_STDIO_PROBE_TIMEOUT = 60.0
def probe_timeout(address: str, use_oauth: bool) -> float:
if use_oauth:
return _OAUTH_PROBE_TIMEOUT
return _STDIO_PROBE_TIMEOUT if is_stdio(address) else _HTTP_PROBE_TIMEOUT
def parse_server_headers(server: dict) -> Optional[dict]:
"""Parsed headers_json. For stdio servers this dict is the process
environment instead of HTTP headers (see _client)."""
raw = server.get("headers_json")
if not raw:
return None
@ -63,6 +114,28 @@ async def clear_oauth_tokens_async(url: str) -> None:
def _client(url: str, headers: Optional[dict], use_oauth: bool = False):
from fastmcp import Client
if is_stdio(url):
# Belt-and-suspenders: never spawn unless stdio is enabled on this host.
if not stdio_mcp_enabled():
raise PermissionError("stdio MCP servers are disabled on this host")
from fastmcp.client.transports import StdioTransport
parts = parse_stdio_command(url)
if not parts:
raise ValueError(f"Empty stdio command: {url!r}")
# env vars ride the headers field (merged over the SDK's safe default env).
# keep_alive=False tears the subprocess down on exit, so a one-shot
# probe/tool call never leaves an orphan process.
return Client(
StdioTransport(
command = parts[0],
args = parts[1:],
env = headers or None,
keep_alive = False,
)
)
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
from fastmcp.mcp_config import infer_transport_type_from_url

View file

@ -18,6 +18,7 @@ cumulative text and dispatches them via ``core.inference.tools``.
"""
import json
import re
import threading
from typing import Callable, Generator, Optional
from urllib.parse import urlparse
@ -27,6 +28,7 @@ from loggers import get_logger
from core.inference.tool_call_parser import (
BUDGET_EXHAUSTED_NUDGE,
DUPLICATE_CALL_NUDGE,
RENDER_HTML_REPEAT_NUDGE,
TOOL_ERROR_NUDGE,
TOOL_ERROR_PREFIXES,
TOOL_XML_SIGNALS,
@ -66,7 +68,34 @@ def _status_for_tool(tool_name: str, arguments: dict) -> str:
return f"Calling: {tool_name}"
_CANONICAL_HEAL_ARG = {"python": "code", "terminal": "command"}
_CANONICAL_HEAL_ARG = {
"python": "code",
"terminal": "command",
"render_html": "code",
}
_FUNCTION_SIGNAL_RE = re.compile(r"<function=([\w-]+)>")
_TOOL_CALL_NAME_RE = re.compile(r'"name"\s*:\s*"([\w-]+)"')
def _detect_render_html_tool_start(content: str) -> bool:
"""Return True when the first drained tool call is clearly render_html."""
function_match = _FUNCTION_SIGNAL_RE.search(content)
tool_call_index = content.find("<tool_call>")
if not function_match and tool_call_index < 0:
return False
if function_match and (
tool_call_index < 0 or function_match.start() < tool_call_index
):
return function_match.group(1) == "render_html"
if tool_call_index >= 0:
name_match = _TOOL_CALL_NAME_RE.search(content[tool_call_index:])
return bool(name_match and name_match.group(1) == "render_html")
return False
def _coerce_arguments(raw_args, *, heal: bool, tool_name: str = "") -> dict:
@ -136,6 +165,7 @@ def run_safetensors_tool_loop(
"""
conversation = list(messages)
tool_call_history: list[tuple[str, bool]] = []
render_html_succeeded = False
final_attempt_done = False
allowed_tool_names = {
(tool.get("function") or {}).get("name")
@ -162,6 +192,8 @@ def run_safetensors_tool_loop(
content_accum = ""
cumulative_display = ""
last_emitted = ""
provisional_render_html_started = False
provisional_render_html_id = f"call_{next_call_id}"
gen = single_turn(conversation)
prev_cumulative = ""
@ -180,6 +212,18 @@ def run_safetensors_tool_loop(
content_accum += delta
if detect_state == _state_draining:
if (
not render_html_succeeded
and not provisional_render_html_started
and _detect_render_html_tool_start(content_accum)
):
provisional_render_html_started = True
yield {
"type": "tool_start",
"tool_name": "render_html",
"tool_call_id": provisional_render_html_id,
"arguments": {},
}
continue
if detect_state == _state_streaming:
@ -197,6 +241,18 @@ def run_safetensors_tool_loop(
yield {"type": "content", "text": cleaned_before}
cumulative_display = candidate
detect_state = _state_draining
if (
not render_html_succeeded
and not provisional_render_html_started
and _detect_render_html_tool_start(content_accum)
):
provisional_render_html_started = True
yield {
"type": "tool_start",
"tool_name": "render_html",
"tool_call_id": provisional_render_html_id,
"arguments": {},
}
continue
cumulative_display = candidate
cleaned = strip_tool_markup(cumulative_display)
@ -223,6 +279,18 @@ def run_safetensors_tool_loop(
if is_match:
detect_state = _state_draining
if (
not render_html_succeeded
and not provisional_render_html_started
and _detect_render_html_tool_start(content_accum)
):
provisional_render_html_started = True
yield {
"type": "tool_start",
"tool_name": "render_html",
"tool_call_id": provisional_render_html_id,
"arguments": {},
}
elif is_prefix and len(stripped) < _MAX_BUFFER_CHARS:
continue
else:
@ -283,6 +351,13 @@ def run_safetensors_tool_loop(
# literal "<tool_call>" prose is preserved.
if content_accum:
yield {"type": "content", "text": content_accum}
if provisional_render_html_started:
yield {
"type": "tool_end",
"tool_name": "render_html",
"tool_call_id": provisional_render_html_id,
"result": "Error: render_html tool call could not be parsed.",
}
yield {"type": "status", "text": ""}
return
content_text = strip_tool_markup(content_accum, final = True)
@ -309,16 +384,20 @@ def run_safetensors_tool_loop(
tool_name = tool_name,
)
yield {"type": "status", "text": _status_for_tool(tool_name, arguments)}
yield {
"type": "tool_start",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"arguments": arguments,
}
repeat_render_html = tool_name == "render_html" and render_html_succeeded
if not repeat_render_html:
yield {"type": "status", "text": _status_for_tool(tool_name, arguments)}
yield {
"type": "tool_start",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"arguments": arguments,
}
tc_key = tool_name + str(arguments)
if allowed_tool_names and tool_name not in allowed_tool_names:
if repeat_render_html:
result = RENDER_HTML_REPEAT_NUDGE
elif allowed_tool_names and tool_name not in allowed_tool_names:
result = (
f"Error: tool '{tool_name}' is not enabled for this "
"request. Use one of the enabled tools or provide a "
@ -347,16 +426,19 @@ def run_safetensors_tool_loop(
logger.exception("Tool %s raised: %s", tool_name, exc)
result = f"Error: tool raised an exception: {exc}"
yield {
"type": "tool_end",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"result": result,
}
if not repeat_render_html:
yield {
"type": "tool_end",
"tool_name": tool_name,
"tool_call_id": tc.get("id", ""),
"result": result,
}
is_error = isinstance(result, str) and result.lstrip().startswith(
TOOL_ERROR_PREFIXES
)
if tool_name == "render_html" and not is_error:
render_html_succeeded = True
tool_call_history.append((tc_key, is_error))
# Strip frontend image sentinel from the model's view.

View file

@ -49,6 +49,12 @@ DUPLICATE_CALL_NUDGE = (
"provide your final answer now."
)
RENDER_HTML_REPEAT_NUDGE = (
"Error: render_html was already called for this response. Do not call "
"render_html again in this response unless the user asks for changes. "
"Provide the final answer now."
)
TOOL_ERROR_NUDGE = (
"\n\nThe tool call encountered an issue. Please try a different "
"approach or rephrase your request."
@ -70,6 +76,20 @@ _TC_FUNC_CLOSE_RE = re.compile(r"\s*</function>\s*$")
# `issue-number`, `repo-name`); using `\w+` here dropped those keys.
_TC_PARAM_START_RE = re.compile(r"<parameter=([\w-]+)>\s*")
_TC_PARAM_CLOSE_RE = re.compile(r"\s*</parameter>\s*$")
_PARAM_CLOSE_TAG = "</parameter>"
_FUNC_CLOSE_TAG = "</function>"
def _inside_open_parameter(content: str, pos: int) -> bool:
"""Return True when ``pos`` falls inside an unclosed parameter value."""
last_param_start = -1
for match in _TC_PARAM_START_RE.finditer(content, 0, pos):
last_param_start = match.start()
if last_param_start < 0:
return False
last_param_close = content.rfind(_PARAM_CLOSE_TAG, 0, pos)
last_func_close = content.rfind(_FUNC_CLOSE_TAG, 0, pos)
return last_param_start > max(last_param_close, last_func_close)
def strip_tool_markup(text: str, *, final: bool = False) -> str:
@ -151,7 +171,11 @@ def parse_tool_calls_from_text(content: str, *, id_offset: int = 0) -> list[dict
# optional; don't use </function> as body boundary because code
# values can contain that literal.
if not tool_calls:
func_starts = list(_TC_FUNC_START_RE.finditer(content))
func_starts = [
fm
for fm in _TC_FUNC_START_RE.finditer(content)
if not _inside_open_parameter(content, fm.start())
]
for idx, fm in enumerate(func_starts):
func_name = fm.group(1)
body_start = fm.end()

View file

@ -28,8 +28,11 @@ import urllib.request
from core.inference.mcp_client import (
MCP_TOOL_PREFIX,
call_tool_sync,
is_stdio,
list_tools_async,
parse_server_headers,
probe_timeout,
stdio_mcp_enabled,
)
from storage import mcp_servers_db
@ -418,6 +421,35 @@ _workdirs: dict[str, str] = {}
# Non-matching session_ids collapse to ``_invalid`` to block cross-session escapes.
_SESSION_ID_RE = re.compile(r"\A[A-Za-z0-9_\-]{1,64}\Z")
_PROJECT_SESSION_PREFIX = "project-"
def _get_project_workdir(session_id: str) -> str | None:
if not session_id.startswith(_PROJECT_SESSION_PREFIX):
return None
project_id = session_id[len(_PROJECT_SESSION_PREFIX) :]
if not project_id or not _SESSION_ID_RE.match(project_id):
return None
try:
from storage.studio_db import ensure_chat_project_workspace
project = ensure_chat_project_workspace(project_id)
except Exception:
logger.warning(
"Failed to resolve project sandbox for %s", session_id, exc_info = True
)
return None
if not project:
return None
root_path = project.get("rootPath")
sandbox_path = project.get("sandboxPath")
if not root_path or not sandbox_path:
return None
root_real = os.path.realpath(root_path)
sandbox_real = os.path.realpath(sandbox_path)
if sandbox_real != root_real and not sandbox_real.startswith(root_real + os.sep):
return None
return sandbox_real
def _get_workdir(session_id: str | None = None) -> str:
@ -427,7 +459,14 @@ def _get_workdir(session_id: str | None = None) -> str:
if key not in _workdirs or not os.path.isdir(_workdirs[key]):
home = os.path.expanduser("~")
sandbox_root = os.path.join(home, "studio_sandbox")
if session_id and _SESSION_ID_RE.match(session_id):
project_workdir = (
_get_project_workdir(session_id)
if session_id and _SESSION_ID_RE.match(session_id)
else None
)
if project_workdir:
workdir = project_workdir
elif session_id and _SESSION_ID_RE.match(session_id):
workdir = os.path.join(sandbox_root, session_id)
if not os.path.realpath(workdir).startswith(
os.path.realpath(sandbox_root) + os.sep
@ -450,6 +489,10 @@ def _get_workdir(session_id: str | None = None) -> str:
return _workdirs[key]
def get_sandbox_workdir(session_id: str | None = None) -> str:
return _get_workdir(session_id)
WEB_SEARCH_TOOL = {
"type": "function",
"function": {
@ -511,7 +554,6 @@ TERMINAL_TOOL = {
},
}
# Lazy import: don't pull rag stack on inference paths that never see RAG.
def _get_rag_tool_spec():
from core.rag.tool import SEARCH_KNOWLEDGE_BASE_TOOL
@ -519,7 +561,41 @@ def _get_rag_tool_spec():
return SEARCH_KNOWLEDGE_BASE_TOOL
ALL_TOOLS = [WEB_SEARCH_TOOL, PYTHON_TOOL, TERMINAL_TOOL, _get_rag_tool_spec()]
RENDER_HTML_TOOL = {
"type": "function",
"function": {
"name": "render_html",
"description": (
"Render a self-contained HTML/CSS/JavaScript artifact for the user. "
"Call this at most once per assistant response unless the user "
"explicitly asks for changes in that response. Future user requests "
"for new artifacts may call render_html once. Put the entire document "
"in code, including any CSS in <style> tags and JavaScript in <script> tags."
),
"parameters": {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "A complete self-contained HTML document.",
},
"title": {
"type": "string",
"description": "Short display title for the artifact.",
},
},
"required": ["code"],
},
},
}
ALL_TOOLS = [
WEB_SEARCH_TOOL,
PYTHON_TOOL,
TERMINAL_TOOL,
RENDER_HTML_TOOL,
_get_rag_tool_spec(),
]
# OpenAI's function.name regex: ^[a-zA-Z0-9_-]{1,64}$ -- enforced before
@ -576,17 +652,19 @@ def _mcp_specs_for_server(server: dict, mcp_tools: list[dict]) -> list[dict]:
async def get_enabled_mcp_tools() -> list[dict]:
servers = [s for s in mcp_servers_db.list_servers() if s.get("is_enabled")]
# Never spawn stdio servers when stdio is disabled on this host (e.g. a DB
# carried over from a desktop install onto a Colab / network deployment).
if not stdio_mcp_enabled():
servers = [s for s in servers if not is_stdio(s["url"])]
if not servers:
return []
# OAuth probes need minutes for first-connect/expired-token browser
# sign-in; non-OAuth probes fail fast. Matches routes/mcp_servers.py.
results = await asyncio.gather(
*(
list_tools_async(
url = s["url"],
headers = parse_server_headers(s),
timeout = 305.0 if s.get("use_oauth") else 8.0,
timeout = probe_timeout(s["url"], bool(s.get("use_oauth"))),
use_oauth = bool(s.get("use_oauth")),
)
for s in servers
@ -611,6 +689,25 @@ async def get_enabled_mcp_tools() -> list[dict]:
_TIMEOUT_UNSET = object()
def _render_html_result(arguments: dict) -> str:
code = arguments.get("code")
if not isinstance(code, str) or not code.strip():
return "Error: render_html requires a non-empty code string."
title = arguments.get("title")
if isinstance(title, str) and title.strip():
safe_title = title.strip()[:120]
return (
f"Rendered HTML artifact: {safe_title}. Do not call render_html "
"again in this response unless the user asks for changes. For a later "
"user request for a new artifact, call render_html once."
)
return (
"Rendered HTML artifact. Do not call render_html again in this response "
"unless the user asks for changes. For a later user request for a new "
"artifact, call render_html once."
)
def execute_tool(
name: str,
arguments: dict,
@ -633,6 +730,8 @@ def execute_tool(
f"execute_tool: name={name}, session_id={session_id}, timeout={timeout}"
)
effective_timeout = _EXEC_TIMEOUT if timeout is _TIMEOUT_UNSET else timeout
if name == "render_html":
return _render_html_result(arguments)
if name.startswith(MCP_TOOL_PREFIX):
try:
_, server_id, tool_name = name.split("__", 2)
@ -643,6 +742,8 @@ def execute_tool(
return f"Error: MCP server '{server_id}' not found"
if not server.get("is_enabled"):
return f"Error: MCP server '{server_id}' is disabled"
if is_stdio(server["url"]) and not stdio_mcp_enabled():
return f"Error: stdio MCP server '{server_id}' is disabled on this host"
return call_tool_sync(
url = server["url"],
headers = parse_server_headers(server),

View file

@ -298,6 +298,11 @@ def _load_desktop_owner() -> dict[str, str] | None:
_DESKTOP_OWNER = _load_desktop_owner()
# The Tauri desktop app runs the backend on the owner's own machine, so local
# stdio MCP servers are safe there. setdefault lets an explicit "0" opt out.
if _DESKTOP_OWNER:
os.environ.setdefault("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", "1")
def _desktop_owner() -> dict[str, str] | None:
return _DESKTOP_OWNER
@ -431,6 +436,7 @@ from starlette.requests import Request as _StarletteRequest # noqa: E402
_CSP_SCRIPT_NONCE_HEADER = "x-internal-script-nonce"
_ARTIFACT_PREVIEW_FRAME_PATH = "/api/inference/artifact-preview-frame"
# /content is Colab's working directory — more reliable than env vars which
@ -484,6 +490,7 @@ def _build_csp(script_nonce: "str | None" = None) -> str:
"style-src 'self' 'unsafe-inline'; "
f"{script_src}; "
"font-src 'self' data:; "
"frame-src 'self'; "
f"frame-ancestors {frame_ancestors}; "
"form-action 'self'; "
"base-uri 'self'"
@ -502,13 +509,13 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
response.headers.setdefault("Content-Security-Policy", _build_csp(nonce))
# Omit X-Frame-Options in Colab — CSP frame-ancestors handles it, and
# DENY would block serve_kernel_port_as_iframe regardless of CSP.
if not _IS_COLAB:
if not _IS_COLAB and request.url.path != _ARTIFACT_PREVIEW_FRAME_PATH:
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("Referrer-Policy", "no-referrer")
response.headers.setdefault(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(), interest-cohort=()",
"camera=(), microphone=(), geolocation=()",
)
response.headers["server"] = "unsloth-studio"
return response

View file

@ -709,11 +709,12 @@ class ChatCompletionRequest(BaseModel):
enabled_tools: Optional[list[str]] = Field(
None,
description = (
"[x-unsloth] List of enabled tool names. Local GGUF models accept "
"['web_search', 'python', 'terminal']. External providers accept "
"['web_search', 'web_fetch', 'code_execution'] for Anthropic and "
"['web_search', 'code_execution'] for OpenAI Responses. If None, "
"all local tools are enabled and no server-side tools are forwarded."
"[x-unsloth] List of enabled tool names. Local GGUF/safetensors models "
"accept ['web_search', 'python', 'terminal', 'render_html']. External "
"providers accept ['web_search', 'web_fetch', 'code_execution'] for "
"Anthropic and ['web_search', 'code_execution', 'image_generation'] for "
"OpenAI Responses. If None, all local tools are enabled and no "
"server-side tools are forwarded."
),
)
mcp_enabled: Optional[bool] = Field(

View file

@ -18,15 +18,21 @@ from storage.studio_db import (
clear_chat_history,
count_chat_threads,
delete_chat_threads,
delete_chat_project,
ensure_chat_project_workspace,
get_chat_project,
get_chat_thread,
get_chat_message,
list_chat_projects,
list_chat_legacy_imports,
list_chat_settings,
list_chat_messages,
list_chat_messages_for_threads,
list_chat_threads,
sync_chat_messages,
update_chat_project,
update_chat_thread,
upsert_chat_project,
upsert_chat_legacy_imports,
upsert_chat_message,
upsert_chat_settings_merge,
@ -42,6 +48,7 @@ class ChatThread(BaseModel):
modelType: Literal["base", "lora", "model1", "model2"]
modelId: str = ""
pairId: Optional[str] = None
projectId: Optional[str] = None
archived: bool = False
createdAt: int
openaiCodeExecContainerId: Optional[str] = None
@ -53,6 +60,7 @@ class ChatThreadPatch(BaseModel):
modelType: Optional[Literal["base", "lora", "model1", "model2"]] = None
modelId: Optional[str] = None
pairId: Optional[str] = None
projectId: Optional[str] = None
archived: Optional[bool] = None
createdAt: Optional[int] = None
openaiCodeExecContainerId: Optional[str] = None
@ -70,10 +78,33 @@ class ChatMessage(BaseModel):
createdAt: int
class ChatProject(BaseModel):
id: str
name: str
instructions: str = ""
rootPath: Optional[str] = None
sandboxPath: Optional[str] = None
archived: bool = False
createdAt: int
updatedAt: int
class ChatProjectPatch(BaseModel):
name: Optional[str] = None
instructions: Optional[str] = None
archived: Optional[bool] = None
createdAt: Optional[int] = None
updatedAt: Optional[int] = None
class ChatThreadListResponse(BaseModel):
threads: list[ChatThread]
class ChatProjectListResponse(BaseModel):
projects: list[ChatProject]
class ChatMessageListResponse(BaseModel):
messages: list[ChatMessage]
@ -95,6 +126,7 @@ class ChatExportResponse(BaseModel):
exportedAt: str
version: int
threadCount: int
projects: list[ChatProject] = Field(default_factory = list)
threads: list[ChatThread]
messages: list[ChatMessage]
@ -135,6 +167,8 @@ class ChatSettingsPayload(BaseModel):
Literal["none", "minimal", "low", "medium", "high", "max", "xhigh"]
] = None
preserveThinking: Optional[bool] = None
collapseHtmlArtifacts: Optional[bool] = None
allowArtifactNetworkAccess: Optional[bool] = None
autoHealToolCalls: Optional[bool] = None
maxToolCallsPerMessage: Optional[int] = Field(default = None, ge = 1)
toolCallTimeout: Optional[int] = Field(default = None, ge = 1)
@ -176,12 +210,14 @@ class ChatImportLedgerRecordResponse(BaseModel):
async def list_threads(
model_type: Optional[str] = Query(None),
pair_id: Optional[str] = Query(None),
project_id: Optional[str] = Query(None),
include_archived: bool = Query(True),
current_subject: str = Depends(get_current_subject),
):
threads = list_chat_threads(
model_type = model_type,
pair_id = pair_id,
project_id = project_id,
include_archived = include_archived,
)
return ChatThreadListResponse(threads = [ChatThread(**t) for t in threads])
@ -192,6 +228,11 @@ async def save_thread(
payload: ChatThread,
current_subject: str = Depends(get_current_subject),
):
if payload.projectId and get_chat_project(payload.projectId) is None:
raise HTTPException(
status_code = 404,
detail = f"Project {payload.projectId} not found",
)
return ChatThread(**upsert_chat_thread(payload.model_dump()))
@ -216,6 +257,11 @@ async def patch_thread(
for field in ("title", "modelType", "modelId", "archived", "createdAt"):
if field in patch and patch[field] is None:
raise HTTPException(status_code = 400, detail = f"{field} cannot be null")
if patch.get("projectId") and get_chat_project(patch["projectId"]) is None:
raise HTTPException(
status_code = 404,
detail = f"Project {patch['projectId']} not found",
)
thread = update_chat_thread(
thread_id,
patch,
@ -237,6 +283,77 @@ async def delete_threads(
return {"status": "deleted"}
@router.get("/projects", response_model = ChatProjectListResponse)
async def list_projects(
include_archived: bool = Query(False),
current_subject: str = Depends(get_current_subject),
):
return ChatProjectListResponse(
projects = [
ChatProject(**(ensure_chat_project_workspace(project["id"]) or project))
for project in list_chat_projects(include_archived = include_archived)
]
)
@router.post("/projects", response_model = ChatProject)
async def save_project(
payload: ChatProject,
current_subject: str = Depends(get_current_subject),
):
return ChatProject(**upsert_chat_project(payload.model_dump()))
@router.get("/projects/{project_id}", response_model = ChatProject)
async def get_project(
project_id: str,
current_subject: str = Depends(get_current_subject),
):
project = ensure_chat_project_workspace(project_id)
if project is None:
raise HTTPException(
status_code = 404,
detail = f"Project {project_id} not found",
)
return ChatProject(**project)
@router.patch("/projects/{project_id}", response_model = ChatProject)
async def patch_project(
project_id: str,
payload: ChatProjectPatch,
current_subject: str = Depends(get_current_subject),
):
patch = payload.model_dump(exclude_unset = True)
for field in ("name", "archived", "createdAt", "updatedAt"):
if field in patch and patch[field] is None:
raise HTTPException(status_code = 400, detail = f"{field} cannot be null")
project = update_chat_project(project_id, patch)
if project is not None:
project = ensure_chat_project_workspace(project_id)
if project is None:
raise HTTPException(
status_code = 404,
detail = f"Project {project_id} not found",
)
return ChatProject(**project)
@router.delete("/projects/{project_id}", response_model = ChatProject)
async def delete_project(
project_id: str,
delete_files: bool = Query(False),
current_subject: str = Depends(get_current_subject),
):
project = delete_chat_project(project_id, delete_files = delete_files)
if project is None:
raise HTTPException(
status_code = 404,
detail = f"Project {project_id} not found",
)
return ChatProject(**project)
@router.get("/threads/{thread_id}/messages", response_model = ChatMessageListResponse)
async def get_thread_messages(
thread_id: str,
@ -392,11 +509,13 @@ async def export_history(current_subject: str = Depends(get_current_subject)):
from datetime import datetime, timezone
threads = list_chat_threads(include_archived = True)
projects = list_chat_projects(include_archived = True)
messages = list_chat_messages_for_threads([thread["id"] for thread in threads])
return ChatExportResponse(
exportedAt = datetime.now(timezone.utc).isoformat(),
version = 1,
threadCount = len(threads),
projects = [ChatProject(**project) for project in projects],
threads = [ChatThread(**thread) for thread in threads],
messages = [ChatMessage(**message) for message in messages],
)

View file

@ -36,8 +36,18 @@ def list_mcp_tools(payload: McpToolsListRequest) -> McpToolsListResponse:
providers: list[McpToolsProviderResult] = []
tool_to_providers: dict[str, list[str]] = defaultdict(list)
from core.inference.mcp_client import stdio_mcp_enabled
for provider_payload in payload.mcp_providers:
provider_name = str(provider_payload.get("name", "")).strip()
if provider_payload.get("provider_type") == "stdio" and not stdio_mcp_enabled():
providers.append(
McpToolsProviderResult(
name = provider_name,
error = "Local (stdio) MCP servers are disabled on this host.",
)
)
continue
built = build_mcp_providers({"mcp_providers": [provider_payload]})
if len(built) != 1:
providers.append(

View file

@ -239,6 +239,135 @@ router = APIRouter()
studio_router = APIRouter()
_ARTIFACT_PREVIEW_FRAME_ANCESTORS = "'self' tauri://localhost http://tauri.localhost"
_ARTIFACT_PREVIEW_FRAME_STRICT_CSP = (
"default-src 'none'; "
"script-src 'unsafe-inline'; "
"style-src 'unsafe-inline'; "
"img-src data: blob:; "
"font-src data:; "
"media-src data: blob:; "
"connect-src 'none'; "
"object-src 'none'; "
"base-uri 'none'; "
"form-action 'none'; "
f"frame-ancestors {_ARTIFACT_PREVIEW_FRAME_ANCESTORS}; "
"sandbox allow-scripts"
)
_ARTIFACT_PREVIEW_FRAME_NETWORK_CSP = (
"default-src http: https: data: blob:; "
"script-src 'unsafe-inline' 'unsafe-eval' http: https: data: blob:; "
"script-src-elem 'unsafe-inline' http: https: data: blob:; "
"style-src 'unsafe-inline' http: https: data: blob:; "
"style-src-elem 'unsafe-inline' http: https: data: blob:; "
"img-src http: https: data: blob:; "
"font-src http: https: data: blob:; "
"media-src http: https: data: blob:; "
"connect-src http: https: ws: wss: data: blob:; "
"worker-src http: https: blob:; "
"object-src 'none'; "
"base-uri 'none'; "
"form-action 'none'; "
f"frame-ancestors {_ARTIFACT_PREVIEW_FRAME_ANCESTORS}; "
"sandbox allow-scripts"
)
_ARTIFACT_PREVIEW_FRAME_HTML = """<!doctype html>
<html>
<head><meta charset=\"utf-8\" /></head>
<body>
<script>
(() => {
const createMemoryStorage = () => {
const data = new Map();
return {
get length() { return data.size; },
key: (index) => Array.from(data.keys())[index] ?? null,
getItem: (key) => data.has(String(key)) ? data.get(String(key)) : null,
setItem: (key, value) => data.set(String(key), String(value)),
removeItem: (key) => data.delete(String(key)),
clear: () => data.clear(),
};
};
const installStorageFallback = (name) => {
try {
void window[name];
return;
} catch {
// Opaque-origin sandboxed frames throw SecurityError for Web Storage.
}
try {
Object.defineProperty(window, name, {
value: createMemoryStorage(),
configurable: true,
});
} catch {
// Leave the sandbox failure contained in the artifact if the
// browser refuses to shadow the Web Storage accessor.
}
};
const installStorageFallbacks = () => {
installStorageFallback("localStorage");
installStorageFallback("sessionStorage");
};
const render = (html) => {
installStorageFallbacks();
document.open();
document.write(html);
document.close();
};
installStorageFallbacks();
window.addEventListener("message", (event) => {
const data = event.data;
if (!data || data.type !== "unsloth:artifact-html" || typeof data.html !== "string") return;
render(data.html);
});
})();
</script>
</body>
</html>"""
@studio_router.get("/artifact-preview-frame", include_in_schema = False)
async def artifact_preview_frame(
request: Request,
allow_network: bool = False,
token: Optional[str] = None,
):
"""Serve the opaque sandbox shell used for client-side HTML artifacts."""
if allow_network:
auth_header = request.headers.get("authorization")
if auth_header and auth_header.lower().startswith("bearer "):
jwt_token = auth_header[7:]
elif token:
jwt_token = token
else:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Missing authentication token",
)
from fastapi.security import HTTPAuthorizationCredentials
creds = HTTPAuthorizationCredentials(scheme = "Bearer", credentials = jwt_token)
await get_current_subject(creds)
csp = (
_ARTIFACT_PREVIEW_FRAME_NETWORK_CSP
if allow_network
else _ARTIFACT_PREVIEW_FRAME_STRICT_CSP
)
return Response(
content = _ARTIFACT_PREVIEW_FRAME_HTML,
media_type = "text/html; charset=utf-8",
headers = {
"Cache-Control": "no-store",
"Content-Security-Policy": csp,
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
},
)
def _detect_safetensors_features(backend, chat_template: Optional[str]) -> dict:
"""Classify reasoning/tool capabilities via the GGUF classifier so
flags match across backends. gpt-oss is overridden because Harmony
@ -458,13 +587,28 @@ async def _await_cancel_then_close(cancel_event, resp) -> None:
return
# Appended to tool-use nudge to discourage plan-without-action
# Appended to tool-use nudge to discourage plan-without-action.
# Keep render_html guidance gated to turns where the artifact tool is actually
# present in the tool schema; otherwise small local models can hallucinate a
# missing tool call instead of following the fenced-HTML fallback prompt.
_TOOL_ACTION_NUDGE = (
" IMPORTANT: Always call tools directly -- never write code yourself."
" Never describe what you plan to do -- just call the tool immediately."
" For any code request, call the python tool. For any factual question, call web_search."
" Do NOT output code blocks -- use the python tool instead."
" For non-artifact code requests, call the python tool when it is available."
" For factual questions that require current information, call web_search when it is available."
" Do NOT output raw code blocks when an enabled tool can satisfy the request."
)
_ARTIFACT_TOOL_ACTION_NUDGE = (
" For HTML, CSS, or JavaScript artifact requests, call render_html once when "
"it is available. After render_html succeeds, do not call it again in the "
"same response unless the user asks for changes. Future user requests for "
"new artifacts may call render_html once."
)
def _tool_action_nudge(has_artifact: bool) -> str:
return _TOOL_ACTION_NUDGE + (_ARTIFACT_TOOL_ACTION_NUDGE if has_artifact else "")
# Strip tool-call XML the speculative buffer in core/inference/llama_cpp.py
# split across the visible/DRAIN boundary. Four leak shapes:
@ -2741,6 +2885,7 @@ async def openai_chat_completions(
_tool_names = {t["function"]["name"] for t in tools_to_use}
_has_web = "web_search" in _tool_names
_has_code = "python" in _tool_names or "terminal" in _tool_names
_has_artifact = "render_html" in _tool_names
_date_line = f"The current date is {_date.today().isoformat()}."
@ -2762,34 +2907,34 @@ async def openai_chat_completions(
"Use code execution for math, calculations, data processing, "
"or to parse and analyze information from tool results."
)
_artifact_tips = (
"Use render_html for HTML, CSS, or JavaScript artifact requests "
"with one complete self-contained HTML document in the code argument. "
"Call it once, then do not call it again in the same response unless "
"the user asks for changes. Future user requests for new artifacts may "
"call render_html once."
)
if _has_web and _has_code:
_tool_tip_parts = []
if _has_web:
_tool_tip_parts.append(_web_tips)
if _has_code:
_tool_tip_parts.append(_code_tips)
if _has_artifact:
_tool_tip_parts.append(_artifact_tips)
if _tool_tip_parts:
_nudge = (
_date_line + " "
"You have access to tools. When appropriate, prefer using "
"tools rather than answering from memory. "
+ _web_tips
+ " "
+ _code_tips
)
elif _has_code:
_nudge = (
_date_line + " "
"You have access to tools. When appropriate, prefer using "
"code execution rather than answering from memory. " + _code_tips
)
elif _has_web:
_nudge = (
_date_line + " "
"You have access to tools. When appropriate, prefer using "
"web search for up-to-date or uncertain factual "
"information rather than answering from memory. " + _web_tips
+ " ".join(_tool_tip_parts)
)
else:
_nudge = ""
if _nudge:
_nudge += _TOOL_ACTION_NUDGE
_nudge += _tool_action_nudge(_has_artifact)
# Append nudge to system prompt (preserve user's prompt)
if system_prompt:
system_prompt = system_prompt.rstrip() + "\n\n" + _nudge
@ -3271,6 +3416,7 @@ async def openai_chat_completions(
_sf_tool_names = {t["function"]["name"] for t in _sf_tools_to_use}
_sf_has_web = "web_search" in _sf_tool_names
_sf_has_code = "python" in _sf_tool_names or "terminal" in _sf_tool_names
_sf_has_artifact = "render_html" in _sf_tool_names
_sf_date_line = f"The current date is {_date.today().isoformat()}."
_sf_model_size_b = _extract_model_size_b(model_name)
@ -3289,35 +3435,35 @@ async def openai_chat_completions(
"Use code execution for math, calculations, data processing, "
"or to parse and analyze information from tool results."
)
_sf_artifact_tips = (
"Use render_html for HTML, CSS, or JavaScript artifact requests "
"with one complete self-contained HTML document in the code argument. "
"Call it once, then do not call it again in the same response unless "
"the user asks for changes. Future user requests for new artifacts may "
"call render_html once."
)
if _sf_has_web and _sf_has_code:
_sf_tool_tip_parts = []
if _sf_has_web:
_sf_tool_tip_parts.append(_sf_web_tips)
if _sf_has_code:
_sf_tool_tip_parts.append(_sf_code_tips)
if _sf_has_artifact:
_sf_tool_tip_parts.append(_sf_artifact_tips)
if _sf_tool_tip_parts:
_sf_nudge = (
_sf_date_line + " "
"You have access to tools. When appropriate, prefer using "
"tools rather than answering from memory. "
+ _sf_web_tips
+ " "
+ _sf_code_tips
)
elif _sf_has_code:
_sf_nudge = (
_sf_date_line + " "
"You have access to tools. When appropriate, prefer using "
"code execution rather than answering from memory. " + _sf_code_tips
)
elif _sf_has_web:
_sf_nudge = (
_sf_date_line + " "
"You have access to tools. When appropriate, prefer using "
"web search for up-to-date or uncertain factual "
"information rather than answering from memory. " + _sf_web_tips
+ " ".join(_sf_tool_tip_parts)
)
else:
_sf_nudge = ""
_sf_system_prompt = system_prompt
if _sf_nudge:
_sf_nudge += _TOOL_ACTION_NUDGE
_sf_nudge += _tool_action_nudge(_sf_has_artifact)
if _sf_system_prompt:
_sf_system_prompt = _sf_system_prompt.rstrip() + "\n\n" + _sf_nudge
else:
@ -3744,16 +3890,11 @@ async def serve_sandbox_file(
)
# ── Path containment check ──────────────────────────────────
home = os.path.expanduser("~")
sandbox_root = os.path.realpath(os.path.join(home, "studio_sandbox"))
safe_session = os.path.basename(session_id.replace("..", ""))
if not safe_session:
raise HTTPException(status_code = 404, detail = "Not found")
from core.inference.tools import get_sandbox_workdir
file_path = os.path.realpath(
os.path.join(sandbox_root, safe_session, safe_filename)
)
if not file_path.startswith(sandbox_root + os.sep):
sandbox_dir = os.path.realpath(get_sandbox_workdir(session_id))
file_path = os.path.realpath(os.path.join(sandbox_dir, safe_filename))
if file_path != sandbox_dir and not file_path.startswith(sandbox_dir + os.sep):
raise HTTPException(
status_code = status.HTTP_403_FORBIDDEN,
detail = "Access denied",
@ -4967,6 +5108,7 @@ async def anthropic_messages(
_tool_names = {t["function"]["name"] for t in openai_tools}
_has_web = "web_search" in _tool_names
_has_code = "python" in _tool_names or "terminal" in _tool_names
_has_artifact = "render_html" in _tool_names
_date_line = f"The current date is {_date.today().isoformat()}."
_model_size_b = _extract_model_size_b(model_name)
@ -4985,34 +5127,33 @@ async def anthropic_messages(
"Use code execution for math, calculations, data processing, "
"or to parse and analyze information from tool results."
)
_artifact_tips = (
"Use render_html for HTML, CSS, or JavaScript artifact requests "
"with one complete self-contained HTML document in the code argument. "
"Call it once, then do not call it again in the same response unless "
"the user asks for changes. Future user requests for new artifacts may "
"call render_html once."
)
if _has_web and _has_code:
_tool_tip_parts = []
if _has_web:
_tool_tip_parts.append(_web_tips)
if _has_code:
_tool_tip_parts.append(_code_tips)
if _has_artifact:
_tool_tip_parts.append(_artifact_tips)
if _tool_tip_parts:
_nudge = (
_date_line + " "
"You have access to tools. When appropriate, prefer using "
"tools rather than answering from memory. "
+ _web_tips
+ " "
+ _code_tips
)
elif _has_code:
_nudge = (
_date_line + " "
"You have access to tools. When appropriate, prefer using "
"code execution rather than answering from memory. " + _code_tips
)
elif _has_web:
_nudge = (
_date_line + " "
"You have access to tools. When appropriate, prefer using "
"web search for up-to-date or uncertain factual "
"information rather than answering from memory. " + _web_tips
"tools rather than answering from memory. " + " ".join(_tool_tip_parts)
)
else:
_nudge = ""
if _nudge:
_nudge += _TOOL_ACTION_NUDGE
_nudge += _tool_action_nudge(_has_artifact)
# Inject into system prompt
if openai_messages and openai_messages[0].get("role") == "system":
openai_messages[0]["content"] = (
@ -5200,6 +5341,7 @@ async def _anthropic_tool_non_streaming(run_gen, message_id, model_name):
baseline, not against turn N's final length.
"""
content_blocks: list = []
tool_blocks_by_id: dict[str, AnthropicResponseToolUseBlock] = {}
usage = {}
prev_text = ""
@ -5218,13 +5360,25 @@ async def _anthropic_tool_non_streaming(run_gen, message_id, model_name):
else:
content_blocks.append(AnthropicResponseTextBlock(text = new))
elif etype == "tool_start":
content_blocks.append(
AnthropicResponseToolUseBlock(
id = event["tool_call_id"],
name = event["tool_name"],
input = event.get("arguments", {}),
)
tool_call_id = event["tool_call_id"]
arguments = event.get("arguments", {})
existing_tool_block = (
tool_blocks_by_id.get(tool_call_id) if tool_call_id else None
)
if existing_tool_block is not None:
if arguments or not existing_tool_block.input:
existing_tool_block.input = arguments
if event.get("tool_name") and not existing_tool_block.name:
existing_tool_block.name = event["tool_name"]
else:
tool_block = AnthropicResponseToolUseBlock(
id = tool_call_id,
name = event["tool_name"],
input = arguments,
)
if tool_call_id:
tool_blocks_by_id[tool_call_id] = tool_block
content_blocks.append(tool_block)
elif etype == "tool_end":
prev_text = ""
elif etype == "metadata":

View file

@ -11,8 +11,12 @@ from fastapi import APIRouter, Depends, HTTPException
from auth.authentication import get_current_subject
from core.inference.mcp_client import (
clear_oauth_tokens_async,
is_stdio,
list_tools_async,
parse_server_headers,
parse_stdio_command,
probe_timeout,
stdio_mcp_enabled,
)
from models.mcp_servers import (
McpServerCreate,
@ -28,22 +32,48 @@ logger = structlog.get_logger(__name__)
router = APIRouter()
_PROBE_TIMEOUT_SECONDS = 8.0
# When OAuth probes need to open a browser, wait long enough for the user to
# sign in. Matches fastmcp's default OAuth callback_timeout (300 s) + slack.
_OAUTH_PROBE_TIMEOUT_SECONDS = 305.0
def _looks_like_command(value: str) -> bool:
"""Whitespace is a one-way signal: a URL can't hold an unencoded space, so a
value with whitespace is definitely a command. No whitespace proves nothing
(a lone token may be a single-arg command or a scheme-less URL)."""
return any(ch.isspace() for ch in value)
def _validate_url(url: str) -> str:
trimmed = (url or "").strip()
if not trimmed:
raise HTTPException(status_code = 400, detail = "url must not be empty")
# When stdio is enabled on this host, a non-HTTP value is a local command.
# Reuse this field so stdio servers ride the existing CRUD/storage with no
# schema change. A lone token (example.com, /usr/bin/srv) is ambiguous, so we
# keep the existing behaviour and treat any non-HTTP value as a command here.
if stdio_mcp_enabled() and is_stdio(trimmed):
try:
parts = parse_stdio_command(trimmed)
except ValueError as exc:
raise HTTPException(status_code = 400, detail = f"Invalid command: {exc}")
if not parts or not parts[0].strip():
raise HTTPException(status_code = 400, detail = "command must not be empty")
if "://" in parts[0]:
# A URL-scheme first token is a mistyped URL, not a command. Reject
# it cleanly instead of exec-ing it (mirrors the frontend check).
raise HTTPException(
status_code = 400,
detail = "Enter an http(s):// URL, or a local command whose "
"first token is an executable (not a URL).",
)
return trimmed
parsed = urlparse(trimmed)
if parsed.scheme not in ("http", "https"):
raise HTTPException(
status_code = 400,
detail = "url must start with http:// or https://",
detail = (
"MCP server address must start with http:// or https:// "
"(for example https://example.com/mcp)."
)
# Host-scoped wording ("this server"), not "desktop only": self-hosted
# hosts can opt in via the env var.
if _looks_like_command(trimmed):
detail += " Running a local command is not enabled on this server."
raise HTTPException(status_code = 400, detail = detail)
if not parsed.netloc:
raise HTTPException(status_code = 400, detail = "url is missing a host")
return trimmed
@ -91,6 +121,9 @@ async def create_mcp_server(
raise HTTPException(status_code = 400, detail = "display_name must not be empty")
url = _validate_url(payload.url)
headers = _normalize_headers(payload.headers)
# OAuth is HTTP-only; force it off for stdio commands so a stale flag can't
# push the probe onto the 305s OAuth timeout. Backend is the enforcer.
use_oauth = payload.use_oauth and not is_stdio(url)
server_id = uuid.uuid4().hex[:16]
mcp_servers_db.create_server(
@ -99,7 +132,7 @@ async def create_mcp_server(
url = url,
headers_json = json.dumps(headers) if headers else None,
is_enabled = payload.is_enabled,
use_oauth = payload.use_oauth,
use_oauth = use_oauth,
)
return _row_to_response(mcp_servers_db.get_server(server_id))
@ -132,6 +165,9 @@ def _changes_from_payload(payload: McpServerUpdate) -> dict:
status_code = 400, detail = "use_oauth must be true or false"
)
changes["use_oauth"] = payload.use_oauth
# stdio is OAuth-less: drop a stale OAuth flag when switching to a command.
if "url" in changes and is_stdio(changes["url"]):
changes["use_oauth"] = False
return changes
@ -147,6 +183,15 @@ async def update_mcp_server(
changes = _changes_from_payload(payload)
if not changes:
raise HTTPException(status_code = 400, detail = "No fields to update")
# headers == HTTP headers (remote) or env vars (stdio). On a transport-type
# switch with no new headers, drop the old ones so env secrets are not
# re-sent as HTTP headers (or vice versa).
if (
"url" in changes
and is_stdio(changes["url"]) != is_stdio(old["url"])
and "headers_json" not in changes
):
changes["headers_json"] = None
# Clear persisted OAuth tokens when the URL changes or OAuth is
# disabled; fastmcp keys tokens by URL and would otherwise let a
# re-pointed server silently inherit the old account's credentials.
@ -180,15 +225,19 @@ async def refresh_mcp_server_tools(
server = mcp_servers_db.get_server(server_id)
if not server:
raise HTTPException(status_code = 404, detail = "MCP server not found")
# Refresh uses the stored address, so re-check the stdio gate here too: a
# stdio row from a desktop DB must not spawn on a hosted/network host.
if is_stdio(server["url"]) and not stdio_mcp_enabled():
raise HTTPException(
status_code = 400, detail = "stdio MCP servers are disabled on this host"
)
use_oauth = bool(server.get("use_oauth"))
try:
tools = await list_tools_async(
url = server["url"],
headers = parse_server_headers(server),
timeout = _OAUTH_PROBE_TIMEOUT_SECONDS
if use_oauth
else _PROBE_TIMEOUT_SECONDS,
timeout = probe_timeout(server["url"], use_oauth),
use_oauth = use_oauth,
)
except Exception as exc: # noqa: BLE001 — surface transport+timeout errors to UI
@ -212,9 +261,7 @@ async def test_mcp_server(
tools = await list_tools_async(
url = url,
headers = headers,
timeout = _OAUTH_PROBE_TIMEOUT_SECONDS
if payload.use_oauth
else _PROBE_TIMEOUT_SECONDS,
timeout = probe_timeout(url, payload.use_oauth),
use_oauth = payload.use_oauth,
)
except Exception as exc: # noqa: BLE001

View file

@ -14,16 +14,19 @@ import json
import logging
import os
import platform
import re
import shutil
import sqlite3
import threading
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
logger = logging.getLogger(__name__)
from typing import Any, Iterable, Optional
from utils.paths import studio_db_path, ensure_dir
from utils.paths import project_workspaces_root, studio_db_path, ensure_dir
def _denied_path_prefixes() -> list[str]:
@ -56,6 +59,77 @@ def _denied_path_prefixes() -> list[str]:
_schema_lock = threading.Lock()
_schema_ready = False
_SQLITE_IN_CHUNK_SIZE = 900
_PROJECT_WORKSPACE_SUBDIRS = ("sandbox",)
def _project_slug(name: str) -> str:
slug = re.sub(r"[^A-Za-z0-9._-]+", "-", name.strip()).strip(".-_")
return slug[:48] or "project"
def _default_project_root(project: dict) -> str:
project_id = str(project["id"])
suffix = re.sub(r"[^A-Za-z0-9_-]+", "-", project_id)[:8].strip("-_") or "project"
folder_name = f"{_project_slug(str(project.get('name') or 'Project'))}-{suffix}"
return str(project_workspaces_root() / folder_name)
def _ensure_project_workspace(root_path: str) -> str:
root = Path(root_path).expanduser()
root_resolved = ensure_dir(root).resolve()
for subdir in _PROJECT_WORKSPACE_SUBDIRS:
ensure_dir(root_resolved / subdir)
return str(root_resolved)
def _delete_project_workspace(project: dict) -> None:
root_path = project.get("rootPath")
if not root_path:
return
root = Path(root_path).expanduser()
try:
root_resolved = root.resolve(strict = False)
except (OSError, RuntimeError, ValueError):
logger.warning(
"Skipping project workspace delete for invalid path %r", root_path
)
return
project_id = str(project["id"])
suffix = re.sub(r"[^A-Za-z0-9_-]+", "-", project_id)[:8].strip("-_") or "project"
if not root_resolved.name.endswith(f"-{suffix}"):
logger.warning(
"Skipping project workspace delete for unexpected project path %s",
root_resolved,
)
return
if root_resolved.parent == root_resolved or root_resolved == Path.home().resolve():
logger.warning(
"Skipping project workspace delete for unsafe project path %s",
root_resolved,
)
return
check = (
os.path.normcase(str(root_resolved))
if platform.system() == "Windows"
else str(root_resolved)
)
for prefix in _denied_path_prefixes():
if check == prefix or check.startswith(prefix + os.sep):
logger.warning(
"Skipping project workspace delete under denied path %s",
root_resolved,
)
return
if not root_resolved.exists():
return
if root_resolved.is_symlink() or not root_resolved.is_dir():
logger.warning(
"Skipping project workspace delete for non-directory path %s",
root_resolved,
)
return
shutil.rmtree(root_resolved)
def _ensure_schema(conn: sqlite3.Connection) -> None:
@ -120,6 +194,27 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS chat_projects (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
instructions TEXT,
root_path TEXT,
archived INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
chat_project_cols = {
row[1] for row in conn.execute("PRAGMA table_info(chat_projects)").fetchall()
}
if "root_path" not in chat_project_cols:
conn.execute("ALTER TABLE chat_projects ADD COLUMN root_path TEXT")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_chat_projects_archived_updated_at ON chat_projects(archived, updated_at)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS chat_threads (
@ -128,16 +223,20 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
model_type TEXT NOT NULL,
model_id TEXT,
pair_id TEXT,
project_id TEXT,
archived INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
openai_code_exec_container_id TEXT,
anthropic_code_exec_container_id TEXT
anthropic_code_exec_container_id TEXT,
FOREIGN KEY(project_id) REFERENCES chat_projects(id) ON DELETE CASCADE
)
"""
)
chat_thread_cols = {
row[1] for row in conn.execute("PRAGMA table_info(chat_threads)").fetchall()
}
if "project_id" not in chat_thread_cols:
conn.execute("ALTER TABLE chat_threads ADD COLUMN project_id TEXT")
if "openai_code_exec_container_id" not in chat_thread_cols:
conn.execute(
"ALTER TABLE chat_threads ADD COLUMN openai_code_exec_container_id TEXT"
@ -166,6 +265,9 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_chat_threads_pair_id ON chat_threads(pair_id)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_chat_threads_project_id ON chat_threads(project_id)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_chat_messages_thread_id_created_at ON chat_messages(thread_id, created_at)"
)
@ -855,6 +957,7 @@ def _chat_thread_from_row(row: sqlite3.Row) -> dict:
"modelType": data["model_type"],
"modelId": data.get("model_id") or "",
"pairId": data.get("pair_id") or None,
"projectId": data.get("project_id") or None,
"archived": bool(data["archived"]),
"createdAt": data["created_at"],
"openaiCodeExecContainerId": data.get("openai_code_exec_container_id"),
@ -862,6 +965,21 @@ def _chat_thread_from_row(row: sqlite3.Row) -> dict:
}
def _chat_project_from_row(row: sqlite3.Row) -> dict:
data = dict(row)
root_path = data.get("root_path")
return {
"id": data["id"],
"name": data["name"],
"instructions": data.get("instructions") or "",
"rootPath": root_path or None,
"sandboxPath": os.path.join(root_path, "sandbox") if root_path else None,
"archived": bool(data["archived"]),
"createdAt": data["created_at"],
"updatedAt": data["updated_at"],
}
def _chat_message_from_row(row: sqlite3.Row) -> dict:
data = dict(row)
message = {
@ -887,13 +1005,14 @@ def upsert_chat_thread(thread: dict) -> dict:
conn.execute(
"""
INSERT INTO chat_threads
(id, title, model_type, model_id, pair_id, archived, created_at, openai_code_exec_container_id, anthropic_code_exec_container_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
(id, title, model_type, model_id, pair_id, project_id, archived, created_at, openai_code_exec_container_id, anthropic_code_exec_container_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
model_type = excluded.model_type,
model_id = excluded.model_id,
pair_id = excluded.pair_id,
project_id = excluded.project_id,
archived = excluded.archived,
created_at = excluded.created_at,
openai_code_exec_container_id = excluded.openai_code_exec_container_id,
@ -905,6 +1024,7 @@ def upsert_chat_thread(thread: dict) -> dict:
thread["modelType"],
thread.get("modelId") or "",
thread.get("pairId"),
thread.get("projectId"),
1 if thread.get("archived") else 0,
int(thread["createdAt"]),
thread.get("openaiCodeExecContainerId"),
@ -923,6 +1043,7 @@ def update_chat_thread(id: str, patch: dict) -> Optional[dict]:
"modelType": ("model_type", patch.get("modelType")),
"modelId": ("model_id", patch.get("modelId")),
"pairId": ("pair_id", patch.get("pairId")),
"projectId": ("project_id", patch.get("projectId")),
"archived": ("archived", 1 if patch.get("archived") else 0),
"createdAt": ("created_at", patch.get("createdAt")),
"openaiCodeExecContainerId": (
@ -968,6 +1089,7 @@ def get_chat_thread(id: str) -> Optional[dict]:
def list_chat_threads(
model_type: str | None = None,
pair_id: str | None = None,
project_id: str | None = None,
include_archived: bool = True,
) -> list[dict]:
clauses = []
@ -978,6 +1100,9 @@ def list_chat_threads(
if pair_id is not None:
clauses.append("pair_id = ?")
values.append(pair_id)
if project_id is not None:
clauses.append("project_id = ?")
values.append(project_id)
if not include_archived:
clauses.append("archived = 0")
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
@ -1020,6 +1145,136 @@ def count_chat_threads() -> int:
conn.close()
def upsert_chat_project(project: dict) -> dict:
existing = get_chat_project(project["id"])
root_path = existing.get("rootPath") if existing else None
if not root_path:
root_path = _default_project_root(project)
root_path = _ensure_project_workspace(root_path)
conn = get_connection()
try:
conn.execute(
"""
INSERT INTO chat_projects
(id, name, instructions, root_path, archived, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
instructions = excluded.instructions,
root_path = COALESCE(chat_projects.root_path, excluded.root_path),
archived = excluded.archived,
created_at = excluded.created_at,
updated_at = excluded.updated_at
""",
(
project["id"],
project["name"],
project.get("instructions") or "",
root_path,
1 if project.get("archived") else 0,
int(project["createdAt"]),
int(project["updatedAt"]),
),
)
conn.commit()
return get_chat_project(project["id"]) or project
finally:
conn.close()
def update_chat_project(id: str, patch: dict) -> Optional[dict]:
allowed = {
"name": ("name", patch.get("name")),
"instructions": ("instructions", patch.get("instructions")),
"archived": ("archived", 1 if patch.get("archived") else 0),
"createdAt": ("created_at", patch.get("createdAt")),
"updatedAt": ("updated_at", patch.get("updatedAt")),
}
assignments = []
values = []
for key, (column, value) in allowed.items():
if key in patch:
assignments.append(f"{column} = ?")
values.append(value)
if not assignments:
return get_chat_project(id)
conn = get_connection()
try:
conn.execute(
f"UPDATE chat_projects SET {', '.join(assignments)} WHERE id = ?",
(*values, id),
)
conn.commit()
row = conn.execute("SELECT * FROM chat_projects WHERE id = ?", (id,)).fetchone()
return _chat_project_from_row(row) if row is not None else None
finally:
conn.close()
def ensure_chat_project_workspace(id: str) -> Optional[dict]:
project = get_chat_project(id)
if project is None:
return None
root_path = project.get("rootPath") or _default_project_root(project)
root_path = _ensure_project_workspace(root_path)
if project.get("rootPath") == root_path:
return project
conn = get_connection()
try:
conn.execute(
"UPDATE chat_projects SET root_path = ? WHERE id = ?",
(root_path, id),
)
conn.commit()
finally:
conn.close()
return get_chat_project(id)
def get_chat_project(id: str) -> Optional[dict]:
conn = get_connection()
try:
row = conn.execute("SELECT * FROM chat_projects WHERE id = ?", (id,)).fetchone()
return _chat_project_from_row(row) if row is not None else None
finally:
conn.close()
def list_chat_projects(include_archived: bool = False) -> list[dict]:
conn = get_connection()
try:
where = "" if include_archived else "WHERE archived = 0"
rows = conn.execute(
f"SELECT * FROM chat_projects {where} ORDER BY updated_at DESC"
).fetchall()
return [_chat_project_from_row(row) for row in rows]
finally:
conn.close()
def delete_chat_project(id: str, delete_files: bool = False) -> Optional[dict]:
conn = get_connection()
try:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute("SELECT * FROM chat_projects WHERE id = ?", (id,)).fetchone()
if row is None:
conn.rollback()
return None
project = _chat_project_from_row(row)
conn.execute("DELETE FROM chat_threads WHERE project_id = ?", (id,))
conn.execute("DELETE FROM chat_projects WHERE id = ?", (id,))
conn.commit()
if delete_files:
_delete_project_workspace(project)
return project
except Exception:
conn.rollback()
raise
finally:
conn.close()
class ChatMessageConflictError(RuntimeError):
"""Raised when a chat message id already belongs to another thread."""

View file

@ -38,6 +38,7 @@ from routes.inference import (
_normalize_anthropic_openai_images,
_select_anthropic_server_tools,
_anthropic_requested_studio_tools,
_anthropic_tool_non_streaming,
anthropic_messages,
)
from state.tool_policy import reset_tool_policy, set_tool_policy
@ -551,6 +552,54 @@ class TestAnthropicStreamEmitter:
assert "tool_use" in events[1]
assert "input_json_delta" in events[2]
def test_duplicate_tool_start_merges_into_open_tool_block(self):
e = AnthropicStreamEmitter()
e.start("msg_1", "m")
first_events = e.feed(
{
"type": "tool_start",
"tool_name": "render_html",
"tool_call_id": "call_0",
"arguments": {},
}
)
second_events = e.feed(
{
"type": "tool_start",
"tool_name": "render_html",
"tool_call_id": "call_0",
"arguments": {"code": "<!doctype html><html></html>"},
}
)
first_payloads = [
json.loads(event.split("data: ")[1]) for event in first_events
]
second_payloads = [
json.loads(event.split("data: ")[1]) for event in second_events
]
tool_starts = [
payload
for payload in first_payloads + second_payloads
if payload["type"] == "content_block_start"
and payload["content_block"]["type"] == "tool_use"
]
assert len(tool_starts) == 1
assert tool_starts[0]["content_block"]["id"] == "call_0"
assert second_payloads == [
{
"type": "content_block_delta",
"index": tool_starts[0]["index"],
"delta": {
"type": "input_json_delta",
"partial_json": json.dumps(
{"code": "<!doctype html><html></html>"}
),
},
}
]
def test_tool_end_closes_tool_opens_new_text_block(self):
e = AnthropicStreamEmitter()
e.start("msg_1", "m")
@ -674,6 +723,49 @@ class TestAnthropicStreamEmitter:
assert parsed["delta"]["text"] == "After tool"
# =====================================================================
# Non-streaming tool response tests
# =====================================================================
class TestAnthropicToolNonStreaming:
def test_duplicate_tool_start_replaces_provisional_tool_block(self):
def _run_gen():
yield {
"type": "tool_start",
"tool_name": "render_html",
"tool_call_id": "call_0",
"arguments": {},
}
yield {
"type": "tool_start",
"tool_name": "render_html",
"tool_call_id": "call_0",
"arguments": {"code": "<!doctype html><html></html>"},
}
yield {
"type": "tool_end",
"tool_name": "render_html",
"tool_call_id": "call_0",
"result": "Rendered HTML artifact.",
}
response = asyncio.run(_anthropic_tool_non_streaming(_run_gen, "msg_1", "m"))
body = json.loads(response.body)
tool_blocks = [
block for block in body["content"] if block["type"] == "tool_use"
]
assert tool_blocks == [
{
"type": "tool_use",
"id": "call_0",
"name": "render_html",
"input": {"code": "<!doctype html><html></html>"},
}
]
# =====================================================================
# Pass-through emitter tests (client-side tool execution path)
# =====================================================================

View file

@ -1,18 +1,49 @@
# 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 os
import platform
import shutil
import threading
import uuid
from pathlib import Path
import pytest
from storage import studio_db
def _reset_studio_db(tmp_path, monkeypatch):
def _reset_studio_db(tmp_path, monkeypatch, projects_home = None):
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
monkeypatch.setenv(
"UNSLOTH_STUDIO_PROJECTS_HOME",
str(projects_home if projects_home is not None else tmp_path / "Projects"),
)
monkeypatch.setattr(studio_db, "_schema_ready", False)
@pytest.fixture
def workspace_projects_home(tmp_path):
"""Projects root outside the platform delete denylist.
tmp_path resolves under /private/tmp on macOS, which the workspace
delete guard refuses by design. Linux/Windows tmp is not denied and is
used as-is; only the denied case falls back to a home subdir.
"""
candidate = tmp_path / "Projects"
resolved = str(candidate.resolve())
check = os.path.normcase(resolved) if platform.system() == "Windows" else resolved
denied = studio_db._denied_path_prefixes()
if any(check == p or check.startswith(p + os.sep) for p in denied):
candidate = Path.home() / ".unsloth-studio-tests" / uuid.uuid4().hex
candidate.mkdir(parents = True, exist_ok = True)
try:
yield candidate
finally:
if ".unsloth-studio-tests" in candidate.parts:
shutil.rmtree(candidate, ignore_errors = True)
def _thread(thread_id: str = "thread-1") -> dict:
return {
"id": thread_id,
@ -41,6 +72,17 @@ def _message(
}
def _project(project_id: str = "project-1") -> dict:
return {
"id": project_id,
"name": "Research",
"instructions": "Use terse answers.",
"archived": False,
"createdAt": 1_700_000_000_000,
"updatedAt": 1_700_000_000_000,
}
def test_sync_chat_messages_upserts_without_pruning(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
studio_db.upsert_chat_thread(_thread())
@ -63,6 +105,53 @@ def test_sync_chat_messages_upserts_without_pruning(tmp_path, monkeypatch):
assert by_id["msg-2"]["content"] == [{"type": "text", "text": "updated text"}]
def test_chat_projects_delete_cascades_threads_and_messages(
tmp_path,
monkeypatch,
):
_reset_studio_db(tmp_path, monkeypatch)
project = studio_db.upsert_chat_project(_project())
assert project["rootPath"].startswith(str(tmp_path / "Projects"))
assert (tmp_path / "Projects" / "Research-project").exists()
assert (tmp_path / "Projects" / "Research-project" / "sandbox").is_dir()
assert not (tmp_path / "Projects" / "Research-project" / "chats").exists()
assert not (tmp_path / "Projects" / "Research-project" / "files").exists()
assert not (tmp_path / "Projects" / "Research-project" / "exports").exists()
studio_db.upsert_chat_thread({**_thread(), "projectId": "project-1"})
studio_db.upsert_chat_message(_message("msg-1", 1, "delete with project"))
[thread] = studio_db.list_chat_threads(project_id = "project-1")
assert thread["projectId"] == "project-1"
deleted = studio_db.delete_chat_project("project-1")
assert deleted is not None
assert deleted["id"] == "project-1"
assert studio_db.get_chat_project("project-1") is None
assert studio_db.list_chat_threads(project_id = "project-1") == []
assert studio_db.get_chat_thread("thread-1") is None
assert studio_db.list_chat_messages("thread-1") == []
assert (tmp_path / "Projects" / "Research-project").exists()
def test_chat_project_delete_files_removes_workspace(
tmp_path, monkeypatch, workspace_projects_home
):
_reset_studio_db(tmp_path, monkeypatch, projects_home = workspace_projects_home)
project = studio_db.upsert_chat_project(_project())
# Derive root from the created project so it tracks the projects home.
root = Path(project["rootPath"])
marker = root / "sandbox" / "marker.txt"
marker.write_text("created by code execution", encoding = "utf-8")
deleted = studio_db.delete_chat_project(project["id"], delete_files = True)
assert deleted is not None
assert deleted["rootPath"] == project["rootPath"]
assert not root.exists()
assert studio_db.get_chat_project(project["id"]) is None
def test_sync_chat_messages_prunes_when_requested(tmp_path, monkeypatch):
_reset_studio_db(tmp_path, monkeypatch)
studio_db.upsert_chat_thread(_thread())

View file

@ -0,0 +1,144 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Tests for LlamaCppBackend._classify_llama_start_failure.
When llama-server exits before becoming healthy, load_model turns its
captured stdout/stderr into a user-facing reason. A diffusion / image
GGUF (FLUX, Qwen-Image, ...) is a valid file with plenty of memory, so
the generic "invalid file or out of memory" message is actively
misleading (issue #5842). These tests pin the classification.
"""
from __future__ import annotations
import sys
import types as _types
from pathlib import Path
import pytest
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
# Match the stubbing pattern in sibling tests so the module imports in a
# lightweight env without fastapi.
_loggers_stub = _types.ModuleType("loggers")
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
sys.modules.setdefault("loggers", _loggers_stub)
# Give the structlog stub a real get_logger: a bare ModuleType poisons
# sys.modules for later tests that call structlog.get_logger at import time.
_structlog_stub = _types.ModuleType("structlog")
_structlog_stub.get_logger = lambda *a, **k: __import__("logging").getLogger(
"structlog"
)
sys.modules.setdefault("structlog", _structlog_stub)
if not hasattr(sys.modules["structlog"], "get_logger"):
sys.modules["structlog"].get_logger = _structlog_stub.get_logger
from core.inference.llama_cpp import LlamaCppBackend # noqa: E402
_classify = LlamaCppBackend._classify_llama_start_failure
# Real llama-server failure lines (lower-cased downstream anyway).
_QWEN_IMAGE_OUT = (
"load_model: loading model 'qwen-image-edit-2511-Q4_K_M.gguf'\n"
"llama_model_load: error loading model: unknown model architecture: 'qwen_image'\n"
"llama_model_load_from_file_impl: failed to load model"
)
_OOM_OUT = (
"ggml_backend_cuda_buffer_type_alloc_buffer: allocating 12000.00 MiB on "
"device 0: cudaMalloc failed: out of memory"
)
class TestDiffusionArchitectures:
def test_qwen_image_routes_to_images_page(self):
msg = _classify(_QWEN_IMAGE_OUT, "/models/qwen-image.gguf", "local/qwen-image")
assert "diffusion" in msg.lower()
assert "Images page" in msg
assert "qwen_image" in msg
# Must NOT keep blaming memory / file validity.
assert "out of memory" not in msg.lower()
assert "enough memory" not in msg.lower()
# Parametrize over the production set so new arches are auto-covered.
@pytest.mark.parametrize("arch", sorted(LlamaCppBackend._DIFFUSION_ARCHES))
def test_every_diffusion_arch_is_recognised(self, arch):
out = f"error loading model: unknown model architecture: '{arch}'"
msg = _classify(out, f"/models/{arch}.gguf", f"local/{arch}")
assert "diffusion" in msg.lower()
assert "Images page" in msg
assert arch in msg
class TestUnsupportedNonDiffusionArchitecture:
def test_unknown_llm_arch_says_unsupported_not_oom(self):
out = "error loading model: unknown model architecture: 'some_new_llm'"
msg = _classify(out, "/models/x.gguf", "local/x")
assert "some_new_llm" in msg
assert "architecture" in msg.lower()
# Specific, not the misleading memory message.
assert "enough memory" not in msg.lower()
assert "diffusion" not in msg.lower()
# Exact match: a chat arch merely containing a diffusion token (wan,
# sd1, flux, ...) must not be routed to the Images page.
@pytest.mark.parametrize(
"arch",
[
"taiwan", # contains "wan"
"swan_llm", # contains "wan"
"fluxion", # contains "flux"
"sd1234", # contains "sd1"
"sd3_chat", # contains "sd3"
"aura2_text", # contains "aura"
"cosmos_reason", # contains "cosmos"
"qwen_image_text", # contains "qwen_image"
],
)
def test_arch_containing_diffusion_token_is_not_misrouted(self, arch):
out = f"error loading model: unknown model architecture: '{arch}'"
msg = _classify(out, f"/models/{arch}.gguf", f"local/{arch}")
assert arch in msg
assert "does not support" in msg.lower()
assert "diffusion" not in msg.lower()
assert "Images page" not in msg
class TestOllamaAndFallback:
_OLLAMA_GGUF = (
f"/home/u/.ollama{__import__('os').sep}ollama_links"
f"{__import__('os').sep}m.gguf"
)
def test_ollama_compat_message_still_works(self):
out = "llama_model_load: error loading model: key not found"
msg = _classify(out, self._OLLAMA_GGUF, "ollama/llama3")
assert "Ollama" in msg
def test_ollama_unknown_arch_keeps_ollama_guidance(self):
# Ollama + non-diffusion unknown arch keeps the Ollama hint, not the
# generic llama.cpp "unsupported" message.
out = "error loading model: unknown model architecture: 'some_new_llm'"
msg = _classify(out, self._OLLAMA_GGUF, "ollama/some-new")
assert "Ollama" in msg
assert "directly through Ollama" in msg
assert "does not support" not in msg.lower()
def test_ollama_diffusion_arch_still_routes_to_images(self):
# Diffusion routing wins over the Ollama hint.
out = "error loading model: unknown model architecture: 'flux'"
msg = _classify(out, self._OLLAMA_GGUF, "ollama/flux")
assert "diffusion" in msg.lower()
assert "Images page" in msg
def test_generic_oom_keeps_memory_message(self):
msg = _classify(_OOM_OUT, "/models/big.gguf", "local/big")
assert "enough memory" in msg.lower()
assert "diffusion" not in msg.lower()
def test_empty_output_is_safe(self):
msg = _classify("", None, None)
assert "llama-server failed to start" in msg

View file

@ -0,0 +1,236 @@
"""Tests for the proposed PR #5863 improvements.
Covers: _client() self-gating + keep_alive, OAuth normalised off for stdio
(create + update), env/header dropped on a transport-type switch, and the
backend rejecting a command whose first token is a URL scheme.
Run from studio/backend: python -m pytest tests/test_mcp_stdio_improvements.py -q
"""
import asyncio
import pytest
from fastapi import HTTPException
from core.inference import mcp_client
from storage import mcp_servers_db
def _reset_db(tmp_path, monkeypatch):
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
monkeypatch.setattr(mcp_servers_db, "_schema_ready", False)
def _enable(monkeypatch):
monkeypatch.setenv("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", "1")
def _disable(monkeypatch):
monkeypatch.delenv("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", raising = False)
# ── P1: _client() self-gates the stdio sink ─────────────────────────
def test_client_refuses_stdio_when_disabled(monkeypatch):
_disable(monkeypatch)
with pytest.raises(PermissionError):
mcp_client._client("npx -y server /tmp", None)
def test_client_builds_stdio_when_enabled_without_spawning(monkeypatch):
_enable(monkeypatch)
# Constructing the Client must not spawn the subprocess (spawn happens on
# __aenter__); we only assert it builds.
client = mcp_client._client("npx -y server /tmp", {"K": "v"})
assert client is not None
def test_client_http_unaffected_by_gate(monkeypatch):
_disable(monkeypatch)
assert mcp_client._client("https://example.com/mcp", None) is not None
# ── P3: OAuth normalised off for stdio (create + update) ────────────
def test_create_forces_oauth_off_for_stdio(tmp_path, monkeypatch):
import routes.mcp_servers as routes_mcp
from models.mcp_servers import McpServerCreate
_reset_db(tmp_path, monkeypatch)
_enable(monkeypatch)
resp = asyncio.run(
routes_mcp.create_mcp_server(
McpServerCreate(
display_name = "FS", url = "npx -y server /tmp", use_oauth = True
),
current_subject = "u",
)
)
assert resp.use_oauth is False
assert mcp_servers_db.get_server(resp.id)["use_oauth"] == 0
def test_create_keeps_oauth_for_http(tmp_path, monkeypatch):
import routes.mcp_servers as routes_mcp
from models.mcp_servers import McpServerCreate
_reset_db(tmp_path, monkeypatch)
_enable(monkeypatch)
resp = asyncio.run(
routes_mcp.create_mcp_server(
McpServerCreate(display_name = "GH", url = "https://gh/mcp", use_oauth = True),
current_subject = "u",
)
)
assert resp.use_oauth is True
def test_update_url_to_stdio_clears_oauth(tmp_path, monkeypatch):
import routes.mcp_servers as routes_mcp
from models.mcp_servers import McpServerUpdate
_reset_db(tmp_path, monkeypatch)
_enable(monkeypatch)
monkeypatch.setattr(mcp_client, "_oauth_token_store", None)
monkeypatch.setattr(
routes_mcp, "clear_oauth_tokens_async", lambda *a, **k: asyncio.sleep(0)
)
mcp_servers_db.create_server(
id = "s1", display_name = "A", url = "https://a/mcp", use_oauth = True
)
resp = asyncio.run(
routes_mcp.update_mcp_server(
"s1", McpServerUpdate(url = "npx -y server /tmp"), current_subject = "u"
)
)
assert resp.use_oauth is False
# ── P4: env/headers dropped on a transport-type switch ──────────────
def test_switch_stdio_to_http_drops_env(tmp_path, monkeypatch):
import routes.mcp_servers as routes_mcp
from models.mcp_servers import McpServerUpdate
_reset_db(tmp_path, monkeypatch)
_enable(monkeypatch)
mcp_servers_db.create_server(
id = "s1",
display_name = "A",
url = "npx server",
headers_json = '{"API_KEY": "secret"}',
)
resp = asyncio.run(
routes_mcp.update_mcp_server(
"s1", McpServerUpdate(url = "https://remote/mcp"), current_subject = "u"
)
)
# the stdio env must NOT survive as HTTP headers on the remote endpoint
assert resp.headers == {}
assert mcp_servers_db.get_server("s1")["headers_json"] is None
def test_switch_keeps_explicitly_supplied_headers(tmp_path, monkeypatch):
import routes.mcp_servers as routes_mcp
from models.mcp_servers import McpServerUpdate
_reset_db(tmp_path, monkeypatch)
_enable(monkeypatch)
mcp_servers_db.create_server(
id = "s1",
display_name = "A",
url = "npx server",
headers_json = '{"API_KEY": "secret"}',
)
resp = asyncio.run(
routes_mcp.update_mcp_server(
"s1",
McpServerUpdate(
url = "https://remote/mcp", headers = {"Authorization": "Bearer new"}
),
current_subject = "u",
)
)
assert resp.headers == {"Authorization": "Bearer new"}
def test_same_transport_edit_keeps_headers(tmp_path, monkeypatch):
import routes.mcp_servers as routes_mcp
from models.mcp_servers import McpServerUpdate
_reset_db(tmp_path, monkeypatch)
_enable(monkeypatch)
mcp_servers_db.create_server(
id = "s1",
display_name = "A",
url = "npx server",
headers_json = '{"API_KEY": "secret"}',
)
# editing only the display name (still stdio) must not wipe env vars
resp = asyncio.run(
routes_mcp.update_mcp_server(
"s1", McpServerUpdate(display_name = "B"), current_subject = "u"
)
)
assert resp.headers == {"API_KEY": "secret"}
# ── P5: reject a command whose first token is a URL scheme ───────────
def test_validate_url_rejects_url_scheme_command_when_enabled(monkeypatch):
from routes.mcp_servers import _validate_url
_enable(monkeypatch)
for bad in ["ftp://host/x", "file:///etc/passwd", "ws://h/y"]:
with pytest.raises(HTTPException) as exc:
_validate_url(bad)
assert exc.value.status_code == 400
def test_validate_url_allows_url_in_argument(monkeypatch):
from routes.mcp_servers import _validate_url
_enable(monkeypatch)
# :// inside an ARGUMENT (not the first token) is still a valid command
assert _validate_url("npx server --url https://x/mcp") == (
"npx server --url https://x/mcp"
)
# ── P6: Data Recipe stdio path obeys the same host gate ─────────────
# build_mcp_providers needs the data_designer plugin, which is only installed in
# the Studio test job; skip there rather than fail the core matrix.
_STDIO_RECIPE = {
"mcp_providers": [
{
"provider_type": "stdio",
"name": "fs",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
"env": {},
}
]
}
def test_data_recipe_skips_stdio_when_disabled(monkeypatch):
pytest.importorskip("data_designer")
_disable(monkeypatch)
from core.data_recipe.service import build_mcp_providers
# gate off -> the stdio provider is dropped (no subprocess can be spawned)
assert build_mcp_providers(_STDIO_RECIPE) == []
def test_data_recipe_builds_stdio_when_enabled(monkeypatch):
pytest.importorskip("data_designer")
_enable(monkeypatch)
from core.data_recipe.service import build_mcp_providers
built = build_mcp_providers(_STDIO_RECIPE)
assert len(built) == 1 # constructed (not spawned) only when enabled

View file

@ -0,0 +1,401 @@
"""Verification tests for PR #5863 (stdio MCP server support).
Covers the pure helpers (is_stdio / parse_stdio_command / stdio_mcp_enabled /
probe_timeout), the route-level _validate_url gate, and - most importantly -
that the UNSLOTH_STUDIO_ALLOW_STDIO_MCP gate blocks the stdio transport at all
five enforcement points (create, update, test, refresh, discovery, execute)
when disabled, and reaches it when enabled. The transport (_client) is stubbed
so no real subprocess is spawned; a recorder asserts whether it was reached.
Run from studio/backend: python -m pytest tests/test_mcp_stdio_pr5863.py -q
"""
import sys
import pytest
from fastapi import HTTPException
from core.inference import mcp_client
from storage import mcp_servers_db
def _reset_db(tmp_path, monkeypatch):
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
monkeypatch.setattr(mcp_servers_db, "_schema_ready", False)
def _enable(monkeypatch):
monkeypatch.setenv("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", "1")
def _disable(monkeypatch):
monkeypatch.delenv("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", raising = False)
# ── transport stub + recorder ───────────────────────────────────────
class _FakeTool:
def __init__(self, name):
self._name = name
def model_dump(self, exclude_none = True):
return {"name": self._name, "description": f"{self._name} tool"}
class _Block:
def __init__(self, text):
self.type = "text"
self.text = text
class _FakeResult:
is_error = False
def __init__(self, text):
self.content = [_Block(text)]
class _RecordingClient:
"""Stands in for fastmcp.Client; records that the transport was opened."""
def __init__(self, url, headers, use_oauth, recorder):
recorder.append({"url": url, "headers": headers, "use_oauth": use_oauth})
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def list_tools(self):
return [_FakeTool("list_directory"), _FakeTool("write_file")]
async def call_tool(self, name, args):
return _FakeResult(f"called {name}")
@pytest.fixture
def transport(monkeypatch):
"""Patch mcp_client._client with a recorder. Returns the recorder list;
empty == the stdio transport was never reached."""
recorder = []
monkeypatch.setattr(
mcp_client,
"_client",
lambda url, headers, use_oauth = False: _RecordingClient(
url, headers, use_oauth, recorder
),
)
return recorder
# ── 1. is_stdio ─────────────────────────────────────────────────────
@pytest.mark.parametrize(
"addr",
[
"http://localhost:8000/mcp",
"https://example.com/mcp",
" https://example.com/mcp ",
"HTTPS://EXAMPLE.COM/mcp",
],
)
def test_is_stdio_false_for_http(addr):
assert mcp_client.is_stdio(addr) is False
@pytest.mark.parametrize(
"addr",
[
"npx -y @modelcontextprotocol/server-filesystem /tmp",
"python -m some.module",
"uvx some-server --flag",
"/usr/local/bin/my-server",
],
)
def test_is_stdio_true_for_commands(addr):
assert mcp_client.is_stdio(addr) is True
# ── 2. parse_stdio_command ──────────────────────────────────────────
def test_parse_basic_argv():
assert mcp_client.parse_stdio_command(
"npx -y @modelcontextprotocol/server-filesystem /tmp"
) == ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
def test_parse_keeps_url_argument_as_one_command():
# gemini "high": a :// inside an ARGUMENT must not break the command.
assert mcp_client.parse_stdio_command(
"npx server --endpoint https://example.com/mcp"
) == ["npx", "server", "--endpoint", "https://example.com/mcp"]
def test_parse_quoted_arg():
assert mcp_client.parse_stdio_command('python -m mod --name "a b"') == [
"python",
"-m",
"mod",
"--name",
"a b",
]
def test_parse_empty_returns_empty_list():
assert mcp_client.parse_stdio_command(" ") == []
def test_parse_unclosed_quote_raises_valueerror():
with pytest.raises(ValueError):
mcp_client.parse_stdio_command('npx "unclosed')
def test_parse_windows_strips_wrapping_quotes(monkeypatch):
# gemini "medium": posix=False keeps backslash paths but also the wrapping
# quotes; the PR strips a matched pair so argv[0] reaches the OS clean.
monkeypatch.setattr(sys, "platform", "win32")
parts = mcp_client.parse_stdio_command(
r'"C:\Program Files\node\node.exe" server.js'
)
assert parts[0] == r"C:\Program Files\node\node.exe"
assert parts[1] == "server.js"
# ── 3. stdio_mcp_enabled ────────────────────────────────────────────
@pytest.mark.parametrize("val", ["0", "false", "true", "", " 1 ", "yes", "2"])
def test_stdio_disabled_for_non_exact_one(monkeypatch, val):
monkeypatch.setenv("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", val)
assert mcp_client.stdio_mcp_enabled() is False
def test_stdio_enabled_only_for_exact_one(monkeypatch):
_disable(monkeypatch)
assert mcp_client.stdio_mcp_enabled() is False
monkeypatch.setenv("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", "1")
assert mcp_client.stdio_mcp_enabled() is True
# ── 4. probe_timeout ────────────────────────────────────────────────
def test_probe_timeout_matrix():
assert mcp_client.probe_timeout("https://x/mcp", False) == 8.0
assert mcp_client.probe_timeout("https://x/mcp", True) == 305.0
assert mcp_client.probe_timeout("npx server", False) == 60.0
# oauth wins regardless of address kind (documented behaviour)
assert mcp_client.probe_timeout("npx server", True) == 305.0
# ── 5. _validate_url gate ───────────────────────────────────────────
def test_validate_url_gate_off_rejects_stdio(monkeypatch):
_disable(monkeypatch)
from routes.mcp_servers import _validate_url
assert _validate_url("https://example.com/mcp") == "https://example.com/mcp"
# urlparse reads "localhost:8000" scheme as "localhost", so it lands here too.
for bad in [
"npx server",
"python -m mod",
"ftp://host",
"example.com",
"localhost:8000",
r"C:\node\node.exe server.js",
]:
with pytest.raises(HTTPException) as exc:
_validate_url(bad)
assert exc.value.status_code == 400
def test_validate_url_gate_off_message_depends_on_whitespace(monkeypatch):
# The message names a command only when the value has whitespace, and never
# says "desktop app only" (self-hosted hosts can opt in via the env var).
_disable(monkeypatch)
from routes.mcp_servers import _validate_url
with pytest.raises(HTTPException) as exc:
_validate_url("npx -y @modelcontextprotocol/server-filesystem /tmp")
cmd = exc.value.detail.lower()
assert "http://" in cmd and "https://" in cmd
assert "local command" in cmd
assert "desktop app" not in cmd
with pytest.raises(HTTPException) as exc:
_validate_url("example.com")
lone = exc.value.detail.lower()
assert "http://" in lone and "https://" in lone
assert "local command" not in lone
def test_validate_url_gate_on_accepts_stdio(monkeypatch):
_enable(monkeypatch)
from routes.mcp_servers import _validate_url
assert _validate_url("npx -y server /tmp") == "npx -y server /tmp"
# http still works when stdio is on
assert _validate_url("https://x/mcp") == "https://x/mcp"
# url-bearing argument accepted as a command
assert _validate_url("npx server --url https://x/mcp") == (
"npx server --url https://x/mcp"
)
# A lone token is ambiguous; keep the prior behaviour and accept it as a
# command rather than guessing it's a URL (no regression for single binaries).
assert (
_validate_url("/usr/local/bin/my-mcp-server") == "/usr/local/bin/my-mcp-server"
)
assert _validate_url("mcp-server-sqlite") == "mcp-server-sqlite"
# empty / unparseable still rejected
for bad in [" ", '"unclosed']:
with pytest.raises(HTTPException) as exc:
_validate_url(bad)
assert exc.value.status_code == 400
# ── 6. gate enforcement at every spawn path (mocked transport) ──────
def test_create_route_gate(tmp_path, monkeypatch, transport):
import asyncio
from models.mcp_servers import McpServerCreate
import routes.mcp_servers as routes_mcp
_reset_db(tmp_path, monkeypatch)
payload = McpServerCreate(display_name = "FS", url = "npx -y server /tmp")
_disable(monkeypatch)
with pytest.raises(HTTPException) as exc:
asyncio.run(routes_mcp.create_mcp_server(payload, current_subject = "u"))
assert exc.value.status_code == 400
_enable(monkeypatch)
resp = asyncio.run(routes_mcp.create_mcp_server(payload, current_subject = "u"))
assert resp.url == "npx -y server /tmp"
def test_update_http_to_stdio_blocked_when_off(tmp_path, monkeypatch):
import asyncio
from models.mcp_servers import McpServerUpdate
import routes.mcp_servers as routes_mcp
_reset_db(tmp_path, monkeypatch)
_disable(monkeypatch)
mcp_servers_db.create_server(id = "s1", display_name = "A", url = "https://a/mcp")
# editing url -> stdio command must 400 (http->stdio edit bypass closed)
with pytest.raises(HTTPException) as exc:
asyncio.run(
routes_mcp.update_mcp_server(
"s1", McpServerUpdate(url = "npx server"), current_subject = "u"
)
)
assert exc.value.status_code == 400
def test_test_route_gate(tmp_path, monkeypatch, transport):
import asyncio
from models.mcp_servers import McpServerTestRequest
import routes.mcp_servers as routes_mcp
_reset_db(tmp_path, monkeypatch)
req = McpServerTestRequest(url = "npx -y server /tmp")
_disable(monkeypatch)
with pytest.raises(HTTPException) as exc:
asyncio.run(routes_mcp.test_mcp_server(req, current_subject = "u"))
assert exc.value.status_code == 400
assert transport == [] # transport never opened
_enable(monkeypatch)
res = asyncio.run(routes_mcp.test_mcp_server(req, current_subject = "u"))
assert res.ok and res.tool_count == 2
assert len(transport) == 1
def test_refresh_route_gate(tmp_path, monkeypatch, transport):
import asyncio
import routes.mcp_servers as routes_mcp
_reset_db(tmp_path, monkeypatch)
# a stdio row as if carried over from a desktop DB
mcp_servers_db.create_server(id = "stdio1", display_name = "FS", url = "npx server")
_disable(monkeypatch)
with pytest.raises(HTTPException) as exc:
asyncio.run(routes_mcp.refresh_mcp_server_tools("stdio1", current_subject = "u"))
assert exc.value.status_code == 400
assert transport == []
_enable(monkeypatch)
res = asyncio.run(
routes_mcp.refresh_mcp_server_tools("stdio1", current_subject = "u")
)
assert res.ok and res.tool_count == 2
assert len(transport) == 1
def test_discovery_gate(tmp_path, monkeypatch, transport):
import asyncio
from core.inference.tools import get_enabled_mcp_tools
_reset_db(tmp_path, monkeypatch)
mcp_servers_db.create_server(
id = "stdio1", display_name = "FS", url = "npx server", is_enabled = True
)
_disable(monkeypatch)
assert asyncio.run(get_enabled_mcp_tools()) == []
assert transport == [] # filtered out before any probe
_enable(monkeypatch)
specs = asyncio.run(get_enabled_mcp_tools())
assert len(specs) == 2
assert len(transport) == 1
def test_execute_gate(tmp_path, monkeypatch, transport):
from core.inference.tools import execute_tool
_reset_db(tmp_path, monkeypatch)
mcp_servers_db.create_server(
id = "stdio1", display_name = "FS", url = "npx server", is_enabled = True
)
_disable(monkeypatch)
out = execute_tool("mcp__stdio1__list_directory", {"path": "/tmp"})
assert "disabled on this host" in out
assert transport == []
_enable(monkeypatch)
out = execute_tool("mcp__stdio1__list_directory", {"path": "/tmp"})
assert out == "called list_directory"
assert len(transport) == 1
# ── 7. env vars ride headers_json as the subprocess env ─────────────
def test_stdio_env_passed_through(tmp_path, monkeypatch, transport):
from core.inference.tools import execute_tool
_reset_db(tmp_path, monkeypatch)
_enable(monkeypatch)
mcp_servers_db.create_server(
id = "stdio1",
display_name = "FS",
url = "npx server",
headers_json = '{"API_KEY": "sk-test"}',
is_enabled = True,
)
execute_tool("mcp__stdio1__list_directory", {})
assert transport[-1]["headers"] == {"API_KEY": "sk-test"}

View file

@ -28,12 +28,14 @@ Edge cases under coverage:
"""
import threading
from typing import cast
import pytest
from core.inference import safetensors_agentic
from core.inference.safetensors_agentic import (
_coerce_arguments,
_detect_render_html_tool_start,
run_safetensors_tool_loop,
)
from core.inference.tool_call_parser import (
@ -97,6 +99,17 @@ class TestParser:
assert len(result) == 1
assert "print('hi')" in result[0]["function"]["arguments"]
def test_function_signal_inside_parameter_is_literal(self):
text = (
"<function=python>"
"<parameter=code>print('<function=render_html>')</parameter>"
"</function>"
)
result = parse_tool_calls_from_text(text)
assert len(result) == 1
assert result[0]["function"]["name"] == "python"
assert "<function=render_html>" in result[0]["function"]["arguments"]
def test_multiple_calls(self):
text = (
'<tool_call>{"name":"web_search","arguments":{"query":"a"}}</tool_call>'
@ -118,6 +131,18 @@ class TestParser:
assert has_tool_signal("hi <function=foo>...")
assert not has_tool_signal("hello world")
def test_render_html_start_detector_uses_first_tool(self):
assert _detect_render_html_tool_start("<function=render_html>")
assert _detect_render_html_tool_start(
'<tool_call>{"name":"render_html","arguments":{"code":"<html>"}'
)
assert not _detect_render_html_tool_start(
"<function=python><parameter=code>'<function=render_html>'"
)
assert not _detect_render_html_tool_start(
'<tool_call>{"name":"python","arguments":{"code":"<function=render_html>"}}'
)
def test_strip_markup_closed(self):
text = "before <tool_call>{}</tool_call> after"
assert strip_tool_markup(text) == "before after"
@ -281,6 +306,104 @@ class TestLoopBasic:
contents = [e for e in events if e["type"] == "content"]
assert "Result: 1" in contents[-1]["text"]
def test_render_html_emits_provisional_tool_start(self):
exec_fn = FakeExecuteTool(["Rendered HTML artifact."])
turn_iter = iter(
[
[
"<function=render_html>",
"<parameter=code><!doctype html><html>",
"<body>Hi</body></html></parameter></function>",
],
["Done."],
]
)
def _gen(_messages):
chunks = next(turn_iter)
acc = ""
for chunk in chunks:
acc += chunk
yield acc
loop = run_safetensors_tool_loop(
single_turn = _gen,
messages = [{"role": "user", "content": "make html"}],
tools = [{"type": "function", "function": {"name": "render_html"}}],
execute_tool = exec_fn,
)
events = _collect_events(loop)
tool_starts = [e for e in events if e["type"] == "tool_start"]
assert len(tool_starts) == 2
assert tool_starts[0]["tool_name"] == "render_html"
assert tool_starts[0]["arguments"] == {}
assert tool_starts[1]["tool_name"] == "render_html"
assert "<!doctype html>" in tool_starts[1]["arguments"]["code"]
assert exec_fn.calls[0][0] == "render_html"
assert "<!doctype html>" in exec_fn.calls[0][1]["code"]
def test_python_tool_containing_render_html_signal_does_not_emit_provisional_start(
self,
):
loop, exec_fn = _make_loop(
turns = [
[
"<function=python>",
"<parameter=code>print('<function=render_html>')",
"</parameter></function>",
],
["Done."],
],
exec_results = ["ok"],
)
events = _collect_events(loop)
tool_starts = [e for e in events if e["type"] == "tool_start"]
assert len(tool_starts) == 1
assert tool_starts[0]["tool_name"] == "python"
assert exec_fn.calls == [
("python", {"code": "print('<function=render_html>')"})
]
def test_render_html_success_blocks_second_artifact_call(self):
exec_fn = FakeExecuteTool(["Rendered HTML artifact."])
turn_iter = iter(
[
[
'<tool_call>{"name":"render_html",',
'"arguments":{"code":"<html>one</html>"}}',
],
[
'<tool_call>{"name":"render_html",',
'"arguments":{"code":"<html>two</html>"}}',
],
["Done."],
]
)
def _gen(_messages):
chunks = next(turn_iter)
acc = ""
for chunk in chunks:
acc += chunk
yield acc
loop = run_safetensors_tool_loop(
single_turn = _gen,
messages = [{"role": "user", "content": "make html"}],
tools = [{"type": "function", "function": {"name": "render_html"}}],
execute_tool = exec_fn,
)
events = _collect_events(loop)
tool_starts = [e for e in events if e["type"] == "tool_start"]
assert exec_fn.calls == [("render_html", {"code": "<html>one</html>"})]
assert [e["arguments"] for e in tool_starts] == [
{},
{"code": "<html>one</html>"},
]
def test_truncated_unclosed_tool_call(self):
loop, exec_fn = _make_loop(
turns = [
@ -596,6 +719,7 @@ class TestChatTemplateHelper:
tok = self._Tok({"tools", "enable_thinking"})
self.apply(tok, [], tools = [{}], enable_thinking = True)
assert tok.call_count == 1
assert tok.last_kwargs is not None
assert "tools" in tok.last_kwargs
assert "enable_thinking" in tok.last_kwargs
@ -782,7 +906,7 @@ class TestGptOssNameDetection:
def test_empty_or_none_returns_false(self):
assert is_gpt_oss_model_name("") is False
assert is_gpt_oss_model_name(None) is False
assert is_gpt_oss_model_name(cast(str, None)) is False
if __name__ == "__main__":

View file

@ -25,6 +25,8 @@ from .storage_roots import (
auth_root,
auth_db_path,
studio_db_path,
documents_root,
project_workspaces_root,
tmp_root,
seed_uploads_root,
unstructured_seed_cache_root,
@ -44,6 +46,10 @@ from .storage_roots import (
resolve_dataset_path,
)
# Re-export shim: name-load the project-path helpers so the import-hoist
# safety net sees them used here, not just listed in __all__ as strings.
_REEXPORTED = (documents_root, project_workspaces_root)
__all__ = [
"normalize_path",
"is_local_path",
@ -62,6 +68,8 @@ __all__ = [
"auth_root",
"auth_db_path",
"studio_db_path",
"documents_root",
"project_workspaces_root",
"tmp_root",
"seed_uploads_root",
"unstructured_seed_cache_root",

View file

@ -96,6 +96,38 @@ def studio_db_path() -> Path:
return studio_root() / "studio.db"
def _xdg_user_dir(key: str) -> Path | None:
config = Path.home() / ".config" / "user-dirs.dirs"
try:
lines = config.read_text(encoding = "utf-8").splitlines()
except OSError:
return None
prefix = f"{key}="
for line in lines:
line = line.strip()
if not line.startswith(prefix):
continue
value = line[len(prefix) :].strip().strip('"')
if not value:
return None
return Path(value.replace("$HOME", str(Path.home()))).expanduser()
return None
def documents_root() -> Path:
override = (os.environ.get("UNSLOTH_STUDIO_DOCUMENTS_HOME") or "").strip()
if override:
return Path(override).expanduser()
return _xdg_user_dir("XDG_DOCUMENTS_DIR") or (Path.home() / "Documents")
def project_workspaces_root() -> Path:
override = (os.environ.get("UNSLOTH_STUDIO_PROJECTS_HOME") or "").strip()
if override:
return Path(override).expanduser()
return documents_root() / "Unsloth Studio" / "Projects"
def tmp_root() -> Path:
return Path(tempfile.gettempdir()) / "unsloth-studio"

View file

@ -14101,9 +14101,9 @@
}
},
"node_modules/react-is": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz",
"integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==",
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz",
"integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==",
"license": "MIT",
"peer": true
},

View file

@ -14,6 +14,7 @@ import { Route as indexRoute } from "./routes/index";
import { Route as knowledgeBasesRoute } from "./routes/knowledge-bases";
import { Route as loginRoute } from "./routes/login";
import { Route as onboardingRoute } from "./routes/onboarding";
import { Route as projectsRoute } from "./routes/projects";
import { Route as changePasswordRoute } from "./routes/change-password";
import { Route as settingsRoute } from "./routes/settings";
import { Route as studioRoute } from "./routes/studio";
@ -28,6 +29,7 @@ const routeTree = rootRoute.addChildren([
knowledgeBasesRoute,
studioRoute,
chatRoute,
projectsRoute,
exportRoute,
dataRecipesRoute,
dataRecipeRoute,

View file

@ -7,6 +7,7 @@ import { fetchDeviceType, usePlatformStore } from "@/config/env";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { IngestionToastStack } from "@/features/rag/components/ingestion-toast-stack";
import { SettingsDialog, useSettingsDialogStore } from "@/features/settings";
import { useChatRuntimeStore } from "@/features/chat";
import { useTrainingUnloadGuard } from "@/features/training";
import { useSidebarPin } from "@/hooks/use-sidebar-pin";
import { useT, type TranslationKey } from "@/i18n";
@ -41,6 +42,7 @@ function RouteFallback() {
const CHAT_ONLY_ALLOWED = new Set([
"/",
"/chat",
"/projects",
"/login",
"/signup",
"/change-password",
@ -111,6 +113,13 @@ function RootLayout() {
return () => window.removeEventListener("keydown", handler);
}, []);
useEffect(() => {
if (isChatRoute) return;
const chatRuntime = useChatRuntimeStore.getState();
chatRuntime.setActiveProjectId(null);
chatRuntime.setActiveThreadId(null);
}, [isChatRoute]);
return (
<AppProvider>
<SettingsDialog />

View file

@ -10,6 +10,7 @@ export type ChatSearch = {
thread?: string;
compare?: string;
new?: string;
project?: string;
};
export const Route = createRoute({
@ -21,6 +22,7 @@ export const Route = createRoute({
thread: typeof search.thread === "string" ? search.thread : undefined,
compare: typeof search.compare === "string" ? search.compare : undefined,
new: typeof search.new === "string" ? search.new : undefined,
project: typeof search.project === "string" ? search.project : undefined,
}),
component: ChatPage,
});

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
import { createRoute } from "@tanstack/react-router";
import { lazy } from "react";
import { requireAuth } from "../auth-guards";
import { Route as rootRoute } from "./__root";
const ProjectsPage = lazy(() =>
import("@/features/chat/projects-page").then((m) => ({
default: m.ProjectsPage,
})),
);
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/projects",
staticData: { title: "Projects" },
beforeLoad: () => requireAuth(),
component: ProjectsPage,
});

View file

@ -26,6 +26,9 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
@ -38,6 +41,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useAnimatedThemeToggle } from "@/components/ui/animated-theme-toggler";
import { cn } from "@/lib/utils";
import {
@ -47,9 +51,12 @@ import {
Delete02Icon,
DownloadSquare01Icon,
Edit03Icon,
FolderAddIcon,
Folder01Icon,
Globe02Icon,
HelpCircleIcon,
Logout05Icon,
MoreVerticalIcon,
Search01Icon,
PowerIcon,
PencilEdit02Icon,
@ -68,11 +75,17 @@ import { ChevronDown, ChevronsUpDown, MoreHorizontalIcon, Moon, Sun } from "luci
import { Link, useNavigate, useRouterState } from "@tanstack/react-router";
import {
ChatSearchDialog,
createChatProject,
deleteChatProject,
deleteChatItem,
moveChatItemToProject,
renameChatItem,
renameChatProject,
useChatRuntimeStore,
useChatProjects,
useChatSearchStore,
useChatSidebarItems,
type ProjectRecord,
type SidebarItem,
} from "@/features/chat";
import { useSettingsDialogStore } from "@/features/settings";
@ -90,7 +103,7 @@ import {
useTrainingRuntimeStore,
} from "@/features/training";
import type { TrainingRunSummary } from "@/features/training";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { toast } from "@/lib/toast";
import { ShutdownDialog } from "@/components/shutdown-dialog";
import { translate, useT, type TranslationKey } from "@/i18n";
@ -185,7 +198,7 @@ function NavItem({
active: boolean;
disabled?: boolean;
onClick: () => void;
children?: React.ReactNode;
children?: ReactNode;
dataTour?: string;
}) {
return (
@ -197,7 +210,7 @@ function NavItem({
onClick={onClick}
isActive={active}
data-tour={dataTour}
className="sidebar-nav-btn h-[35px] rounded-[10px] gap-[8.5px] px-2.5 font-medium group-data-[collapsible=icon]:!w-[32px] group-data-[collapsible=icon]:!rounded-[10px] group-data-[collapsible=icon]:mx-auto"
className="sidebar-nav-btn h-[33px] rounded-[10px] gap-[8.5px] px-2.5 font-medium group-data-[collapsible=icon]:!w-[32px] group-data-[collapsible=icon]:!rounded-[10px] group-data-[collapsible=icon]:mx-auto"
>
<HugeiconsIcon icon={icon} strokeWidth={1.75} className="size-icon! shrink-0 group-hover/menu-button:animate-icon-pop" />
<span className="text-[14.5px] leading-[19px] tracking-nav">{label}</span>
@ -231,22 +244,53 @@ export function AppSidebar() {
const isChatRoute = pathname.startsWith("/chat");
const isStudioRoute = pathname === "/studio" || pathname.startsWith("/studio/");
const [chatOpen, setChatOpen] = useState(true);
const [trainOpen, setTrainOpen] = useState(true);
const [runsOpen, setRunsOpen] = useState(true);
useEffect(() => {
if (!isChatRoute) return;
queueMicrotask(() => setChatOpen(true));
}, [isChatRoute]);
useEffect(() => {
if (!isStudioRoute) return;
queueMicrotask(() => setRunsOpen(true));
}, [isStudioRoute]);
const scrollRef = useRef<HTMLDivElement | null>(null);
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const handler = () => setScrolled(el.scrollTop > 0);
handler();
el.addEventListener("scroll", handler, { passive: true });
return () => el.removeEventListener("scroll", handler);
}, []);
// Bottom fade hides at the very bottom (and for short, non-scrolling lists)
// so the last row isn't washed out - Gemini-style.
const [canScrollDown, setCanScrollDown] = useState(false);
// Driven only from onScroll + a content-change effect below. Deliberately NO
// ResizeObserver: its callback-driven setState created a render loop (React
// #185). Both setters bail out when unchanged, so neither path can loop.
const syncScrollState = (el: HTMLDivElement) => {
const nextScrolled = el.scrollTop > 0;
setScrolled((prev) => (prev === nextScrolled ? prev : nextScrolled));
const nextCanScrollDown =
el.scrollHeight - el.scrollTop - el.clientHeight > 1;
setCanScrollDown((prev) =>
prev === nextCanScrollDown ? prev : nextCanScrollDown,
);
};
const isRecipesRoute = pathname.startsWith("/data-recipes");
const { displayTitle, avatarDataUrl } = useEffectiveProfile();
const { items: chatItems } = useChatSidebarItems();
const { projects } = useChatProjects();
const activeProjectId = isChatRoute
? ((search.project as string | undefined) ?? null)
: null;
const { items: allChatItems } = useChatSidebarItems({
enabled: !isStudioRoute,
requireMessages: false,
});
const recentChatItems = useMemo(
() => allChatItems.filter((item) => !item.projectId),
[allChatItems],
);
const chatItems = allChatItems;
const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
const setActiveThreadId = useChatRuntimeStore((s) => s.setActiveThreadId);
const activeThreadId = isChatRoute
@ -261,33 +305,87 @@ export function AppSidebar() {
!chatOnly && isStudioRoute,
);
const activeJobId = useTrainingRuntimeStore((s) => s.jobId);
const currentRunViewActive = useTrainingRuntimeStore((s) => s.currentRunViewActive);
const selectedHistoryRunId = useTrainingRuntimeStore((s) => s.selectedHistoryRunId);
const setSelectedHistoryRunId = useTrainingRuntimeStore((s) => s.setSelectedHistoryRunId);
// Recompute the bottom-fade state on mount and whenever the list height can
// change (items load, sections collapse/expand, route switches the visible
// list) - onScroll never fires for short, non-scrolling lists. Guarded
// setState below means this can't loop even if a dep is a fresh reference.
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const next = el.scrollHeight - el.scrollTop - el.clientHeight > 1;
setCanScrollDown((prev) => (prev === next ? prev : next));
}, [
recentChatItems.length,
runItems.length,
projects.length,
chatOpen,
trainOpen,
runsOpen,
isStudioRoute,
]);
const chatDisabled = isTrainingRunning;
function chatSearchForProject(projectId: string | null) {
if (projectId) {
return { project: projectId };
}
return {
new: createNavigationNonce(),
};
}
function openNewChat(projectId = activeProjectId) {
if (chatDisabled) return;
setActiveThreadId(null);
useChatRuntimeStore.getState().setActiveProjectId(projectId);
navigate({ to: "/chat", search: chatSearchForProject(projectId) });
closeMobileIfOpen();
}
function openProject(projectId: string) {
if (chatDisabled) return;
setActiveThreadId(null);
useChatRuntimeStore.getState().setActiveProjectId(projectId);
navigate({ to: "/chat", search: { project: projectId } });
closeMobileIfOpen();
}
async function handleDeleteThread(item: Parameters<typeof deleteChatItem>[0]) {
await deleteChatItem(item, activeThreadId, (view) => {
navigate({
to: "/chat",
search: { new: view.newThreadNonce },
search: item.projectId
? { project: item.projectId }
: { new: view.newThreadNonce },
});
});
}
type RenameTarget =
| { kind: "chat"; item: SidebarItem; current: string }
| { kind: "project"; project: ProjectRecord; current: string }
| { kind: "run"; run: TrainingRunSummary; current: string };
const [renamingTarget, setRenamingTarget] = useState<RenameTarget | null>(
null,
);
const [renameDraft, setRenameDraft] = useState("");
const [creatingProject, setCreatingProject] = useState(false);
const [projectNameDraft, setProjectNameDraft] = useState("");
const [projectCreateMoveTarget, setProjectCreateMoveTarget] =
useState<SidebarItem | null>(null);
const renameTrimmed = renameDraft.trim();
const nextRunDisplayName = renameTrimmed.length > 0 ? renameTrimmed : null;
const renameDirty =
renamingTarget !== null &&
(renamingTarget.kind === "chat"
? renameTrimmed.length > 0 && renameTrimmed !== renamingTarget.current
: renamingTarget.kind === "project"
? renameTrimmed.length > 0 && renameTrimmed !== renamingTarget.current
: renameTrimmed.length > 0
? renameTrimmed !== renamingTarget.current
: renamingTarget.run.display_name != null);
@ -315,6 +413,16 @@ export function AppSidebar() {
}
return;
}
if (target.kind === "project") {
try {
await renameChatProject(target.project.id, renameTrimmed);
} catch (err) {
toast.error("Failed to rename project", {
description: err instanceof Error ? err.message : undefined,
});
}
return;
}
try {
const updated = await renameTrainingRun(target.run.id, nextRunDisplayName);
emitTrainingRunUpdated(updated);
@ -327,13 +435,23 @@ export function AppSidebar() {
type DeleteTarget =
| { kind: "chat"; item: SidebarItem }
| { kind: "project"; project: ProjectRecord }
| { kind: "run"; run: TrainingRunSummary };
const [confirmingDelete, setConfirmingDelete] =
useState<DeleteTarget | null>(null);
const [deleteProjectFiles, setDeleteProjectFiles] = useState(false);
useEffect(() => {
if (confirmingDelete?.kind !== "project") {
setDeleteProjectFiles(false);
}
}, [confirmingDelete]);
async function commitDelete() {
const target = confirmingDelete;
if (!target) return;
const shouldDeleteProjectFiles =
target.kind === "project" && deleteProjectFiles;
setConfirmingDelete(null);
if (target.kind === "chat") {
try {
@ -345,6 +463,22 @@ export function AppSidebar() {
}
return;
}
if (target.kind === "project") {
try {
await deleteChatProject(target.project.id, {
deleteFiles: shouldDeleteProjectFiles,
});
if (activeProjectId === target.project.id) {
useChatRuntimeStore.getState().setActiveProjectId(null);
navigate({ to: "/chat", search: { new: createNavigationNonce() } });
}
} catch (err) {
toast.error("Failed to delete project", {
description: err instanceof Error ? err.message : undefined,
});
}
return;
}
if (target.run.status === "running") {
toast.error(t("shell.toast.cannotDeleteRunningRun"));
return;
@ -362,6 +496,168 @@ export function AppSidebar() {
}
}
async function commitCreateProject() {
const name = projectNameDraft.trim();
if (!name) return;
const moveTarget = projectCreateMoveTarget;
try {
const project = await createChatProject(name);
if (moveTarget) {
await moveChatItemToProject(moveTarget, project.id);
if (activeThreadId === moveTarget.id) {
useChatRuntimeStore.getState().setActiveProjectId(project.id);
}
}
setCreatingProject(false);
setProjectNameDraft("");
setProjectCreateMoveTarget(null);
if (moveTarget) {
return;
} else {
openProject(project.id);
}
} catch (err) {
toast.error(moveTarget ? "Failed to create and move chat" : "Failed to create project", {
description: err instanceof Error ? err.message : undefined,
});
}
}
async function moveChatToProject(item: SidebarItem, projectId: string | null) {
if (item.projectId === projectId) return;
try {
await moveChatItemToProject(item, projectId);
if (activeThreadId === item.id) {
useChatRuntimeStore.getState().setActiveProjectId(projectId);
}
} catch (err) {
toast.error("Failed to move chat", {
description: err instanceof Error ? err.message : undefined,
});
}
}
function renderChatSidebarItem(
item: SidebarItem,
variant: "project" | "recent",
) {
const itemClass =
variant === "project"
? "group/project-chat-item relative"
: "group/recent-item relative";
const actionClass =
variant === "project"
? "sidebar-row-action group-hover/project-chat-item:opacity-100 group-hover/project-chat-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"
: "sidebar-row-action group-hover/recent-item:opacity-100 group-hover/recent-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto";
const buttonClass = cn(
"sidebar-nav-btn h-[33px] cursor-pointer rounded-[10px] pr-4 text-[14.5px] leading-[19px] tracking-nav font-medium",
variant === "project" ? "pl-[37px]" : "pl-2.5",
variant === "project"
? "group-hover/project-chat-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/project-chat-item:pr-8"
: "group-hover/recent-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/recent-item:pr-8",
);
return (
<SidebarMenuItem key={item.id} className={itemClass}>
<SidebarMenuButton
data-testid="recent-thread"
data-thread-type={item.type}
data-thread-id={item.id}
isActive={activeThreadId === item.id}
className={buttonClass}
onClick={() => {
navigate({
to: "/chat",
search:
item.type === "single"
? {
thread: item.id,
...(item.projectId ? { project: item.projectId } : {}),
}
: {
compare: item.id,
...(item.projectId ? { project: item.projectId } : {}),
},
});
closeMobileIfOpen();
}}
>
<span className="truncate">{item.title}</span>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
aria-label="Chat options"
className={actionClass}
>
<span className="sidebar-row-action-glyph">
<HugeiconsIcon icon={MoreVerticalIcon} strokeWidth={1.75} className="size-icon" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
sideOffset={4}
className="app-user-menu menu-soft-surface menu-flat-destructive ring-0 w-44 py-2 font-heading rounded-[14px] border-0"
>
<DropdownMenuItem onSelect={() => openRenameChat(item)}>
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
<span>Rename</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<HugeiconsIcon icon={Folder01Icon} strokeWidth={1.75} className="size-icon" />
<span>Move to project</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
sideOffset={8}
alignOffset={-4}
className="app-user-menu menu-soft-surface menu-flat-destructive ring-0 w-56 py-2 font-heading rounded-[14px] border-0"
>
<DropdownMenuItem
onSelect={() => {
setProjectCreateMoveTarget(item);
setProjectNameDraft("");
setCreatingProject(true);
}}
>
<HugeiconsIcon icon={FolderAddIcon} strokeWidth={1.75} className="size-icon" />
<span>New project</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!item.projectId}
onSelect={() => void moveChatToProject(item, null)}
>
<span>Recents</span>
</DropdownMenuItem>
{projects.map((project) => (
<DropdownMenuItem
key={project.id}
disabled={item.projectId === project.id}
onSelect={() => void moveChatToProject(item, project.id)}
>
<HugeiconsIcon icon={Folder01Icon} strokeWidth={1.75} className="size-icon" />
<span className="truncate">{project.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem
variant="destructive"
onSelect={() => setConfirmingDelete({ kind: "chat", item })}
>
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.75} className="size-icon" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
);
}
return (
<>
<Sidebar
@ -369,7 +665,7 @@ export function AppSidebar() {
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-[8px] group-data-[collapsible=icon]:px-0">
<SidebarHeader className="pl-[17px] pr-3 pt-[14px] pb-[8px] group-data-[collapsible=icon]:px-0">
{/* Expanded: compact logo + close toggle */}
<div className="flex items-center justify-between gap-[8.5px] group-data-[collapsible=icon]:hidden">
<Link
@ -377,12 +673,7 @@ export function AppSidebar() {
onClick={(event) => {
event.preventDefault();
if (chatDisabled) return;
setActiveThreadId(null);
closeMobileIfOpen();
void navigate({
to: "/chat",
search: { new: createNavigationNonce() },
});
openNewChat(null);
}}
className="flex items-center gap-[6px] select-none"
aria-label={t("shell.aria.home")}
@ -392,7 +683,7 @@ export function AppSidebar() {
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">
<span className="font-heading text-[21px] font-semibold tracking-[0em] dark:tracking-[0.02em] leading-none text-black dark:text-white">
unsloth
</span>
<span className="nav-badge ml-0.5 inline-flex items-center justify-center rounded-full border border-nav-beta-border px-[5px] pt-[3px] pb-[2px] text-[8px] font-medium leading-none tracking-[0.04em] text-nav-fg-muted antialiased subpixel-antialiased shadow-[0_1px_2px_rgba(0,0,0,0.06)] dark:shadow-[0_1px_2px_rgba(0,0,0,0.35)]">
@ -405,7 +696,7 @@ export function AppSidebar() {
<button
type="button"
onClick={togglePinned}
className="inline-flex h-[35px] w-[32px] items-center justify-center rounded-[10px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="inline-flex h-[33px] w-[32px] cursor-pointer items-center justify-center rounded-[10px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t("shell.aria.closeSidebar")}
>
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" />
@ -424,13 +715,13 @@ export function AppSidebar() {
{/* Collapsed: panel icon doubles as expand trigger */}
{!isMobile && (
<div className="hidden group-data-[collapsible=icon]:flex h-[35px] items-center justify-center w-full">
<div className="hidden group-data-[collapsible=icon]:flex h-[33px] items-center justify-center w-full">
<Tooltip>
<TooltipPrimitive.Trigger asChild>
<button
type="button"
onClick={togglePinned}
className="inline-flex h-[35px] w-[32px] items-center justify-center rounded-[10px] text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="inline-flex h-[33px] w-[32px] cursor-pointer items-center justify-center rounded-[10px] text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t("shell.aria.openSidebar")}
>
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" />
@ -448,31 +739,37 @@ export function AppSidebar() {
)}
</SidebarHeader>
<SidebarGroup className="group-data-[collapsible=icon]:px-0 px-2 pt-[9px] pb-[8px] shrink-0">
<SidebarGroup className="group-data-[collapsible=icon]:px-0 px-2 pt-[9px] pb-px shrink-0">
<SidebarGroupContent>
<SidebarMenu>
<NavItem
icon={PencilEdit02Icon}
label={t("shell.navigation.newChat")}
active={false}
active={
isChatRoute &&
!search.thread &&
!search.compare &&
!search.project
}
disabled={chatDisabled}
onClick={() => {
if (chatDisabled) return;
setActiveThreadId(null);
navigate({ to: "/chat", search: { new: createNavigationNonce() } });
closeMobileIfOpen();
}}
onClick={() => openNewChat(null)}
/>
<NavItem
icon={ColumnInsertIcon}
label={t("shell.navigation.compare")}
active={!!search.compare && !chatItems.some((i) => i.id === search.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() } });
navigate({
to: "/chat",
search: { compare: createNavigationNonce() },
});
closeMobileIfOpen();
}}
/>
@ -480,9 +777,10 @@ export function AppSidebar() {
icon={Search01Icon}
label={t("shell.navigation.search")}
active={false}
disabled={chatDisabled}
onClick={() => {
if (chatDisabled) return;
// Search is read-only over chat history and never runs
// inference, so it stays available while training (unlike
// New chat, which is gated on `chatDisabled`).
useChatSearchStore.getState().open();
closeMobileIfOpen();
}}
@ -491,140 +789,127 @@ export function AppSidebar() {
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup data-tour="navbar" className="group-data-[collapsible=icon]:px-0 px-2 pt-[9px] pb-[20px] shrink-0">
<SidebarGroupContent>
<SidebarMenu>
<NavItem
icon={TestTubeOutlineIcon}
label={t("shell.navigation.train")}
active={pathname === "/studio" || pathname.startsWith("/studio/")}
disabled={chatOnly}
onClick={() => {
if (chatOnly) return;
navigate({ to: "/studio" });
closeMobileIfOpen();
}}
/>
<SidebarContent
ref={scrollRef}
onScroll={(e) => syncScrollState(e.currentTarget)}
className={cn(
// pb-2 keeps the last row's rounded highlight clear of the
// overflow clip edge so its bottom corners aren't shaved off.
"sidebar-scroll-fade gap-0 overflow-y-auto overscroll-contain min-h-0 pb-2",
scrolled && "is-scrolled",
)}
>
<SidebarGroup className="group-data-[collapsible=icon]:px-0 px-2 py-0 shrink-0">
<SidebarGroupContent>
<SidebarMenu>
<NavItem
icon={Folder01Icon}
label="Projects"
active={
pathname === "/projects" || pathname.startsWith("/projects/")
}
onClick={() => {
navigate({ to: "/projects" });
closeMobileIfOpen();
}}
/>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<NavItem
icon={ChefHatIcon}
label={t("shell.navigation.recipes")}
active={isRecipesRoute}
onClick={() => {
navigate({ to: "/data-recipes" });
closeMobileIfOpen();
}}
/>
<NavItem
icon={DownloadSquare01Icon}
label={t("shell.navigation.export")}
active={pathname === "/export" || pathname.startsWith("/export/")}
disabled={chatOnly}
onClick={() => {
if (chatOnly) return;
navigate({ to: "/export" });
closeMobileIfOpen();
}}
/>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarContent ref={scrollRef} className="gap-0 overflow-y-auto overscroll-contain min-h-0">
{!isStudioRoute && chatItems.length > 0 && (
<Collapsible
key={isChatRoute ? "chat-route" : "non-chat-route"}
defaultOpen
asChild
>
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
<SidebarGroupLabel className={cn("sidebar-sticky-label", scrolled && "is-scrolled")} asChild>
<CollapsibleTrigger className="cursor-pointer flex w-full items-center justify-between">
{t("shell.navigation.recents")}
<ChevronDown className="size-3.5 transition-transform duration-200 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
<Collapsible open={trainOpen} onOpenChange={setTrainOpen} asChild>
<SidebarGroup data-tour="navbar" className="group-data-[collapsible=icon]:hidden px-0 py-0">
<SidebarGroupLabel className={cn("sidebar-sticky-label sidebar-sticky-label-following", scrolled && "is-scrolled")} asChild>
<CollapsibleTrigger className="cursor-pointer flex w-full items-center gap-1 group/sb-collap">
{t("shell.navigation.train")}
<ChevronDown className="size-3.5 opacity-0 transition-[transform,opacity] duration-200 group-hover/sb-collap:opacity-100 group-focus-visible/sb-collap:opacity-100 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent className="px-2">
<SidebarMenu>
{chatItems.map((item) => (
<SidebarMenuItem key={item.id} className="group/recent-item relative">
<SidebarMenuButton
data-testid="recent-thread"
data-thread-type={item.type}
data-thread-id={item.id}
isActive={activeThreadId === item.id}
className="sidebar-nav-btn h-[32px] rounded-[10px] pl-2.5 pr-2.5 group-hover/recent-item:pr-10 group-has-[.sidebar-row-action[data-state=open]]/recent-item:pr-10 text-[14.5px] leading-[19px] tracking-nav font-medium"
onClick={() => {
navigate({
to: "/chat",
search:
item.type === "single"
? { thread: item.id }
: { compare: item.id },
});
closeMobileIfOpen();
}}
>
<span className="truncate">{item.title}</span>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
aria-label={t("shell.aria.chatOptions")}
className="sidebar-row-action group-hover/recent-item:opacity-100 group-hover/recent-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"
>
<span className="sidebar-row-action-glyph">
<MoreHorizontalIcon strokeWidth={1.75} className="size-icon" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
sideOffset={4}
className="app-user-menu menu-soft-surface menu-flat-destructive ring-0 w-44 py-2 font-heading rounded-[14px] border-0"
>
<DropdownMenuItem onSelect={() => openRenameChat(item)}>
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
<span>{t("common.rename")}</span>
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => setConfirmingDelete({ kind: "chat", item })}
>
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.75} className="size-icon" />
<span>{t("common.delete")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
<SidebarGroupContent className="px-2">
<SidebarMenu>
<NavItem
icon={TestTubeOutlineIcon}
label={t("shell.navigation.train")}
active={pathname === "/studio" || pathname.startsWith("/studio/")}
disabled={chatOnly}
onClick={() => {
if (chatOnly) return;
navigate({ to: "/studio" });
closeMobileIfOpen();
}}
/>
<NavItem
icon={ChefHatIcon}
label={t("shell.navigation.recipes")}
active={isRecipesRoute}
onClick={() => {
navigate({ to: "/data-recipes" });
closeMobileIfOpen();
}}
/>
<NavItem
icon={DownloadSquare01Icon}
label={t("shell.navigation.export")}
active={pathname === "/export" || pathname.startsWith("/export/")}
disabled={chatOnly}
onClick={() => {
if (chatOnly) return;
navigate({ to: "/export" });
closeMobileIfOpen();
}}
/>
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
{!isStudioRoute && (
<Collapsible open={chatOpen} onOpenChange={setChatOpen} asChild>
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
<SidebarGroupLabel className={cn("sidebar-sticky-label sidebar-sticky-label-following", scrolled && "is-scrolled")} asChild>
<CollapsibleTrigger className="cursor-pointer flex w-full items-center gap-1 group/sb-collap">
{t("shell.navigation.recents")}
<ChevronDown className="size-3.5 opacity-0 transition-[transform,opacity] duration-200 group-hover/sb-collap:opacity-100 group-focus-visible/sb-collap:opacity-100 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent className="px-2">
<SidebarMenu>
{recentChatItems.map((item) =>
renderChatSidebarItem(item, "recent"),
)}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
)}
{isStudioRoute && runItems.length > 0 && !chatOnly && (
<Collapsible key="studio-runs-route" defaultOpen asChild>
<Collapsible open={runsOpen} onOpenChange={setRunsOpen} asChild>
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
<SidebarGroupLabel className={cn("sidebar-sticky-label", scrolled && "is-scrolled")} asChild>
<CollapsibleTrigger className="cursor-pointer flex w-full items-center justify-between">
<SidebarGroupLabel className={cn("sidebar-sticky-label sidebar-sticky-label-following", scrolled && "is-scrolled")} asChild>
<CollapsibleTrigger className="cursor-pointer flex w-full items-center gap-1 group/sb-collap">
{t("shell.navigation.recents")}
<ChevronDown className="size-3.5 transition-transform duration-200 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
<ChevronDown className="size-3.5 opacity-0 transition-[transform,opacity] duration-200 group-hover/sb-collap:opacity-100 group-focus-visible/sb-collap:opacity-100 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent className="px-2">
<SidebarMenu>
{runItems.map((run) => {
// An explicit sidebar selection wins. Otherwise highlight
// the active job only while the "Current Run" tab is the
// view - that covers a live run (it auto-switches there) and
// a just-finished/errored run you're still viewing, while
// keeping the Configure tab unhighlighted even though
// `activeJobId` stays pinned to the last job.
const isActiveRun =
selectedHistoryRunId === run.id || activeJobId === run.id;
selectedHistoryRunId != null
? run.id === selectedHistoryRunId
: currentRunViewActive && run.id === activeJobId;
return (
<SidebarMenuItem
key={run.id}
@ -703,7 +988,18 @@ export function AppSidebar() {
)}
</SidebarContent>
<SidebarFooter className="border-t border-sidebar-border group-data-[collapsible=icon]:border-transparent group-data-[collapsible=icon]:px-0">
<SidebarFooter className="relative group-data-[collapsible=icon]:px-0">
{/* Fade above the profile box, shown only while there's more list below
the fold; at the very bottom (or for short lists) it fades out so the
last row shows fully (Gemini-style). `right-2` keeps it clear of the
8px scrollbar gutter so the scrollbar isn't faded out. */}
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute left-0 right-2 bottom-full h-10 bg-gradient-to-t from-[var(--sidebar)] to-transparent transition-opacity duration-200",
canScrollDown ? "opacity-100" : "opacity-0",
)}
/>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
@ -711,14 +1007,14 @@ export function AppSidebar() {
<SidebarMenuButton
size="lg"
aria-label={t("shell.accountMenu", { name: displayTitle })}
className="sidebar-nav-btn !h-[50px] gap-[8px] px-2 py-[9px] rounded-[10px]"
className="sidebar-nav-btn !h-[40px] gap-[8px] px-2 py-[5px] rounded-[10px]"
>
<div className="shrink-0">
<UserAvatar
name={displayTitle}
imageUrl={avatarDataUrl}
size="sm"
className="!size-8"
className="!size-[30px]"
/>
</div>
<div className="flex flex-col gap-0.5 leading-tight group-data-[collapsible=icon]:hidden">
@ -818,7 +1114,10 @@ export function AppSidebar() {
<Dialog
open={confirmingDelete !== null}
onOpenChange={(open) => {
if (!open) setConfirmingDelete(null);
if (!open) {
setConfirmingDelete(null);
setDeleteProjectFiles(false);
}
}}
>
<DialogContent className="menu-flat-destructive corner-squircle border border-border/60 bg-background/98 shadow-none sm:max-w-md">
@ -826,7 +1125,9 @@ export function AppSidebar() {
<DialogTitle>
{confirmingDelete?.kind === "run"
? t("shell.dialog.deleteRun.title")
: t("shell.dialog.deleteChat.title")}
: confirmingDelete?.kind === "project"
? "Delete project"
: t("shell.dialog.deleteChat.title")}
</DialogTitle>
<DialogDescription>
{confirmingDelete?.kind === "run" ? (
@ -842,9 +1143,37 @@ export function AppSidebar() {
"shell.dialog.deleteChat.description",
confirmingDelete.item.title,
)
) : confirmingDelete?.kind === "project" ? (
<>
Delete{" "}
<span className="font-medium text-foreground">
&quot;{confirmingDelete.project.name}&quot;
</span>
? Its chats will be permanently deleted.
</>
) : null}
</DialogDescription>
</DialogHeader>
{confirmingDelete?.kind === "project" ? (
<div className="flex items-start justify-between gap-4 rounded-md border border-border/60 bg-muted/35 px-3 py-2.5">
<label htmlFor="delete-project-files" className="min-w-0 space-y-1">
<span className="block text-sm font-medium text-foreground">
Delete files and sandbox folder
</span>
<span className="block break-words text-xs leading-5 text-muted-foreground">
{confirmingDelete.project.rootPath
? confirmingDelete.project.rootPath
: "The project workspace folder will be removed from disk."}
</span>
</label>
<Switch
id="delete-project-files"
checked={deleteProjectFiles}
onCheckedChange={setDeleteProjectFiles}
aria-label="Delete project files and sandbox folder"
/>
</div>
) : null}
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
<Button
type="button"
@ -858,7 +1187,9 @@ export function AppSidebar() {
variant="destructive"
onClick={() => void commitDelete()}
>
{t("common.delete")}
{confirmingDelete?.kind === "project" && deleteProjectFiles
? "Delete all"
: t("common.delete")}
</Button>
</DialogFooter>
</DialogContent>
@ -874,7 +1205,9 @@ export function AppSidebar() {
<DialogTitle>
{renamingTarget?.kind === "run"
? t("shell.dialog.renameRun.title")
: t("shell.dialog.renameChat.title")}
: renamingTarget?.kind === "project"
? "Rename project"
: t("shell.dialog.renameChat.title")}
</DialogTitle>
</DialogHeader>
<Input
@ -891,12 +1224,16 @@ export function AppSidebar() {
placeholder={
renamingTarget?.kind === "run"
? t("shell.dialog.renameRun.placeholder")
: t("shell.dialog.renameChat.placeholder")
: renamingTarget?.kind === "project"
? "Project name"
: t("shell.dialog.renameChat.placeholder")
}
aria-label={
renamingTarget?.kind === "run"
? t("shell.dialog.renameRun.placeholder")
: t("shell.dialog.renameChat.placeholder")
: renamingTarget?.kind === "project"
? "Project name"
: t("shell.dialog.renameChat.placeholder")
}
className="focus-visible:border-input focus-visible:ring-0"
/>
@ -918,6 +1255,58 @@ export function AppSidebar() {
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={creatingProject}
onOpenChange={(open) => {
setCreatingProject(open);
if (!open) {
setProjectNameDraft("");
setProjectCreateMoveTarget(null);
}
}}
>
<DialogContent className="corner-squircle border border-border/60 bg-background/98 shadow-none sm:max-w-md">
<DialogHeader>
<DialogTitle>
{projectCreateMoveTarget ? "Move to new project" : "New project"}
</DialogTitle>
</DialogHeader>
<Input
value={projectNameDraft}
onChange={(event) => setProjectNameDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void commitCreateProject();
}
}}
autoFocus
maxLength={120}
placeholder="Project name"
aria-label="Project name"
className="focus-visible:border-input focus-visible:ring-0"
/>
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
<Button
type="button"
variant="ghost"
onClick={() => {
setCreatingProject(false);
setProjectCreateMoveTarget(null);
}}
>
Cancel
</Button>
<Button
type="button"
onClick={() => void commitCreateProject()}
disabled={!projectNameDraft.trim()}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -3,18 +3,19 @@
"use client";
import { ArtifactCard, useChatRuntimeStore } from "@/features/chat";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { preprocessLaTeX } from "@/lib/latex";
import { openLink } from "@/lib/open-link";
import { INTERNAL, useMessagePartText } from "@assistant-ui/react";
import { INTERNAL, useAuiState, useMessagePartText } from "@assistant-ui/react";
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { createCodePlugin } from "./code-plugin";
import { createMathPlugin } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { DownloadIcon, Maximize2Icon, Minimize2Icon } from "lucide-react";
import { DownloadIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Block, type BlockProps, Streamdown } from "streamdown";
import { createCodePlugin } from "./code-plugin";
import "katex/dist/katex.min.css";
import { AudioPlayer } from "./audio-player";
import { unslothDarkTheme, unslothLightTheme } from "./code-themes";
@ -26,11 +27,7 @@ const code = createCodePlugin({
const { withSmoothContextProvider } = INTERNAL;
const STREAMDOWN_COMPONENTS = {
a: ({
href,
children,
...props
}: React.ComponentProps<"a">) => (
a: ({ href, children, ...props }: React.ComponentProps<"a">) => (
<a
href={href}
rel="noopener noreferrer"
@ -59,6 +56,34 @@ type CodeFence = {
source: string;
};
type ToolCallPartLike = {
type?: string;
toolName?: string;
args?: unknown;
result?: unknown;
};
function isRenderableRenderHtmlToolPart(part: unknown): boolean {
const toolPart = part as ToolCallPartLike;
if (toolPart.type !== "tool-call" || toolPart.toolName !== "render_html") {
return false;
}
if (
typeof toolPart.result === "string" &&
toolPart.result.startsWith("Error:")
) {
return false;
}
if (
typeof toolPart.result === "string" &&
toolPart.result.startsWith("Rendered HTML artifact")
) {
return true;
}
const args = toolPart.args as { code?: unknown } | undefined;
return typeof args?.code === "string" && args.code.trim().length > 0;
}
function getMermaidSource(blockContent: string): string | null {
const source = blockContent.match(MERMAID_SOURCE_RE)?.[1]?.trim();
return source && source.length > 0 ? source : null;
@ -123,7 +148,13 @@ function isHtmlFence(codeFence: CodeFence): boolean {
return lang === "html" && !isSvgFence(codeFence);
}
const UNSAFE_SVG_RE = /<script[\s>]|on\w+\s*=|javascript:|<foreignObject[\s>]|<iframe[\s>]|<embed[\s>]|<object[\s>]/i;
function isFullHtmlDocument(source: string): boolean {
const trimmed = source.trimStart();
return /^<!doctype\s+html\b/i.test(trimmed) || /^<html[\s>]/i.test(trimmed);
}
const UNSAFE_SVG_RE =
/<script[\s>]|on\w+\s*=|javascript:|<foreignObject[\s>]|<iframe[\s>]|<embed[\s>]|<object[\s>]/i;
function sanitizeSvg(source: string): string | null {
if (UNSAFE_SVG_RE.test(source)) return null;
@ -145,96 +176,6 @@ function SvgPreview({ source }: { source: string }) {
);
}
const HTML_PREVIEW_DEFAULT_HEIGHT = 400;
const HTML_PREVIEW_MAX_HEIGHT = 800;
function HtmlPreview({ source }: { source: string }) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState(HTML_PREVIEW_DEFAULT_HEIGHT);
const [enlarged, setEnlarged] = useState(false);
useEffect(() => {
const handler = (e: MessageEvent) => {
if (e.source !== iframeRef.current?.contentWindow) return;
if (typeof e.data?.htmlPreviewHeight === "number") {
setHeight(Math.min(Math.max(e.data.htmlPreviewHeight, 100), HTML_PREVIEW_MAX_HEIGHT));
}
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, []);
useEffect(() => {
if (!enlarged) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") setEnlarged(false);
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [enlarged]);
const resizeScript = `<script>new ResizeObserver(()=>{
parent.postMessage({htmlPreviewHeight:document.documentElement.scrollHeight},"*");
}).observe(document.documentElement);</script>`;
const srcDoc = source + resizeScript;
if (enlarged) {
return (
<>
<div className="mt-2 overflow-hidden rounded-lg border border-border" style={{ height }}>
{/* Placeholder keeps layout stable while overlay is shown */}
</div>
<div
className="fixed inset-0 z-50 flex flex-col bg-background/80 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) setEnlarged(false); }}
>
<div className="flex items-center justify-end gap-2 px-4 py-2">
<button
type="button"
className="flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => setEnlarged(false)}
title="Exit fullscreen (Esc)"
>
<Minimize2Icon className="size-4" />
Exit fullscreen
</button>
</div>
<div className="mx-4 mb-4 flex-1 overflow-hidden rounded-lg border border-border bg-background">
<iframe
ref={iframeRef}
srcDoc={srcDoc}
sandbox="allow-scripts"
style={{ width: "100%", height: "100%", border: "none", display: "block" }}
title="HTML preview"
/>
</div>
</div>
</>
);
}
return (
<div className="group/html-preview relative mt-2 overflow-hidden rounded-lg border border-border">
<button
type="button"
className="absolute top-2 right-2 z-10 rounded-md border border-border bg-background/80 p-1.5 text-muted-foreground opacity-0 transition-all hover:bg-muted hover:text-foreground group-hover/html-preview:opacity-100 supports-[backdrop-filter]:backdrop-blur"
onClick={() => setEnlarged(true)}
title="Enlarge preview"
>
<Maximize2Icon className="size-4" />
</button>
<iframe
ref={iframeRef}
srcDoc={srcDoc}
sandbox="allow-scripts"
style={{ width: "100%", height, border: "none", display: "block" }}
title="HTML preview"
/>
</div>
);
}
function downloadTextFile(filename: string, text: string): void {
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
@ -346,6 +287,12 @@ function CodeBlockActions({
}
function StreamdownBlock(props: BlockProps) {
const shouldCollapseHtmlArtifacts = useChatRuntimeStore(
(state) => state.artifactsEnabled || state.collapseHtmlArtifacts,
);
const messageHasRenderableRenderHtmlTool = useAuiState(({ message }) =>
message.parts.some(isRenderableRenderHtmlToolPart),
);
const hasMermaidFence = props.content.includes("```mermaid");
const mermaidSource = getMermaidSource(props.content);
const codeFence = getCodeFence(props.content);
@ -362,7 +309,9 @@ function StreamdownBlock(props: BlockProps) {
return (
<div className="relative isolate">
<div className="my-4 rounded-xl border border-border bg-muted/30 p-4">
<div className="mb-2 text-xs font-medium text-muted-foreground">svg</div>
<div className="mb-2 text-xs font-medium text-muted-foreground">
svg
</div>
<pre className="overflow-x-auto text-xs text-muted-foreground whitespace-pre-wrap break-all">
<code>{codeFence.source}</code>
</pre>
@ -371,10 +320,17 @@ function StreamdownBlock(props: BlockProps) {
);
}
if (props.isIncomplete && codeFence && isHtmlFence(codeFence)) {
if (
shouldCollapseHtmlArtifacts &&
!messageHasRenderableRenderHtmlTool &&
props.isIncomplete &&
codeFence &&
isHtmlFence(codeFence) &&
isFullHtmlDocument(codeFence.source)
) {
return (
<div className="my-4 flex h-48 items-center justify-center rounded-xl border border-border bg-muted/30 text-sm text-muted-foreground animate-pulse">
Loading preview...
Loading artifact preview...
</div>
);
}
@ -389,8 +345,24 @@ function StreamdownBlock(props: BlockProps) {
}
if (codeFence) {
const svgSource = !props.isIncomplete && isSvgFence(codeFence) ? sanitizeSvg(codeFence.source) : null;
const htmlSource = !props.isIncomplete && isHtmlFence(codeFence) ? codeFence.source : null;
const svgSource =
!props.isIncomplete && isSvgFence(codeFence)
? sanitizeSvg(codeFence.source)
: null;
const htmlSource =
shouldCollapseHtmlArtifacts &&
!messageHasRenderableRenderHtmlTool &&
!props.isIncomplete &&
isHtmlFence(codeFence) &&
isFullHtmlDocument(codeFence.source)
? codeFence.source
: null;
if (htmlSource) {
return (
<ArtifactCard code={htmlSource} title="HTML preview" source="fence" />
);
}
return (
<>
<div className="relative isolate">
@ -402,7 +374,6 @@ function StreamdownBlock(props: BlockProps) {
/>
</div>
{svgSource && <SvgPreview source={svgSource} />}
{htmlSource && <HtmlPreview source={htmlSource} />}
</>
);
}

View file

@ -17,7 +17,7 @@ import {
CloudIcon,
DashboardSquare01Icon,
FolderSearchIcon,
Logout01Icon,
RemoveCircleIcon,
Search01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
@ -326,7 +326,7 @@ function ModelSelectorContent({
className="flex w-full items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-destructive transition-colors hover:bg-destructive/10"
title="Eject model"
>
<HugeiconsIcon icon={Logout01Icon} className="size-3.5" />
<HugeiconsIcon icon={RemoveCircleIcon} className="size-3.5" />
Eject loaded model
</button>
</div>

View file

@ -406,35 +406,49 @@ const SourcesGroup: FC = () => {
return (
<div className="relative mt-2 mb-3">
{/* Hidden measurement container — renders all badges to measure row positions */}
{/* Hidden measurement container. Renders all badges off-screen so we
can read each child's offsetTop and decide how many fit in two
rows. Wrapped in an absolute, h-0, overflow-hidden box so the
measurement pills do NOT contribute to the viewport's scrollable
overflow region. Without this clip, every hidden source row
adds ~30px to scrollHeight, producing a phantom empty scroll
area below the message: visible to users as unbounded blank
space below the assistant action bar. The inner div still
flex-wraps its children for measurement; offsetTop reads
correctly because the wrapper is positioned (absolute) and the
children's offsetTop is measured relative to it. */}
<div
ref={containerRef}
aria-hidden={true}
className="flex w-full flex-wrap gap-1 invisible absolute pointer-events-none"
aria-hidden
className="absolute pointer-events-none overflow-hidden h-0 w-full left-0 top-0"
>
{sources.map((source) => (
<span key={sourceKey(source)} className="inline-block">
{source.kind === "url" ? (
<Source href={source.url}>
<SourceIcon url={source.url} />
<SourceTitle>
{source.title || extractDomain(source.url)}
</SourceTitle>
</Source>
) : (
<Badge
variant="outline"
className="rounded-full inline-flex items-center gap-1.5"
>
<span className="font-mono text-[10px] font-semibold text-muted-foreground">
[{source.chunkId}]
</span>
<FileTextIcon className="size-3 shrink-0 text-muted-foreground" />
<SourceTitle>{source.filename}</SourceTitle>
</Badge>
)}
</span>
))}
<div
ref={containerRef}
className="flex w-full flex-wrap gap-1 invisible"
>
{sources.map((source) => (
<span key={sourceKey(source)} className="inline-block">
{source.kind === "url" ? (
<Source href={source.url}>
<SourceIcon url={source.url} />
<SourceTitle>
{source.title || extractDomain(source.url)}
</SourceTitle>
</Source>
) : (
<Badge
variant="outline"
className="rounded-full inline-flex items-center gap-1.5"
>
<span className="font-mono text-[10px] font-semibold text-muted-foreground">
[{source.chunkId}]
</span>
<FileTextIcon className="size-3 shrink-0 text-muted-foreground" />
<SourceTitle>{source.filename}</SourceTitle>
</Badge>
)}
</span>
))}
</div>
</div>
{/* Visible container */}

View file

@ -24,6 +24,7 @@ import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { ToolGroup } from "@/components/assistant-ui/tool-group";
import { CodeExecutionToolUI } from "@/components/assistant-ui/tool-ui-code-execution";
import { ImageGenerationToolUI } from "@/components/assistant-ui/tool-ui-image-generation";
import { RenderHtmlToolUI } from "@/components/assistant-ui/tool-ui-render-html";
import { PythonToolUI } from "@/components/assistant-ui/tool-ui-python";
import { SearchKnowledgeBaseToolUI } from "@/components/assistant-ui/tool-ui-search-knowledge-base";
import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal";
@ -50,6 +51,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { sentAudioNames } from "@/features/chat/api/chat-adapter";
import { parseExternalModelId } from "@/features/chat/external-providers";
import { McpComposerButton } from "@/features/chat/mcp-composer-button";
import { getExternalReasoningCapabilities } from "@/features/chat/provider-capabilities";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
import { useExternalProvidersStore } from "@/features/chat/stores/external-providers-store";
@ -82,6 +84,7 @@ import {
ChevronRightIcon,
DownloadIcon,
FolderIcon,
FileTextIcon,
GlobeIcon,
HeadphonesIcon,
ImageIcon,
@ -382,7 +385,7 @@ const ThreadWelcome: FC<{
<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 src={currentEmojiSrc} alt="Sloth mascot" className="size-20" />
<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">
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in font-heading font-semibold text-2xl tracking-[0em] duration-200">
Chat with your model
</h1>
<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">
@ -396,14 +399,30 @@ const ThreadWelcome: FC<{
);
};
export const ProjectComposer: FC<{
disabled?: boolean;
placeholder?: string;
}> = ({ disabled, placeholder }) => {
return (
<GeneratedImageOverlayProvider>
<ComposerAnimated disabled={disabled} placeholder={placeholder} />
</GeneratedImageOverlayProvider>
);
};
const ComposerAnimated: FC<{
disabled?: boolean;
placeholder?: string;
threadId?: string | null;
}> = ({ disabled, threadId }) => {
}> = ({ disabled, placeholder, threadId }) => {
return (
<div className="relative mx-auto min-w-0 w-full max-w-(--thread-max-width)">
<div className="relative z-10 w-full">
<Composer disabled={disabled} threadId={threadId} />
<Composer
disabled={disabled}
placeholder={placeholder}
threadId={threadId}
/>
</div>
</div>
);
@ -435,8 +454,9 @@ const PendingAudioChip: FC = () => {
const Composer: FC<{
disabled?: boolean;
placeholder?: string;
threadId?: string | null;
}> = ({ disabled, threadId }) => {
}> = ({ disabled, placeholder = "Send a message...", threadId }) => {
const aui = useAui();
const { overlay, closeOverlay } = useGeneratedImageOverlay();
const setImageToolsEnabled = useChatRuntimeStore(
@ -553,9 +573,7 @@ const Composer: FC<{
<PendingDocChips docs={pendingDocs} onRemove={removeDoc} />
<ToolStatusDisplay />
<ComposerPrimitive.Input
placeholder={
overlay ? "Type your edits for your image" : "Send a message..."
}
placeholder={overlay ? "Type your edits for your image" : placeholder}
className="aui-composer-input composer-input"
minRows={1}
maxRows={12}
@ -996,8 +1014,8 @@ const PreserveThinkingToggle: FC = () => {
disabled
? "cursor-not-allowed opacity-40"
: preserveThinking
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
? "cursor-pointer text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
: "cursor-pointer hover:bg-primary/10 dark:hover:bg-white/[0.08]",
)}
aria-label={
preserveThinking ? "Disable preserve think" : "Enable preserve think"
@ -1185,6 +1203,29 @@ const RagToggle: FC = () => {
);
};
const ArtifactsToggle: FC = () => {
const modelLoaded = useChatRuntimeStore(
(s) => !!s.params.checkpoint && !s.modelLoading,
);
const artifactsEnabled = useChatRuntimeStore((s) => s.artifactsEnabled);
const setArtifactsEnabled = useChatRuntimeStore((s) => s.setArtifactsEnabled);
const disabled = !modelLoaded;
return (
<button
type="button"
disabled={disabled}
onClick={() => setArtifactsEnabled(!artifactsEnabled)}
className="composer-pill-btn"
data-active={artifactsEnabled && !disabled ? "true" : "false"}
aria-label={artifactsEnabled ? "Disable artifacts" : "Enable artifacts"}
>
<FileTextIcon className="size-3.5" />
<span>Artifacts</span>
</button>
);
};
const ToolStatusDisplay: FC = () => {
const toolStatus = useChatRuntimeStore((s) => s.toolStatus);
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
@ -1331,6 +1372,8 @@ const ComposerAction: FC<{
<CodeToolsToggle />
<ImagesToggle />
<RagToggle />
<ArtifactsToggle />
<McpComposerButton />
</div>
<div className="flex items-center gap-1">
<ComposerPrimitive.If dictation={false}>
@ -1459,6 +1502,7 @@ const AssistantMessage: FC = () => {
code_execution: CodeExecutionToolUI,
image_generation: ImageGenerationToolUI,
search_knowledge_base: SearchKnowledgeBaseToolUI,
render_html: RenderHtmlToolUI,
},
Fallback: ToolFallback,
},

View file

@ -8,6 +8,7 @@ import {
type FC,
type PropsWithChildren,
} from "react";
import { useAuiState } from "@assistant-ui/react";
import { ChevronDownIcon, LoaderIcon } from "lucide-react";
import { Wrench01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
@ -27,7 +28,8 @@ const toolGroupVariants = cva("aui-tool-group-root group/tool-group w-full", {
variant: {
outline: "corner-squircle rounded-lg border py-3",
ghost: "rounded-lg bg-muted/10 py-2",
muted: "corner-squircle rounded-lg border border-muted-foreground/30 bg-muted/30 py-3",
muted:
"corner-squircle rounded-lg border border-muted-foreground/30 bg-muted/30 py-3",
},
},
defaultVariants: { variant: "ghost" },
@ -209,9 +211,17 @@ const ToolGroupImpl: FC<
PropsWithChildren<{ startIndex: number; endIndex: number }>
> = ({ children, startIndex, endIndex }) => {
const toolCount = endIndex - startIndex + 1;
const containsArtifactTool = useAuiState(({ message }) =>
message.parts
.slice(startIndex, endIndex + 1)
.some(
(part) => part.type === "tool-call" && part.toolName === "render_html",
),
);
// Single tool call — render directly without wrapper
if (toolCount <= 1) {
// Single tool calls and artifacts render directly so cards never hide inside
// a collapsed tool group.
if (toolCount <= 1 || containsArtifactTool) {
return <>{children}</>;
}

View file

@ -10,7 +10,7 @@ import { DownloadIcon, ImageIcon, PencilIcon } from "lucide-react";
import type { CSSProperties, MouseEvent } from "react";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { useGeneratedImageOverlay } from "./generated-image-overlay-context";
import { Image, downloadImagePart } from "./image";
import { downloadImagePart } from "./image";
import {
ToolFallbackContent,
ToolFallbackRoot,
@ -64,6 +64,8 @@ type GeneratedImagePart = {
};
const CAPTION_COLLAPSED_LINES = 4;
const INLINE_IMAGE_MAX_WIDTH = 520;
const INLINE_IMAGE_MAX_HEIGHT = 620;
const extensionForMime = (mime: string): string => {
switch (mime.toLowerCase()) {
@ -100,6 +102,31 @@ const formatGeneratedImageLabel = (prompt: string): string => {
: `Generated image: ${prompt}`;
};
const parseImageSize = (
size?: string,
): { width: number; height: number } | null => {
const match = size?.match(/^(\d+)x(\d+)$/i);
if (!match) return null;
const width = Number(match[1]);
const height = Number(match[2]);
return width > 0 && height > 0 ? { width, height } : null;
};
const getInlineImageFrameWidth = ({
width,
height,
}: {
width: number;
height: number;
}): number =>
Math.round(
Math.min(
width,
INLINE_IMAGE_MAX_WIDTH,
(INLINE_IMAGE_MAX_HEIGHT * width) / height,
),
);
const loadingDots = Array.from({ length: 64 }, (_, index) => {
const row = Math.floor(index / 8);
const col = index % 8;
@ -150,6 +177,16 @@ const ImageGenerationToolUIImpl: ToolCallMessagePartComponent = ({
typeof result === "object" &&
typeof (result as ImageGenerationResult).image_b64 === "string";
const imageResult = isImageResult ? (result as ImageGenerationResult) : null;
const imageDimensions = parseImageSize(imageResult?.size);
const imageFrameStyle: CSSProperties = {
width: imageDimensions
? getInlineImageFrameWidth(imageDimensions)
: INLINE_IMAGE_MAX_WIDTH,
maxWidth: "100%",
};
const imageBoxStyle: CSSProperties | undefined = imageDimensions
? { aspectRatio: `${imageDimensions.width} / ${imageDimensions.height}` }
: undefined;
const mime = imageResult?.image_mime || "image/png";
const imageSrc = imageResult?.image_b64
? `data:${mime};base64,${imageResult.image_b64}`
@ -202,8 +239,7 @@ const ImageGenerationToolUIImpl: ToolCallMessagePartComponent = ({
const computedStyle = window.getComputedStyle(captionElement);
const lineHeight = Number.parseFloat(computedStyle.lineHeight);
const collapsedHeight =
(Number.isFinite(lineHeight) ? lineHeight : 20) *
CAPTION_COLLAPSED_LINES;
(Number.isFinite(lineHeight) ? lineHeight : 20) * CAPTION_COLLAPSED_LINES;
const hasOverflow = captionElement.scrollHeight > collapsedHeight + 1;
setPromptOverflow((current) =>
current?.prompt === captionPrompt && current.canExpand === hasOverflow
@ -289,26 +325,34 @@ const ImageGenerationToolUIImpl: ToolCallMessagePartComponent = ({
/>
<ToolFallbackContent>
{imagePart ? (
<figure className="m-0 flex flex-col gap-2">
<div className="group/generated-image relative aspect-square w-[480px] max-w-full overflow-hidden rounded-2xl bg-muted/25 shadow-lg shadow-foreground/5 dark:shadow-black/25">
<img
src={imagePart.image}
alt=""
aria-hidden={true}
className="pointer-events-none absolute inset-0 size-full scale-110 object-cover opacity-25 blur-2xl saturate-125"
/>
<div className="pointer-events-none absolute inset-0 bg-background/45" />
<figure
className="m-0 flex max-w-full flex-col items-start gap-2 align-top"
style={imageFrameStyle}
>
<div
className="group/generated-image relative w-full overflow-hidden rounded-2xl align-top"
style={imageBoxStyle}
>
<button
type="button"
className="relative z-10 block size-full cursor-zoom-in overflow-hidden rounded-2xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
className={cn(
"block cursor-zoom-in rounded-2xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
imageDimensions ? "size-full" : "max-w-full",
)}
onClick={showPreview}
aria-label="Open generated image preview"
>
<Image.Preview
<img
src={imagePart.image}
alt={imageTitle}
containerClassName="flex size-full min-h-0 items-center justify-center bg-transparent"
className="size-full object-contain"
width={imageDimensions?.width}
height={imageDimensions?.height}
className={cn(
"block rounded-2xl object-contain",
imageDimensions
? "size-full"
: "h-auto max-h-[min(70vh,620px)] max-w-full",
)}
/>
</button>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 flex items-end justify-between gap-2 bg-gradient-to-t from-black/55 via-black/20 to-transparent p-3 opacity-100 transition-opacity sm:opacity-0 sm:group-hover/generated-image:opacity-100 sm:group-focus-within/generated-image:opacity-100">
@ -335,7 +379,7 @@ const ImageGenerationToolUIImpl: ToolCallMessagePartComponent = ({
</div>
</div>
{captionPrompt ? (
<figcaption className="max-w-[480px] text-xs leading-5 text-muted-foreground">
<figcaption className="w-full text-xs leading-5 text-muted-foreground">
<div
ref={captionRef}
className={cn(

View file

@ -0,0 +1,142 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"use client";
import {
ArtifactCard,
useChatArtifactsStore,
useSelectedChatArtifact,
} from "@/features/chat";
import {
type ToolCallMessagePartComponent,
useAuiState,
useToolArgsStatus,
} from "@assistant-ui/react";
import { BrowserIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { memo, useEffect } from "react";
// Context7 assistant-ui docs: tool UIs can read streaming args via
// useToolArgsStatus, so render_html does not need to wait for tool completion.
type RenderHtmlArgs = Record<string, unknown> & {
code?: string;
title?: string;
};
const RENDER_HTML_SESSION_STARTED_AT = Date.now();
const RenderHtmlToolUIImpl: ToolCallMessagePartComponent = ({
args,
result,
status,
toolCallId,
}) => {
const { propStatus } = useToolArgsStatus<RenderHtmlArgs>();
const parsedArgs = (args as RenderHtmlArgs) ?? {};
const code = typeof parsedArgs.code === "string" ? parsedArgs.code : "";
const hasCode = code.trim().length > 0;
const title =
typeof parsedArgs.title === "string" ? parsedArgs.title : "HTML artifact";
const isRunning = status?.type === "running";
const codeIsStreaming = propStatus.code === "streaming";
// Surface the backend error when the tool call completed with invalid
// args. Backend success results start with "Rendered HTML artifact";
// error results start with "Error:".
const errorText =
status?.type === "complete" &&
typeof result === "string" &&
result.startsWith("Error:")
? result
: null;
const messageId = useAuiState(({ message }) => message.id) ?? null;
const isMessageRunning = useAuiState(
({ message }) => message.status?.type === "running",
);
const messageCreatedAtMs = useAuiState(({ message }) =>
message.createdAt instanceof Date ? message.createdAt.getTime() : null,
);
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
const isLiveGeneratingArtifact =
isThreadRunning && isMessageRunning && (isRunning || codeIsStreaming);
const isStaleGeneratingArtifact =
!(isThreadRunning && isMessageRunning) && (isRunning || codeIsStreaming);
const messageCreatedThisSession =
messageCreatedAtMs != null &&
messageCreatedAtMs >= RENDER_HTML_SESSION_STARTED_AT - 1000;
const shouldAutoOpenArtifact =
(isLiveGeneratingArtifact && (hasCode || isRunning || codeIsStreaming)) ||
(hasCode && messageCreatedThisSession);
const selectedArtifact = useSelectedChatArtifact();
const closeArtifactSurface = useChatArtifactsStore(
(state) => state.closeArtifactSurface,
);
useEffect(() => {
if (!errorText) {
return;
}
if (!(messageId && toolCallId)) {
return;
}
if (selectedArtifact?.sourceToolCallId !== toolCallId) {
return;
}
if (selectedArtifact?.sourceMessageId !== messageId) {
return;
}
closeArtifactSurface();
}, [
closeArtifactSurface,
errorText,
messageId,
selectedArtifact,
toolCallId,
]);
if (hasCode || (isLiveGeneratingArtifact && !errorText)) {
return (
<ArtifactCard
code={code}
title={title}
source="tool"
sourceToolCallId={toolCallId}
autoOpen={!errorText && shouldAutoOpenArtifact}
isStreaming={!errorText && isLiveGeneratingArtifact}
/>
);
}
return (
<div className="relative my-2 flex min-h-[52px] w-full max-w-md items-center overflow-hidden rounded-lg border border-border/70 bg-muted/15 px-3 py-2 text-left dark:bg-muted/10">
<div className="relative z-10 flex min-w-0 flex-1 items-center gap-2.5">
<HugeiconsIcon
icon={BrowserIcon}
strokeWidth={1.75}
className="size-5 shrink-0 text-muted-foreground"
/>
<span className="grid min-w-0 flex-1 gap-1">
<span className="truncate text-sm font-medium leading-tight text-foreground">
{errorText
? "Artifact error"
: isStaleGeneratingArtifact
? "Artifact interrupted"
: "Artifact unavailable"}
</span>
<span className="truncate text-[11px] leading-none text-muted-foreground">
{errorText ??
(isStaleGeneratingArtifact
? "Refresh stopped this preview"
: "HTML artifact")}
</span>
</span>
</div>
</div>
);
};
export const RenderHtmlToolUI = memo(
RenderHtmlToolUIImpl,
) as unknown as ToolCallMessagePartComponent;
RenderHtmlToolUI.displayName = "RenderHtmlToolUI";

View file

@ -78,7 +78,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2.5 rounded-lg px-3 py-2 text-sm [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2.5 rounded-lg px-3 py-2 text-sm [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-pointer items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
@ -96,7 +96,7 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2.5 rounded-lg py-2 pr-8 pl-3 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2.5 rounded-lg py-2 pr-8 pl-3 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-pointer items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
checked={checked}
@ -135,7 +135,7 @@ function DropdownMenuRadioItem({
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2.5 rounded-lg py-2 pr-8 pl-3 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2.5 rounded-lg py-2 pr-8 pl-3 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-pointer items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
@ -221,7 +221,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-lg px-3 py-2 text-sm [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-lg px-3 py-2 text-sm [&_svg:not([class*='size-'])]:size-4 flex cursor-pointer items-center outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}

View file

@ -1,54 +1,54 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import type * as React from "react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils";
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.Group>): React.ReactElement {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className,
)}
{...props}
/>
);
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>): React.ReactElement {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
withHandle?: boolean;
}): React.ReactElement {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="bg-border h-6 w-1 rounded-lg z-10 flex shrink-0" />
)}
</ResizablePrimitive.Separator>
);
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
import type * as React from "react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils";
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.Group>): React.ReactElement {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className,
)}
{...props}
/>
);
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>): React.ReactElement {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
withHandle?: boolean;
}): React.ReactElement {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"group bg-border/80 relative z-10 flex w-px cursor-col-resize items-center justify-center transition-[background-color,box-shadow] duration-150 ease-out after:absolute after:inset-y-0 after:left-1/2 after:w-2 after:-translate-x-1/2 hover:bg-primary/80 hover:shadow-[0_0_16px_rgba(23,184,139,0.55)] active:bg-primary/90 active:shadow-[0_0_18px_rgba(23,184,139,0.7)] focus-visible:bg-primary/80 focus-visible:ring-1 focus-visible:ring-primary/50 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:cursor-row-resize data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-2 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-6 w-1 shrink-0 rounded-lg bg-border transition-[background-color,box-shadow,transform] duration-150 ease-out group-hover:scale-y-110 group-hover:bg-primary/80 group-hover:shadow-[0_0_12px_rgba(23,184,139,0.65)] group-active:bg-primary" />
)}
</ResizablePrimitive.Separator>
);
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View file

@ -30,7 +30,7 @@ import { LayoutAlignLeftIcon } from "@hugeicons/core-free-icons"
const noop = () => {}
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH = "17.5rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
@ -472,7 +472,7 @@ function SidebarGroupLabel({
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"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",
"text-[#94a3b8] dark:text-[#666] ring-sidebar-ring h-auto pt-3 pb-2 px-4 rounded-md text-[10px] font-semibold uppercase tracking-[0em] 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}

View file

@ -17,12 +17,20 @@ type TerminalProps = {
className?: string
sequence?: boolean
startOnView?: boolean
/**
* Render every line in its final state immediately, skipping the typing /
* fade-in animations. Used when the terminal re-mounts for a run whose intro
* already played (e.g. navigating away from the training page and back), so
* the logs don't visually "restart" while the run itself keeps going.
*/
instant?: boolean
}
type InternalLineProps = {
__isActive?: boolean
__onDone?: () => void
__sequence?: boolean
__instant?: boolean
}
function useStartOnView(enabled: boolean): {
@ -65,15 +73,18 @@ export function Terminal({
className,
sequence = true,
startOnView = true,
instant = false,
}: TerminalProps): ReactElement {
const { ref, started } = useStartOnView(startOnView)
const childElements = Children.toArray(children).filter(isValidElement)
const [activeIndex, setActiveIndex] = useState(0)
const visibleIndex = sequence
? started
? activeIndex
: -1
: Number.MAX_SAFE_INTEGER
const visibleIndex = instant
? Number.MAX_SAFE_INTEGER
: sequence
? started
? activeIndex
: -1
: Number.MAX_SAFE_INTEGER
function handleLineDone(index: number): void {
if (!sequence) {
@ -99,7 +110,8 @@ export function Terminal({
{childElements.map((child, index) =>
cloneElement(child, {
__sequence: sequence,
__isActive: !sequence || visibleIndex >= index,
__isActive: instant || !sequence || visibleIndex >= index,
__instant: instant,
__onDone: () => handleLineDone(index),
key: child.key ?? index,
} as InternalLineProps)
@ -122,11 +134,12 @@ export function AnimatedSpan({
startOnView = false,
__isActive,
__sequence,
__instant,
__onDone,
}: AnimatedSpanProps): ReactElement {
const { ref, started } = useStartOnView(startOnView)
const [visible, setVisible] = useState(false)
const doneRef = useRef(false)
const [visible, setVisible] = useState(Boolean(__instant))
const doneRef = useRef(Boolean(__instant))
const onDoneRef = useRef(__onDone)
const shouldStart = __sequence ? __isActive : started
@ -180,11 +193,12 @@ export function TypingAnimation({
startOnView = true,
__isActive,
__sequence,
__instant,
__onDone,
}: TypingAnimationProps): ReactElement {
const { ref, started } = useStartOnView(startOnView)
const [typed, setTyped] = useState("")
const doneRef = useRef(false)
const [typed, setTyped] = useState(__instant ? children : "")
const doneRef = useRef(Boolean(__instant))
const onDoneRef = useRef(__onDone)
const shouldStart = __sequence ? __isActive : started

View file

@ -30,7 +30,6 @@ import {
} from "../external-providers";
import { pickFriendlyContainerName } from "../lib/friendly-names";
import {
EXTERNAL_MAX_OUTPUT_TOKENS,
clampReasoningEffortToLevels,
getExternalMaxOutputTokens,
getExternalMinOutputTokens,
@ -59,6 +58,7 @@ import type {
import type { ChatModelSummary } from "../types/runtime";
import {
getStoredChatThread,
getStoredChatProject,
listStoredChatThreads,
updateStoredChatThread,
} from "../utils/chat-history-storage";
@ -968,36 +968,6 @@ function toOpenAIMessages(message: RunMessage): SerializedMessage[] {
return toolResults.length > 0 ? [base, ...toolResults] : [base];
}
// Thin singular wrapper: returns only the first serialized message
// (without tool_calls or tool follow-ups) so the OpenAI image-edit
// replay path can map a thread to flat OpenAI chat messages without
// pulling in tool history.
function toOpenAIMessage(message: RunMessage): {
role: "system" | "user" | "assistant";
content: OpenAIMessageContent;
} | null {
const serialized = toOpenAIMessages(message);
if (serialized.length === 0) return null;
const first = serialized[0];
if (
first.role !== "system" &&
first.role !== "user" &&
first.role !== "assistant"
) {
return null;
}
if (first.content === null || first.content === undefined) {
return null;
}
if (typeof first.content === "string" && !first.content) {
return null;
}
return {
role: first.role,
content: first.content as OpenAIMessageContent,
};
}
function extractImageBase64(input: string): string | undefined {
if (!input) {
return undefined;
@ -1090,6 +1060,45 @@ async function resolveUseAdapter(
}
}
async function resolveProjectInstructions(
threadId: string | undefined,
): Promise<string> {
const projectId = await resolveProjectId(threadId);
if (!projectId) {
return "";
}
const project = await getStoredChatProject(projectId).catch(() => null);
if (!project || project.archived) {
return "";
}
return project.instructions?.trim() ?? "";
}
async function resolveProjectId(
threadId: string | undefined,
): Promise<string | null> {
let projectId: string | null | undefined;
if (threadId) {
const thread = await getStoredChatThread(threadId).catch(() => null);
projectId = thread?.projectId ?? null;
}
if (!projectId) {
projectId = useChatRuntimeStore.getState().activeProjectId;
}
if (!projectId) {
return null;
}
return projectId;
}
async function resolveSandboxSessionId(
threadId: string | undefined,
): Promise<string | undefined> {
const projectId = await resolveProjectId(threadId);
return projectId ? `project-${projectId}` : threadId;
}
/** Wait for an in-progress model load to finish (polls store every 500ms). */
function waitForModelReady(abortSignal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
@ -1426,6 +1435,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
// the user switches chats while waiting for model load / auto-load.
const resolvedThreadId =
(unstable_threadId ?? runtime.activeThreadId) || undefined;
const sandboxSessionId = await resolveSandboxSessionId(resolvedThreadId);
const resolvedThreadKey = resolvedThreadId ?? null;
const pendingImageEditReferenceForRun = runtime.pendingImageEditReference;
const selectedImageEditReference =
@ -1496,6 +1506,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
toolsEnabled,
codeToolsEnabled,
imageToolsEnabled,
artifactsEnabled,
mcpEnabledForChat,
webFetchToolsEnabled,
} = runtime;
@ -1701,7 +1712,14 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
const safeSystemPrompt =
typeof params.systemPrompt === "string" ? params.systemPrompt : "";
const projectInstructions =
await resolveProjectInstructions(resolvedThreadId);
const systemPromptParts: string[] = [];
if (projectInstructions) {
systemPromptParts.push(
`<project_instructions>\n${projectInstructions}\n</project_instructions>`,
);
}
if (safeSystemPrompt.trim()) {
systemPromptParts.push(safeSystemPrompt.trim());
}
@ -1743,8 +1761,6 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
outboundMessages.unshift({
role: "system",
content: systemPromptParts.join("\n\n"),
});
}
if (ragPrefetchEnabled && ragToolEnabled && ragSource.kind !== "off") {
const lastUser = [...outboundMessages]
@ -1845,36 +1861,62 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
"Do not return tool-call syntax inside your response.";
}
}
if (disabledToolGuard) {
const firstMessage = outboundMessages[0];
type OutboundMessage = (typeof outboundMessages)[number];
function addSystemInstruction(
targetMessages: OutboundMessage[],
text: string | null,
): void {
if (!text) return;
const firstMessage = targetMessages[0];
if (firstMessage?.role === "system") {
if (typeof firstMessage.content === "string") {
outboundMessages[0] = {
targetMessages[0] = {
...firstMessage,
content: `${firstMessage.content}\n\n${disabledToolGuard}`,
content: `${firstMessage.content}\n\n${text}`,
};
} else {
outboundMessages[0] = {
targetMessages[0] = {
...firstMessage,
content: [
...(Array.isArray(firstMessage.content)
? firstMessage.content
: []),
{ type: "text", text: `\n\n${disabledToolGuard}` },
{ type: "text", text: `\n\n${text}` },
],
};
}
} else {
outboundMessages.unshift({
role: "system",
content: disabledToolGuard,
});
return;
}
targetMessages.unshift({ role: "system", content: text });
}
// Scan post-prune history so a refused user turn's image/audio
// doesn't gate or mis-attribute the next non-refused turn.
const imageBase64 = findLatestUserImageBase64(survivingMessages);
const audioBase64 = findLatestUserAudioBase64(survivingMessages);
const hasOutboundImage = Boolean(imageBase64);
// Keep render_html local-only and mirror the backend image-turn gate.
// Artifacts are independent of Search/Code: if a local tool-capable
// model has Artifacts enabled, expose render_html even when no other
// tool pills are active.
const renderHtmlToolEnabledForThisTurn = Boolean(
!isExternalRequest &&
supportsTools &&
artifactsEnabled &&
!hasOutboundImage,
);
const artifactInstruction = artifactsEnabled
? renderHtmlToolEnabledForThisTurn
? "When the user asks for an HTML, CSS, or JavaScript artifact, call render_html once with one complete self-contained HTML document in the code argument. Embed CSS and JavaScript inside the document. After render_html succeeds, do not call it again in the same response unless the user asks for changes. Future user requests for new artifacts may call render_html once."
: "When the user asks for an HTML, CSS, or JavaScript artifact, return one complete self-contained fenced html code block. Embed CSS and JavaScript inside the document. Do not emit tool-call syntax."
: null;
const effectiveDisabledToolGuard =
disabledToolGuard && artifactsEnabled
? `${disabledToolGuard} HTML, CSS, or JavaScript artifact requests can still be answered by following the artifact fallback instruction.`
: disabledToolGuard;
addSystemInstruction(outboundMessages, effectiveDisabledToolGuard);
addSystemInstruction(outboundMessages, artifactInstruction);
// Block when ANY image is in the outbound payload (current or
// prior turns) and the loaded model can't process images. Keeps
@ -2074,7 +2116,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
// /inference/cancel explicitly on abort.
const onAbortCancel = () => {
const body: Record<string, string> = { cancel_id: cancelId };
if (resolvedThreadId) body.session_id = resolvedThreadId;
if (sandboxSessionId) body.session_id = sandboxSessionId;
// Plain fetch, not authFetch: authFetch redirects to login on
// 401, which would kick the user out mid-stop.
const token = getAuthToken();
@ -2394,7 +2436,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
image_base64: imageBase64,
audio_base64: audioBase64,
cancel_id: cancelId,
...(resolvedThreadId ? { session_id: resolvedThreadId } : {}),
...(sandboxSessionId ? { session_id: sandboxSessionId } : {}),
...(useAdapter === undefined ? {} : { use_adapter: useAdapter }),
...(supportsReasoning
? reasoningStyle === "reasoning_effort"
@ -2410,6 +2452,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
(toolsEnabled ||
codeToolsEnabled ||
ragToolPathTaken ||
renderHtmlToolEnabledForThisTurn ||
mcpEnabledForChat)
? {
enable_tools: true,
@ -2419,6 +2462,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
...(ragToolPathTaken ? ["search_knowledge_base"] : []),
...(toolsEnabled ? ["web_search"] : []),
...(codeToolsEnabled ? ["python", "terminal"] : []),
...(renderHtmlToolEnabledForThisTurn
? ["render_html"]
: []),
],
// Per-request scope for the LLM-invoked tool; tool path only.
...(ragToolPathTaken
@ -2550,13 +2596,25 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
`${toolEvent.tool_name}_${Date.now()}`;
const toolArgs = (toolEvent.arguments ??
{}) as ToolCallMessagePart["args"];
toolCallParts.push({
type: "tool-call" as const,
toolCallId: id,
toolName: toolEvent.tool_name as string,
argsText: JSON.stringify(toolArgs),
args: toolArgs,
});
const idx = toolCallParts.findIndex(
(p) => p.toolCallId === id,
);
if (idx !== -1) {
toolCallParts[idx] = {
...toolCallParts[idx],
toolName: toolEvent.tool_name as string,
argsText: JSON.stringify(toolArgs),
args: toolArgs,
};
} else {
toolCallParts.push({
type: "tool-call" as const,
toolCallId: id,
toolName: toolEvent.tool_name as string,
argsText: JSON.stringify(toolArgs),
args: toolArgs,
});
}
} else if (toolEvent.type === "tool_end") {
const id =
(toolEvent.tool_call_id as string) ||
@ -2602,7 +2660,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
const text = rawResult.slice(0, imgIdx);
// Fall back to "_default" to match the backend sandbox directory
// used when no session_id is provided (see tools.py _get_workdir).
const sessionId = resolvedThreadId || "_default";
const sessionId = sandboxSessionId || "_default";
try {
const images = JSON.parse(
rawResult.slice(imgIdx + imgMarker.length),

View file

@ -3,7 +3,12 @@
import { authFetch } from "@/features/auth";
import { formatFastApiDetail } from "@/lib/format-fastapi-error";
import type { MessageRecord, ModelType, ThreadRecord } from "../types";
import type {
MessageRecord,
ModelType,
ProjectRecord,
ThreadRecord,
} from "../types";
import type {
AudioGenerationResponse,
GgufVariantsResponse,
@ -287,12 +292,14 @@ export async function listChatThreads(
args: {
modelType?: ModelType;
pairId?: string;
projectId?: string | null;
includeArchived?: boolean;
} = {},
): Promise<ThreadRecord[]> {
const params = new URLSearchParams();
if (args.modelType) params.set("model_type", args.modelType);
if (args.pairId) params.set("pair_id", args.pairId);
if (args.projectId) params.set("project_id", args.projectId);
if (args.includeArchived !== undefined) {
params.set("include_archived", String(args.includeArchived));
}
@ -353,6 +360,74 @@ export async function deleteChatThreads(threadIds: string[]): Promise<void> {
notifyChatHistoryUpdated();
}
export async function listChatProjects(
args: { includeArchived?: boolean } = {},
): Promise<ProjectRecord[]> {
const params = new URLSearchParams();
if (args.includeArchived !== undefined) {
params.set("include_archived", String(args.includeArchived));
}
const qs = params.toString();
const response = await authFetch(`/api/chat/projects${qs ? `?${qs}` : ""}`);
const data = await parseJsonOrThrow<{ projects: ProjectRecord[] }>(response);
return data.projects;
}
export async function getChatProject(
projectId: string,
): Promise<ProjectRecord | null> {
const response = await authFetch(
`/api/chat/projects/${encodeURIComponent(projectId)}`,
);
if (response.status === 404) return null;
return parseJsonOrThrow<ProjectRecord>(response);
}
export async function saveChatProject(
project: ProjectRecord,
): Promise<ProjectRecord> {
const response = await authFetch("/api/chat/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(project),
});
const saved = await parseJsonOrThrow<ProjectRecord>(response);
notifyChatHistoryUpdated();
return saved;
}
export async function updateChatProject(
projectId: string,
patch: Partial<ProjectRecord>,
): Promise<ProjectRecord> {
const response = await authFetch(
`/api/chat/projects/${encodeURIComponent(projectId)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
},
);
const project = await parseJsonOrThrow<ProjectRecord>(response);
notifyChatHistoryUpdated();
return project;
}
export async function deleteChatProject(
projectId: string,
args: { deleteFiles?: boolean } = {},
): Promise<void> {
const params = new URLSearchParams();
if (args.deleteFiles) params.set("delete_files", "true");
const qs = params.toString();
const response = await authFetch(
`/api/chat/projects/${encodeURIComponent(projectId)}${qs ? `?${qs}` : ""}`,
{ method: "DELETE" },
);
await parseJsonOrThrow<ProjectRecord>(response);
notifyChatHistoryUpdated();
}
export async function listChatMessages(
threadId: string,
): Promise<MessageRecord[]> {
@ -464,6 +539,7 @@ export async function buildBackendChatExport(): Promise<{
exportedAt: string;
version: number;
threadCount: number;
projects?: ProjectRecord[];
threads: ThreadRecord[];
messages: MessageRecord[];
}> {

View file

@ -30,6 +30,8 @@ export interface PersistedChatSettings {
autoTitle?: boolean;
reasoningEffort?: ReasoningEffort;
preserveThinking?: boolean;
collapseHtmlArtifacts?: boolean;
allowArtifactNetworkAccess?: boolean;
autoHealToolCalls?: boolean;
maxToolCallsPerMessage?: number;
toolCallTimeout?: number;

View file

@ -0,0 +1,141 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"use client";
import { cn } from "@/lib/utils";
import { useAuiState } from "@assistant-ui/react";
import { LayoutTwoColumnIcon as Layout2ColumnIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useLayoutEffect, useMemo } from "react";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
import {
hasAutoOpenedArtifact,
rememberAutoOpenedArtifact,
useChatArtifactsStore,
} from "./store";
import {
type ChatArtifact,
type ChatArtifactSource,
createChatArtifact,
} from "./types";
export function ArtifactCard({
code,
title,
source,
sourceToolCallId,
sourceMessageId,
className,
autoOpen = false,
isStreaming = false,
}: {
code: string;
title?: string | null;
source: ChatArtifactSource;
sourceToolCallId?: string | null;
sourceMessageId?: string | null;
className?: string;
autoOpen?: boolean;
isStreaming?: boolean;
}) {
const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId);
const messageIdFromContext = useAuiState(({ message }) => message.id);
const threadIdFromContext = useAuiState(
({ threads }) => threads.mainThreadId,
);
const artifactThreadId = threadIdFromContext ?? activeThreadId ?? null;
const openArtifact = useChatArtifactsStore((state) => state.openArtifact);
const updateArtifact = useChatArtifactsStore((state) => state.updateArtifact);
const selectedArtifactId = useChatArtifactsStore(
(state) => state.selectedArtifactId,
);
const artifact = useMemo<ChatArtifact>(
() =>
createChatArtifact({
code,
title,
source,
sourceMessageId: sourceMessageId ?? messageIdFromContext ?? null,
sourceToolCallId: sourceToolCallId ?? null,
threadId: artifactThreadId,
isStreaming,
}),
[
artifactThreadId,
code,
isStreaming,
messageIdFromContext,
source,
sourceMessageId,
sourceToolCallId,
title,
],
);
const surface = artifactThreadId ? "panel" : "overlay";
useLayoutEffect(() => {
if (selectedArtifactId === artifact.id) {
updateArtifact(artifact);
}
if (!autoOpen) {
return;
}
if (hasAutoOpenedArtifact(artifact.id)) {
return;
}
rememberAutoOpenedArtifact(artifact.id);
openArtifact(artifact, { surface });
}, [
artifact,
autoOpen,
openArtifact,
selectedArtifactId,
surface,
updateArtifact,
]);
return (
<button
type="button"
className={cn(
"group/artifact-card relative my-2 flex min-h-[52px] w-full max-w-md cursor-pointer items-center overflow-hidden rounded-lg border border-border/70 bg-muted/15 px-3 py-2 text-left transition-colors hover:bg-muted/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"dark:bg-muted/10 dark:hover:bg-muted/20",
isStreaming &&
"border-border/80 bg-muted/20 dark:border-border/70 dark:bg-muted/15",
className,
)}
onClick={() => openArtifact(artifact, { surface })}
aria-label={`Open ${artifact.title}`}
>
{isStreaming ? (
<span
aria-hidden={true}
className="artifact-card-shimmer pointer-events-none absolute inset-0 z-0 motion-reduce:hidden"
/>
) : null}
<div className="relative z-10 flex min-w-0 flex-1 items-center gap-2.5">
<HugeiconsIcon
icon={Layout2ColumnIcon}
strokeWidth={1.75}
className="size-5 shrink-0 text-muted-foreground"
/>
<span className="grid min-w-0 flex-1 gap-1">
<span className="truncate text-sm font-medium leading-tight text-foreground">
{artifact.title}
</span>
<span className="truncate text-[11px] leading-none text-muted-foreground">
HTML artifact
</span>
</span>
{isStreaming ? (
<span className="shimmer shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary motion-reduce:animate-none">
Generating
</span>
) : null}
</div>
</button>
);
}

View file

@ -0,0 +1,362 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"use client";
import { createCodePlugin } from "@/components/assistant-ui/code-plugin";
import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon";
import {
unslothDarkTheme,
unslothLightTheme,
} from "@/components/assistant-ui/code-themes";
import { Button } from "@/components/ui/button";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { cn } from "@/lib/utils";
import {
CheckIcon,
CopyIcon,
DownloadIcon,
EyeIcon,
Maximize2Icon,
XIcon,
} from "lucide-react";
import {
type KeyboardEvent,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Streamdown } from "streamdown";
import { ArtifactHtmlFrame, type ArtifactViewMode } from "./html-frame";
import type { ChatArtifact } from "./types";
import { getArtifactFilename } from "./types";
const COPY_RESET_MS = 2000;
const artifactSourceCodePlugin = createCodePlugin({
themes: [unslothLightTheme, unslothDarkTheme],
});
function buildHtmlFence(source: string): string {
const longestBacktickRun = Math.max(
2,
...(source.match(/`+/g) ?? []).map((match) => match.length),
);
const fence = "`".repeat(longestBacktickRun + 1);
return `${fence}html\n${source}\n${fence}`;
}
// Sandboxed artifact iframes are intentionally excluded from the overlay focus
// trap. Granting same-origin sandbox privileges would weaken isolation, so
// keyboard users can reach Studio controls here while fully interactive artifact
// content remains a known sandbox limitation.
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
function getFocusableElements(container: HTMLElement): HTMLElement[] {
return Array.from(
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
).filter(
(element) =>
!element.hasAttribute("disabled") &&
element.getAttribute("aria-hidden") !== "true" &&
element.tabIndex !== -1,
);
}
function ArtifactLoadingLine() {
return (
<div className="absolute inset-x-0 bottom-0 h-[2.5px] overflow-hidden bg-border/45">
<span
aria-hidden={true}
className="artifact-loading-line block h-full rounded-full motion-reduce:hidden"
/>
</div>
);
}
function ArtifactGeneratingPanel() {
return (
<div className="flex h-full min-h-0 flex-col items-center justify-center bg-muted/10 px-6 text-center">
<div className="max-w-[30ch] space-y-1.5">
<img
src="/Sloth%20emojis/sloth%20w%20pc%20transparent.png"
alt=""
aria-hidden={true}
className="mx-auto mb-3 size-20 object-contain"
/>
<p className="text-sm font-medium text-foreground">
Building artifact preview
</p>
<p className="text-xs leading-relaxed text-muted-foreground">
The preview will appear here when the HTML is ready.
</p>
</div>
</div>
);
}
function downloadTextFile(filename: string, text: string): void {
const blob = new Blob([text], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}
export function ArtifactSurface({
artifact,
variant,
onClose,
onOpenFullscreen,
}: {
artifact: ChatArtifact;
variant: "panel" | "overlay";
onClose: () => void;
onOpenFullscreen?: () => void;
}) {
const [viewMode, setViewMode] = useState<ArtifactViewMode>("preview");
const [copied, setCopied] = useState(false);
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const surfaceRef = useRef<HTMLElement>(null);
const previousFocusRef = useRef<Element | null>(null);
const filename = getArtifactFilename(artifact);
const sourceMarkdown = useMemo(
() => buildHtmlFence(artifact.code),
[artifact.code],
);
const hasArtifactCode = artifact.code.trim().length > 0;
const isLoadingArtifact = Boolean(artifact.isStreaming);
const effectiveViewMode = isLoadingArtifact ? "preview" : viewMode;
useEffect(() => {
return () => {
if (copyResetRef.current) clearTimeout(copyResetRef.current);
};
}, []);
useEffect(() => {
if (variant !== "overlay") return;
previousFocusRef.current = document.activeElement;
const timeoutId = window.setTimeout(() => {
const surface = surfaceRef.current;
if (!surface) return;
const firstFocusable = getFocusableElements(surface)[0];
if (firstFocusable) {
firstFocusable.focus();
} else {
surface.focus();
}
}, 0);
return () => {
window.clearTimeout(timeoutId);
const previousFocus = previousFocusRef.current;
if (previousFocus instanceof HTMLElement) previousFocus.focus();
};
}, [variant]);
const handleCopy = async () => {
if (!(await copyToClipboard(artifact.code))) return;
setCopied(true);
if (copyResetRef.current) clearTimeout(copyResetRef.current);
copyResetRef.current = setTimeout(() => {
setCopied(false);
copyResetRef.current = null;
}, COPY_RESET_MS);
};
const handleDialogKeyDown = (event: KeyboardEvent<HTMLElement>) => {
if (variant !== "overlay") return;
if (event.key === "Escape") {
event.preventDefault();
onClose();
return;
}
if (event.key !== "Tab") return;
const focusable = getFocusableElements(event.currentTarget);
if (focusable.length === 0) {
event.preventDefault();
event.currentTarget.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
const content = (
<section
ref={surfaceRef}
role={variant === "overlay" ? "dialog" : undefined}
aria-modal={variant === "overlay" ? true : undefined}
tabIndex={variant === "overlay" ? -1 : undefined}
onKeyDown={handleDialogKeyDown}
className={cn(
"relative flex min-h-0 flex-col bg-background",
variant === "panel"
? "artifact-panel-shell mx-2 mt-[72px] mb-8 h-[calc(100%_-_104px)] overflow-visible rounded-[28px] border-t border-border/70 bg-card/95"
: "h-[min(92vh,900px)] w-[min(96vw,1200px)] overflow-hidden rounded-2xl border border-border shadow-xl",
)}
aria-label={`${artifact.title} artifact`}
>
<header
className={cn(
"relative flex shrink-0 items-center justify-between gap-3 px-2.5 py-2",
variant === "panel" && "rounded-t-[28px]",
)}
>
<div
className="flex items-center gap-1 rounded-full bg-muted/40 p-0.5"
role="tablist"
aria-label="Artifact view"
>
{(["preview", "source"] as const).map((mode) => {
const isPreview = mode === "preview";
const Icon = isPreview ? EyeIcon : CodeToggleIcon;
return (
<button
key={mode}
type="button"
role="tab"
disabled={isLoadingArtifact && !isPreview}
onClick={() => setViewMode(mode)}
className={cn(
"flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors",
effectiveViewMode === mode
? "bg-background text-foreground shadow-sm"
: "hover:bg-background/70 hover:text-foreground",
isLoadingArtifact &&
!isPreview &&
"cursor-not-allowed opacity-50",
)}
aria-label={
isPreview ? "Preview artifact" : "View artifact source"
}
aria-selected={effectiveViewMode === mode}
aria-pressed={effectiveViewMode === mode}
title={
isPreview
? "Preview"
: isLoadingArtifact
? "Source available when generation finishes"
: "Source"
}
>
<Icon className="size-4" />
</button>
);
})}
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
disabled={isLoadingArtifact || !hasArtifactCode}
onClick={() => downloadTextFile(filename, artifact.code)}
aria-label="Download artifact HTML"
>
<DownloadIcon className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
disabled={isLoadingArtifact || !hasArtifactCode}
onClick={handleCopy}
aria-label="Copy artifact HTML"
>
{copied ? (
<CheckIcon className="size-4" />
) : (
<CopyIcon className="size-4" />
)}
</Button>
{variant === "panel" && onOpenFullscreen ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
onClick={onOpenFullscreen}
aria-label="Open artifact fullscreen"
>
<Maximize2Icon className="size-4" />
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
onClick={onClose}
aria-label="Close artifact"
>
<XIcon className="size-4" />
</Button>
</div>
{isLoadingArtifact ? (
<ArtifactLoadingLine />
) : (
<div className="absolute inset-x-0 bottom-0 h-px bg-border" />
)}
</header>
<div
className={cn(
"min-h-0 flex-1 overflow-hidden bg-background",
variant === "panel" && "rounded-b-[28px]",
)}
>
{isLoadingArtifact ? (
<ArtifactGeneratingPanel />
) : effectiveViewMode === "preview" ? (
<ArtifactHtmlFrame
key={artifact.id}
code={artifact.code}
title={artifact.title}
fill={true}
className="h-full"
/>
) : (
<div className="h-full overflow-auto text-xs leading-relaxed [&_[data-streamdown=code-block]]:!my-0 [&_[data-streamdown=code-block]]:!gap-0 [&_[data-streamdown=code-block]]:!rounded-none [&_[data-streamdown=code-block]]:!border-0 [&_[data-streamdown=code-block]]:!bg-transparent [&_[data-streamdown=code-block]]:!p-0 [&_[data-streamdown=code-block-body]]:!border-0 [&_[data-streamdown=code-block-body]]:!bg-transparent [&_[data-streamdown=code-block-body]]:!p-0 [&_pre]:!m-0 [&_pre]:!bg-transparent [&_pre]:!p-0 [&_pre]:text-xs [&_pre]:leading-relaxed [&_code]:text-xs">
<Streamdown
mode="streaming"
plugins={{ code: artifactSourceCodePlugin }}
controls={{ code: false }}
shikiTheme={[unslothLightTheme, unslothDarkTheme]}
>
{sourceMarkdown}
</Streamdown>
</div>
)}
</div>
</section>
);
if (variant === "overlay") {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 p-4 backdrop-blur-sm"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
{content}
</div>
);
}
return content;
}

View file

@ -0,0 +1,100 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"use client";
import { getAuthToken } from "@/features/auth";
import { apiUrl } from "@/lib/api-base";
import { cn } from "@/lib/utils";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
import { hashArtifactCode } from "./types";
const HTML_FRAME_DEFAULT_HEIGHT = 400;
const HTML_FRAME_MAX_HEIGHT = 900;
export type ArtifactViewMode = "preview" | "source";
export const ARTIFACT_VIEW_MODES: readonly ArtifactViewMode[] = [
"preview",
"source",
];
export function isArtifactViewMode(value: string): value is ArtifactViewMode {
return (ARTIFACT_VIEW_MODES as readonly string[]).includes(value);
}
export function buildArtifactSrcDoc(code: string): string {
const resizeScript = `<script>(()=>{const post=()=>parent.postMessage({chatArtifactHeight:document.documentElement.scrollHeight},"*");new ResizeObserver(post).observe(document.documentElement);window.addEventListener("load",post);post();})();</script>`;
return `${code}\n${resizeScript}`;
}
// Preview iframes intentionally omit allow-downloads: generated artifacts can
// offer their own UI, but downloads must go through Studio's explicit
// copy/download controls outside the no-same-origin sandbox.
export function ArtifactHtmlFrame({
code,
title = "HTML artifact preview",
className,
fill = false,
}: {
code: string;
title?: string;
className?: string;
fill?: boolean;
}) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const allowNetworkAccess = useChatRuntimeStore(
(state) => state.allowArtifactNetworkAccess,
);
const [height, setHeight] = useState(HTML_FRAME_DEFAULT_HEIGHT);
const artifactHtml = useMemo(() => buildArtifactSrcDoc(code), [code]);
const src = useMemo(() => {
const query = new URLSearchParams({ v: hashArtifactCode(code) });
if (allowNetworkAccess) {
const token = getAuthToken();
if (token) {
query.set("allow_network", "1");
query.set("token", token);
}
}
return apiUrl(`/api/inference/artifact-preview-frame?${query.toString()}`);
}, [allowNetworkAccess, code]);
const postArtifactHtml = useCallback(() => {
// The sandboxed frame intentionally has an opaque origin ("null").
// A wildcard target is required here;
// the payload is sent only to this iframe's contentWindow.
iframeRef.current?.contentWindow?.postMessage(
{ type: "unsloth:artifact-html", html: artifactHtml },
"*",
);
}, [artifactHtml]);
useEffect(() => {
const handler = (event: MessageEvent) => {
if (event.source !== iframeRef.current?.contentWindow) return;
if (event.origin !== "null") return;
if (typeof event.data?.chatArtifactHeight !== "number") return;
setHeight(
Math.min(
Math.max(event.data.chatArtifactHeight, 160),
HTML_FRAME_MAX_HEIGHT,
),
);
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, [postArtifactHtml]);
return (
<iframe
ref={iframeRef}
src={src}
sandbox="allow-scripts"
referrerPolicy="no-referrer"
onLoad={postArtifactHtml}
className={cn("block w-full border-0 bg-background", className)}
style={{ height: fill ? "100%" : height }}
title={title}
/>
);
}

View file

@ -0,0 +1,107 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { create } from "zustand";
import type { ChatArtifact, ChatArtifactSurface } from "./types";
const autoOpenedArtifactIds = new Set<string>();
export function hasAutoOpenedArtifact(artifactId: string): boolean {
return autoOpenedArtifactIds.has(artifactId);
}
export function rememberAutoOpenedArtifact(artifactId: string): void {
autoOpenedArtifactIds.add(artifactId);
}
export function clearAutoOpenedArtifacts(): void {
autoOpenedArtifactIds.clear();
}
type ChatArtifactsState = {
artifactsById: Record<string, ChatArtifact>;
selectedArtifactId: string | null;
surface: ChatArtifactSurface;
openArtifact: (
artifact: ChatArtifact,
options?: { surface?: ChatArtifactSurface },
) => void;
updateArtifact: (artifact: ChatArtifact) => void;
closeArtifactSurface: () => void;
clearArtifactsForThread: (threadId: string | null | undefined) => void;
clearOrphanedArtifacts: () => void;
resetArtifacts: () => void;
};
export const useChatArtifactsStore = create<ChatArtifactsState>((set) => ({
artifactsById: {},
selectedArtifactId: null,
surface: "panel",
openArtifact: (artifact, options) =>
set((state) => ({
artifactsById: {
...state.artifactsById,
[artifact.id]: artifact,
},
selectedArtifactId: artifact.id,
surface: options?.surface ?? state.surface,
})),
updateArtifact: (artifact) =>
set((state) =>
state.artifactsById[artifact.id]
? {
artifactsById: {
...state.artifactsById,
[artifact.id]: artifact,
},
}
: state,
),
closeArtifactSurface: () =>
set({ selectedArtifactId: null, surface: "panel" }),
clearArtifactsForThread: (threadId) =>
set((state) => {
if (!threadId) return state;
const artifactsById = Object.fromEntries(
Object.entries(state.artifactsById).filter(
([, artifact]) => artifact.threadId !== threadId,
),
);
const selected = state.selectedArtifactId
? artifactsById[state.selectedArtifactId]
: null;
return {
artifactsById,
selectedArtifactId: selected ? selected.id : null,
};
}),
clearOrphanedArtifacts: () =>
set((state) => {
const artifactsById = Object.fromEntries(
Object.entries(state.artifactsById).filter(
([, artifact]) => artifact.threadId != null,
),
);
const selected = state.selectedArtifactId
? artifactsById[state.selectedArtifactId]
: null;
return {
artifactsById,
selectedArtifactId: selected ? selected.id : null,
};
}),
resetArtifacts: () =>
set({
artifactsById: {},
selectedArtifactId: null,
surface: "panel",
}),
}));
export function useSelectedChatArtifact(): ChatArtifact | null {
return useChatArtifactsStore((state) =>
state.selectedArtifactId
? (state.artifactsById[state.selectedArtifactId] ?? null)
: null,
);
}

View file

@ -0,0 +1,84 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export type ChatArtifactSource = "tool" | "fence";
export type ChatArtifactSurface = "panel" | "overlay";
export interface ChatArtifact {
id: string;
title: string;
code: string;
source: ChatArtifactSource;
sourceMessageId?: string | null;
sourceToolCallId?: string | null;
threadId?: string | null;
isStreaming?: boolean;
createdAt: number;
}
export interface ChatArtifactInput {
title?: string | null;
code: string;
source: ChatArtifactSource;
sourceMessageId?: string | null;
sourceToolCallId?: string | null;
threadId?: string | null;
isStreaming?: boolean;
}
const DEFAULT_ARTIFACT_TITLE = "HTML artifact";
export function normalizeArtifactTitle(title?: string | null): string {
const trimmed = title?.trim();
return trimmed && trimmed.length > 0 ? trimmed : DEFAULT_ARTIFACT_TITLE;
}
export function hashArtifactCode(code: string): string {
let hash = 5381;
for (let i = 0; i < code.length; i += 1) {
hash = ((hash << 5) + hash) ^ code.charCodeAt(i);
}
return (hash >>> 0).toString(36);
}
export function createArtifactId(input: ChatArtifactInput): string {
const threadSegment = input.threadId || "no-thread";
const messageSegment = input.sourceMessageId || "transient";
// Backend tool call IDs (call_0, call_1, …) reset per request, so
// the message ID is needed to scope them to a specific turn.
const parts = [input.source, threadSegment, messageSegment];
if (input.source === "tool" && input.sourceToolCallId) {
parts.push(input.sourceToolCallId);
} else {
parts.push(hashArtifactCode(input.code));
}
return parts.join(":");
}
export function createChatArtifact(input: ChatArtifactInput): ChatArtifact {
return {
id: createArtifactId(input),
title: normalizeArtifactTitle(input.title),
code: input.code,
source: input.source,
sourceMessageId: input.sourceMessageId ?? null,
sourceToolCallId: input.sourceToolCallId ?? null,
threadId: input.threadId ?? null,
isStreaming: input.isStreaming,
createdAt: Date.now(),
};
}
export function getArtifactFilename(
artifact: Pick<ChatArtifact, "title">,
): string {
const slug = artifact.title
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48);
return `${slug || "artifact"}.html`;
}

View file

@ -66,23 +66,40 @@ function headersToObject(rows: HeaderRow[]): Record<string, string> | undefined
return Object.keys(out).length > 0 ? out : undefined;
}
function isValidUrl(url: string): boolean {
const trimmed = url.trim();
// A non-HTTP address is a local stdio command. Case-insensitive to match the
// backend's is_stdio(), so all layers split http-vs-command identically.
function isHttpAddress(value: string): boolean {
const trimmed = value.trim().toLowerCase();
return trimmed.startsWith("http://") || trimmed.startsWith("https://");
}
function isValidAddress(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) return false;
try {
const parsed = new URL(trimmed);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
if (isHttpAddress(trimmed)) {
try {
const parsed = new URL(trimmed);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
// Anything else is treated as a local command (stdio); the backend gates
// whether stdio servers are allowed on this host. Reject other URL schemes
// only when the command itself is a URL; "://" is fine inside an argument
// (e.g. a database connection string passed to the server).
return !trimmed.split(/\s+/)[0].includes("://");
}
function HeadersEditor({
rows,
onChange,
stdio,
}: {
rows: HeaderRow[];
onChange: (rows: HeaderRow[]) => void;
// stdio servers reuse this editor for environment variables instead of headers.
stdio: boolean;
}) {
const update = (id: string, patch: Partial<HeaderRow>) =>
onChange(rows.map((row) => (row.id === id ? { ...row, ...patch } : row)));
@ -91,19 +108,41 @@ function HeadersEditor({
const remove = (id: string) =>
onChange(rows.filter((row) => row.id !== id));
const copy = stdio
? {
label: "Environment variables",
add: "Add variable",
keyPlaceholder: "Variable name",
valuePlaceholder: "Variable value",
remove: "Remove variable",
}
: {
label: "Custom headers",
add: "Add header",
keyPlaceholder: "Header name",
valuePlaceholder: "Header value",
remove: "Remove header",
};
return (
<>
<div className="flex items-center justify-between">
<Label className="text-sm">Custom headers</Label>
<Label className="text-sm">{copy.label}</Label>
<Button type="button" variant="ghost" size="sm" onClick={add}>
<HugeiconsIcon icon={PlusSignIcon} size={14} />
Add header
{copy.add}
</Button>
</div>
{rows.length === 0 ? (
<div className="text-xs text-muted-foreground">
Optional. Add an <code>Authorization</code> header here for servers
that require auth.
{stdio ? (
"Optional. Environment variables passed to the server process."
) : (
<>
Optional. Add an <code>Authorization</code> header here for servers
that require auth.
</>
)}
</div>
) : (
<div className="flex flex-col gap-2">
@ -111,12 +150,12 @@ function HeadersEditor({
<div key={row.id} className="flex items-center gap-2">
<Input
value={row.key}
placeholder="Header name"
placeholder={copy.keyPlaceholder}
onChange={(e) => update(row.id, { key: e.target.value })}
/>
<Input
value={row.value}
placeholder="Header value"
placeholder={copy.valuePlaceholder}
onChange={(e) => update(row.id, { value: e.target.value })}
/>
<Button
@ -124,7 +163,7 @@ function HeadersEditor({
variant="ghost"
size="icon"
onClick={() => remove(row.id)}
aria-label="Remove header"
aria-label={copy.remove}
>
<HugeiconsIcon icon={Delete02Icon} size={14} />
</Button>
@ -139,6 +178,8 @@ function HeadersEditor({
export interface ChatMcpServersDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Open straight into the "add server" form instead of the list view. */
openToCreate?: boolean;
}
type View =
@ -149,6 +190,7 @@ type View =
export function ChatMcpServersDialog({
open,
onOpenChange,
openToCreate = false,
}: ChatMcpServersDialogProps) {
const [servers, setServers] = useState<McpServerConfig[]>([]);
const [loading, setLoading] = useState(false);
@ -175,7 +217,12 @@ export function ChatMcpServersDialog({
useEffect(() => {
if (!open) return;
refresh();
}, [open, refresh]);
// Land on the create form when opened via "Add custom MCP".
if (openToCreate) {
setView({ kind: "create" });
setForm(EMPTY_FORM);
}
}, [open, openToCreate, refresh]);
function startCreate() {
setView({ kind: "create" });
@ -199,8 +246,8 @@ export function ChatMcpServersDialog({
async function testConnection() {
const trimmedUrl = form.url.trim();
if (!isValidUrl(trimmedUrl)) {
toast.error("Enter a valid http:// or https:// URL first");
if (!isValidAddress(trimmedUrl)) {
toast.error("Enter an http(s):// URL or a local command first");
return;
}
setTesting(true);
@ -236,11 +283,11 @@ export function ChatMcpServersDialog({
return;
}
if (!trimmedUrl) {
toast.error("URL is required");
toast.error("URL or command is required");
return;
}
if (!isValidUrl(trimmedUrl)) {
toast.error("URL must start with http:// or https://");
if (!isValidAddress(trimmedUrl)) {
toast.error("Enter an http(s):// URL or a local command");
return;
}
setSaving(true);
@ -331,6 +378,9 @@ export function ChatMcpServersDialog({
}
const showForm = view.kind !== "list";
// A local stdio command uses env vars, not headers or OAuth.
const addressIsCommand =
form.url.trim() !== "" && !isHttpAddress(form.url);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -338,7 +388,7 @@ export function ChatMcpServersDialog({
<DialogHeader>
<DialogTitle>MCP Servers</DialogTitle>
<DialogDescription>
Register remote MCP servers.
Register remote (HTTP) or local (stdio command) MCP servers.
</DialogDescription>
</DialogHeader>
@ -356,40 +406,47 @@ export function ChatMcpServersDialog({
/>
</div>
<div className="grid gap-2">
<Label htmlFor="mcp-url">URL</Label>
<Label htmlFor="mcp-url">URL or command</Label>
<Input
id="mcp-url"
value={form.url}
onChange={(e) =>
setForm((prev) => ({ ...prev, url: e.target.value }))
}
placeholder="https://example.com/mcp"
placeholder="https://example.com/mcp or npx -y @modelcontextprotocol/server-filesystem /tmp"
/>
<span className="text-xs text-muted-foreground">
An http(s) URL for a remote server, or a local command to run an
stdio server (desktop app only).
</span>
</div>
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-0.5">
<Label className="text-sm" htmlFor="mcp-oauth">
Use OAuth sign-in
</Label>
<span className="text-xs text-muted-foreground">
For servers that require browser-based authentication
(GitHub, Linear, etc.). A browser window will open on first
connect.
</span>
{!addressIsCommand && (
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-0.5">
<Label className="text-sm" htmlFor="mcp-oauth">
Use OAuth sign-in
</Label>
<span className="text-xs text-muted-foreground">
For servers that require browser-based authentication
(GitHub, Linear, etc.). A browser window will open on first
connect.
</span>
</div>
<Switch
id="mcp-oauth"
checked={form.useOauth}
onCheckedChange={(useOauth) =>
setForm((prev) => ({ ...prev, useOauth }))
}
/>
</div>
<Switch
id="mcp-oauth"
checked={form.useOauth}
onCheckedChange={(useOauth) =>
setForm((prev) => ({ ...prev, useOauth }))
}
/>
</div>
)}
<HeadersEditor
rows={form.headers}
onChange={(headers) => setForm((prev) => ({ ...prev, headers }))}
stdio={addressIsCommand}
/>
<div className="flex items-center justify-between gap-2 pt-2">
@ -415,7 +472,7 @@ export function ChatMcpServersDialog({
</div>
</div>
) : (
<div className="flex flex-col gap-3">
<div className="flex min-w-0 flex-col gap-3">
<div className="flex justify-end">
<Button size="sm" onClick={startCreate}>
<HugeiconsIcon icon={PlusSignIcon} size={14} />

File diff suppressed because it is too large Load diff

View file

@ -391,9 +391,22 @@ export function ChatProvidersSettings({
const updatedAt = Number.isFinite(Date.parse(config.updated_at))
? Date.parse(config.updated_at)
: Date.now();
const registryEntry =
registryRows.find((entry) => entry.provider_type === uiProviderType) ??
registryRows.find((entry) => entry.provider_type === config.provider_type);
const defaultModels = pruneProviderModelIds(
uiProviderType,
registryEntry?.default_models ?? [],
);
const savedModels = existing?.models ?? [];
const savedAvailableModels = existing?.availableModels ?? [];
const existingModels = pruneProviderModelIds(
uiProviderType,
existing?.models ?? [],
savedModels.length > 0 ? savedModels : defaultModels,
);
const existingAvailableModels = pruneProviderModelIds(
uiProviderType,
savedAvailableModels.length > 0 ? savedAvailableModels : defaultModels,
);
return {
id: config.id,
@ -401,7 +414,7 @@ export function ChatProvidersSettings({
name: config.display_name,
baseUrl: config.base_url ?? "",
models: existingModels,
availableModels: existing?.availableModels ?? [],
availableModels: existingAvailableModels,
enablePromptCaching: supportsProviderPromptCaching(uiProviderType)
? (existing?.enablePromptCaching ?? true)
: undefined,

View file

@ -69,7 +69,7 @@ import {
} from "@hugeicons/core-free-icons";
import { Add01Icon, Delete02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { ChevronDown } from "lucide-react";
import { ChevronDown, ExternalLink } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { type CSSProperties, Fragment, type ReactNode } from "react";
import {
@ -100,7 +100,6 @@ import {
toPresetParams,
} from "./presets/preset-policy";
import {
EXTERNAL_MAX_OUTPUT_TOKENS,
type ProviderCapabilities,
getExternalMaxOutputTokens,
getExternalMinOutputTokens,
@ -108,8 +107,6 @@ import {
providerSupportsFastMode,
} from "./provider-capabilities";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import { ChatMcpServersDialog } from "./chat-mcp-servers-dialog";
import { listMcpServers } from "./api/mcp-servers-api";
import type { InferenceParams } from "./types/runtime";
function ragSourceLabel(
@ -391,11 +388,19 @@ function saveCollapsibleOpen(label: string, open: boolean) {
function CollapsibleSection({
label,
labelHref,
children,
defaultOpen = false,
first = false,
}: {
label: string;
/**
* When set, the label text becomes an external link (e.g. to the feature's
* GitHub PR) instead of part of the collapse toggle. The chevron still
* toggles open/close, so we render the two as siblings rather than nesting
* an <a> inside the <button> (invalid HTML).
*/
labelHref?: string;
children?: ReactNode;
defaultOpen?: boolean;
first?: boolean;
@ -405,31 +410,59 @@ function CollapsibleSection({
return Object.hasOwn(saved, label) ? saved[label] : defaultOpen;
});
const toggle = () => {
const next = !open;
setOpen(next);
saveCollapsibleOpen(label, next);
};
const headerClasses = cn(
"flex w-full items-center justify-between text-[12px] font-medium normal-case tracking-[0.04em] text-nav-fg-muted transition-colors focus-visible:outline-none focus-visible:ring-0",
first ? "pt-4 pb-5" : "py-5",
);
return (
<div
className={cn(
!first && "border-t border-black/[0.13] dark:border-white/[0.09]",
)}
>
<button
type="button"
onClick={() => {
const next = !open;
setOpen(next);
saveCollapsibleOpen(label, next);
}}
className={cn(
"flex w-full cursor-pointer items-center justify-between text-[12px] font-medium normal-case tracking-[0.04em] text-nav-fg-muted transition-colors hover:text-nav-fg focus-visible:outline-none focus-visible:ring-0",
first ? "pt-4 pb-5" : "py-5",
)}
>
<span className="leading-none">{label}</span>
<span className="flex shrink-0 items-center leading-none">
<ChevronDown
className={cn("size-3.5", open ? "rotate-0" : "-rotate-90")}
/>
</span>
</button>
{labelHref ? (
<div className={headerClasses}>
<a
href={labelHref}
target="_blank"
rel="noopener noreferrer"
className="inline-flex cursor-pointer items-center gap-1 leading-none transition-colors hover:text-nav-fg"
>
<span>{label}</span>
<ExternalLink className="size-3" />
</a>
<button
type="button"
onClick={toggle}
aria-label={open ? `Collapse ${label}` : `Expand ${label}`}
className="flex shrink-0 cursor-pointer items-center leading-none transition-colors hover:text-nav-fg"
>
<ChevronDown
className={cn("size-3.5", open ? "rotate-0" : "-rotate-90")}
/>
</button>
</div>
) : (
<button
type="button"
onClick={toggle}
className={cn("cursor-pointer hover:text-nav-fg", headerClasses)}
>
<span className="leading-none">{label}</span>
<span className="flex shrink-0 items-center leading-none">
<ChevronDown
className={cn("size-3.5", open ? "rotate-0" : "-rotate-90")}
/>
</span>
</button>
)}
{open && <div className="pb-7">{children}</div>}
</div>
);
@ -604,16 +637,6 @@ export function ChatSettingsPanel({
(s) => s.modelRequiresTrustRemoteCode,
);
const currentCheckpoint = params.checkpoint;
const currentModelIsMultimodal = useChatRuntimeStore((s) => {
if (s.loadedIsMultimodal) return true;
const m = s.models.find((m) => m.id === currentCheckpoint);
return (
Boolean(m?.isVision) ||
Boolean(m?.isAudio) ||
Boolean(m?.hasAudioInput) ||
m?.audioType === "audio_vlm"
);
});
const ggufContextLength = useChatRuntimeStore((s) => s.ggufContextLength);
const ggufMaxContextLength = useChatRuntimeStore(
(s) => s.ggufMaxContextLength,
@ -861,45 +884,50 @@ export function ChatSettingsPanel({
const previewTarget = usePreviewStore((s) => s.target);
const previewStatus = usePreviewStore((s) => s.status);
const settingsScrollRef = useRef<HTMLDivElement>(null);
const settingsContent = (
<>
<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 bg-panel-surface pl-[18px] pr-[14px] pt-[11px]">
{rightSlotUsesSheet ? (
<span className="flex h-[34px] flex-1 items-center text-[15px] font-semibold tracking-[-0.01em] dark:tracking-[0.015em] text-nav-fg">
Configuration
<div
ref={settingsScrollRef}
className="relative h-full overflow-y-auto"
>
<div className="sticky top-0 z-10 flex h-[48px] items-start gap-2 bg-panel-surface pl-[18px] pr-[16px] pt-[11px]">
{rightSlotUsesSheet ? (
<span className="flex h-[34px] flex-1 items-center text-[15px] font-semibold tracking-[0em] dark:tracking-[0.015em] text-nav-fg">
Run settings
</span>
) : (
<>
<span className="flex h-[34px] flex-1 items-center text-[15px] font-semibold tracking-[0em] dark:tracking-[0.015em] text-nav-fg">
Run settings
</span>
) : (
<>
<span className="flex h-[34px] flex-1 items-center text-[15px] font-semibold tracking-[-0.01em] dark:tracking-[0.015em] text-nav-fg">
Configuration
</span>
<Tooltip>
<TooltipPrimitive.Trigger asChild={true}>
<button
type="button"
onClick={() => onOpenChange?.(false)}
className="flex h-[34px] w-[34px] items-center justify-center rounded-[12px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover 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={LayoutAlignRightIcon}
strokeWidth={1.75}
className="size-icon"
/>
</button>
</TooltipPrimitive.Trigger>
<TooltipContent
side="bottom"
sideOffset={6}
className="tooltip-compact"
<Tooltip>
<TooltipPrimitive.Trigger asChild={true}>
<button
type="button"
onClick={() => onOpenChange?.(false)}
className="flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-[12px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Close run settings"
>
Close configuration
</TooltipContent>
</Tooltip>
</>
)}
</div>
<HugeiconsIcon
icon={LayoutAlignRightIcon}
strokeWidth={1.75}
className="size-icon"
/>
</button>
</TooltipPrimitive.Trigger>
<TooltipContent
side="bottom"
sideOffset={6}
className="tooltip-compact"
>
Close run settings
</TooltipContent>
</Tooltip>
</>
)}
</div>
<div className="px-[18px] pt-3">
{hasModelContent && (
@ -1876,7 +1904,7 @@ export function ChatSettingsPanel({
}
max={
isExternalModel
? EXTERNAL_MAX_OUTPUT_TOKENS
? getExternalMaxOutputTokens(externalProviderType)
: isGguf && ggufContextLength
? ggufContextLength
: 32768
@ -1904,12 +1932,6 @@ export function ChatSettingsPanel({
</div>
</CollapsibleSection>
)}
{!isExternalModel ? (
<CollapsibleSection label="MCP Servers">
<McpServersSection />
</CollapsibleSection>
) : null}
</div>
</div>
<Dialog
@ -2010,7 +2032,7 @@ export function ChatSettingsPanel({
>
<SheetHeader className="sr-only">
<SheetTitle>
{slotShowsPreview ? "Document preview" : "Configuration"}
{slotShowsPreview ? "Document preview" : "Run settings"}
</SheetTitle>
<SheetDescription>
{slotShowsPreview
@ -2189,74 +2211,6 @@ function AutoHealToolCallsToggle() {
);
}
function McpServersSection() {
const mcpEnabledForChat = useChatRuntimeStore((s) => s.mcpEnabledForChat);
const setMcpEnabledForChat = useChatRuntimeStore(
(s) => s.setMcpEnabledForChat,
);
const [enabledServerCount, setEnabledServerCount] = useState<number | null>(
null,
);
const [dialogOpen, setDialogOpen] = useState(false);
const [refreshTick, setRefreshTick] = useState(0);
useEffect(() => {
let cancelled = false;
listMcpServers()
.then((rows) => {
if (cancelled) return;
setEnabledServerCount(rows.filter((row) => row.is_enabled).length);
})
.catch(() => {
if (!cancelled) setEnabledServerCount(0);
});
return () => {
cancelled = true;
};
}, [refreshTick]);
return (
<div className="flex flex-col gap-3 pt-1">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
Use MCP Servers
</span>
<InfoHint>
When on, every server marked enabled in the manage dialog is
attached to this chat's tool list.
</InfoHint>
</div>
<Switch
className="panel-switch"
checked={mcpEnabledForChat}
onCheckedChange={setMcpEnabledForChat}
disabled={enabledServerCount === 0 && !mcpEnabledForChat}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-[11px] text-muted-foreground">
{enabledServerCount === null
? "Loading…"
: enabledServerCount === 0
? "No servers configured"
: `${enabledServerCount} server${enabledServerCount === 1 ? "" : "s"} enabled`}
</span>
<Button variant="ghost" size="sm" onClick={() => setDialogOpen(true)}>
Manage
</Button>
</div>
<ChatMcpServersDialog
open={dialogOpen}
onOpenChange={(next) => {
setDialogOpen(next);
if (!next) setRefreshTick((tick) => tick + 1);
}}
/>
</div>
);
}
function ChatTemplateFields() {
const defaultTemplate = useChatRuntimeStore((s) => s.defaultChatTemplate);
const override = useChatRuntimeStore((s) => s.chatTemplateOverride);

View file

@ -8,7 +8,6 @@ import {
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";
@ -36,7 +35,6 @@ export function ChatSearchDialog() {
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;
@ -51,10 +49,10 @@ export function ChatSearchDialog() {
<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]"
className="corner-squircle top-[25%] w-[635px] max-w-[calc(100%-2rem)] gap-0 p-0 sm:max-w-[635px] border-0 ring-0 shadow-[0_10px_34px_rgba(0,0,0,0.12)] dark:border dark:border-border dark:ring-0 dark:shadow-none"
overlayClassName="bg-transparent"
>
<Command className="rounded-none p-0">
<Command className="rounded-4xl p-0">
<div className="flex items-center gap-3 border-b border-border/40 px-4 py-3">
<HugeiconsIcon
icon={SearchIcon}
@ -74,7 +72,7 @@ export function ChatSearchDialog() {
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} className="size-4" />
</button>
</div>
<CommandList className="max-h-[420px] p-1">
<CommandList className="cmd-native-scrollbar max-h-[420px] p-1">
<CommandEmpty className="py-6 text-center text-xs text-muted-foreground">
{loading
? "Loading…"
@ -92,12 +90,18 @@ export function ChatSearchDialog() {
to: "/chat",
search:
item.type === "single"
? { thread: item.id }
: { compare: item.id },
? {
thread: item.id,
...(item.projectId ? { project: item.projectId } : {}),
}
: {
compare: item.id,
...(item.projectId ? { project: item.projectId } : {}),
},
});
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"
className="relative flex cursor-pointer 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}

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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
ArrowDown01Icon,
Folder01Icon,
Tick02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import type { ReactElement } from "react";
import type { ProjectRecord } from "../types";
export function ProjectSwitcher({
currentProject,
projects,
isLoading,
onSelectProject,
onViewAllProjects,
}: {
currentProject: ProjectRecord | null;
projects: ProjectRecord[];
isLoading: boolean;
onSelectProject: (projectId: string) => void;
onViewAllProjects: () => void;
}): ReactElement {
const showLoadingRow = isLoading && projects.length === 0;
const showEmptyRow = !isLoading && projects.length === 0;
const label = currentProject?.name ?? (isLoading ? "Project" : "Projects");
return (
<DropdownMenu>
<DropdownMenuTrigger asChild={true}>
<button
type="button"
aria-label={
currentProject
? `Project: ${currentProject.name}. Switch project`
: isLoading
? "Loading project"
: "Pick a project"
}
className="-mx-1 flex h-[34px] shrink-0 items-center gap-2 rounded-[10px] px-1.5 transition-colors hover:bg-[#ececec] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:hover:bg-[#2d2e32]"
>
<HugeiconsIcon
icon={Folder01Icon}
strokeWidth={1.75}
className="size-icon shrink-0 text-foreground/70"
/>
<span className="flex min-w-0 flex-1 items-baseline">
<span className="min-w-0 flex max-w-[150px] flex-1 items-baseline truncate font-heading text-[16px] font-medium leading-tight text-black dark:text-white">
{label}
</span>
</span>
<span className="flex size-4 shrink-0 items-center justify-center">
<HugeiconsIcon
icon={ArrowDown01Icon}
strokeWidth={1.75}
className="relative top-0.5 size-3.5 text-muted-foreground"
aria-hidden={true}
/>
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="start"
sideOffset={6}
className="app-user-menu menu-soft-surface ring-0 min-w-56 max-w-72 max-h-72 py-2 font-heading rounded-[14px] border-0"
>
{showLoadingRow ? (
<DropdownMenuItem disabled={true} className="text-muted-foreground">
Loading
</DropdownMenuItem>
) : null}
{showEmptyRow ? (
<DropdownMenuItem disabled={true} className="text-muted-foreground">
No projects yet
</DropdownMenuItem>
) : null}
{projects.map((project) => {
const isActive = currentProject?.id === project.id;
return (
<DropdownMenuItem
key={project.id}
onSelect={() => onSelectProject(project.id)}
className="justify-between"
>
<span className="flex min-w-0 items-center gap-2">
<HugeiconsIcon
icon={Folder01Icon}
strokeWidth={1.75}
className="size-icon shrink-0 text-foreground/70"
/>
<span className="truncate">{project.name}</span>
</span>
{isActive ? (
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}
className="size-icon shrink-0 text-foreground/80"
/>
) : null}
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onViewAllProjects}>
View all projects
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -1123,15 +1123,15 @@ export function useChatModelRuntime() {
],
);
const ejectModel = useCallback(async () => {
const ejectModel = useCallback(async (): Promise<boolean> => {
if (!params.checkpoint) {
return;
return false;
}
setModelsError(null);
if (isExternalModelId(params.checkpoint)) {
clearCheckpoint();
await refresh();
return;
return true;
}
try {
async function performUnload(): Promise<void> {
@ -1140,17 +1140,21 @@ export function useChatModelRuntime() {
await refresh();
}
await toast.promise(performUnload(), {
const unloadPromise = performUnload();
toast.promise(unloadPromise, {
loading: "Unloading model",
success: { message: "Model unloaded", duration: 1200 },
error: (err) =>
err instanceof Error ? err.message : "Failed to unload model",
description: "Releases VRAM and resets inference state.",
});
await unloadPromise;
return true;
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to unload model";
setModelsError(message);
return false;
}
}, [clearCheckpoint, params.checkpoint, refresh, setModelsError]);

View file

@ -0,0 +1,97 @@
// 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 { CHAT_HISTORY_UPDATED_EVENT } from "../api/chat-api";
import type { ProjectRecord } from "../types";
import {
createStoredChatProject,
deleteStoredChatProject,
isExpectedBackgroundChatStorageError,
listStoredChatProjects,
moveStoredChatItemToProject,
updateStoredChatProject,
} from "../utils/chat-history-storage";
import type { SidebarItem } from "./use-chat-sidebar-items";
let cachedProjects: ProjectRecord[] = [];
export function useChatProjects(): {
projects: ProjectRecord[];
isLoading: boolean;
hasLoaded: boolean;
} {
const [projects, setProjects] = useState<ProjectRecord[]>(cachedProjects);
const [isLoading, setIsLoading] = useState(cachedProjects.length === 0);
const [hasLoaded, setHasLoaded] = useState(cachedProjects.length > 0);
useEffect(() => {
let cancelled = false;
async function load() {
if (!cancelled) setIsLoading(true);
try {
const next = await listStoredChatProjects({ includeArchived: false });
cachedProjects = next;
if (!cancelled) setProjects(next);
} catch (error) {
if (isExpectedBackgroundChatStorageError(error)) {
return;
}
if (!cancelled) throw error;
} finally {
if (!cancelled) {
setHasLoaded(true);
setIsLoading(false);
}
}
}
const onHistoryUpdated = () => {
void load();
};
void load();
window.addEventListener(CHAT_HISTORY_UPDATED_EVENT, onHistoryUpdated);
return () => {
cancelled = true;
window.removeEventListener(CHAT_HISTORY_UPDATED_EVENT, onHistoryUpdated);
};
}, []);
return { projects, isLoading, hasLoaded };
}
export async function createChatProject(name: string): Promise<ProjectRecord> {
return createStoredChatProject(name);
}
export async function renameChatProject(
projectId: string,
name: string,
): Promise<void> {
const trimmed = name.trim();
if (!trimmed) throw new Error("Project name is required.");
await updateStoredChatProject(projectId, { name: trimmed });
}
export async function updateChatProjectInstructions(
projectId: string,
instructions: string,
): Promise<void> {
await updateStoredChatProject(projectId, { instructions: instructions.trim() });
}
export async function deleteChatProject(
projectId: string,
args: { deleteFiles?: boolean } = {},
): Promise<void> {
await deleteStoredChatProject(projectId, args);
}
export async function moveChatItemToProject(
item: SidebarItem,
projectId: string | null,
): Promise<void> {
await moveStoredChatItemToProject(item, projectId);
}

View file

@ -2,7 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useEffect, useRef, useState } from "react";
import { CHAT_HISTORY_UPDATED_EVENT } from "../api/chat-api";
import { batchListChatMessages, CHAT_HISTORY_UPDATED_EVENT } from "../api/chat-api";
import type { MessageRecord } from "../types";
import {
listStoredChatMessages,
@ -15,6 +15,7 @@ export interface ChatSearchItem {
title: string;
preview: string;
createdAt: number;
projectId?: string | null;
}
const THREAD_LIMIT = 200;
@ -68,6 +69,7 @@ async function buildIndex(): Promise<ChatSearchItem[]> {
id: t.pairId,
title: t.title,
createdAt: t.createdAt,
projectId: t.projectId ?? null,
},
threadIds: [t.id],
});
@ -78,6 +80,7 @@ async function buildIndex(): Promise<ChatSearchItem[]> {
id: t.id,
title: t.title,
createdAt: t.createdAt,
projectId: t.projectId ?? null,
},
threadIds: [t.id],
});
@ -87,26 +90,34 @@ async function buildIndex(): Promise<ChatSearchItem[]> {
const allThreadIds = Array.from(itemThreadIds.values()).flatMap(
(e) => e.threadIds,
);
const storedMessagesByThread = await Promise.all(
allThreadIds.map(async (threadId) => ({
threadId,
messages: await listStoredChatMessages(threadId),
})),
let messagesByThread = await batchListChatMessages(allThreadIds).catch(
() => new Map<string, MessageRecord[]>(),
);
const messages = storedMessagesByThread.flatMap((entry) => entry.messages);
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]);
// Legacy-only chats can exist before server-side history import finishes.
// Fill just the missing ids from the legacy-aware path instead of issuing
// one request per thread up front.
const missingThreadIds = allThreadIds.filter(
(threadId) => !messagesByThread.has(threadId),
);
if (missingThreadIds.length > 0) {
const legacyEntries = await Promise.all(
missingThreadIds.map(async (threadId) => [
threadId,
await listStoredChatMessages(threadId).catch(() => []),
] as const),
);
messagesByThread = new Map(messagesByThread);
for (const [threadId, messages] of legacyEntries) {
messagesByThread.set(threadId, messages);
}
}
const results: ChatSearchItem[] = [];
for (const { item, threadIds } of itemThreadIds.values()) {
const merged: MessageRecord[] = [];
for (const tid of threadIds) {
const arr = byThreadId.get(tid);
const arr = messagesByThread.get(tid);
if (arr) merged.push(...arr);
}
if (merged.length === 0) {
@ -141,6 +152,7 @@ export function useChatSearchIndex(enabled: boolean): {
if (!enabled) {
// Clear stale results so the next open doesn't flash old items.
setItems([]);
setLoading(false);
return;
}
let cancelled = false;

View file

@ -4,6 +4,7 @@
import { useEffect, useState } from "react";
import { CHAT_HISTORY_UPDATED_EVENT } from "../api/chat-api";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
import { useChatArtifactsStore } from "../artifacts/store";
import type { ThreadRecord } from "../types";
import {
deleteStoredChatThreads,
@ -23,6 +24,7 @@ export interface SidebarItem {
id: string;
title: string;
createdAt: number;
projectId?: string | null;
}
export function groupThreads(threads: ThreadRecord[]): SidebarItem[] {
@ -43,6 +45,7 @@ export function groupThreads(threads: ThreadRecord[]): SidebarItem[] {
id: t.pairId,
title: t.title,
createdAt: t.createdAt,
projectId: t.projectId ?? null,
});
} else if (!t.pairId) {
items.push({
@ -50,6 +53,7 @@ export function groupThreads(threads: ThreadRecord[]): SidebarItem[] {
id: t.id,
title: t.title,
createdAt: t.createdAt,
projectId: t.projectId ?? null,
});
}
}
@ -62,18 +66,32 @@ export function groupThreads(threads: ThreadRecord[]): SidebarItem[] {
// discards stale responses.
const SIDEBAR_REFRESH_DEBOUNCE_MS = 300;
export function useChatSidebarItems() {
export function useChatSidebarItems(options?: {
projectId?: string | null;
enabled?: boolean;
requireMessages?: boolean;
}) {
const [allThreads, setAllThreads] = useState<ThreadRecord[]>([]);
const enabled = options?.enabled ?? true;
const requireMessages = options?.requireMessages ?? true;
useEffect(() => {
if (!enabled) {
return;
}
let cancelled = false;
let pendingTimer: ReturnType<typeof setTimeout> | null = null;
let requestSeq = 0;
async function doLoad(seq: number) {
try {
const threads = await listStoredChatThreadsWithMessages({
const listThreads = requireMessages
? listStoredChatThreadsWithMessages
: listStoredChatThreads;
const threads = await listThreads({
includeArchived: false,
projectId: options?.projectId,
});
// Discard the response if a newer request was scheduled while we
// were in flight, or if the effect was torn down.
@ -106,7 +124,7 @@ export function useChatSidebarItems() {
if (pendingTimer !== null) clearTimeout(pendingTimer);
window.removeEventListener(CHAT_HISTORY_UPDATED_EVENT, load);
};
}, []);
}, [enabled, options?.projectId, requireMessages]);
const items = groupThreads(allThreads ?? []);
const canCompare = useChatRuntimeStore((s) => Boolean(s.params.checkpoint));
@ -157,6 +175,10 @@ export async function deleteChatItem(
// generating against a thread that no longer exists.
for (const id of threadIds) cancelIfRunning(id);
const artifactStore = useChatArtifactsStore.getState();
for (const id of threadIds) artifactStore.clearArtifactsForThread(id);
artifactStore.clearOrphanedArtifacts();
// Optimistic tombstone: hide immediately; roll back on backend error.
markChatThreadsDeleted(threadIds);
notifyChatHistoryUpdated();

View file

@ -21,7 +21,13 @@ export { useChatSearchStore } from "./stores/chat-search-store";
export { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
export { ChatSearchDialog } from "./components/chat-search-dialog";
export { setTrainingCompareHandoff } from "./lib/training-compare-handoff";
export type { ProjectRecord } from "./types";
export { clearAllChats, countAllChats } from "./utils/clear-all-chats";
export { ArtifactCard } from "./artifacts/artifact-card";
export {
useChatArtifactsStore,
useSelectedChatArtifact,
} from "./artifacts/store";
export { downloadChatExport } from "./utils/export-chat-history";
export {
deleteChatItem,
@ -29,3 +35,11 @@ export {
useChatSidebarItems,
type SidebarItem,
} from "./hooks/use-chat-sidebar-items";
export {
createChatProject,
deleteChatProject,
moveChatItemToProject,
renameChatProject,
updateChatProjectInstructions,
useChatProjects,
} from "./hooks/use-chat-projects";

View file

@ -0,0 +1,355 @@
// 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 {
Cancel01Icon,
McpServerIcon,
Tick02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import {
type McpServerConfig,
createMcpServer,
listMcpServers,
updateMcpServer,
} from "./api/mcp-servers-api";
import { ChatMcpServersDialog } from "./chat-mcp-servers-dialog";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
type McpPreset = {
id: string;
displayName: string; // stored row name
url: string;
label?: string; // dropdown text, if different from displayName
hint?: string; // shown when the row is highlighted
disablesWebSearch?: boolean; // turn the built-in Search pill off when enabled
};
// Keyless remote MCP presets (rate-limited free tiers, no API key).
// Hugging Face runs anonymously; add a token via "Add custom MCP".
const MCP_PRESETS: readonly McpPreset[] = [
{
id: "context7",
displayName: "Context7",
url: "https://mcp.context7.com/mcp",
label: "Context7 (Realtime Docs)",
},
{
id: "exa",
displayName: "Exa",
url: "https://mcp.exa.ai/mcp",
label: "Exa (Semantic Search)",
hint: "Enabling Exa will disable default search",
disablesWebSearch: true,
},
{
id: "huggingface",
displayName: "Hugging Face",
url: "https://huggingface.co/mcp",
},
] as const;
// mcp_servers has no UNIQUE(url); dedupe by normalized URL so a preset
// toggle reuses its row instead of creating duplicates.
function normalizeMcpUrl(url: string): string {
return (url || "").trim().toLowerCase().replace(/\/+$/, "");
}
// Static, so it is not rebuilt on every render.
const PRESET_URLS = new Set(MCP_PRESETS.map((p) => normalizeMcpUrl(p.url)));
export function McpComposerButton() {
const modelLoaded = useChatRuntimeStore(
(s) => !!s.params.checkpoint && !s.modelLoading,
);
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
const mcpEnabledForChat = useChatRuntimeStore((s) => s.mcpEnabledForChat);
const setMcpEnabledForChat = useChatRuntimeStore(
(s) => s.setMcpEnabledForChat,
);
const setToolsEnabled = useChatRuntimeStore((s) => s.setToolsEnabled);
const [servers, setServers] = useState<McpServerConfig[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [pendingUrl, setPendingUrl] = useState<string | null>(null);
const [hintKey, setHintKey] = useState<string | null>(null);
// mcp_enabled only applies on the local tool-capable send path; grey out otherwise.
const usable = modelLoaded && supportsTools;
// Keep the per-chat flag in step with whether any server is enabled. Reads the
// store directly so the callback stays stable (no refetch loop on mount).
const reconcileFlag = useCallback(
(rows: McpServerConfig[]) => {
const anyEnabled = rows.some((s) => s.is_enabled);
const current = useChatRuntimeStore.getState().mcpEnabledForChat;
if (anyEnabled && !current) setMcpEnabledForChat(true);
else if (!anyEnabled && current) setMcpEnabledForChat(false);
},
[setMcpEnabledForChat],
);
const refresh = useCallback(async () => {
try {
const rows = await listMcpServers();
setServers(rows);
reconcileFlag(rows);
} catch {
// Keep prior state if the list call fails.
}
}, [reconcileFlag]);
// Initial load reconciles the pill with already-enabled servers (also on open).
useEffect(() => {
void refresh();
}, [refresh]);
const enabledUrls = new Set(
servers.filter((s) => s.is_enabled).map((s) => normalizeMcpUrl(s.url)),
);
// Non-preset servers, shown below the presets so they stay toggleable.
const customServers = servers.filter(
(s) => !PRESET_URLS.has(normalizeMcpUrl(s.url)),
);
const enabledCount = servers.filter((s) => s.is_enabled).length;
const active = mcpEnabledForChat && enabledCount > 0;
async function toggleServer(args: {
url: string;
displayName: string;
checked: boolean;
existing?: McpServerConfig;
disablesWebSearch?: boolean;
}) {
const norm = normalizeMcpUrl(args.url);
if (pendingUrl === norm) return; // guard rapid double-clicks
setPendingUrl(norm);
try {
if (args.checked) {
// Reuse the already-loaded row, else create one.
if (args.existing) {
if (!args.existing.is_enabled) {
await updateMcpServer(args.existing.id, { isEnabled: true });
}
} else {
await createMcpServer({
displayName: args.displayName,
url: args.url,
isEnabled: true,
});
}
setMcpEnabledForChat(true);
// Exa is a search server; turn off the built-in Web Search to avoid overlap.
if (args.disablesWebSearch) setToolsEnabled(false);
} else if (args.existing) {
await updateMcpServer(args.existing.id, { isEnabled: false });
}
await refresh();
} catch (err) {
toast.error("Failed to update MCP server", {
description: err instanceof Error ? err.message : String(err),
});
} finally {
setPendingUrl(null);
}
}
// One dropdown row. Enabled rows get a green underlay and a tick that
// becomes an X on hover so a click removes them. A hint shows as a tooltip
// driven by row hover; the tooltip anchor is pointer-events-none so the whole
// row stays clickable (a Radix TooltipTrigger would swallow the select).
const renderRow = (opts: {
key: string;
label: string;
url: string;
displayName: string;
enabled: boolean;
existing?: McpServerConfig;
hint?: string;
disablesWebSearch?: boolean;
}) => (
<DropdownMenuItem
key={opts.key}
disabled={pendingUrl === normalizeMcpUrl(opts.url)}
onSelect={(e) => {
e.preventDefault();
void toggleServer({
url: opts.url,
displayName: opts.displayName,
checked: !opts.enabled,
existing: opts.existing,
disablesWebSearch: opts.disablesWebSearch,
});
}}
onPointerEnter={opts.hint ? () => setHintKey(opts.key) : undefined}
onPointerLeave={
opts.hint
? () => setHintKey((k) => (k === opts.key ? null : k))
: undefined
}
className={cn(
"group/mcp relative flex items-center justify-between gap-2",
opts.enabled &&
"bg-emerald-500/10 data-[highlighted]:bg-emerald-500/20",
)}
>
<span className="truncate">{opts.label}</span>
{opts.enabled ? (
<span className="flex size-4 shrink-0 items-center justify-center text-emerald-600 dark:text-emerald-400">
<HugeiconsIcon
icon={Tick02Icon}
className="size-4 group-data-[highlighted]/mcp:hidden"
strokeWidth={2}
/>
<HugeiconsIcon
icon={Cancel01Icon}
className="hidden size-4 text-foreground group-data-[highlighted]/mcp:block"
strokeWidth={2}
/>
</span>
) : null}
{opts.hint ? (
<Tooltip open={hintKey === opts.key}>
<TooltipTrigger asChild={true}>
<span
aria-hidden={true}
className="pointer-events-none absolute inset-y-0 right-0 w-0"
/>
</TooltipTrigger>
<TooltipContent side="right">{opts.hint}</TooltipContent>
</Tooltip>
) : null}
</DropdownMenuItem>
);
return (
<>
{usable ? (
<DropdownMenu
open={menuOpen}
onOpenChange={(open) => {
setMenuOpen(open);
if (open) void refresh();
}}
>
<DropdownMenuTrigger asChild={true}>
<button
type="button"
className="composer-pill-btn"
data-active={active ? "true" : "false"}
aria-label="MCP servers"
>
<HugeiconsIcon
icon={McpServerIcon}
className="size-3.5"
strokeWidth={2}
/>
<span>MCP</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<div className="flex items-center justify-between pr-1">
<DropdownMenuLabel>MCP Servers</DropdownMenuLabel>
<button
type="button"
aria-label="Close"
onClick={() => setMenuOpen(false)}
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<HugeiconsIcon
icon={Cancel01Icon}
className="size-3.5"
strokeWidth={2}
/>
</button>
</div>
{MCP_PRESETS.map((preset) => {
const norm = normalizeMcpUrl(preset.url);
return renderRow({
key: preset.id,
label: preset.label ?? preset.displayName,
url: preset.url,
displayName: preset.displayName,
enabled: enabledUrls.has(norm),
existing: servers.find((s) => normalizeMcpUrl(s.url) === norm),
hint: preset.hint,
disablesWebSearch: preset.disablesWebSearch,
});
})}
{customServers.length > 0 ? <DropdownMenuSeparator /> : null}
{customServers.map((server) =>
renderRow({
key: server.id,
label: server.display_name,
url: server.url,
displayName: server.display_name,
enabled: server.is_enabled,
existing: server,
}),
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
setMenuOpen(false);
setDialogOpen(true);
}}
>
Add custom MCP
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Tooltip>
<TooltipTrigger asChild={true}>
{/* Not disabled, so the tooltip still fires on hover. */}
<button
type="button"
className="composer-pill-btn cursor-not-allowed opacity-40"
data-active="false"
aria-disabled={true}
aria-label="MCP servers"
>
<HugeiconsIcon
icon={McpServerIcon}
className="size-3.5"
strokeWidth={2}
/>
<span>MCP</span>
</button>
</TooltipTrigger>
<TooltipContent>
MCP works with local tool-capable models
</TooltipContent>
</Tooltip>
)}
<ChatMcpServersDialog
open={dialogOpen}
onOpenChange={(next) => {
setDialogOpen(next);
// Resync after managing servers.
if (!next) void refresh();
}}
openToCreate={true}
/>
</>
);
}

View file

@ -0,0 +1,401 @@
// 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 {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "@/lib/toast";
import {
createChatProject,
deleteChatProject,
renameChatProject,
useChatProjects,
useChatRuntimeStore,
type ProjectRecord,
} from "@/features/chat";
import {
Delete02Icon,
Edit03Icon,
FolderAddIcon,
Search01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { MoreHorizontalIcon } from "lucide-react";
import { useNavigate } from "@tanstack/react-router";
import { useMemo, useState } from "react";
type SortMode = "activity" | "name";
function formatUpdatedAgo(ts: number): string {
const diff = Date.now() - ts;
if (!Number.isFinite(diff) || diff < 0) return "just now";
const s = Math.floor(diff / 1000);
if (s < 60) return "just now";
const m = Math.floor(s / 60);
if (m < 60) return `${m} minute${m === 1 ? "" : "s"} ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h} hour${h === 1 ? "" : "s"} ago`;
const d = Math.floor(h / 24);
if (d < 30) return `${d} day${d === 1 ? "" : "s"} ago`;
const mo = Math.floor(d / 30);
if (mo < 12) return `${mo} month${mo === 1 ? "" : "s"} ago`;
const y = Math.floor(mo / 12);
return `${y} year${y === 1 ? "" : "s"} ago`;
}
export function ProjectsPage() {
const navigate = useNavigate();
const { projects, hasLoaded } = useChatProjects();
const [query, setQuery] = useState("");
const [sortMode, setSortMode] = useState<SortMode>("activity");
const [creating, setCreating] = useState(false);
const [nameDraft, setNameDraft] = useState("");
const [renaming, setRenaming] = useState<ProjectRecord | null>(null);
const [renameDraft, setRenameDraft] = useState("");
const [deleting, setDeleting] = useState<ProjectRecord | null>(null);
const visibleProjects = useMemo(() => {
const trimmed = query.trim().toLowerCase();
const filtered = trimmed
? projects.filter((p) => p.name.toLowerCase().includes(trimmed))
: projects.slice();
filtered.sort((a, b) =>
sortMode === "name"
? a.name.localeCompare(b.name)
: b.updatedAt - a.updatedAt,
);
return filtered;
}, [projects, query, sortMode]);
function openProject(projectId: string) {
const runtime = useChatRuntimeStore.getState();
runtime.setActiveThreadId(null);
runtime.setActiveProjectId(projectId);
navigate({ to: "/chat", search: { project: projectId } });
}
async function commitCreate() {
const name = nameDraft.trim();
if (!name) return;
try {
const project = await createChatProject(name);
setCreating(false);
setNameDraft("");
openProject(project.id);
} catch (err) {
toast.error("Failed to create project", {
description: err instanceof Error ? err.message : undefined,
});
}
}
async function commitRename() {
const target = renaming;
const name = renameDraft.trim();
if (!target || !name || name === target.name) {
setRenaming(null);
return;
}
setRenaming(null);
try {
await renameChatProject(target.id, name);
} catch (err) {
toast.error("Failed to rename project", {
description: err instanceof Error ? err.message : undefined,
});
}
}
async function commitDelete() {
const target = deleting;
if (!target) return;
setDeleting(null);
try {
await deleteChatProject(target.id);
} catch (err) {
toast.error("Failed to delete project", {
description: err instanceof Error ? err.message : undefined,
});
}
}
return (
<main className="mx-auto w-full max-w-7xl px-4 py-8 font-heading sm:px-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
Projects
</h1>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Sort by</span>
<Select
value={sortMode}
onValueChange={(v) => setSortMode(v as SortMode)}
>
<SelectTrigger className="h-9 w-[130px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="activity">Activity</SelectItem>
<SelectItem value="name">Name</SelectItem>
</SelectContent>
</Select>
</div>
<Button
onClick={() => {
setNameDraft("");
setCreating(true);
}}
>
New project
</Button>
</div>
</div>
<div className="relative mt-6">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
<HugeiconsIcon icon={Search01Icon} strokeWidth={1.75} className="size-icon" />
</span>
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search projects..."
className="h-11 pl-10"
aria-label="Search projects"
/>
</div>
{!hasLoaded ? (
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className="min-h-[160px] rounded-[14px] border border-border/70 bg-card p-5"
>
<Skeleton className="h-5 w-2/3 rounded-[6px]" />
<Skeleton className="mt-3 h-4 w-full rounded-[6px]" />
<Skeleton className="mt-2 h-4 w-4/5 rounded-[6px]" />
<Skeleton className="mt-12 h-3 w-24 rounded-[6px]" />
</div>
))}
</div>
) : visibleProjects.length === 0 ? (
<div className="mt-16 flex flex-col items-center justify-center gap-2 text-center text-muted-foreground">
<p className="text-sm">
{projects.length === 0
? "No projects yet."
: "No projects match your search."}
</p>
{projects.length === 0 && (
<Button
variant="outline"
className="mt-2"
onClick={() => {
setNameDraft("");
setCreating(true);
}}
>
<HugeiconsIcon icon={FolderAddIcon} strokeWidth={1.75} className="size-icon" />
Create your first project
</Button>
)}
</div>
) : (
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{visibleProjects.map((project) => (
<div
key={project.id}
role="button"
tabIndex={0}
onClick={() => openProject(project.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openProject(project.id);
}
}}
className="group/project-card relative flex min-h-[160px] cursor-pointer flex-col rounded-[14px] border border-border/70 bg-card p-5 text-left transition-colors hover:border-border hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<div className="flex items-start justify-between gap-2">
<h2 className="truncate pr-2 text-[16px] font-semibold text-foreground">
{project.name}
</h2>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
aria-label="Project options"
className="-mr-1 -mt-1 inline-flex size-7 shrink-0 items-center justify-center rounded-[8px] text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover/project-card:opacity-100"
>
<MoreHorizontalIcon strokeWidth={1.75} className="size-icon" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
sideOffset={4}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
className="app-user-menu menu-soft-surface menu-flat-destructive ring-0 w-44 py-2 font-heading rounded-[14px] border-0"
>
<DropdownMenuItem
onSelect={() => {
setRenameDraft(project.name);
setRenaming(project);
}}
>
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
<span>Rename</span>
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => setDeleting(project)}
>
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.75} className="size-icon" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{project.instructions ? (
<p className="mt-2 line-clamp-3 text-sm text-muted-foreground">
{project.instructions}
</p>
) : null}
<span className="mt-auto pt-4 text-xs text-muted-foreground">
Updated {formatUpdatedAgo(project.updatedAt)}
</span>
</div>
))}
</div>
)}
{/* Create project */}
<Dialog
open={creating}
onOpenChange={(open) => {
if (!open) setCreating(false);
}}
>
<DialogContent className="corner-squircle border border-border/60 bg-background/98 shadow-none sm:max-w-md">
<DialogHeader>
<DialogTitle>New project</DialogTitle>
</DialogHeader>
<Input
value={nameDraft}
onChange={(e) => setNameDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void commitCreate();
}
}}
autoFocus
maxLength={120}
placeholder="Project name"
aria-label="Project name"
className="focus-visible:border-input focus-visible:ring-0"
/>
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
<Button type="button" variant="ghost" onClick={() => setCreating(false)}>
Cancel
</Button>
<Button type="button" onClick={() => void commitCreate()} disabled={!nameDraft.trim()}>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Rename project */}
<Dialog
open={renaming !== null}
onOpenChange={(open) => {
if (!open) setRenaming(null);
}}
>
<DialogContent className="corner-squircle border border-border/60 bg-background/98 shadow-none sm:max-w-md">
<DialogHeader>
<DialogTitle>Rename project</DialogTitle>
</DialogHeader>
<Input
value={renameDraft}
onChange={(e) => setRenameDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void commitRename();
}
}}
autoFocus
maxLength={120}
placeholder="Project name"
aria-label="Project name"
className="focus-visible:border-input focus-visible:ring-0"
/>
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
<Button type="button" variant="ghost" onClick={() => setRenaming(null)}>
Cancel
</Button>
<Button
type="button"
onClick={() => void commitRename()}
disabled={!renameDraft.trim() || renameDraft.trim() === renaming?.name}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete project */}
<Dialog
open={deleting !== null}
onOpenChange={(open) => {
if (!open) setDeleting(null);
}}
>
<DialogContent className="menu-flat-destructive corner-squircle border border-border/60 bg-background/98 shadow-none sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete project</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Are you sure you want to delete <em>{deleting?.name}</em>? Chats in this
project will be moved back to Recents.
</p>
<DialogFooter className="flex-wrap gap-2 sm:justify-end">
<Button type="button" variant="ghost" onClick={() => setDeleting(null)}>
Cancel
</Button>
<Button type="button" variant="destructive" onClick={() => void commitDelete()}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</main>
);
}

View file

@ -66,6 +66,7 @@ import { syncExportedRepositoryToBackend } from "./utils/delete-thread-message";
import { getImageInputUnavailableReason } from "./utils/image-input-support";
const pendingHistoryAppendByMessageId = new Map<string, Promise<void>>();
const pendingRunStartReadyByMessageId = new Map<string, Promise<void>>();
type TitleResponse = {
choices?: Array<{
@ -534,10 +535,12 @@ export async function ensureThreadRecord({
threadId,
modelType,
pairId,
projectId,
}: {
threadId: string;
modelType: ModelType;
pairId?: string;
projectId?: string | null;
}): Promise<void> {
if (isChatThreadDeleted(threadId)) {
return;
@ -556,6 +559,7 @@ export async function ensureThreadRecord({
modelType,
modelId: currentModelId,
pairId,
projectId: projectId ?? null,
archived: false,
createdAt: Date.now(),
};
@ -579,6 +583,8 @@ export async function ensureThreadRecord({
function createStudioDbAdapter(
modelType: ModelType,
pairId?: string,
projectId?: string | null,
listThreads = true,
): unstable_RemoteThreadListAdapter {
return {
async fetch(remoteId: string) {
@ -594,9 +600,16 @@ function createStudioDbAdapter(
},
async list() {
if (!listThreads) {
return { threads: [] };
}
let threads: ThreadRecord[];
try {
threads = await listStoredChatThreads({ modelType, pairId });
threads = await listStoredChatThreads({
modelType,
pairId,
...(projectId !== undefined ? { projectId } : {}),
});
} catch (error) {
if (!isExpectedBackgroundChatStorageError(error)) {
throw error;
@ -615,7 +628,7 @@ function createStudioDbAdapter(
},
async initialize(threadId: string) {
await ensureThreadRecord({ threadId, modelType, pairId });
await ensureThreadRecord({ threadId, modelType, pairId, projectId });
return { remoteId: threadId, externalId: undefined };
},
@ -696,7 +709,7 @@ function createStudioDbAdapter(
const running = useChatRuntimeStore.getState().runningByThreadId;
if (running[paired.id]) {
setTimeout(() => {
void createStudioDbAdapter(modelType, pairId).generateTitle(
void createStudioDbAdapter(modelType, pairId, projectId).generateTitle(
remoteId,
messages,
);
@ -740,6 +753,22 @@ function trackHistoryAppend(
return write;
}
function trackRunStartReady(
messageId: string,
ready: Promise<void>,
): Promise<void> {
pendingRunStartReadyByMessageId.set(messageId, ready);
const cleanup = () => {
setTimeout(() => {
if (pendingRunStartReadyByMessageId.get(messageId) === ready) {
pendingRunStartReadyByMessageId.delete(messageId);
}
}, 30_000);
};
ready.then(cleanup, cleanup);
return ready;
}
async function waitForRunStartHistoryAppend(
messages: Parameters<ChatModelAdapter["run"]>[0]["messages"],
): Promise<void> {
@ -747,20 +776,22 @@ async function waitForRunStartHistoryAppend(
if (!lastMessage || lastMessage.role !== "user") {
return;
}
const write = pendingHistoryAppendByMessageId.get(lastMessage.id);
if (!write) {
const ready =
pendingRunStartReadyByMessageId.get(lastMessage.id) ??
pendingHistoryAppendByMessageId.get(lastMessage.id);
if (!ready) {
return;
}
let didPersist = false;
let didBecomeReady = false;
try {
await write;
didPersist = true;
await ready;
didBecomeReady = true;
} finally {
if (
didPersist &&
pendingHistoryAppendByMessageId.get(lastMessage.id) === write
didBecomeReady &&
pendingRunStartReadyByMessageId.get(lastMessage.id) === ready
) {
pendingHistoryAppendByMessageId.delete(lastMessage.id);
pendingRunStartReadyByMessageId.delete(lastMessage.id);
}
}
}
@ -783,7 +814,10 @@ function createPersistedRunAdapter(adapter: ChatModelAdapter): ChatModelAdapter
};
}
function useStudioRuntimeAdapters(): StudioRuntimeAdapters {
function useStudioRuntimeAdapters(
modelType: ModelType,
pairId?: string,
): StudioRuntimeAdapters {
const aui = useAui();
const history = useMemo<ThreadHistoryAdapter>(
@ -871,14 +905,22 @@ function useStudioRuntimeAdapters(): StudioRuntimeAdapters {
},
append({ parentId, message }: ExportedMessageRepositoryItem) {
const initializeThread = aui.threadListItem().initialize();
trackRunStartReady(message.id, initializeThread.then(() => undefined));
const write = (async () => {
const { remoteId } = await aui.threadListItem().initialize();
const { remoteId } = await initializeThread;
if (isChatThreadDeleted(remoteId)) {
await deleteStoredChatThreads([remoteId]);
return;
}
// Keep single-chat runtime state in sync once a new chat is first
// persisted. Compare panes intentionally do not write global activeThreadId.
if (modelType === "base" && !pairId) {
const store = useChatRuntimeStore.getState();
if (store.activeThreadId !== remoteId) {
store.setActiveThreadId(remoteId);
}
}
const thread = await getStoredChatThread(remoteId);
if (thread) {
await ensureStoredChatThread(remoteId, thread);
@ -915,7 +957,7 @@ function useStudioRuntimeAdapters(): StudioRuntimeAdapters {
return trackHistoryAppend(message.id, write);
},
}),
[aui],
[aui, modelType, pairId],
);
const dictation = useMemo(
@ -947,8 +989,11 @@ function useStudioRuntimeAdapters(): StudioRuntimeAdapters {
const chatAdapter = createOpenAIStreamAdapter();
function useRuntimeHook(): ReturnType<typeof useLocalRuntime> {
const adapters = useStudioRuntimeAdapters();
function useRuntimeHook(
modelType: ModelType,
pairId?: string,
): ReturnType<typeof useLocalRuntime> {
const adapters = useStudioRuntimeAdapters(modelType, pairId);
const persistedChatAdapter = useMemo(
() => createPersistedRunAdapter(chatAdapter),
[],
@ -956,6 +1001,12 @@ function useRuntimeHook(): ReturnType<typeof useLocalRuntime> {
return useLocalRuntime(persistedChatAdapter, { adapters });
}
function createRuntimeHook(modelType: ModelType, pairId?: string) {
return function useConfiguredRuntimeHook(): ReturnType<typeof useLocalRuntime> {
return useRuntimeHook(modelType, pairId);
};
}
function ThreadAutoSwitch({
threadId,
syncActiveThreadId = true,
@ -1133,20 +1184,28 @@ export function ChatRuntimeProvider({
children,
modelType = "base",
pairId,
projectId,
initialThreadId,
newThreadNonce,
syncActiveThreadId = true,
listThreads = true,
}: {
children: ReactNode;
modelType?: ModelType;
pairId?: string;
projectId?: string | null;
initialThreadId?: string;
newThreadNonce?: string;
syncActiveThreadId?: boolean;
listThreads?: boolean;
}): ReactElement {
const runtimeHook = useMemo(
() => createRuntimeHook(modelType, pairId),
[modelType, pairId],
);
const runtime = useRemoteThreadListRuntime({
runtimeHook: useRuntimeHook,
adapter: createStudioDbAdapter(modelType, pairId),
runtimeHook,
adapter: createStudioDbAdapter(modelType, pairId, projectId, listThreads),
});
const aui = useAui({});

View file

@ -43,8 +43,12 @@ import { useRagStore } from "@/features/rag/stores/rag-store";
import { acquireIndexSlot, releaseIndexSlot } from "./utils/rag-index-queue";
import { toast } from "@/lib/toast";
import { loadModel, validateModel } from "./api/chat-api";
import { parseExternalModelId, providerTypeSupportsVision } from "./external-providers";
import {
parseExternalModelId,
providerTypeSupportsVision,
} from "./external-providers";
import { useExternalProvidersStore } from "./stores/external-providers-store";
import { McpComposerButton } from "./mcp-composer-button";
import {
type ReasoningEffort,
useChatRuntimeStore,
@ -53,6 +57,7 @@ import {
getExternalReasoningCapabilities,
providerSupportsBuiltinCodeExecution,
providerSupportsBuiltinImageGeneration,
providerSupportsBuiltinWebSearch,
providerSupportsBuiltinWebFetch,
} from "./provider-capabilities";
import {
@ -77,9 +82,9 @@ export type CompareMessagePart =
export interface CompareHandle {
append: (content: CompareMessagePart[]) => void;
/** Append a user message without triggering generation. */
appendMessage: (content: CompareMessagePart[]) => void;
appendMessage: (content: CompareMessagePart[]) => Promise<string | null>;
/** Trigger generation on the current thread (after appendMessage). */
startRun: () => void;
startRun: (parentId?: string | null) => void;
cancel: () => void;
isRunning: () => boolean;
/** Returns a promise that resolves when the current or next run finishes. */
@ -114,6 +119,7 @@ type PendingDoc = {
errorMessage?: string;
};
const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
const COMPARE_APPEND_MESSAGE_TIMEOUT_MS = 10_000;
function isNativeComposing(event: Event) {
return "isComposing" in event && (event as InputEvent).isComposing === true;
@ -133,7 +139,10 @@ function fileToBase64DataURL(file: File): Promise<string> {
});
}
function formatReasoningEffortLabel(level: ReasoningEffort, modelId?: string): string {
function formatReasoningEffortLabel(
level: ReasoningEffort,
modelId?: string,
): string {
if (level === "max") return "Max";
if (level === "xhigh") {
const normalized = modelId?.trim().toLowerCase() ?? "";
@ -169,7 +178,12 @@ function useDictation(
const start = useCallback(() => {
const SpeechRecognitionAPI =
typeof window !== "undefined" &&
(window.SpeechRecognition ?? (window as unknown as { webkitSpeechRecognition?: typeof SpeechRecognition }).webkitSpeechRecognition);
(window.SpeechRecognition ??
(
window as unknown as {
webkitSpeechRecognition?: typeof SpeechRecognition;
}
).webkitSpeechRecognition);
if (!SpeechRecognitionAPI) {
return;
}
@ -215,7 +229,11 @@ function useDictation(
const supported =
typeof window !== "undefined" &&
!!(window.SpeechRecognition ?? (window as unknown as { webkitSpeechRecognition?: unknown }).webkitSpeechRecognition);
!!(
window.SpeechRecognition ??
(window as unknown as { webkitSpeechRecognition?: unknown })
.webkitSpeechRecognition
);
return { isDictating, start, stop, supported };
}
@ -245,6 +263,7 @@ export function RegisterCompareHandle({
}): ReactElement | null {
const handlesRef = useContext(CompareHandlesContext);
const aui = useAui();
const pendingAppendWaitersRef = useRef<Set<() => void>>(new Set());
useEffect(() => {
if (!handlesRef) {
@ -254,13 +273,79 @@ export function RegisterCompareHandle({
currentHandles[name] = {
// fixes occasional reorder on reload.
append: (content) =>
aui.thread().append({ role: "user", content, createdAt: new Date() } as never),
appendMessage: (content) =>
aui.thread().append({ role: "user", content, createdAt: new Date(), startRun: false } as never),
startRun: () => {
aui
.thread()
.append({ role: "user", content, createdAt: new Date() } as never),
appendMessage: (content) => {
const thread = aui.thread();
const beforeIds = new Set(
thread.getState().messages.map((message) => message.id),
);
thread.append({
role: "user",
content,
createdAt: new Date(),
startRun: false,
} as never);
const findAppendedUserMessageId = () => {
const messages = thread.getState().messages;
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (beforeIds.has(message.id) || message.role !== "user") {
continue;
}
return message.id;
}
return null;
};
const appendedId = findAppendedUserMessageId();
if (appendedId) {
return Promise.resolve(appendedId);
}
return new Promise<string | null>((resolve) => {
const startedAt = Date.now();
let settled = false;
let timer: number | null = null;
let cancel: (() => void) | null = null;
const cleanup = () => {
if (timer !== null) {
window.clearTimeout(timer);
timer = null;
}
if (cancel) {
pendingAppendWaitersRef.current.delete(cancel);
}
};
const finish = (messageId: string | null) => {
if (settled) return;
settled = true;
cleanup();
resolve(messageId);
};
cancel = () => finish(null);
const poll = () => {
timer = null;
const messageId = findAppendedUserMessageId();
if (
messageId ||
Date.now() - startedAt >= COMPARE_APPEND_MESSAGE_TIMEOUT_MS
) {
finish(messageId);
return;
}
timer = window.setTimeout(poll, 16);
};
pendingAppendWaitersRef.current.add(cancel);
timer = window.setTimeout(poll, 0);
});
},
startRun: (parentId) => {
const msgs = aui.thread().getState().messages;
const lastId = msgs.length > 0 ? msgs[msgs.length - 1].id : null;
aui.thread().startRun({ parentId: lastId });
const fallbackId = msgs.length > 0 ? msgs[msgs.length - 1].id : null;
aui.thread().startRun({ parentId: parentId ?? fallbackId });
},
cancel: () => aui.thread().cancelRun(),
isRunning: () => aui.thread().getState().isRunning,
@ -278,6 +363,10 @@ export function RegisterCompareHandle({
}),
};
return () => {
for (const cancel of pendingAppendWaitersRef.current) {
cancel();
}
pendingAppendWaitersRef.current.clear();
delete currentHandles[name];
};
}, [handlesRef, name, aui]);
@ -300,7 +389,8 @@ function PendingImageThumb({
setSrc(url);
return () => URL.revokeObjectURL(url);
}, [file]);
if (!src) return <div className="size-14 animate-pulse rounded-[14px] bg-muted" />;
if (!src)
return <div className="size-14 animate-pulse rounded-[14px] bg-muted" />;
return (
<div className="relative size-14 shrink-0 overflow-hidden rounded-[14px] border border-foreground/20 bg-muted">
<img src={src} alt={file.name} className="h-full w-full object-cover" />
@ -335,7 +425,10 @@ export function SharedComposer({
const [running, setRunning] = useState(false);
const [comparing, setComparing] = useState(false);
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
const [pendingAudio, setPendingAudio] = useState<{ name: string; base64: string } | null>(null);
const [pendingAudio, setPendingAudio] = useState<{
name: string;
base64: string;
} | null>(null);
const [pendingDocs, setPendingDocs] = useState<PendingDoc[]>([]);
const [dragging, setDragging] = useState(false);
const [isComposing, setIsComposing] = useState(false);
@ -368,10 +461,16 @@ export function SharedComposer({
const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled);
const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle);
const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort);
const supportsReasoningOff = useChatRuntimeStore((s) => s.supportsReasoningOff);
const reasoningEffortLevels = useChatRuntimeStore((s) => s.reasoningEffortLevels);
const supportsReasoningOff = useChatRuntimeStore(
(s) => s.supportsReasoningOff,
);
const reasoningEffortLevels = useChatRuntimeStore(
(s) => s.reasoningEffortLevels,
);
const setReasoningEffort = useChatRuntimeStore((s) => s.setReasoningEffort);
const supportsPreserveThinking = useChatRuntimeStore((s) => s.supportsPreserveThinking);
const supportsPreserveThinking = useChatRuntimeStore(
(s) => s.supportsPreserveThinking,
);
const preserveThinking = useChatRuntimeStore((s) => s.preserveThinking);
const setPreserveThinking = useChatRuntimeStore((s) => s.setPreserveThinking);
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
@ -388,6 +487,8 @@ export function SharedComposer({
);
const ragToolEnabled = useChatRuntimeStore((s) => s.ragToolEnabled);
const setRagToolEnabled = useChatRuntimeStore((s) => s.setRagToolEnabled);
const artifactsEnabled = useChatRuntimeStore((s) => s.artifactsEnabled);
const setArtifactsEnabled = useChatRuntimeStore((s) => s.setArtifactsEnabled);
const webFetchToolsEnabled = useChatRuntimeStore(
(s) => s.webFetchToolsEnabled,
);
@ -523,6 +624,7 @@ export function SharedComposer({
// it needs the tool-calling loop. No external-builtin equivalent —
// gate purely on supportsTools (mirrors web/code without a builtin).
const ragDisabled = !modelLoaded || !supportsTools;
const artifactDisabled = !modelLoaded;
// Fetch pill: Anthropic-only (web_fetch_20250910 / web_fetch_20260209).
const webFetchDisabled = !modelLoaded || !supportsBuiltinWebFetch;
const showWebFetchPill = supportsBuiltinWebFetch;
@ -530,12 +632,17 @@ export function SharedComposer({
// reference `toolsDisabled` (rare; both pills used it before).
const toolsDisabled = codeDisabled;
const setPendingAudioStore = useChatRuntimeStore((s) => s.setPendingAudio);
const clearPendingAudioStore = useChatRuntimeStore((s) => s.clearPendingAudio);
const { isDictating, start: startDictation, stop: stopDictation, supported: dictationSupported } = useDictation(
setText,
const clearPendingAudioStore = useChatRuntimeStore(
(s) => s.clearPendingAudio,
);
const {
isDictating,
start: startDictation,
stop: stopDictation,
supported: dictationSupported,
} = useDictation(setText);
useEffect(() => {
const id = setInterval(() => {
const handles = handlesRef.current;
@ -552,8 +659,10 @@ export function SharedComposer({
ta.style.height = "auto";
const styles = window.getComputedStyle(ta);
const lineHeight = parseFloat(styles.lineHeight) || 20;
const paddingY = parseFloat(styles.paddingTop) + parseFloat(styles.paddingBottom);
const borderY = parseFloat(styles.borderTopWidth) + parseFloat(styles.borderBottomWidth);
const paddingY =
parseFloat(styles.paddingTop) + parseFloat(styles.paddingBottom);
const borderY =
parseFloat(styles.borderTopWidth) + parseFloat(styles.borderBottomWidth);
const maxHeight = lineHeight * 6 + paddingY + borderY;
const next = Math.min(ta.scrollHeight, maxHeight);
ta.style.height = `${next}px`;
@ -886,12 +995,17 @@ export function SharedComposer({
// LoraCompare and single-pane chats are unaffected.
if (hasCompareHandles && !isGeneralizedCompare) {
toast.error("Pick a model in each pane to compare", {
description: "Use the model dropdown above each pane, then send your prompt.",
description:
"Use the model dropdown above each pane, then send your prompt.",
});
return;
}
if (pendingImages.length > 0 && !isGeneralizedCompare && imageUnavailableReason) {
if (
pendingImages.length > 0 &&
!isGeneralizedCompare &&
imageUnavailableReason
) {
// Single mode: the loaded model's runtime capability is known
// here. Compare mode defers — each ensureModelLoaded below sets
// loadedIsMultimodal for its side, and the chat-adapter's
@ -931,20 +1045,96 @@ export function SharedComposer({
const maxSeqLength = store.params.maxSeqLength;
const trustRemoteCode = store.params.trustRemoteCode ?? false;
const chatTemplateOverride = store.chatTemplateOverride;
const effectiveChatTemplateOverride =
chatTemplateOverride?.trim() ? chatTemplateOverride : null;
const effectiveChatTemplateOverride = chatTemplateOverride?.trim()
? chatTemplateOverride
: null;
function modelDisplayName(id: string): string {
const external = parseExternalModelId(id);
if (external) return external.modelId;
const parts = id.split("/");
return parts[parts.length - 1] || id;
}
// Helper: load a model and update store checkpoint
async function ensureModelLoaded(sel: CompareModelSelection): Promise<string> {
async function ensureModelLoaded(
sel: CompareModelSelection,
): Promise<string> {
const external = parseExternalModelId(sel.id);
if (external) {
const externalStore = useExternalProvidersStore.getState();
if (!externalStore.connectionsEnabled) {
throw new Error(
"Connections are disabled. Turn on Enable connections in Settings -> Connections to use hosted models.",
);
}
const provider = externalStore.providers.find(
(p) => p.id === external.providerId,
);
if (!provider) {
throw new Error(
"Connection not found. Open Settings -> Connections and add it again.",
);
}
const reasoningCaps = getExternalReasoningCapabilities(
provider.providerType,
external.modelId,
{
isReasoningProvider: provider.isReasoningModel === true,
baseUrl: provider.baseUrl ?? null,
},
);
const supportsBuiltinWebSearch = providerSupportsBuiltinWebSearch(
provider.providerType,
external.modelId,
provider.baseUrl,
);
const supportsBuiltinCodeExecution =
providerSupportsBuiltinCodeExecution(
provider.providerType,
external.modelId,
provider.baseUrl,
);
const supportsBuiltinImageGeneration =
providerSupportsBuiltinImageGeneration(
provider.providerType,
external.modelId,
provider.baseUrl,
);
const supportsBuiltinWebFetch = providerSupportsBuiltinWebFetch(
provider.providerType,
);
const currentStore = useChatRuntimeStore.getState();
currentStore.setCheckpoint(sel.id, null);
useChatRuntimeStore.setState({
activeGgufVariant: null,
ggufContextLength: null,
ggufMaxContextLength: null,
ggufNativeContextLength: null,
activeNativePathToken: null,
supportsReasoning: reasoningCaps.supportsReasoning,
reasoningAlwaysOn: reasoningCaps.reasoningAlwaysOn,
reasoningStyle: reasoningCaps.reasoningStyle,
supportsReasoningOff: reasoningCaps.supportsReasoningOff,
reasoningEffortLevels: reasoningCaps.reasoningEffortLevels,
supportsPreserveThinking: false,
supportsTools: false,
supportsBuiltinWebSearch,
supportsBuiltinCodeExecution,
supportsBuiltinImageGeneration,
supportsBuiltinWebFetch,
loadedIsMultimodal:
providerTypeSupportsVision(provider.providerType) === true,
});
return "external";
}
const currentStore = useChatRuntimeStore.getState();
const isAlreadyActive =
currentStore.params.checkpoint === sel.id &&
(currentStore.activeGgufVariant ?? null) === (sel.ggufVariant ?? null);
(currentStore.activeGgufVariant ?? null) ===
(sel.ggufVariant ?? null);
if (!isAlreadyActive) {
const validation = await validateModel({
model_path: sel.id,
@ -1022,9 +1212,19 @@ export function SharedComposer({
const handle1 = handlesRef.current["model1"];
const handle2 = handlesRef.current["model2"];
// Show user messages immediately on both sides
if (handle1) handle1.appendMessage(content);
if (handle2) handle2.appendMessage(content);
// Show user messages immediately on both sides and keep the ids
// so delayed model loads can start the run from the intended turn.
const [parentId1, parentId2] = await Promise.all([
handle1 ? handle1.appendMessage(content) : Promise.resolve(null),
handle2 ? handle2.appendMessage(content) : Promise.resolve(null),
]);
if ((handle1 && !parentId1) || (handle2 && !parentId2)) {
toast.error("Compare failed", {
description:
"The prompt could not be added to both compare panes. Try sending it again.",
});
return;
}
const name1 = model1?.id ? modelDisplayName(model1.id) : "";
const name2 = model2?.id ? modelDisplayName(model2.id) : "";
@ -1034,25 +1234,42 @@ export function SharedComposer({
try {
// Side 1: load → generate → wait
if (handle1 && model1?.id) {
toast("Loading Model 1…", { id: toastId, description: name1, duration: Infinity });
toast("Loading Model 1…", {
id: toastId,
description: name1,
duration: Infinity,
});
const status1 = await ensureModelLoaded(model1);
toast("Generating with Model 1…", { id: toastId, description: `${name1} (${status1})`, duration: Infinity });
toast("Generating with Model 1…", {
id: toastId,
description: `${name1} (${status1})`,
duration: Infinity,
});
const done = handle1.waitForRunEnd();
handle1.startRun();
handle1.startRun(parentId1);
await done;
}
// Side 2: load → generate → wait
if (handle2 && model2?.id) {
const needsLoad = model2.id.toLowerCase() !== (model1?.id || "").toLowerCase()
|| (model2.ggufVariant ?? "") !== (model1?.ggufVariant ?? "");
const needsLoad =
model2.id.toLowerCase() !== (model1?.id || "").toLowerCase() ||
(model2.ggufVariant ?? "") !== (model1?.ggufVariant ?? "");
if (needsLoad) {
toast("Loading Model 2…", { id: toastId, description: name2, duration: Infinity });
toast("Loading Model 2…", {
id: toastId,
description: name2,
duration: Infinity,
});
}
const status2 = await ensureModelLoaded(model2);
toast("Generating with Model 2…", { id: toastId, description: `${name2} (${status2})`, duration: Infinity });
toast("Generating with Model 2…", {
id: toastId,
description: `${name2} (${status2})`,
duration: Infinity,
});
const done = handle2.waitForRunEnd();
handle2.startRun();
handle2.startRun(parentId2);
await done;
}
@ -1189,7 +1406,10 @@ export function SharedComposer({
<span className="max-w-48 truncate">{pendingAudio.name}</span>
<button
type="button"
onClick={() => { setPendingAudio(null); clearPendingAudioStore(); }}
onClick={() => {
setPendingAudio(null);
clearPendingAudioStore();
}}
className="flex size-4 items-center justify-center rounded-full hover:bg-destructive hover:text-destructive-foreground"
aria-label="Remove audio"
>
@ -1287,130 +1507,136 @@ export function SharedComposer({
)}
{showReasoningControl ? (
effectiveReasoningStyle === "reasoning_effort" ? (
<DropdownMenu>
<DropdownMenuTrigger asChild={true}>
<button
type="button"
disabled={reasoningDisabled}
className={cn(
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
reasoningDisabled
? "cursor-not-allowed opacity-40"
: effectiveReasoningVisualEnabled
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
)}
aria-label={thinkEffortAriaLabel({
modelLoaded,
reasoningDisabled,
reasoningEffort,
})}
>
{effectiveReasoningVisualEnabled ? (
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3.5" />
)}
<span>
Think:{" "}
{effectiveReasoningVisualEnabled
? formatReasoningEffortLabel(
reasoningEffort,
externalSelection?.modelId,
)
: formatReasoningDisabledLabel(
effectiveSupportsReasoningOff,
isExternalOpenAIReasoning,
checkpoint,
)}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{effectiveSupportsReasoningOff && (
<DropdownMenuItem
onSelect={() => {
setReasoningEnabled(false);
applyQwenThinkingParams(false);
}}
>
{formatReasoningDisabledLabel(
effectiveSupportsReasoningOff,
isExternalOpenAIReasoning,
checkpoint,
<DropdownMenu>
<DropdownMenuTrigger asChild={true}>
<button
type="button"
disabled={reasoningDisabled}
className={cn(
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
reasoningDisabled
? "cursor-not-allowed opacity-40"
: effectiveReasoningVisualEnabled
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
)}
{!effectiveReasoningVisualEnabled ? " \u2713" : ""}
</DropdownMenuItem>
)}
{effectiveReasoningEffortLevels
.filter((level) => level !== "none")
.map((level) => (
<DropdownMenuItem
key={level}
onSelect={() => {
setReasoningEffort(level);
setReasoningEnabled(true);
applyQwenThinkingParams(true);
// Mutual exclusion: turning thinking on for a
// Kimi model forces the web_search builtin off.
if (isKimiExternal && toolsEnabled) {
setToolsEnabled(false, { persist: false });
}
}}
aria-label={thinkEffortAriaLabel({
modelLoaded,
reasoningDisabled,
reasoningEffort,
})}
>
{formatReasoningEffortLabel(level, externalSelection?.modelId)}
{effectiveReasoningVisualEnabled && reasoningEffort === level ? " \u2713" : ""}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
disabled={reasoningDisabled || reasoningLockedOn}
aria-disabled={reasoningDisabled || reasoningLockedOn}
title={
reasoningLockedOn
? "This model requires reasoning to stay on."
: undefined
}
onClick={() => {
if (reasoningLockedOn) return;
const next = !reasoningEnabled;
setReasoningEnabled(next);
applyQwenThinkingParams(next);
// Mutual exclusion: Kimi's $web_search builtin
// requires thinking off, so turning thinking on flips
// the Search pill off (and vice versa).
if (isKimiExternal && next && toolsEnabled) {
setToolsEnabled(false, { persist: false });
{effectiveReasoningVisualEnabled ? (
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3.5" />
)}
<span>
Think:{" "}
{effectiveReasoningVisualEnabled
? formatReasoningEffortLabel(
reasoningEffort,
externalSelection?.modelId,
)
: formatReasoningDisabledLabel(
effectiveSupportsReasoningOff,
isExternalOpenAIReasoning,
checkpoint,
)}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{effectiveSupportsReasoningOff && (
<DropdownMenuItem
onSelect={() => {
setReasoningEnabled(false);
applyQwenThinkingParams(false);
}}
>
{formatReasoningDisabledLabel(
effectiveSupportsReasoningOff,
isExternalOpenAIReasoning,
checkpoint,
)}
{!effectiveReasoningVisualEnabled ? " \u2713" : ""}
</DropdownMenuItem>
)}
{effectiveReasoningEffortLevels
.filter((level) => level !== "none")
.map((level) => (
<DropdownMenuItem
key={level}
onSelect={() => {
setReasoningEffort(level);
setReasoningEnabled(true);
applyQwenThinkingParams(true);
// Mutual exclusion: turning thinking on for a
// Kimi model forces the web_search builtin off.
if (isKimiExternal && toolsEnabled) {
setToolsEnabled(false, { persist: false });
}
}}
>
{formatReasoningEffortLabel(
level,
externalSelection?.modelId,
)}
{effectiveReasoningVisualEnabled &&
reasoningEffort === level
? " \u2713"
: ""}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
disabled={reasoningDisabled || reasoningLockedOn}
aria-disabled={reasoningDisabled || reasoningLockedOn}
title={
reasoningLockedOn
? "This model requires reasoning to stay on."
: undefined
}
}}
className={cn(
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
reasoningLockedOn
? "cursor-not-allowed text-primary"
: reasoningDisabled
? "cursor-not-allowed opacity-40"
: effectiveReasoningEnabled
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
)}
aria-label={thinkToggleAriaLabel({
reasoningLockedOn,
modelLoaded,
reasoningDisabled,
effectiveReasoningEnabled,
})}
>
{reasoningLockedOn ||
(effectiveReasoningEnabled && !reasoningDisabled) ? (
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3.5" />
)}
<span>Think</span>
</button>
onClick={() => {
if (reasoningLockedOn) return;
const next = !reasoningEnabled;
setReasoningEnabled(next);
applyQwenThinkingParams(next);
// Mutual exclusion: Kimi's $web_search builtin
// requires thinking off, so turning thinking on flips
// the Search pill off (and vice versa).
if (isKimiExternal && next && toolsEnabled) {
setToolsEnabled(false, { persist: false });
}
}}
className={cn(
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
reasoningLockedOn
? "cursor-not-allowed text-primary"
: reasoningDisabled
? "cursor-not-allowed opacity-40"
: effectiveReasoningEnabled
? "cursor-pointer text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
: "cursor-pointer hover:bg-primary/10 dark:hover:bg-white/[0.08]",
)}
aria-label={thinkToggleAriaLabel({
reasoningLockedOn,
modelLoaded,
reasoningDisabled,
effectiveReasoningEnabled,
})}
>
{reasoningLockedOn ||
(effectiveReasoningEnabled && !reasoningDisabled) ? (
<LightbulbIcon className="size-3.5" />
) : (
<LightbulbOffIcon className="size-3.5" />
)}
<span>Think</span>
</button>
)
) : null}
{supportsPreserveThinking && (
@ -1423,11 +1649,13 @@ export function SharedComposer({
!modelLoaded
? "cursor-not-allowed opacity-40"
: preserveThinking
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
? "cursor-pointer text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
: "cursor-pointer hover:bg-primary/10 dark:hover:bg-white/[0.08]",
)}
aria-label={
preserveThinking ? "Disable preserve think" : "Enable preserve think"
preserveThinking
? "Disable preserve think"
: "Enable preserve think"
}
>
{preserveThinking && modelLoaded ? (
@ -1456,7 +1684,9 @@ export function SharedComposer({
}}
className="composer-pill-btn"
data-active={toolsEnabled && !searchDisabled ? "true" : "false"}
aria-label={toolsEnabled ? "Disable web search" : "Enable web search"}
aria-label={
toolsEnabled ? "Disable web search" : "Enable web search"
}
>
<GlobeIcon className="size-3.5" />
<span>Search</span>
@ -1467,7 +1697,11 @@ export function SharedComposer({
onClick={() => setCodeToolsEnabled(!codeToolsEnabled)}
className="composer-pill-btn"
data-active={codeToolsEnabled && !codeDisabled ? "true" : "false"}
aria-label={codeToolsEnabled ? "Disable code execution" : "Enable code execution"}
aria-label={
codeToolsEnabled
? "Disable code execution"
: "Enable code execution"
}
>
<CodeToggleIcon className="size-3.5" />
<span>Code</span>
@ -1478,9 +1712,13 @@ export function SharedComposer({
disabled={imageDisabled}
onClick={() => setImageToolsEnabled(!imageToolsEnabled)}
className="composer-pill-btn"
data-active={imageToolsEnabled && !imageDisabled ? "true" : "false"}
data-active={
imageToolsEnabled && !imageDisabled ? "true" : "false"
}
aria-label={
imageToolsEnabled ? "Disable image generation" : "Enable image generation"
imageToolsEnabled
? "Disable image generation"
: "Enable image generation"
}
>
<HugeiconsIcon
@ -1520,6 +1758,22 @@ export function SharedComposer({
<span>RAG</span>
</button>
)}
<button
type="button"
disabled={artifactDisabled}
onClick={() => setArtifactsEnabled(!artifactsEnabled)}
className="composer-pill-btn"
data-active={
artifactsEnabled && !artifactDisabled ? "true" : "false"
}
aria-label={
artifactsEnabled ? "Disable artifacts" : "Enable artifacts"
}
>
<FileTextIcon className="size-3.5" />
<span>Artifacts</span>
</button>
<McpComposerButton />
{showWebFetchPill && (
<button
type="button"

View file

@ -29,6 +29,11 @@ export const CHAT_TOOLS_ENABLED_KEY = "unsloth_chat_tools_enabled";
export const CHAT_CODE_TOOLS_ENABLED_KEY = "unsloth_chat_code_tools_enabled";
export const CHAT_IMAGE_TOOLS_ENABLED_KEY = "unsloth_chat_image_tools_enabled";
export const CHAT_RAG_TOOL_ENABLED_KEY = "unsloth_chat_rag_tool_enabled";
export const CHAT_ARTIFACTS_ENABLED_KEY = "unsloth_chat_artifacts_enabled";
export const CHAT_COLLAPSE_HTML_ARTIFACTS_KEY =
"unsloth_chat_collapse_html_artifacts";
export const CHAT_ALLOW_ARTIFACT_NETWORK_ACCESS_KEY =
"unsloth_chat_allow_artifact_network_access";
export const CHAT_MCP_ENABLED_KEY = "unsloth_chat_mcp_enabled";
export const CHAT_WEB_FETCH_TOOLS_ENABLED_KEY =
"unsloth_chat_web_fetch_tools_enabled";
@ -303,6 +308,9 @@ type ChatRuntimeStore = {
ragToolEnabled: boolean;
codeToolsEnabled: boolean;
imageToolsEnabled: boolean;
artifactsEnabled: boolean;
collapseHtmlArtifacts: boolean;
allowArtifactNetworkAccess: boolean;
mcpEnabledForChat: boolean;
/**
* Fetch pill state, independent of `toolsEnabled` (Search). Only
@ -327,6 +335,7 @@ type ChatRuntimeStore = {
chatTemplateOverride: string | null;
loadedChatTemplateOverride: string | null;
activeThreadId: string | null;
activeProjectId: string | null;
settingsPanelOpen: boolean;
pendingAudioBase64: string | null;
pendingAudioName: string | null;
@ -371,6 +380,7 @@ type ChatRuntimeStore = {
setModelsError: (error: string | null) => void;
setCheckpoint: (modelId: string, ggufVariant?: string | null) => void;
setActiveThreadId: (threadId: string | null) => void;
setActiveProjectId: (projectId: string | null) => void;
setSettingsPanelOpen: (open: boolean) => void;
clearCheckpoint: () => void;
setReasoningEnabled: (
@ -384,6 +394,12 @@ type ChatRuntimeStore = {
setToolsEnabled: (enabled: boolean, options?: { persist?: boolean }) => void;
setCodeToolsEnabled: (enabled: boolean) => void;
setImageToolsEnabled: (enabled: boolean) => void;
setArtifactsEnabled: (
enabled: boolean,
options?: { persist?: boolean },
) => void;
setCollapseHtmlArtifacts: (enabled: boolean) => void;
setAllowArtifactNetworkAccess: (enabled: boolean) => void;
setMcpEnabledForChat: (enabled: boolean) => void;
setWebFetchToolsEnabled: (enabled: boolean) => void;
setToolStatus: (status: string | null) => void;
@ -424,6 +440,8 @@ type ScalarSettingKey =
| "autoTitle"
| "reasoningEffort"
| "preserveThinking"
| "collapseHtmlArtifacts"
| "allowArtifactNetworkAccess"
| "autoHealToolCalls"
| "maxToolCallsPerMessage"
| "toolCallTimeout"
@ -465,6 +483,8 @@ const SCALAR_SETTING_KEYS = [
"autoTitle",
"reasoningEffort",
"preserveThinking",
"collapseHtmlArtifacts",
"allowArtifactNetworkAccess",
"autoHealToolCalls",
"maxToolCallsPerMessage",
"toolCallTimeout",
@ -659,6 +679,12 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
ragToolEnabled: loadBool(CHAT_RAG_TOOL_ENABLED_KEY, false),
codeToolsEnabled: loadBool(CHAT_CODE_TOOLS_ENABLED_KEY, false),
imageToolsEnabled: loadBool(CHAT_IMAGE_TOOLS_ENABLED_KEY, false),
artifactsEnabled: loadBool(CHAT_ARTIFACTS_ENABLED_KEY, false),
collapseHtmlArtifacts: loadBool(CHAT_COLLAPSE_HTML_ARTIFACTS_KEY, false),
allowArtifactNetworkAccess: loadBool(
CHAT_ALLOW_ARTIFACT_NETWORK_ACCESS_KEY,
false,
),
mcpEnabledForChat: loadBool(CHAT_MCP_ENABLED_KEY, false),
webFetchToolsEnabled: loadBool(CHAT_WEB_FETCH_TOOLS_ENABLED_KEY, false),
toolStatus: null,
@ -678,6 +704,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
chatTemplateOverride: null,
loadedChatTemplateOverride: null,
activeThreadId: null,
activeProjectId: null,
settingsPanelOpen: false,
pendingAudioBase64: null,
pendingAudioName: null,
@ -859,6 +886,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
}),
setActiveThreadId: (activeThreadId) =>
set({ activeThreadId, contextUsage: null }),
setActiveProjectId: (activeProjectId) => set({ activeProjectId }),
setSettingsPanelOpen: (settingsPanelOpen) => set({ settingsPanelOpen }),
clearCheckpoint: () => {
// Mirror setCheckpoint's persistence behavior: dropping the
@ -893,6 +921,8 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
toolsEnabled: false,
codeToolsEnabled: false,
imageToolsEnabled: false,
artifactsEnabled: false,
mcpEnabledForChat: false,
webFetchToolsEnabled: false,
toolStatus: null,
kvCacheDtype: null,
@ -1015,6 +1045,36 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
saveBool(CHAT_IMAGE_TOOLS_ENABLED_KEY, imageToolsEnabled);
return { imageToolsEnabled };
}),
setArtifactsEnabled: (artifactsEnabled, options) =>
set(() => {
if (options?.persist !== false) {
saveBool(CHAT_ARTIFACTS_ENABLED_KEY, artifactsEnabled);
}
return { artifactsEnabled };
}),
setCollapseHtmlArtifacts: (collapseHtmlArtifacts) =>
set((state) => {
saveBool(CHAT_COLLAPSE_HTML_ARTIFACTS_KEY, collapseHtmlArtifacts);
setScalarSettingVersion(
"collapseHtmlArtifacts",
collapseHtmlArtifacts,
state.collapseHtmlArtifacts,
);
return { collapseHtmlArtifacts };
}),
setAllowArtifactNetworkAccess: (allowArtifactNetworkAccess) =>
set((state) => {
saveBool(
CHAT_ALLOW_ARTIFACT_NETWORK_ACCESS_KEY,
allowArtifactNetworkAccess,
);
setScalarSettingVersion(
"allowArtifactNetworkAccess",
allowArtifactNetworkAccess,
state.allowArtifactNetworkAccess,
);
return { allowArtifactNetworkAccess };
}),
setMcpEnabledForChat: (mcpEnabledForChat) =>
set(() => {
saveBool(CHAT_MCP_ENABLED_KEY, mcpEnabledForChat);

View file

@ -42,7 +42,11 @@ export function ThreadSidebar({
const { items } = useChatSidebarItems();
const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
const activeId =
view.mode === "single" ? (view.threadId ?? storeThreadId) : view.pairId;
view.mode === "single"
? (view.threadId ?? storeThreadId)
: view.mode === "compare"
? view.pairId
: view.projectId;
function viewForItem(item: SidebarItem): ChatView {
return item.type === "single"

View file

@ -4,8 +4,28 @@
export type ModelType = "base" | "lora" | "model1" | "model2";
export type ChatView =
| { mode: "single"; threadId?: string; newThreadNonce?: string }
| { mode: "compare"; pairId: string };
| {
mode: "project";
projectId: string;
}
| {
mode: "single";
threadId?: string;
newThreadNonce?: string;
projectId?: string | null;
}
| { mode: "compare"; pairId: string; projectId?: string | null };
export interface ProjectRecord {
id: string;
name: string;
instructions?: string;
rootPath?: string | null;
sandboxPath?: string | null;
archived: boolean;
createdAt: number;
updatedAt: number;
}
export interface ThreadRecord {
id: string;
@ -13,6 +33,7 @@ export interface ThreadRecord {
modelType: ModelType;
modelId?: string;
pairId?: string;
projectId?: string | null;
archived: boolean;
createdAt: number;
/**

View file

@ -4,22 +4,32 @@
import {
buildBackendChatExport,
clearBackendChats,
deleteChatProject,
deleteChatThreads,
getChatProject,
getChatMessage,
getChatThread,
batchListChatMessages,
listChatProjects,
listChatImportLedger,
listChatMessages,
listChatThreads,
notifyChatHistoryUpdated,
recordChatImportLedger,
saveChatProject,
saveChatMessage,
saveChatThread,
syncChatMessages,
updateChatProject,
updateChatThread,
} from "../api/chat-api";
import { db, DEXIE_DB_NAME } from "../db";
import type { MessageRecord, ModelType, ThreadRecord } from "../types";
import type {
MessageRecord,
ModelType,
ProjectRecord,
ThreadRecord,
} from "../types";
import {
isChatThreadDeleted,
markChatThreadsDeleted,
@ -28,6 +38,7 @@ import {
type ThreadListArgs = {
modelType?: ModelType;
pairId?: string;
projectId?: string | null;
includeArchived?: boolean;
};
@ -45,6 +56,7 @@ interface ExportedChat {
exportedAt: string;
version: 1;
threadCount: number;
projects?: unknown[];
threads: unknown[];
messages: unknown[];
}
@ -82,6 +94,8 @@ function matchesThreadListArgs(
return (
!isChatThreadDeleted(thread.id) &&
(!args.pairId || thread.pairId === args.pairId) &&
(args.projectId === undefined ||
(thread.projectId ?? null) === args.projectId) &&
(!args.modelType || thread.modelType === args.modelType) &&
(args.includeArchived !== false || !thread.archived)
);
@ -584,6 +598,72 @@ export async function listStoredChatThreadsWithMessages(
return entries.filter((e) => e.hasContent).map((e) => e.thread);
}
export async function listStoredChatProjects(
args: { includeArchived?: boolean } = {},
): Promise<ProjectRecord[]> {
return listChatProjects(args);
}
export async function getStoredChatProject(
projectId: string,
): Promise<ProjectRecord | null> {
return getChatProject(projectId);
}
export async function createStoredChatProject(
name: string,
): Promise<ProjectRecord> {
const trimmed = name.trim();
if (!trimmed) {
throw new Error("Project name is required.");
}
const now = Date.now();
return saveChatProject({
id: crypto.randomUUID(),
name: trimmed,
instructions: "",
archived: false,
createdAt: now,
updatedAt: now,
});
}
export async function updateStoredChatProject(
projectId: string,
patch: Partial<ProjectRecord>,
): Promise<ProjectRecord> {
return updateChatProject(projectId, {
...patch,
updatedAt: patch.updatedAt ?? Date.now(),
});
}
export async function deleteStoredChatProject(
projectId: string,
args: { deleteFiles?: boolean } = {},
): Promise<void> {
await deleteChatProject(projectId, args);
}
export async function moveStoredChatItemToProject(
item: { type: "single" | "compare"; id: string },
projectId: string | null,
): Promise<void> {
const threadIds =
item.type === "single"
? [item.id]
: (await listStoredChatThreads({
pairId: item.id,
includeArchived: true,
})).map((thread) => thread.id);
await Promise.all(
Array.from(new Set(threadIds)).map((threadId) =>
updateStoredChatThread(threadId, { projectId }),
),
);
}
export async function saveStoredChatMessage(
message: MessageRecord,
): Promise<MessageRecord> {
@ -767,6 +847,7 @@ export async function buildStoredChatExport(): Promise<ExportedChat> {
exportedAt: new Date().toISOString(),
version: 1,
threadCount: threads.length,
projects: backend?.projects ?? [],
threads,
messages,
};

View file

@ -28,6 +28,9 @@ const CHAT_ACTIVE_PRESET_KEY = "unsloth_chat_active_preset";
const CHAT_ACTIVE_PRESET_SOURCE_KEY = "unsloth_chat_active_preset_source";
const REASONING_EFFORT_KEY = "unsloth_reasoning_effort";
const PRESERVE_THINKING_KEY = "unsloth_preserve_thinking";
const COLLAPSE_HTML_ARTIFACTS_KEY = "unsloth_chat_collapse_html_artifacts";
const ALLOW_ARTIFACT_NETWORK_ACCESS_KEY =
"unsloth_chat_allow_artifact_network_access";
const CHAT_PRESETS_KEY = "unsloth_chat_custom_presets";
const LEGACY_CHAT_SYSTEM_PROMPTS_KEY = "unsloth_chat_system_prompts";
const LEGACY_CHAT_SETTINGS_IMPORT_KEY =
@ -216,6 +219,10 @@ function sanitizeChatSettings(value: unknown): PersistedChatSettings {
const reasoningEffort = sanitizeReasoningEffort(value.reasoningEffort);
const autoTitle = sanitizeBool(value.autoTitle);
const preserveThinking = sanitizeBool(value.preserveThinking);
const collapseHtmlArtifacts = sanitizeBool(value.collapseHtmlArtifacts);
const allowArtifactNetworkAccess = sanitizeBool(
value.allowArtifactNetworkAccess,
);
const autoHealToolCalls = sanitizeBool(value.autoHealToolCalls);
const maxToolCallsPerMessage = sanitizeInt(value.maxToolCallsPerMessage, 1);
const toolCallTimeout = sanitizeInt(value.toolCallTimeout, 1);
@ -230,6 +237,12 @@ function sanitizeChatSettings(value: unknown): PersistedChatSettings {
if (reasoningEffort) settings.reasoningEffort = reasoningEffort;
if (preserveThinking !== undefined)
settings.preserveThinking = preserveThinking;
if (collapseHtmlArtifacts !== undefined) {
settings.collapseHtmlArtifacts = collapseHtmlArtifacts;
}
if (allowArtifactNetworkAccess !== undefined) {
settings.allowArtifactNetworkAccess = allowArtifactNetworkAccess;
}
if (autoHealToolCalls !== undefined) {
settings.autoHealToolCalls = autoHealToolCalls;
}
@ -290,6 +303,8 @@ export function isEmptyChatSettings(settings: PersistedChatSettings): boolean {
settings.autoTitle === undefined &&
settings.reasoningEffort === undefined &&
settings.preserveThinking === undefined &&
settings.collapseHtmlArtifacts === undefined &&
settings.allowArtifactNetworkAccess === undefined &&
settings.autoHealToolCalls === undefined &&
settings.maxToolCallsPerMessage === undefined &&
settings.toolCallTimeout === undefined
@ -318,6 +333,8 @@ export function loadLegacyChatSettings(): PersistedChatSettings {
);
const autoTitle = loadBool(AUTO_TITLE_KEY);
const preserveThinking = loadBool(PRESERVE_THINKING_KEY);
const collapseHtmlArtifacts = loadBool(COLLAPSE_HTML_ARTIFACTS_KEY);
const allowArtifactNetworkAccess = loadBool(ALLOW_ARTIFACT_NETWORK_ACCESS_KEY);
const autoHealToolCalls = loadBool(AUTO_HEAL_TOOL_CALLS_KEY);
const maxToolCallsPerMessage = loadInt(MAX_TOOL_CALLS_KEY, 1);
const toolCallTimeout = loadInt(TOOL_CALL_TIMEOUT_KEY, 1);
@ -336,6 +353,12 @@ export function loadLegacyChatSettings(): PersistedChatSettings {
if (reasoningEffort) settings.reasoningEffort = reasoningEffort;
if (preserveThinking !== undefined)
settings.preserveThinking = preserveThinking;
if (collapseHtmlArtifacts !== undefined) {
settings.collapseHtmlArtifacts = collapseHtmlArtifacts;
}
if (allowArtifactNetworkAccess !== undefined) {
settings.allowArtifactNetworkAccess = allowArtifactNetworkAccess;
}
if (autoHealToolCalls !== undefined) {
settings.autoHealToolCalls = autoHealToolCalls;
}

View file

@ -0,0 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export { NativeModelChip } from "./components/native-model-chip";
export { NativeModelDropOverlay } from "./components/native-model-drop-overlay";
export { useNativeIntentStore } from "./store";
export type { NativeIntent } from "./types";
export { useChooseNativeModel } from "./use-native-dialogs";
export { useNativeModelDrop } from "./use-native-drop";
export type { NativeModelDropState } from "./use-native-drop";
export { useNativePathLeasesSupported } from "./use-native-readiness";

View file

@ -7,7 +7,7 @@ export function initialsFromName(name: string): string {
return trimmed[0]!.toUpperCase();
}
/** Default blue background for avatar fallback (readable white text). */
/** Default Unsloth-green background for avatar fallback (readable white text). */
export function avatarBgStyle(): { backgroundColor: string } {
return { backgroundColor: "hsl(217 58% 48%)" };
return { backgroundColor: "#14b789" };
}

View file

@ -2,6 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { toast } from "@/lib/toast";
import {
Dialog,
@ -15,6 +16,7 @@ import {
clearAllChats,
countAllChats,
downloadChatExport,
useChatRuntimeStore,
} from "@/features/chat";
import { useT } from "@/i18n";
import { Delete02Icon, Download02Icon } from "@hugeicons/core-free-icons";
@ -29,10 +31,26 @@ export function ChatTab() {
const [count, setCount] = useState<number | null>(null);
const [exporting, setExporting] = useState(false);
const [clearing, setClearing] = useState(false);
const collapseHtmlArtifacts = useChatRuntimeStore(
(state) => state.collapseHtmlArtifacts,
);
const setCollapseHtmlArtifacts = useChatRuntimeStore(
(state) => state.setCollapseHtmlArtifacts,
);
const allowArtifactNetworkAccess = useChatRuntimeStore(
(state) => state.allowArtifactNetworkAccess,
);
const setAllowArtifactNetworkAccess = useChatRuntimeStore(
(state) => state.setAllowArtifactNetworkAccess,
);
const hydratePersistedSettings = useChatRuntimeStore(
(state) => state.hydratePersistedSettings,
);
useEffect(() => {
void countAllChats().then(setCount);
}, []);
void hydratePersistedSettings();
}, [hydratePersistedSettings]);
const handleExport = async () => {
setExporting(true);
@ -111,6 +129,31 @@ export function ChatTab() {
</p>
</header>
<SettingsSection title={t("settings.chat.artifacts.title")}>
<SettingsRow
label={t("settings.chat.artifacts.collapseHtmlBlocks")}
description={t(
"settings.chat.artifacts.collapseHtmlBlocksDescription",
)}
>
<Switch
checked={collapseHtmlArtifacts}
onCheckedChange={setCollapseHtmlArtifacts}
/>
</SettingsRow>
<SettingsRow
label={t("settings.chat.artifacts.allowNetworkAccess")}
description={t(
"settings.chat.artifacts.allowNetworkAccessDescription",
)}
>
<Switch
checked={allowArtifactNetworkAccess}
onCheckedChange={setAllowArtifactNetworkAccess}
/>
</SettingsRow>
</SettingsSection>
<SettingsSection title={t("settings.chat.data")}>
<SettingsRow
label={t("settings.chat.exportHistory")}

View file

@ -53,6 +53,10 @@ export function StudioPage(): ReactElement {
const selectedHistoryRunId = useTrainingRuntimeStore((s) => s.selectedHistoryRunId);
const setSelectedHistoryRunId = useTrainingRuntimeStore((s) => s.setSelectedHistoryRunId);
const setCurrentRunViewActive = useTrainingRuntimeStore(
(s) => s.setCurrentRunViewActive,
);
useEffect(() => {
return () => setSelectedHistoryRunId(null);
}, [setSelectedHistoryRunId]);
@ -67,6 +71,13 @@ export function StudioPage(): ReactElement {
? "configure"
: requestedTab;
// Mirror "Current Run" tab state into the store so the sidebar can highlight
// the run this view refers to. Cleared on unmount (leaving the studio page).
useEffect(() => {
setCurrentRunViewActive(activeTab === "current-run");
return () => setCurrentRunViewActive(false);
}, [activeTab, setCurrentRunViewActive]);
const { setPinned } = useSidebar();
const pinSidebar = useCallback(() => setPinned(true), [setPinned]);

View file

@ -37,6 +37,12 @@ import { useT } from "@/i18n";
const HF_REPO_REGEX = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
// Tracks which jobs have already played the terminal intro animation. The
// overlay unmounts when you navigate away from the training page, so without
// this its typing/fade-in would replay on every return even though the run
// itself is still going. Module-level so it survives remounts.
const animatedJobs = new Set<string>();
function formatBytes(n: number): string {
if (n <= 0) return "0 B";
if (n < 1024) return `${n} B`;
@ -254,6 +260,7 @@ export function TrainingStartOverlay({
const { stopTrainingRun, dismissTrainingRun } = useTrainingActions();
const isStarting = useTrainingRuntimeStore((s) => s.isStarting);
const phase = useTrainingRuntimeStore((s) => s.phase);
const jobId = useTrainingRuntimeStore((s) => s.jobId);
const startModelName = useTrainingRuntimeStore((s) => s.startModelName);
const startDatasetName = useTrainingRuntimeStore((s) => s.startDatasetName);
const startFromResume = useTrainingRuntimeStore((s) => s.startFromResume);
@ -298,6 +305,16 @@ export function TrainingStartOverlay({
}
}, [isStarting]);
// Play the intro animation only the first time we mount for a given job.
// On later remounts (e.g. leaving the training page and coming back) the
// terminal renders its final state instantly so the logs don't restart.
const alreadyAnimated = jobId != null && animatedJobs.has(jobId);
useEffect(() => {
if (jobId != null) {
animatedJobs.add(jobId);
}
}, [jobId]);
return (
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center rounded-2xl bg-background/45 backdrop-blur-[1px]">
<div className="pointer-events-auto relative flex w-[860px] max-w-[calc(100%-2rem)] flex-col items-center gap-4">
@ -311,7 +328,7 @@ export function TrainingStartOverlay({
<Button
variant="ghost"
size="icon"
className="absolute right-3 top-3 z-10 size-7 cursor-pointer rounded-full text-muted-foreground/60 hover:bg-destructive/10 hover:text-destructive"
className="absolute right-3 top-3 z-10 size-7 cursor-pointer rounded-full text-muted-foreground/90 hover:bg-destructive/10 hover:text-destructive"
onClick={() => setCancelDialogOpen(true)}
disabled={cancelRequested}
>
@ -349,6 +366,7 @@ export function TrainingStartOverlay({
<Terminal
className="w-full min-h-[390px] rounded-2xl px-7 py-6 text-left"
startOnView={false}
instant={alreadyAnimated}
>
<TypingAnimation
duration={36}

View file

@ -46,6 +46,7 @@ const initialState: TrainingRuntimeState = {
resetGeneration: 0,
stopRequested: false,
selectedHistoryRunId: null,
currentRunViewActive: false,
};
function sortSeries(points: TrainingSeriesPoint[]): TrainingSeriesPoint[] {
@ -182,6 +183,9 @@ export const useTrainingRuntimeStore = create<TrainingRuntimeStore>()((set) => (
setSelectedHistoryRunId: (selectedHistoryRunId) =>
set({ selectedHistoryRunId }),
setCurrentRunViewActive: (currentRunViewActive) =>
set({ currentRunViewActive }),
applyStatus: (payload) =>
set((state) => {
const metricHistory = applyMetricHistoryFromStatus(payload);

View file

@ -105,6 +105,9 @@ export interface TrainingRuntimeState {
resetGeneration: number;
stopRequested: boolean;
selectedHistoryRunId: string | null;
// True while the studio "Current Run" tab is the active view, so the sidebar
// can highlight which run row the current run refers to (the active job).
currentRunViewActive: boolean;
}
export interface TrainingRuntimeActions {
@ -127,6 +130,7 @@ export interface TrainingRuntimeActions {
setStartQueued: (jobId: string, message: string) => void;
setRuntimeError: (message: string) => void;
setSelectedHistoryRunId: (id: string | null) => void;
setCurrentRunViewActive: (value: boolean) => void;
}
export type TrainingRuntimeStore = TrainingRuntimeState & TrainingRuntimeActions;

View file

@ -166,6 +166,15 @@ export const en = {
chat: {
title: "Chat",
description: "Manage your chat history stored on this device.",
artifacts: {
title: "Artifacts",
collapseHtmlBlocks: "Collapse HTML blocks",
collapseHtmlBlocksDescription:
"Artifacts mode collapses full HTML fallback automatically. Turn this on to also collapse full fenced HTML documents when Artifacts is off.",
allowNetworkAccess: "Allow artifact network access",
allowNetworkAccessDescription:
"Let artifact previews load scripts, styles, fonts, media, fetch, and WebSocket resources from HTTP(S) CDNs. Keep off for fully offline previews.",
},
data: "Data",
exportHistory: "Export chat history",
exportHistoryDescription:

View file

@ -14,6 +14,51 @@
@custom-variant dark (&:is(.dark *));
@layer components {
/* Sidebar scroll area (Gemini-style). The BOTTOM edge always fades so the
last rows dissolve into the profile footer (no divider line). Once
scrolled, the TOP edge also fades so rows dissolve as they pass under the
pinned New Chat / Search header. */
/* Top edge: when scrolled, rows dissolve as they pass under the pinned
New Chat / Search header (mask fades content to transparent). */
.sidebar-scroll-fade.is-scrolled {
-webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 14px);
mask-image: linear-gradient(to bottom, transparent 0, #000 14px);
}
/* Bottom edge: Gemini-style sticky gradient OVERLAY painted in the sidebar
background colour, so the last rows wash into the profile footer. Uses a
zero-height sticky anchor with an absolutely-positioned gradient so it
never takes layout space. */
.sidebar-bottom-fade {
position: sticky;
bottom: 0;
height: 0;
z-index: 1;
pointer-events: none;
}
.sidebar-bottom-fade::after {
content: "";
position: absolute;
inset-inline: 0;
bottom: 0;
height: 40px;
background: linear-gradient(to top, var(--sidebar), transparent);
}
}
@layer base {
h1,
h2,
h3,
[data-slot="dialog-title"],
[data-slot="alert-dialog-title"] {
font-family: "Hellix", "Space Grotesk Variable", var(--font-sans) !important;
font-weight: 500 !important;
}
}
@font-face {
font-family: "Hellix";
src: url("/fonts/Hellix-Regular.woff") format("woff");
@ -82,13 +127,15 @@
--chart-4: oklch(0.6926 0.1112 346.5775);
--chart-5: oklch(0.7497 0.1003 85.0057);
--radius: 1.1rem;
--sidebar: #f9faf9;
/* Match the page background so the sidebar reads as one surface with the
content; the faint --sidebar-border on its right edge is the separator. */
--sidebar: oklch(1 0 0);
--sidebar-foreground: oklch(0.1281 0.0179 169.2764);
--sidebar-primary: #17b88b;
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.96 0.0279 166.55);
--sidebar-accent-foreground: oklch(0.2868 0.0649 159.9823);
--sidebar-border: oklch(0.945 0.0101 164.8536);
--sidebar-border: oklch(0.92 0 0);
--sidebar-ring: #17b88b;
--destructive-foreground: oklch(1 0 0);
--font-sans: "Inter Variable", ui-sans-serif, sans-serif, system-ui;
@ -121,7 +168,7 @@
/* 0px 0px 0px 0px hsl(0 0% 0% / 0),*/
/* 0px 8px 10px 0px hsl(0 0% 0% / 0);*/
/*--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 0% / 0);*/
--tracking-normal: -0.01em;
--tracking-normal: 0em;
/* Hex (not OKLCH) so the rendered surface matches the design mockup pixel-for-pixel. */
--nav-fg: #383835;
@ -202,7 +249,8 @@
--chart-3: oklch(0.7554 0.1285 197.339);
--chart-4: oklch(0.7503 0.1199 346.7805);
--chart-5: oklch(0.799 0.1196 84.6633);
--sidebar: #18181a;
/* Match the page background (dark); --sidebar-border is the right-edge separator. */
--sidebar: #1f2023;
--sidebar-foreground: #ececee;
--sidebar-primary: #17b88b;
--sidebar-primary-foreground: oklch(1 0 0);
@ -306,8 +354,8 @@
--font-mono: JetBrains Mono, monospace;
--font-serif: Source Serif 4, serif;
--radius: 1.1rem;
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-tighter: 0em;
--tracking-tight: 0em;
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
@ -439,7 +487,7 @@
h5,
h6 {
font-family: var(--font-sans);
letter-spacing: -0.02em;
letter-spacing: 0;
}
}
@ -451,15 +499,14 @@
on global antialiased font-smoothing to neutralize the bloom. */
.font-heading {
font-family: var(--font-heading);
letter-spacing: -0.01em;
letter-spacing: 0;
}
/* Dark mode loosens tracking to offset optical bloom on dark surfaces. */
.tracking-nav {
letter-spacing: 0.015em;
letter-spacing: 0;
}
.dark .tracking-nav {
letter-spacing: 0.03em;
letter-spacing: 0;
}
.nav-icon-btn {
@ -486,6 +533,10 @@
.sidebar-nav-btn:hover,
.sidebar-nav-btn[data-active="true"],
.sidebar-nav-btn[data-state="open"],
.group\/project-item:hover .sidebar-nav-btn,
.group\/project-item:has(.sidebar-row-action[data-state="open"]) .sidebar-nav-btn,
.group\/project-chat-item:hover .sidebar-nav-btn,
.group\/project-chat-item:has(.sidebar-row-action[data-state="open"]) .sidebar-nav-btn,
.group\/recent-item:hover .sidebar-nav-btn,
.group\/recent-item:has(.sidebar-row-action[data-state="open"]) .sidebar-nav-btn,
.group\/run-item:hover .sidebar-nav-btn,
@ -496,6 +547,10 @@
.dark .sidebar-nav-btn:hover,
.dark .sidebar-nav-btn[data-active="true"],
.dark .sidebar-nav-btn[data-state="open"],
.dark .group\/project-item:hover .sidebar-nav-btn,
.dark .group\/project-item:has(.sidebar-row-action[data-state="open"]) .sidebar-nav-btn,
.dark .group\/project-chat-item:hover .sidebar-nav-btn,
.dark .group\/project-chat-item:has(.sidebar-row-action[data-state="open"]) .sidebar-nav-btn,
.dark .group\/recent-item:hover .sidebar-nav-btn,
.dark .group\/recent-item:has(.sidebar-row-action[data-state="open"]) .sidebar-nav-btn,
.dark .group\/run-item:hover .sidebar-nav-btn,
@ -504,7 +559,7 @@
}
.sidebar-row-action {
@apply absolute top-0 bottom-0 right-0 inline-flex items-center justify-end pl-2 pr-1.5 opacity-0 pointer-events-none outline-none;
@apply absolute top-0 bottom-0 right-0 inline-flex cursor-pointer items-center justify-end pl-2 pr-1.5 opacity-0 pointer-events-none outline-none;
}
.sidebar-row-action[data-state="open"] {
@apply opacity-100 pointer-events-auto;
@ -530,12 +585,22 @@
}
.sidebar-sticky-label {
@apply sticky top-0 z-20 rounded-none bg-sidebar pt-0 pb-1.5 pl-[18px] pr-4 text-[13px]! font-medium normal-case tracking-[0.04em] text-nav-fg-muted focus-visible:ring-0! focus-visible:outline-none shadow-[0_-8px_0_0_var(--sidebar)] transition-shadow duration-150;
@apply rounded-none bg-sidebar pt-0 pb-[8px] pl-[18px] pr-4 text-[14.5px]! leading-[17px] font-medium normal-case focus-visible:ring-0! focus-visible:outline-none transition-shadow duration-150;
/* Muted section-header gray, matching Gemini's "Notebooks"/"Recents".
Lightened from #5f6368 so the label reads as a header, clearly
lighter than the near-black nav items. */
color: #80868b;
letter-spacing: 0;
}
.sidebar-sticky-label.is-scrolled {
@apply shadow-[0_-8px_0_0_var(--sidebar),0_0.5px_0_0_var(--sidebar-border)];
.sidebar-sticky-label-following {
@apply pt-[21px];
}
.dark .sidebar-sticky-label {
/* Muted gray, clearly dimmer than the near-white nav items (#ececee) so
"Train"/"Recents" read as section headers like Gemini's dark mode. */
color: #9aa0a6;
letter-spacing: 0;
}
/* Neutral panel input surface sidesteps the green cast on
`--input` / `--border` (both have a small chroma at hue ~165 in
light mode). Same value drives the preset input pill, the system
@ -698,7 +763,8 @@
border-color: rgb(255 255 255 / 0.12) !important;
}
.app-user-menu [data-slot="dropdown-menu-item"] {
.app-user-menu [data-slot="dropdown-menu-item"],
.app-user-menu [data-slot="dropdown-menu-sub-trigger"] {
height: 32px;
padding: 0 0.625rem !important;
gap: 8.5px !important;
@ -706,28 +772,38 @@
font-weight: 500;
font-size: 14.5px;
line-height: 19px;
letter-spacing: 0.015em;
letter-spacing: 0;
color: var(--nav-fg);
}
.dark .app-user-menu [data-slot="dropdown-menu-item"] {
letter-spacing: 0.03em;
.dark .app-user-menu [data-slot="dropdown-menu-item"],
.dark .app-user-menu [data-slot="dropdown-menu-sub-trigger"] {
letter-spacing: 0;
}
.app-user-menu [data-slot="dropdown-menu-item"] svg {
.app-user-menu [data-slot="dropdown-menu-item"] svg,
.app-user-menu [data-slot="dropdown-menu-sub-trigger"] svg {
width: 19px !important;
height: 19px !important;
flex-shrink: 0;
}
.app-user-menu [data-slot="dropdown-menu-item"]:focus {
.app-user-menu [data-slot="dropdown-menu-item"]:focus,
.app-user-menu [data-slot="dropdown-menu-sub-trigger"]:focus,
.app-user-menu [data-slot="dropdown-menu-sub-trigger"][data-state="open"] {
background-color: var(--nav-surface-hover);
color: #000;
}
.dark .app-user-menu [data-slot="dropdown-menu-item"]:focus {
.dark .app-user-menu [data-slot="dropdown-menu-item"]:focus,
.dark .app-user-menu [data-slot="dropdown-menu-sub-trigger"]:focus,
.dark .app-user-menu [data-slot="dropdown-menu-sub-trigger"][data-state="open"] {
color: #fff;
}
.app-user-menu [data-slot="dropdown-menu-item"]:focus * {
.app-user-menu [data-slot="dropdown-menu-item"]:focus *,
.app-user-menu [data-slot="dropdown-menu-sub-trigger"]:focus *,
.app-user-menu [data-slot="dropdown-menu-sub-trigger"][data-state="open"] * {
color: #000 !important;
}
.dark .app-user-menu [data-slot="dropdown-menu-item"]:focus * {
.dark .app-user-menu [data-slot="dropdown-menu-item"]:focus *,
.dark .app-user-menu [data-slot="dropdown-menu-sub-trigger"]:focus *,
.dark .app-user-menu [data-slot="dropdown-menu-sub-trigger"][data-state="open"] * {
color: #fff !important;
}
@ -800,7 +876,7 @@
}
.composer-pill-btn {
@apply flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors hover:bg-primary/10 dark:hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-40;
@apply flex cursor-pointer items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors hover:bg-primary/10 dark:hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-40;
}
.composer-pill-btn[data-active="true"] {
color: var(--primary);
@ -815,10 +891,161 @@
}
.composer-footer-note {
@apply mt-1.5 text-center text-[11px] tracking-[0.04em] text-muted-foreground;
@apply mt-1.5 text-center text-[11px] tracking-[0em] text-muted-foreground;
font-family: var(--font-sans);
}
.artifact-panel-shell {
box-shadow: 0 2px 8px -2px rgba(27, 27, 31, 0.16);
}
.dark .artifact-panel-shell {
box-shadow: none;
}
.chat-artifact-split[data-artifact-layout-animating="true"] > [data-panel] {
transition:
flex-basis 260ms var(--ease-out-cubic),
flex-grow 260ms var(--ease-out-cubic),
flex-shrink 260ms var(--ease-out-cubic);
will-change: flex-basis, flex-grow;
}
.chat-artifact-pop-surface {
pointer-events: none;
opacity: 0;
transform: scale(0.965) translateY(8px);
transform-origin: center center;
transition:
opacity 180ms var(--ease-out-cubic),
transform 220ms var(--ease-out-cubic);
will-change: opacity, transform;
}
.chat-artifact-pop-surface[data-artifact-surface-visible="true"] {
pointer-events: auto;
opacity: 1;
transform: scale(1) translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.chat-artifact-pop-surface {
transition: none;
transform: none;
}
}
.artifact-card-shimmer {
background: linear-gradient(
105deg,
transparent 0%,
color-mix(in oklch, var(--muted-foreground) 4%, transparent) 40%,
color-mix(in oklch, var(--muted-foreground) 8%, transparent) 50%,
color-mix(in oklch, var(--muted-foreground) 4%, transparent) 60%,
transparent 100%
);
transform: translateX(-120%);
animation: artifact-card-shimmer 1.55s var(--ease-out-cubic) infinite;
}
.artifact-loading-line {
width: 48%;
background: color-mix(in oklch, var(--primary) 88%, transparent);
transform: translate3d(-125%, 0, 0) scaleX(0.78);
transform-origin: center center;
animation: artifact-loading-line 1.7s linear infinite;
}
.artifact-panel-shell::before,
.artifact-panel-shell::after {
content: "";
position: absolute;
z-index: 20;
pointer-events: none;
transition:
border-color 150ms var(--ease-out-cubic),
background-color 150ms var(--ease-out-cubic),
box-shadow 150ms var(--ease-out-cubic),
transform 150ms var(--ease-out-cubic);
}
.artifact-panel-shell::before {
inset: 0;
border: 2px solid transparent;
border-radius: inherit;
-webkit-mask: linear-gradient(90deg, #000 0 24px, transparent 24px);
mask: linear-gradient(90deg, #000 0 24px, transparent 24px);
}
.artifact-panel-shell::after {
top: 50%;
left: -2px;
height: 28px;
width: 4px;
border-radius: 999px;
background: transparent;
transform: translateY(-50%);
}
[data-slot="resizable-handle"]:hover
+ [data-slot="resizable-panel"]
.artifact-panel-shell::before,
[data-slot="resizable-handle"]:active
+ [data-slot="resizable-panel"]
.artifact-panel-shell::before,
[data-slot="resizable-handle"][data-resize-handle-state="hover"]
+ [data-slot="resizable-panel"]
.artifact-panel-shell::before,
[data-slot="resizable-handle"][data-resize-handle-state="drag"]
+ [data-slot="resizable-panel"]
.artifact-panel-shell::before {
border-color: color-mix(in oklch, var(--primary) 58%, var(--border));
}
[data-slot="resizable-handle"]:hover
+ [data-slot="resizable-panel"]
.artifact-panel-shell::after,
[data-slot="resizable-handle"]:active
+ [data-slot="resizable-panel"]
.artifact-panel-shell::after,
[data-slot="resizable-handle"][data-resize-handle-state="hover"]
+ [data-slot="resizable-panel"]
.artifact-panel-shell::after,
[data-slot="resizable-handle"][data-resize-handle-state="drag"]
+ [data-slot="resizable-panel"]
.artifact-panel-shell::after {
background: color-mix(in oklch, var(--primary) 58%, var(--border));
box-shadow: none;
transform: translateY(-50%) scaleY(1.06);
}
@keyframes artifact-card-shimmer {
to {
transform: translateX(120%);
}
}
@keyframes artifact-loading-line {
0% {
transform: translate3d(-125%, 0, 0) scaleX(0.78);
}
35% {
transform: translate3d(-18%, 0, 0) scaleX(0.96);
}
62% {
transform: translate3d(72%, 0, 0) scaleX(1.12);
}
82% {
transform: translate3d(132%, 0, 0) scaleX(1.26);
}
94% {
transform: translate3d(188%, 0, 0) scaleX(1.36);
}
100% {
transform: translate3d(220%, 0, 0) scaleX(1.42);
}
}
/* Fine-tuning Studio: equal default height, expandable when needed (md+) */
.min-h-studio-config-column {
@apply md:min-h-[470px];
@ -1135,24 +1362,16 @@
background: #23252a;
}
[data-sidebar="content"] {
scrollbar-color: oklch(0.5 0 0 / 0.22) var(--sidebar);
}
.dark [data-sidebar="content"] {
scrollbar-color: oklch(0.72 0 0 / 0.25) var(--sidebar);
/* Search list: re-enable the native scrollbar (CommandList defaults to no-scrollbar). */
.cmd-native-scrollbar {
scrollbar-width: thin;
-ms-overflow-style: auto;
}
[data-sidebar="content"]::-webkit-scrollbar-track {
background: var(--sidebar);
}
[data-sidebar="content"]::-webkit-scrollbar-thumb {
background: oklch(0.5 0 0 / 0.22);
}
.dark [data-sidebar="content"]::-webkit-scrollbar-thumb {
background: oklch(0.72 0 0 / 0.25);
.cmd-native-scrollbar::-webkit-scrollbar {
display: block;
width: 8px;
height: 8px;
}
.preview-scrollbar {

View file

@ -169,8 +169,17 @@ DEFAULT_MAX_MACOS_RELEASE_FALLBACKS = env_int(
16,
minimum = 1,
)
# Deterministic macOS pin. At b9428 ggml-org's macOS runner moved to macOS 26
# (Tahoe), so b9428+ prebuilts only load on macOS 26+. b9415 is the last build
# stamped below 26 (arm64 minos 14, x64 minos 13.3); loads on macOS 13.3/14/15/26.
_PINNED_MACOS_FALLBACK_TAG = "b9415"
_PINNED_MACOS_LATEST_FLOOR = (26, 0)
FORCE_COMPILE_DEFAULT_REF = os.environ.get("UNSLOTH_LLAMA_FORCE_COMPILE_REF", "master")
# sm_103 (B300 / GB300 Blackwell Ultra) is not built natively but runs on the
# bundled base compute_100 PTX, which the driver JIT-compiles forward to sm_103.
# It is listed in every bundle that ships the sm_100 build (the "newer" and
# "portable" classes) so those hosts get a prebuilt instead of a source compile.
DIRECT_LINUX_BUNDLE_PROFILES: dict[str, dict[str, Any]] = {
"cuda12-older": {
"runtime_line": "cuda12",
@ -183,7 +192,7 @@ DIRECT_LINUX_BUNDLE_PROFILES: dict[str, dict[str, Any]] = {
"cuda12-newer": {
"runtime_line": "cuda12",
"coverage_class": "newer",
"supported_sms": ["86", "89", "90", "100", "120"],
"supported_sms": ["86", "89", "90", "100", "103", "120"],
"min_sm": 86,
"max_sm": 120,
"rank": 20,
@ -191,7 +200,7 @@ DIRECT_LINUX_BUNDLE_PROFILES: dict[str, dict[str, Any]] = {
"cuda12-portable": {
"runtime_line": "cuda12",
"coverage_class": "portable",
"supported_sms": ["70", "75", "80", "86", "89", "90", "100", "120"],
"supported_sms": ["70", "75", "80", "86", "89", "90", "100", "103", "120"],
"min_sm": 70,
"max_sm": 120,
"rank": 30,
@ -207,7 +216,7 @@ DIRECT_LINUX_BUNDLE_PROFILES: dict[str, dict[str, Any]] = {
"cuda13-newer": {
"runtime_line": "cuda13",
"coverage_class": "newer",
"supported_sms": ["86", "89", "90", "100", "120"],
"supported_sms": ["86", "89", "90", "100", "103", "120"],
"min_sm": 86,
"max_sm": 120,
"rank": 50,
@ -215,13 +224,81 @@ DIRECT_LINUX_BUNDLE_PROFILES: dict[str, dict[str, Any]] = {
"cuda13-portable": {
"runtime_line": "cuda13",
"coverage_class": "portable",
"supported_sms": ["75", "80", "86", "89", "90", "100", "120"],
"supported_sms": ["75", "80", "86", "89", "90", "100", "103", "120"],
"min_sm": 75,
"max_sm": 120,
"rank": 60,
},
}
# Lowest CUDA major we ship prebuilts for, and the highest major we probe for
# installed runtime libraries. Detection and runtime-line derivation are
# generated per major so a new toolkit (cuda14, ...) needs no code change while
# llama.cpp keeps the cudart64_<major>.dll / libcudart.so.<major> naming.
_MIN_CUDA_MAJOR = 12
_MAX_PROBE_CUDA_MAJOR = 19
# Last ggml-org release whose Windows win-cuda-13 build is still sub-13.3
# (cuda-13.1, b9360, 2026-05-27). Upstream bumped win-cuda-13 to 13.3 at b9365
# and now ships only cuda-12.4 + cuda-13.3. cuda-12.4 predates Blackwell (ggml
# compiles sm_120 only at toolkit >= 12.8), so a Blackwell host on a 13.0/13.1/13.2
# driver is gated off 13.3 and would drop to a CPU-only 12.4 build. b9360 is
# immutable, so we pin its cuda-13.1 build (plus paired cudart) as a GPU
# fallback for exactly those hosts. See unslothai/unsloth#5887.
_PINNED_BLACKWELL_FALLBACK_TAG = "b9360"
_PINNED_BLACKWELL_FALLBACK_RUNTIME = "13.1"
# Floor at 13.0: b9360 ships native sm_120a SASS (no PTX/JIT) and a bundled
# cuda-13.1 cudart, both of which run on a CUDA 13.0 r580+ driver via CUDA
# minor-version compatibility, so the mainstream 13.0 Blackwell branch is covered.
_PINNED_BLACKWELL_DRIVER_FLOOR = (13, 0)
_BLACKWELL_MIN_SM = 120
# ggml compiles Blackwell sm_120 only at toolkit >= 12.8, so an in-release
# windows-cuda build at or above this already covers Blackwell and makes the
# older pinned 13.1 fallback unnecessary (cuda-12.4 is below it).
_BLACKWELL_MIN_TOOLKIT = (12, 8)
_PINNED_BLACKWELL_LLAMA_SHA256 = (
"31ddb8b42d7ab4a47cab8c48c397519f580ca502df7e73f3ab396eacc16c8e8d"
)
_PINNED_BLACKWELL_CUDART_SHA256 = (
"f96935e7e385e3b2d0189239077c10fe8fd7e95690fea4afec455b1b6c7e3f18"
)
def _cuda_runtime_lines_for_major(major: int) -> list[str]:
"""Runtime lines a driver of this CUDA major can use, newest major first
down to the minimum we ship. A driver runs its own major and any older one
(backward compatibility)."""
return [f"cuda{m}" for m in range(major, _MIN_CUDA_MAJOR - 1, -1)]
def _resolve_linux_bundle_profile(bundle_profile: str) -> "dict[str, Any] | None":
"""Profile (runtime line + sm coverage) for a linux-x64-cuda<major>-<class>
bundle. Known majors use their published coverage; an unknown future major
reuses the newest known major's coverage for the same class as a forward
default, with the post-build GPU smoke test as the backstop."""
known = DIRECT_LINUX_BUNDLE_PROFILES.get(bundle_profile)
if known is not None:
return known
m = re.fullmatch(
r"cuda(?P<major>\d+)-(?P<klass>older|newer|portable)", bundle_profile
)
if not m:
return None
base_key = max(
(
k
for k, v in DIRECT_LINUX_BUNDLE_PROFILES.items()
if v["coverage_class"] == m.group("klass")
),
key = lambda k: int(re.match(r"cuda(\d+)-", k).group(1)),
default = None,
)
if base_key is None:
return None
profile = dict(DIRECT_LINUX_BUNDLE_PROFILES[base_key])
profile["runtime_line"] = f"cuda{m.group('major')}"
return profile
@dataclass
class HostInfo:
@ -769,6 +846,26 @@ def windows_cuda_asset_aliases(
return aliases
def _published_windows_cuda_runtime(
upstream_assets: dict[str, str], major: int, driver: tuple[int, int] | None
) -> str | None:
"""Highest cuda-<major>.<minor> published upstream that `driver` can run by
default CUDA compatibility, i.e. (major, minor) <= driver. None if nothing
qualifies. Gating on the driver (not just the major) keeps a 13.3 build off
a driver that only advertises 13.1, where it would otherwise rely on the
unguaranteed minor-version-compatibility path."""
if driver is None:
return None
best: int | None = None
for name in upstream_assets:
m = re.search(r"-bin-win-cuda-(\d+)\.(\d+)-x64\.zip$", name)
if m and int(m.group(1)) == major:
minor = int(m.group(2))
if (major, minor) <= driver and (best is None or minor > best):
best = minor
return f"{major}.{best}" if best is not None else None
def format_byte_count(num_bytes: float) -> str:
units = ["B", "KiB", "MiB", "GiB", "TiB"]
value = float(num_bytes)
@ -1225,7 +1322,7 @@ def parse_direct_linux_release_bundle(
inferred_labels: list[str] = []
linux_asset_re = re.compile(
r"^app-(?P<label>.+)-(?P<target>linux-x64(?:-cpu)?|linux-x64-(?:cuda12|cuda13)-(?:older|newer|portable))\.tar\.gz$"
r"^app-(?P<label>.+)-(?P<target>linux-x64(?:-cpu)?|linux-x64-cuda\d+-(?:older|newer|portable))\.tar\.gz$"
)
for asset_name in sorted(assets):
match = linux_asset_re.fullmatch(asset_name)
@ -1250,7 +1347,7 @@ def parse_direct_linux_release_bundle(
continue
bundle_profile = target.removeprefix("linux-x64-")
profile = DIRECT_LINUX_BUNDLE_PROFILES.get(bundle_profile)
profile = _resolve_linux_bundle_profile(bundle_profile)
if profile is None:
continue
artifacts.append(
@ -1312,7 +1409,16 @@ def direct_linux_release_plan(
attempts: list[AssetChoice] = []
if host.has_usable_nvidia:
selection = linux_cuda_choice_from_release(host, bundle)
# Prefer the cudart major Studio loads at runtime (torch's bundled
# libcudart), not the newest detected on disk. Without this a stray
# cuda13 runtime outranks the torch cuda12 the binary links against.
torch_preference = detect_torch_cuda_runtime_preference(host)
selection = linux_cuda_choice_from_release(
host,
bundle,
preferred_runtime_line = torch_preference.runtime_line,
selection_preamble = torch_preference.selection_log,
)
if selection is not None:
attempts.extend(selection.attempts)
if host.has_rocm and not host.has_usable_nvidia:
@ -1400,6 +1506,11 @@ def direct_upstream_release_plan(
torch_preference.selection_log,
)
)
# Blackwell on a 13.1/13.2 driver: prefer the pinned cuda-13.1 GPU
# build over the CPU-only cuda-12.4 the in-release gating leaves.
pinned = _pinned_windows_cuda_fallback(host, attempts)
if pinned is not None:
attempts.insert(0, pinned)
elif host.has_rocm:
lemonade_choice = resolve_lemonade_rocm_choice(
host, "windows", "windows-hip", llama_tag = requested_tag
@ -1526,6 +1637,24 @@ def direct_upstream_release_plan(
)
def pinned_macos_release_tag(host: HostInfo, repo: str) -> str | None:
"""Pin b9415 (the last upstream macOS build that loads below macOS 26) for a
known pre-26 host on ggml-org upstream; return None to keep latest selection.
The unslothai/llama.cpp fork ships its own prebuilts (arm64 minos 14, x64
minos 13.3) and needs no pin, so this is a no-op there and for macOS 26+,
unknown version, non-macOS."""
if repo != UPSTREAM_REPO:
return None
if not host.is_macos:
return None
version = host.macos_version
if version is None:
return None
if version >= _PINNED_MACOS_LATEST_FLOOR:
return None
return _PINNED_MACOS_FALLBACK_TAG
def resolve_simple_install_release_plans(
llama_tag: str,
host: HostInfo,
@ -1536,18 +1665,27 @@ def resolve_simple_install_release_plans(
) -> tuple[str, list[InstallReleasePlan]]:
repo = published_repo or DEFAULT_PUBLISHED_REPO
requested_tag = normalized_requested_llama_tag(llama_tag)
# The unslothai/llama.cpp fork ships only linux-x64 bundles. An arm64 Linux
# host with a GPU (GH200/GB200/DGX Spark) routes here; it must not install an
# x64 binary, so fall back to a source build that targets the GPU rather than
# selecting the wrong arch (or silently dropping to a CPU arm64 build).
if host.is_linux and not host.is_x86_64 and repo == DEFAULT_PUBLISHED_REPO:
raise PrebuiltFallback(
f"{repo} ships only linux-x64 prebuilts; "
f"{host.machine or 'non-x64'} Linux falls back to source build"
)
allow_older_release_fallback = (
requested_tag == "latest" and not published_release_tag
)
# macOS: pin the last upstream build that loads on a pre-26 host instead of
# fetching the latest (macOS 26 only) build and walking back release by
# release. No-op on macOS 26+, unknown version, non-macOS, and the fork.
if allow_older_release_fallback:
pinned_macos = pinned_macos_release_tag(host, repo)
if pinned_macos is not None:
requested_tag = pinned_macos
allow_older_release_fallback = False
release_limit = max(1, max_release_fallbacks)
# macOS may need to walk past a run of too-new prebuilts. Only when the host
# version is known; otherwise keep the default (cannot tell up front).
if (
host.is_macos
and allow_older_release_fallback
and host.macos_version is not None
):
release_limit = max(release_limit, DEFAULT_MAX_MACOS_RELEASE_FALLBACKS)
plans: list[InstallReleasePlan] = []
last_error: PrebuiltFallback | None = None
@ -1767,8 +1905,8 @@ def linux_runtime_dirs_for_required_libraries(
def detected_linux_runtime_lines() -> tuple[list[str], dict[str, list[str]]]:
line_requirements = {
"cuda13": ["libcudart.so.13", "libcublas.so.13"],
"cuda12": ["libcudart.so.12", "libcublas.so.12"],
f"cuda{m}": [f"libcudart.so.{m}", f"libcublas.so.{m}"]
for m in range(_MAX_PROBE_CUDA_MAJOR, _MIN_CUDA_MAJOR - 1, -1)
}
detected: list[str] = []
runtime_dirs: dict[str, list[str]] = {}
@ -2951,6 +3089,46 @@ def detect_host() -> HostInfo:
)
def _normalize_forwarded_gfx(value: str | None) -> str | None:
"""Extract a single gfx token from a forwarded --rocm-gfx / env value.
setup.sh/setup.ps1 already picked the active GPU, so take the token as-is
without re-applying visible-device selection. Ignore anything malformed."""
if not value:
return None
m = re.search(r"gfx[1-9][0-9a-z]{2,3}", value.lower())
return m.group(0) if m else None
def _apply_host_overrides(
host: HostInfo,
*,
override_has_rocm: bool = False,
override_rocm_gfx: str | None = None,
force_cpu: bool = False,
) -> HostInfo:
"""Fold setup.sh/setup.ps1's forwarded detection into the host profile.
A forwarded gfx (--rocm-gfx or UNSLOTH_ROCM_GFX_ARCH) is authoritative and
implies ROCm: the installer's own hipinfo/amd-smi probe can miss the arch on
amd-smi-only hosts or when setup inferred it from the GPU name, leaving
rocm_gfx_target None and no lemonade prebuilt selected. force_cpu is the
opposite explicit signal (arm64 Linux GPU host whose source build failed):
drop GPU attributes so the CPU prebuilt for this OS/arch is selected."""
if force_cpu:
return dataclasses_replace(
host,
has_usable_nvidia = False,
has_physical_nvidia = False,
has_rocm = False,
rocm_gfx_target = None,
)
gfx = _normalize_forwarded_gfx(override_rocm_gfx)
if gfx:
return dataclasses_replace(host, has_rocm = True, rocm_gfx_target = gfx)
if override_has_rocm and not host.has_rocm:
return dataclasses_replace(host, has_rocm = True)
return host
def pick_windows_cuda_runtime(host: HostInfo) -> str | None:
if not host.driver_cuda_version:
return None
@ -2966,17 +3144,21 @@ def compatible_linux_runtime_lines(host: HostInfo) -> list[str]:
if not host.driver_cuda_version:
return []
major, _minor = host.driver_cuda_version
if major >= 13:
return ["cuda13", "cuda12"]
if major >= 12:
return ["cuda12"]
return []
if major < _MIN_CUDA_MAJOR:
return []
return _cuda_runtime_lines_for_major(major)
def windows_runtime_line_info() -> dict[str, tuple[str, ...]]:
# Generated per CUDA major (newest first) so a new toolkit is detected
# without a code change while the cudart64_<major>.dll naming holds.
return {
"cuda13": ("cudart64_13*.dll", "cublas64_13*.dll", "cublasLt64_13*.dll"),
"cuda12": ("cudart64_12*.dll", "cublas64_12*.dll", "cublasLt64_12*.dll"),
f"cuda{m}": (
f"cudart64_{m}*.dll",
f"cublas64_{m}*.dll",
f"cublasLt64_{m}*.dll",
)
for m in range(_MAX_PROBE_CUDA_MAJOR, _MIN_CUDA_MAJOR - 1, -1)
}
@ -2993,12 +3175,13 @@ def detected_windows_runtime_lines() -> tuple[list[str], dict[str, list[str]]]:
def compatible_windows_runtime_lines(host: HostInfo) -> list[str]:
driver_runtime = pick_windows_cuda_runtime(host)
if driver_runtime == "13.1":
return ["cuda13", "cuda12"]
if driver_runtime == "12.4":
return ["cuda12"]
return []
if not host.driver_cuda_version:
return []
major, minor = host.driver_cuda_version
# cuda12 prebuilts need a 12.4+ driver; cuda13+ any minor of the major.
if major < _MIN_CUDA_MAJOR or (major == _MIN_CUDA_MAJOR and minor < 4):
return []
return _cuda_runtime_lines_for_major(major)
def runtime_line_from_cuda_version(cuda_version: str | None) -> str | None:
@ -3075,7 +3258,6 @@ def windows_cuda_attempts(
selection_preamble: Iterable[str] = (),
) -> list[AssetChoice]:
selection_log = list(selection_preamble)
runtime_by_line = {"cuda12": "12.4", "cuda13": "13.1"}
driver_runtime = pick_windows_cuda_runtime(host)
detected_runtime_lines, runtime_dirs = detected_windows_runtime_lines()
compatible_runtime_lines = compatible_windows_runtime_lines(host)
@ -3118,12 +3300,7 @@ def windows_cuda_attempts(
selection_log.append(
"windows_cuda_selection: detected CUDA runtime DLLs were incompatible with the reported driver"
)
fallback_runtime_lines = (
["cuda13", "cuda12"]
if driver_runtime == "13.1"
else (["cuda12"] if driver_runtime == "12.4" else [])
)
normal_runtime_lines = fallback_runtime_lines
normal_runtime_lines = compatible_runtime_lines
runtime_order: list[str] = []
if preferred_runtime_line and preferred_runtime_line in normal_runtime_lines:
@ -3147,6 +3324,13 @@ def windows_cuda_attempts(
for runtime_line in normal_runtime_lines
if runtime_line not in runtime_order
)
# Keep every driver-compatible line reachable as a fallback, so a line gated
# out by the driver version still drops to an older major (cuda13 -> cuda12).
runtime_order.extend(
runtime_line
for runtime_line in compatible_runtime_lines
if runtime_line not in runtime_order
)
selection_log.append(
"windows_cuda_selection: normal_runtime_order="
+ (",".join(normal_runtime_lines) if normal_runtime_lines else "none")
@ -3158,7 +3342,18 @@ def windows_cuda_attempts(
attempts: list[AssetChoice] = []
for runtime_line in runtime_order:
runtime = runtime_by_line[runtime_line]
major = int(runtime_line.removeprefix("cuda"))
# Track whatever minor llama.cpp actually ships for this major
# (cuda13 -> 13.1, 13.3, ...). Skip the line when the release has no
# matching asset instead of guessing a now-missing name.
runtime = _published_windows_cuda_runtime(
upstream_assets, major, host.driver_cuda_version
)
if runtime is None:
selection_log.append(
f"windows_cuda_selection: no driver-supported asset for {runtime_line}"
)
continue
selected_name = None
asset_url = None
for candidate_name in windows_cuda_upstream_asset_names(llama_tag, runtime):
@ -3213,6 +3408,110 @@ def windows_cuda_attempts(
return attempts
def _windows_cuda_attempt_covers_blackwell(attempt: AssetChoice) -> bool:
"""True if an in-release windows-cuda attempt is built with a toolkit that
covers Blackwell sm_120 (>= 12.8), read from its asset name's CUDA minor."""
if attempt.install_kind != "windows-cuda":
return False
m = re.search(r"-bin-win-cuda-(\d+)\.(\d+)-x64\.zip$", attempt.name)
return (
m is not None and (int(m.group(1)), int(m.group(2))) >= _BLACKWELL_MIN_TOOLKIT
)
def _pinned_windows_cuda_fallback(
host: HostInfo, existing_cuda_attempts: list[AssetChoice]
) -> AssetChoice | None:
"""Pinned GPU fallback for a Blackwell host the in-release build gates off.
Upstream stopped publishing a sub-13.3 Windows cuda13 build after b9360, and
cuda-12.4 cannot offload sm_120, so a 13.1/13.2 driver would land on CPU.
b9360's cuda-13.1 build is immutable and runs on those drivers. Returns None
(dormant) whenever the in-release selection already offers a Blackwell-capable
build (toolkit >= 12.8, e.g. a runnable cuda13/cuda14), so it self-disables
once upstream ships a driver-runnable build again.
The b9360 binary reuses the current release's source tree and convert scripts
and is recorded via binary_release_tag, the same binary/source split used for
the lemonade prebuilt."""
if not (host.is_windows and host.is_x86_64 and host.has_usable_nvidia):
return None
driver = host.driver_cuda_version
if driver is None or driver < _PINNED_BLACKWELL_DRIVER_FLOOR:
return None
caps = normalize_compute_caps(host.compute_caps)
if not caps or int(caps[-1]) < _BLACKWELL_MIN_SM:
return None
if any(
_windows_cuda_attempt_covers_blackwell(attempt)
for attempt in existing_cuda_attempts
):
return None
tag = _PINNED_BLACKWELL_FALLBACK_TAG
runtime = _PINNED_BLACKWELL_FALLBACK_RUNTIME
base = (
f"https://github.com/{UPSTREAM_REPO}/releases/download/"
f"{urllib.parse.quote(tag, safe = '')}"
)
name = f"llama-{tag}-bin-win-cuda-{runtime}-x64.zip"
cudart_name = f"cudart-llama-bin-win-cuda-{runtime}-x64.zip"
return AssetChoice(
repo = UPSTREAM_REPO,
tag = tag,
name = name,
url = f"{base}/{name}",
source_label = "upstream",
install_kind = "windows-cuda",
runtime_line = "cuda13",
runtime_name = cudart_name,
runtime_url = f"{base}/{cudart_name}",
expected_sha256 = _PINNED_BLACKWELL_LLAMA_SHA256,
runtime_sha256 = _PINNED_BLACKWELL_CUDART_SHA256,
selection_log = [
f"windows_cuda_selection: pinned {tag} cuda-{runtime} Blackwell GPU "
f"fallback (in-release cuda13 gated off by driver "
f"{driver[0]}.{driver[1]})"
],
)
def _augment_checksums_with_pin(
checksums: ApprovedReleaseChecksums, pin: AssetChoice
) -> ApprovedReleaseChecksums:
"""Add the pin's own verified hashes to a copy of the approved checksums so
apply_approved_hashes keeps it on the published path (b9360 is not in the
release manifest)."""
artifacts = dict(checksums.artifacts)
if pin.expected_sha256:
artifacts[pin.name] = ApprovedArtifactHash(
asset_name = pin.name,
sha256 = pin.expected_sha256,
repo = pin.repo,
kind = "prebuilt",
)
if pin.runtime_name and pin.runtime_sha256:
artifacts[pin.runtime_name] = ApprovedArtifactHash(
asset_name = pin.runtime_name,
sha256 = pin.runtime_sha256,
repo = pin.repo,
kind = "prebuilt",
)
return dataclasses_replace(checksums, artifacts = artifacts)
def _with_pinned_windows_cuda_fallback(
host: HostInfo,
attempts: list[AssetChoice],
checksums: ApprovedReleaseChecksums,
) -> tuple[list[AssetChoice], ApprovedReleaseChecksums]:
"""Insert the Blackwell pin ahead of the Windows CUDA attempts and keep it
through apply_approved_hashes, or return the inputs unchanged when dormant.
Gives the published install path the same GPU fallback as the simple path."""
pin = _pinned_windows_cuda_fallback(host, attempts)
if pin is None:
return attempts, checksums
return [pin, *attempts], _augment_checksums_with_pin(checksums, pin)
def published_windows_cuda_attempts(
host: HostInfo,
release: PublishedReleaseBundle,
@ -3220,13 +3519,26 @@ def published_windows_cuda_attempts(
selection_preamble: Iterable[str] = (),
) -> list[AssetChoice]:
selection_log = list(release.selection_log) + list(selection_preamble)
runtime_by_line = {"cuda12": "12.4", "cuda13": "13.1"}
# Seed the runtime-line ordering from the real published windows-cuda minors
# (their names encode the minor), so a future CUDA major published here is
# ordered too instead of a hardcoded cuda12/cuda13 pair. Keys mirror the
# upstream naming so windows_cuda_attempts can match them; fall back to the
# long-standing default when the release lists no windows-cuda asset.
published_minors: list[str] = []
for artifact in release.artifacts:
if artifact.install_kind != "windows-cuda":
continue
m = re.search(r"-bin-win-cuda-(\d+\.\d+)-x64\.zip$", artifact.asset_name)
if m:
published_minors.append(m.group(1))
if not published_minors:
published_minors = ["12.4", "13.1"]
runtime_order = windows_cuda_attempts(
host,
release.upstream_tag,
{
f"llama-{release.upstream_tag}-bin-win-cuda-{runtime}-x64.zip": "published"
for runtime in runtime_by_line.values()
f"llama-{release.upstream_tag}-bin-win-cuda-{minor}-x64.zip": "published"
for minor in published_minors
},
preferred_runtime_line,
selection_log,
@ -3255,11 +3567,20 @@ def published_windows_cuda_attempts(
asset_url = release.assets.get(artifact.asset_name)
if not asset_url:
continue
# See windows_cuda_attempts: pair the cudart bundle.
am = re.search(r"-bin-win-cuda-(\d+)\.(\d+)-x64\.zip$", artifact.asset_name)
# Gate the real published minor against the driver, so a published
# windows-cuda artifact can never bypass the driver-version gate.
if (
am is not None
and host.driver_cuda_version is not None
and (int(am.group(1)), int(am.group(2))) > host.driver_cuda_version
):
continue
# See windows_cuda_attempts: pair the cudart bundle for the real minor.
runtime_archive_name: str | None = None
runtime_archive_url: str | None = None
if artifact.asset_name.startswith("llama-"):
runtime = runtime_by_line[runtime_line]
if am is not None and artifact.asset_name.startswith("llama-"):
runtime = f"{am.group(1)}.{am.group(2)}"
cudart_name = f"cudart-llama-bin-win-cuda-{runtime}-x64.zip"
cudart_url = release.assets.get(cudart_name)
if cudart_url and cudart_url != asset_url:
@ -3812,18 +4133,23 @@ def resolve_release_asset_choice(
torch_preference.selection_log,
)
if published_attempts:
pin_attempts, pin_checksums = _with_pinned_windows_cuda_fallback(
host, published_attempts, checksums
)
try:
return apply_approved_hashes(published_attempts, checksums)
return apply_approved_hashes(pin_attempts, pin_checksums)
except PrebuiltFallback as exc:
log(
"published Windows CUDA assets ignored for install planning: "
f"{release.repo}@{release.release_tag} ({exc})"
)
upstream_assets = github_release_assets(UPSTREAM_REPO, llama_tag)
return apply_approved_hashes(
upstream_attempts, upstream_checksums = _with_pinned_windows_cuda_fallback(
host,
resolve_windows_cuda_choices(host, llama_tag, upstream_assets),
checksums,
)
return apply_approved_hashes(upstream_attempts, upstream_checksums)
published_choice: AssetChoice | None = None
if host.is_windows and host.is_x86_64:
@ -5065,9 +5391,10 @@ def preflight_macos_installed_binaries(
install_dir: Path,
host: HostInfo,
) -> None:
"""Reject a macos prebuilt whose minimum-OS is newer than the host so the
release walk-back advances to the newest compatible release. No-op when the
host macOS version is unknown (runtime validation remains the backstop)."""
"""Reject a macos prebuilt whose minimum-OS is newer than the host. The
upstream selector pins a loadable release up front, so here this is the
post-download backstop; the published/fork path also uses it to advance the
walk-back. No-op when the host macOS version is unknown (runtime validates)."""
if not host.is_macos or host.macos_version is None:
return
issues = macos_binary_minos_issues(binaries, install_dir, host)
@ -6177,10 +6504,16 @@ def install_prebuilt(
*,
simple_policy: bool = False,
override_has_rocm: bool = False,
override_rocm_gfx: str | None = None,
force_cpu: bool = False,
) -> None:
host = detect_host()
if override_has_rocm and not host.has_rocm:
host = dataclasses_replace(host, has_rocm = True)
host = _apply_host_overrides(
host,
override_has_rocm = override_has_rocm,
override_rocm_gfx = override_rocm_gfx,
force_cpu = force_cpu,
)
choice: AssetChoice | None = None
try:
with install_lock(install_lock_path(install_dir)):
@ -6325,6 +6658,26 @@ def parse_args() -> argparse.Namespace:
"so the HIP llama.cpp prebuilt is selected even when hipinfo is not on PATH."
),
)
parser.add_argument(
"--rocm-gfx",
default = os.environ.get("UNSLOTH_ROCM_GFX_ARCH"),
help = (
"Forward the AMD gfx target (e.g. gfx1151) that setup.ps1/setup.sh "
"resolved, so the lemonade HIP prebuilt is selected even when the "
"installer's own hipinfo/amd-smi probe cannot report it. Implies "
"--has-rocm. Defaults to the UNSLOTH_ROCM_GFX_ARCH environment variable."
),
)
parser.add_argument(
"--cpu-fallback",
action = "store_true",
default = False,
help = (
"Select the CPU prebuilt for this OS/arch even when a GPU is present. "
"setup.sh uses this as a last resort for arm64 Linux GPU hosts whose "
"source build failed (no arm64 CUDA prebuilt exists anywhere)."
),
)
resolve_group = parser.add_mutually_exclusive_group()
resolve_group.add_argument(
"--resolve-llama-tag",
@ -6446,6 +6799,8 @@ def main() -> int:
published_release_tag = args.published_release_tag or "",
simple_policy = args.simple_policy,
override_has_rocm = args.has_rocm,
override_rocm_gfx = args.rocm_gfx,
force_cpu = args.cpu_fallback,
)
return EXIT_SUCCESS

View file

@ -50,6 +50,12 @@ if ($script:UnslothVerbose) {
$env:UNSLOTH_VERBOSE = '1'
}
$script:LlamaCppDegraded = $false
# CUDA toolkit state, published by Resolve-CudaToolkit. Only the Phase 4 source
# build consumes these; the prebuilt path leaves them at these defaults.
$script:CudaToolkitReady = $false
$script:NvccPath = $null
$script:CudaToolkitRoot = $null
$script:CudaArch = $null
# Detect if running from pip install (no frontend/ dir in studio)
$FrontendDir = Join-Path $ScriptDir "frontend"
@ -1050,7 +1056,12 @@ if ($vsResult) {
# ============================================
# 1e. CUDA Toolkit (nvcc for llama.cpp build + env vars)
# ============================================
if ($HasNvidiaSmi) {
# Defined here but invoked lazily right before a Phase 4 source build; the
# prebuilt llama.cpp path needs no local toolkit. With -RequireOrExit a source
# build is committed, so hard-fail if no driver-compatible toolkit can be found
# or installed. Without it, detection is best-effort and only sets the flag.
function Resolve-CudaToolkit {
param([switch]$RequireOrExit)
# IMPORTANT: The CUDA Toolkit version must be <= the max CUDA version the
# NVIDIA driver supports. nvidia-smi reports this as "CUDA Version: X.Y".
# If we install a toolkit newer than the driver supports, llama-server will
@ -1146,6 +1157,11 @@ if ($DriverMaxCuda) {
# -- If incompatible toolkit is blocking, tell user to uninstall it --
if (-not $NvccPath -and $IncompatibleToolkit) {
if (-not $RequireOrExit) {
substep "CUDA Toolkit $IncompatibleToolkit exceeds driver max $DriverMaxCuda -- skipping; prebuilt llama.cpp needs no local toolkit" "Yellow"
$script:CudaToolkitReady = $false
return
}
Write-Host "" -ForegroundColor Red
Write-Host "========================================================================" -ForegroundColor Red
Write-Host "[ERROR] CUDA Toolkit $IncompatibleToolkit is installed but INCOMPATIBLE" -ForegroundColor Red
@ -1163,8 +1179,8 @@ if (-not $NvccPath -and $IncompatibleToolkit) {
exit 1
}
# -- No toolkit at all: install via winget --
if (-not $NvccPath) {
# -- No toolkit at all: install via winget (only when a source build needs it) --
if (-not $NvccPath -and $RequireOrExit) {
Write-Host "CUDA toolkit (nvcc) not found -- installing via winget..." -ForegroundColor Yellow
$HasWinget = $null -ne (Get-Command winget -ErrorAction SilentlyContinue)
if ($HasWinget) {
@ -1223,6 +1239,11 @@ if (-not $NvccPath) {
}
if (-not $NvccPath) {
if (-not $RequireOrExit) {
substep "no driver-compatible CUDA Toolkit found -- skipping; prebuilt llama.cpp needs no local toolkit" "Yellow"
$script:CudaToolkitReady = $false
return
}
Write-Host "[ERROR] CUDA Toolkit (nvcc) is required but could not be found or installed." -ForegroundColor Red
if ($DriverMaxCuda) {
Write-Host " Install CUDA Toolkit $DriverMaxCuda from https://developer.nvidia.com/cuda-toolkit-archive" -ForegroundColor Yellow
@ -1313,8 +1334,11 @@ substep "CudaToolkitDir = $CudaToolkitRoot\"
if (-not $CudaArch) {
substep "could not detect compute capability -- cmake will use defaults" "Yellow"
}
} else {
step "cuda" "skipped (no NVIDIA GPU detected)" "Yellow"
# Publish the resolved toolkit to script scope for the Phase 4 build.
$script:NvccPath = $NvccPath
$script:CudaToolkitRoot = $CudaToolkitRoot
$script:CudaArch = $CudaArch
$script:CudaToolkitReady = $true
}
if ($HasROCm) {
@ -2459,6 +2483,12 @@ if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") {
)
if ($HasROCm) {
$prebuiltArgs += "--has-rocm"
# Forward the resolved gfx arch so the lemonade HIP prebuilt is picked
# even when the installer's own probe cannot report it (amd-smi-only
# hosts, name-inferred arch).
if ($script:ROCmGfxArch) {
$prebuiltArgs += @("--rocm-gfx", $script:ROCmGfxArch)
}
}
if ($env:UNSLOTH_LLAMA_RELEASE_TAG) {
$prebuiltArgs += @("--published-release-tag", $env:UNSLOTH_LLAMA_RELEASE_TAG)
@ -2581,7 +2611,8 @@ if ($NeedLlamaSourceBuild) {
# We build:
# - llama-server: for GGUF model inference (with HTTPS if OpenSSL available)
# - llama-quantize: for GGUF export quantization
# Prerequisites (git, cmake, VS Build Tools, CUDA Toolkit) already installed in Phase 1.
# Prerequisites git, cmake, VS Build Tools were installed in Phase 1; the CUDA
# Toolkit is resolved lazily just below via Resolve-CudaToolkit (source build only).
$OriginalLlamaCppDir = $LlamaCppDir
$BuildDir = Join-Path $LlamaCppDir "build"
$LlamaServerBin = Join-Path $BuildDir "bin\Release\llama-server.exe"
@ -2627,6 +2658,10 @@ if (-not $NeedLlamaSourceBuild) {
substep "Install CMake from https://cmake.org/download/ and re-run setup." "Yellow"
$script:LlamaCppDegraded = $true
} else {
# A source build is committed here. The CUDA toolkit is only needed now, so
# resolve (and winget-install if needed) it lazily, failing fast if no
# driver-compatible toolkit exists. The prebuilt path never reaches this.
if ($HasNvidiaSmi) { Resolve-CudaToolkit -RequireOrExit }
Write-Host ""
if ($HasNvidiaSmi) {
substep "building llama.cpp with CUDA support..."

View file

@ -857,6 +857,16 @@ else
if [ -n "${UNSLOTH_LLAMA_RELEASE_TAG:-}" ]; then
_PREBUILT_CMD+=(--published-release-tag "$UNSLOTH_LLAMA_RELEASE_TAG")
fi
# Forward the gfx arch resolved above so the lemonade HIP prebuilt is picked
# even when the installer's own probe cannot report it (amd-smi-only hosts,
# name-inferred arch). Implies --has-rocm on the installer side.
if [ -n "${_setup_gfx:-}" ]; then
_PREBUILT_CMD+=(--rocm-gfx "$_setup_gfx")
elif [ "$_setup_amd_detected" = true ]; then
# AMD was detected but gfx resolution failed; tell the installer ROCm is
# present so it can still attempt a prebuilt. Mirrors setup.ps1 behaviour.
_PREBUILT_CMD+=(--has-rocm)
fi
_PREBUILT_LOG="$(mktemp)"
set +e
if _is_verbose; then
@ -1363,6 +1373,32 @@ else
}
fi # end _SKIP_GGUF_BUILD check
# ── arm64 Linux GPU: CPU prebuilt as a last resort ──
# arm64 Linux with a GPU has no CUDA prebuilt anywhere (the unslothai fork is
# x64 only; ggml-org ships no Linux CUDA build), so it source-builds for the
# GPU above. If that produced no binary, install ggml-org's arm64 CPU prebuilt
# instead of leaving the host without llama.cpp.
if [ "$_LLAMA_CPP_DEGRADED" = true ] \
&& [ "$_HOST_SYSTEM" = "Linux" ] \
&& { [ "$_HOST_MACHINE" = "aarch64" ] || [ "$_HOST_MACHINE" = "arm64" ]; }; then
substep "GPU source build unavailable; trying ggml-org arm64 CPU prebuilt..."
_ARM64_CPU_CMD=(
python "$SCRIPT_DIR/install_llama_prebuilt.py"
--install-dir "$LLAMA_CPP_DIR"
--llama-tag "$_REQUESTED_LLAMA_TAG"
--published-repo "ggml-org/llama.cpp"
--simple-policy
--cpu-fallback
)
# Trust the installer's exit code: it validates the server before exiting 0,
# the same signal the primary prebuilt path above relies on.
if run_quiet_no_exit "arm64 CPU prebuilt" "${_ARM64_CPU_CMD[@]}"; then
step "llama.cpp" "arm64 CPU prebuilt installed (GPU build unavailable)" "$C_WARN"
_LLAMA_CPP_DEGRADED=false
print_installed_llama_prebuilt_release "$LLAMA_CPP_DIR"
fi
fi
# ── Footer ──
if [ "$_LLAMA_ONLY" = "1" ]; then
echo ""

View file

@ -563,6 +563,80 @@ else
FAIL=$((FAIL + 1))
fi
echo ""
echo "=== Apple Silicon x86_64 (Rosetta) venv rebuild ==="
# Extract the real guard block from install.sh so we exercise the shipped logic
# (comment header down to its column-0 closing fi).
_GUARD_FILE=$(mktemp)
awk '/Guard against two independent Apple Silicon venv problems/{f=1} f{print} f&&/^fi$/{exit}' \
"$INSTALL_SH" > "$_GUARD_FILE"
if [ ! -s "$_GUARD_FILE" ]; then
echo " FAIL: could not extract Apple Silicon venv guard from install.sh"
FAIL=$((FAIL + 1))
else
# Runner: stub uv (via run_install_cmd) + a fake venv python, source the
# guard, then print "<final_arch> <final_ver> | <recreate_selectors>".
# The stub maps a uv arm64 selector to the interpreter uv would produce:
# cpython-3.12-* -> arm64 3.12.7, cpython-3.13-* -> arm64 $REBUILD_313_VERSION.
_RUNNER=$(mktemp)
cat > "$_RUNNER" << 'RUNNER_EOF'
GUARD="$1"; VENV_DIR="$2"
make_python() { # dir machine version
mkdir -p "$1/bin"
printf '#!/usr/bin/env bash\necho "%s %s"\n' "$2" "$3" > "$1/bin/python"
chmod +x "$1/bin/python"
}
RECREATE_LOG=$(mktemp); : > "$RECREATE_LOG"
run_install_cmd() {
shift # drop the human label
if [ "$1" = "uv" ] && [ "$2" = "venv" ]; then
dir="$3"; sel=""; shift 3
while [ $# -gt 0 ]; do [ "$1" = "--python" ] && { sel="$2"; shift; }; shift; done
echo "$sel" >> "$RECREATE_LOG"
case "$sel" in
*3.12-macos-aarch64*) make_python "$dir" arm64 "3.12.7" ;;
*3.13-macos-aarch64*) make_python "$dir" arm64 "${REBUILD_313_VERSION:-3.13.3}" ;;
*) make_python "$dir" arm64 "$sel" ;;
esac
fi
}
[ "$INIT_ARCH" != none ] && make_python "$VENV_DIR" "$INIT_ARCH" "$INIT_VER"
PYTHON_VERSION="3.13"
. "$GUARD" >&2 # guard's user-facing echoes go to stderr; keep stdout clean
final="none"; [ -x "$VENV_DIR/bin/python" ] && final="$("$VENV_DIR/bin/python" -c x)"
printf '%s | %s\n' "$final" "$(paste -sd, "$RECREATE_LOG" 2>/dev/null)"
rm -f "$RECREATE_LOG"
RUNNER_EOF
_run_guard() { # _USER_PYTHON OS _ARCH INIT_ARCH INIT_VER REBUILD_313_VERSION
_vd=$(mktemp -d)
env _USER_PYTHON="$1" OS="$2" _ARCH="$3" INIT_ARCH="$4" INIT_VER="$5" \
REBUILD_313_VERSION="$6" bash "$_RUNNER" "$_GUARD_FILE" "$_vd/venv"
rm -rf "$_vd"
}
assert_eq "clean arm64 venv left untouched" \
"arm64 3.13.3 | " "$(_run_guard '' macos arm64 arm64 3.13.3 '')"
assert_eq "x86_64 venv rebuilt as arm64" \
"arm64 3.13.3 | cpython-3.13-macos-aarch64-none" \
"$(_run_guard '' macos arm64 x86_64 3.13.3 '')"
assert_eq "x86_64 venv that lands on 3.13.8 is rebuilt then downgraded to 3.12" \
"arm64 3.12.7 | cpython-3.13-macos-aarch64-none,cpython-3.12-macos-aarch64-none" \
"$(_run_guard '' macos arm64 x86_64 3.13.3 3.13.8)"
assert_eq "arm64 3.13.8 venv downgraded to 3.12" \
"arm64 3.12.7 | cpython-3.12-macos-aarch64-none" \
"$(_run_guard '' macos arm64 arm64 3.13.8 '')"
assert_eq "--python override skips the guard entirely" \
"x86_64 3.13.3 | " "$(_run_guard 3.11 macos arm64 x86_64 3.13.3 '')"
assert_eq "x86_64 host (Intel/Rosetta shell) is a no-op here" \
"x86_64 3.13.3 | " "$(_run_guard '' macos x86_64 x86_64 3.13.3 '')"
rm -f "$_RUNNER"
fi
rm -f "$_GUARD_FILE"
echo ""
echo "Results: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] || exit 1

View file

@ -438,6 +438,80 @@ def test_simple_linux_direct_release_uses_published_source_checksums_for_branch(
assert exact_source is True
def test_simple_linux_direct_release_honors_torch_cudart_preference(
monkeypatch: pytest.MonkeyPatch,
):
# Regression: a Blackwell host (sm_120, driver 13.0) with BOTH cudart majors
# visible -- a stray cuda13 wheel plus torch's cuda12 -- must install the
# cuda12 build that matches the runtime torch, not the newest-major cuda13
# build (which loads no GPU and silently falls back to CPU).
release = {
"tag_name": "b9334",
"assets": [
{
"name": f"app-b9334-linux-x64-{profile}.tar.gz",
"browser_download_url": f"https://example.test/app-b9334-linux-x64-{profile}.tar.gz",
}
for profile in (
"cuda12-newer",
"cuda12-portable",
"cuda13-newer",
"cuda13-portable",
)
],
}
# cuda13 detected first (newest-major order); both compatible with driver 13.0.
monkeypatch.setattr(
INSTALL_LLAMA_PREBUILT,
"detected_linux_runtime_lines",
lambda: (
["cuda13", "cuda12"],
{
"cuda13": ["/usr/local/lib/python3.13/site-packages/nvidia/cu13/lib"],
"cuda12": [
"/venv/lib/python3.13/site-packages/nvidia/cuda_runtime/lib"
],
},
),
)
host = HostInfo(
system = "Linux",
machine = "x86_64",
is_windows = False,
is_linux = True,
is_macos = False,
is_x86_64 = True,
is_arm64 = False,
nvidia_smi = "nvidia-smi",
driver_cuda_version = (13, 0),
compute_caps = ["120"],
visible_cuda_devices = None,
has_physical_nvidia = True,
has_usable_nvidia = True,
)
def first_asset_for_torch(line):
monkeypatch.setattr(
INSTALL_LLAMA_PREBUILT,
"detect_torch_cuda_runtime_preference",
lambda h: INSTALL_LLAMA_PREBUILT.CudaRuntimePreference(
runtime_line = line, selection_log = []
),
)
plan = INSTALL_LLAMA_PREBUILT.direct_linux_release_plan(
release, host, "unslothai/llama.cpp", "latest"
)
return plan.attempts[0]
# torch reports cuda12 (the cu128 runtime) -> install the cuda12 build.
primary = first_asset_for_torch("cuda12")
assert primary.name == "app-b9334-linux-x64-cuda12-newer.tar.gz"
assert primary.runtime_line == "cuda12"
# torch unavailable -> unchanged newest-major fallback (documents the residual).
assert first_asset_for_torch(None).name == "app-b9334-linux-x64-cuda13-newer.tar.gz"
@pytest.mark.parametrize(
"mutate, expected_match",
[

View file

@ -238,32 +238,46 @@ def _fake_macos_releases(tags):
]
class TestMacosReleaseWalkback:
"""A known-version macOS host must generate enough older-release plans to
walk back past a run of too-new prebuilts; unknown-version and non-macOS
hosts keep the conservative 2-release default."""
class TestMacosReleasePin:
"""A known pre-26 macOS host deterministically pins the last upstream release
whose prebuilt loads on it (b9415) instead of walking back release by release;
macOS 26+ and unknown-version hosts keep normal latest selection with the
conservative 2-release default."""
TAGS = [f"b{n}" for n in range(9437, 9400, -1)] # 37 newest-first releases
TAGS = [f"b{n}" for n in range(9442, 9400, -1)] # newest-first, includes b9415
def _patch_releases(self, monkeypatch):
monkeypatch.setattr(
ILP,
"iter_release_payloads_by_time",
lambda repo, published_release_tag, requested_tag: _fake_macos_releases(
self.TAGS
),
)
def fake_iter(repo, published_release_tag, requested_tag):
# The real iterator yields only the requested tag when one is pinned.
if requested_tag and requested_tag != "latest":
return _fake_macos_releases([requested_tag])
return _fake_macos_releases(self.TAGS)
def test_known_macos_host_walks_back_deeper(self, monkeypatch):
monkeypatch.setattr(ILP, "iter_release_payloads_by_time", fake_iter)
def test_pre26_host_pins_b9415(self, monkeypatch):
self._patch_releases(monkeypatch)
_tag, plans = ILP.resolve_simple_install_release_plans(
tag, plans = ILP.resolve_simple_install_release_plans(
"latest",
make_macos_host((14, 0)),
"ggml-org/llama.cpp",
"",
)
assert len(plans) == ILP.DEFAULT_MAX_MACOS_RELEASE_FALLBACKS
assert len(plans) > ILP.DEFAULT_MAX_PREBUILT_RELEASE_FALLBACKS
assert tag == ILP._PINNED_MACOS_FALLBACK_TAG == "b9415"
assert len(plans) == 1
assert plans[0].release_tag == "b9415"
def test_tahoe_host_takes_latest(self, monkeypatch):
self._patch_releases(monkeypatch)
tag, plans = ILP.resolve_simple_install_release_plans(
"latest",
make_macos_host((26, 0)),
"ggml-org/llama.cpp",
"",
)
assert tag == "latest"
assert plans[0].release_tag == self.TAGS[0] # newest release
assert len(plans) == ILP.DEFAULT_MAX_PREBUILT_RELEASE_FALLBACKS
def test_unknown_macos_host_uses_default(self, monkeypatch):
self._patch_releases(monkeypatch)

View file

@ -35,6 +35,8 @@ AssetChoice = prebuilt_mod.AssetChoice
PrebuiltFallback = prebuilt_mod.PrebuiltFallback
resolve_upstream_asset_choice = prebuilt_mod.resolve_upstream_asset_choice
runtime_patterns_for_choice = prebuilt_mod.runtime_patterns_for_choice
_apply_host_overrides = prebuilt_mod._apply_host_overrides
_normalize_forwarded_gfx = prebuilt_mod._normalize_forwarded_gfx
# install_python_stack.py
_STACK_PATH = PACKAGE_ROOT / "studio" / "install_python_stack.py"
@ -2598,5 +2600,100 @@ class TestHipSdkInstalledButDeviceInaccessible:
assert "GPU not ROCm-accessible" in source
# =============================================================================
# TEST: --rocm-gfx forwarding -- setup.sh/setup.ps1 hand their resolved gfx arch
# to install_llama_prebuilt.py so the lemonade HIP prebuilt is selected even when
# the installer's own hipinfo/amd-smi probe cannot report it.
# =============================================================================
_SETUP_SH_PATH = PACKAGE_ROOT / "studio" / "setup.sh"
class TestNormalizeForwardedGfx:
"""A forwarded gfx string is reduced to a single clean gfx token."""
def test_plain_token(self):
assert _normalize_forwarded_gfx("gfx1151") == "gfx1151"
def test_uppercase_normalized(self):
assert _normalize_forwarded_gfx("GFX1151") == "gfx1151"
def test_extracts_from_noise(self):
assert _normalize_forwarded_gfx("gcnArchName: gfx942") == "gfx942"
def test_malformed_is_ignored(self):
assert _normalize_forwarded_gfx("not-a-gpu") is None
def test_empty_and_none(self):
assert _normalize_forwarded_gfx("") is None
assert _normalize_forwarded_gfx(None) is None
class TestApplyHostOverrides:
"""Forwarded ROCm detection is folded into the host profile correctly."""
def test_forwarded_gfx_fills_empty_probe(self):
# amd-smi-only / name-inferred host: installer probe found no gfx.
host = rocm_host(rocm_gfx_target = None)
out = _apply_host_overrides(host, override_rocm_gfx = "gfx1151")
assert out.has_rocm is True
assert out.rocm_gfx_target == "gfx1151"
def test_forwarded_gfx_implies_rocm(self):
# A CPU-looking host with a forwarded gfx is an AMD host.
out = _apply_host_overrides(cpu_host(), override_rocm_gfx = "gfx1200")
assert out.has_rocm is True
assert out.rocm_gfx_target == "gfx1200"
def test_forwarded_gfx_is_authoritative(self):
# setup already applied visible-device selection; its value wins.
host = rocm_host(rocm_gfx_target = "gfx1100")
out = _apply_host_overrides(host, override_rocm_gfx = "gfx1151")
assert out.rocm_gfx_target == "gfx1151"
def test_has_rocm_only_keeps_probe_gfx(self):
out = _apply_host_overrides(cpu_host(), override_has_rocm = True)
assert out.has_rocm is True
assert out.rocm_gfx_target is None
def test_malformed_forwarded_gfx_falls_back_to_has_rocm(self):
out = _apply_host_overrides(
cpu_host(), override_has_rocm = True, override_rocm_gfx = "junk"
)
assert out.has_rocm is True
assert out.rocm_gfx_target is None
def test_no_overrides_leaves_host_unchanged(self):
host = nvidia_host()
assert _apply_host_overrides(host) is host
class TestRocmGfxForwarding:
"""setup.sh / setup.ps1 forward their resolved gfx; the installer accepts it."""
def test_installer_exposes_rocm_gfx_arg(self):
source = _PREBUILT_PATH.read_text(encoding = "utf-8")
assert '"--rocm-gfx"' in source
# Defaults to the env override so a standalone run still works.
assert 'os.environ.get("UNSLOTH_ROCM_GFX_ARCH")' in source
def test_setup_sh_forwards_rocm_gfx(self):
source = _SETUP_SH_PATH.read_text(encoding = "utf-8")
assert "--rocm-gfx" in source
assert '"$_setup_gfx"' in source
def test_setup_sh_forwards_has_rocm(self):
# When AMD is detected but gfx resolution fails, setup.sh must still
# forward --has-rocm so the installer knows ROCm is present.
source = _SETUP_SH_PATH.read_text(encoding = "utf-8")
assert "--has-rocm" in source
assert "_setup_amd_detected" in source
def test_setup_ps1_forwards_rocm_gfx(self):
source = _SETUP_PS1_PATH.read_text(encoding = "utf-8")
assert "--rocm-gfx" in source
assert "$script:ROCmGfxArch" in source
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View file

@ -52,6 +52,9 @@ compatible_windows_runtime_lines = (
runtime_line_from_cuda_version = INSTALL_LLAMA_PREBUILT.runtime_line_from_cuda_version
apply_approved_hashes = INSTALL_LLAMA_PREBUILT.apply_approved_hashes
linux_cuda_choice_from_release = INSTALL_LLAMA_PREBUILT.linux_cuda_choice_from_release
parse_direct_linux_release_bundle = (
INSTALL_LLAMA_PREBUILT.parse_direct_linux_release_bundle
)
windows_cuda_attempts = INSTALL_LLAMA_PREBUILT.windows_cuda_attempts
resolve_upstream_asset_choice = INSTALL_LLAMA_PREBUILT.resolve_upstream_asset_choice
resolve_requested_install_tag = INSTALL_LLAMA_PREBUILT.resolve_requested_install_tag
@ -74,6 +77,18 @@ windows_cuda_upstream_asset_names = (
INSTALL_LLAMA_PREBUILT.windows_cuda_upstream_asset_names
)
env_int = INSTALL_LLAMA_PREBUILT.env_int
direct_upstream_release_plan = INSTALL_LLAMA_PREBUILT.direct_upstream_release_plan
_pinned_windows_cuda_fallback = INSTALL_LLAMA_PREBUILT._pinned_windows_cuda_fallback
CudaRuntimePreference = INSTALL_LLAMA_PREBUILT.CudaRuntimePreference
published_windows_cuda_attempts = INSTALL_LLAMA_PREBUILT.published_windows_cuda_attempts
_windows_cuda_attempt_covers_blackwell = (
INSTALL_LLAMA_PREBUILT._windows_cuda_attempt_covers_blackwell
)
resolve_release_asset_choice = INSTALL_LLAMA_PREBUILT.resolve_release_asset_choice
pinned_macos_release_tag = INSTALL_LLAMA_PREBUILT.pinned_macos_release_tag
resolve_simple_install_release_plans = (
INSTALL_LLAMA_PREBUILT.resolve_simple_install_release_plans
)
# ---------------------------------------------------------------------------
@ -397,6 +412,44 @@ class TestCompatibleLinuxRuntimeLines:
host = make_host(driver_cuda_version = (13, 0))
assert compatible_linux_runtime_lines(host) == ["cuda13", "cuda12"]
def test_future_major_derives_lines(self):
# A future major (14.x) offers cuda14 first, then older majors.
host = make_host(driver_cuda_version = (14, 0))
assert compatible_linux_runtime_lines(host) == ["cuda14", "cuda13", "cuda12"]
class TestParseDirectLinuxReleaseBundle:
def _release(self, *targets):
names = [f"app-bTEST-linux-x64-{t}.tar.gz" for t in targets]
return {
"tag_name": "bTEST",
"assets": [
{"name": n, "browser_download_url": "https://x/" + n} for n in names
],
}
def _cuda_artifact(self, bundle):
return [a for a in bundle.artifacts if a.install_kind == "linux-cuda"][0]
def test_parses_known_cuda13_bundle(self):
bundle = parse_direct_linux_release_bundle(
"unslothai/llama.cpp", self._release("cuda13-newer")
)
assert bundle is not None
assert self._cuda_artifact(bundle).runtime_line == "cuda13"
def test_parses_future_cuda_major_with_forward_profile(self):
# A future major name parses and inherits the newest known major's
# coverage for the same class as a forward default.
bundle = parse_direct_linux_release_bundle(
"unslothai/llama.cpp", self._release("cuda14-newer")
)
assert bundle is not None
art = self._cuda_artifact(bundle)
assert art.runtime_line == "cuda14"
assert art.coverage_class == "newer"
assert art.max_sm == 120 # inherited from cuda13-newer
# ===========================================================================
# G. pick_windows_cuda_runtime + compatible_windows_runtime_lines
@ -442,6 +495,10 @@ class TestCompatibleWindowsRuntimeLines:
host = make_host(driver_cuda_version = (13, 0))
assert compatible_windows_runtime_lines(host) == ["cuda13", "cuda12"]
def test_future_major_derives_lines(self):
host = make_host(driver_cuda_version = (14, 0))
assert compatible_windows_runtime_lines(host) == ["cuda14", "cuda13", "cuda12"]
# ===========================================================================
# H. runtime_line_from_cuda_version
@ -1195,6 +1252,79 @@ class TestLinuxCudaChoiceFromRelease:
assert result is None
def make_profile_artifact(asset_name, profile_name, **overrides):
profile = INSTALL_LLAMA_PREBUILT.DIRECT_LINUX_BUNDLE_PROFILES[profile_name]
defaults = dict(
runtime_line = profile["runtime_line"],
coverage_class = profile["coverage_class"],
supported_sms = [str(value) for value in profile["supported_sms"]],
min_sm = int(profile["min_sm"]),
max_sm = int(profile["max_sm"]),
bundle_profile = profile_name,
rank = int(profile["rank"]),
)
defaults.update(overrides)
return make_artifact(asset_name, **defaults)
class TestBlackwellUltraSm103Coverage:
"""sm_103 (B300 / GB300) runs on the bundled base compute_100 PTX via JIT."""
def test_profiles_list_sm103_wherever_sm100_is_shipped(self):
for (
name,
profile,
) in INSTALL_LLAMA_PREBUILT.DIRECT_LINUX_BUNDLE_PROFILES.items():
sms = {str(value) for value in profile["supported_sms"]}
if "100" in sms:
assert "103" in sms, name
else:
assert "103" not in sms, name
def test_b300_selects_cuda13_newer_prebuilt(self, monkeypatch):
mock_linux_runtime(monkeypatch, ["cuda13"])
host = make_host(compute_caps = ["103"], driver_cuda_version = (13, 0))
art = make_profile_artifact("cuda13-newer.tar.gz", "cuda13-newer")
release = make_release([art])
result = linux_cuda_choice_from_release(host, release)
assert result is not None
assert result.primary.name == "cuda13-newer.tar.gz"
def test_b300_selects_cuda12_newer_prebuilt(self, monkeypatch):
mock_linux_runtime(monkeypatch, ["cuda12"])
host = make_host(compute_caps = ["103"], driver_cuda_version = (12, 8))
art = make_profile_artifact("cuda12-newer.tar.gz", "cuda12-newer")
release = make_release([art])
result = linux_cuda_choice_from_release(host, release)
assert result is not None
assert result.primary.name == "cuda12-newer.tar.gz"
def test_b300_reported_as_decimal_normalizes_and_matches(self, monkeypatch):
mock_linux_runtime(monkeypatch, ["cuda13"])
host = make_host(compute_caps = ["10.3"], driver_cuda_version = (13, 0))
art = make_profile_artifact("cuda13-portable.tar.gz", "cuda13-portable")
release = make_release([art])
result = linux_cuda_choice_from_release(host, release)
assert result is not None
def test_b300_falls_back_to_portable_when_only_portable_present(self, monkeypatch):
mock_linux_runtime(monkeypatch, ["cuda13"])
host = make_host(compute_caps = ["103"], driver_cuda_version = (13, 0))
art = make_profile_artifact("cuda13-portable.tar.gz", "cuda13-portable")
release = make_release([art])
result = linux_cuda_choice_from_release(host, release)
assert result is not None
assert result.primary.name == "cuda13-portable.tar.gz"
def test_older_bundle_still_rejects_b300(self, monkeypatch):
mock_linux_runtime(monkeypatch, ["cuda13"])
host = make_host(compute_caps = ["103"], driver_cuda_version = (13, 0))
art = make_profile_artifact("cuda13-older.tar.gz", "cuda13-older")
release = make_release([art])
result = linux_cuda_choice_from_release(host, release)
assert result is None
# ===========================================================================
# L. resolve_install_attempts
# ===========================================================================
@ -1777,12 +1907,23 @@ class TestWindowsCudaAttempts:
assert result[0].runtime_line == "cuda13"
assert result[1].runtime_line == "cuda12"
def test_driver_13_0_cuda13_dlls_selects_cuda13_asset(self, monkeypatch):
def test_driver_below_published_minor_is_gated_to_cuda12(self, monkeypatch):
# A 13.0 driver cannot run a 13.1 build (forward minor), so it is gated
# out of cuda13 and falls back to the cuda12 build it can run, even when
# only the cuda13 runtime libs are detected.
mock_windows_runtime(monkeypatch, ["cuda13"])
host = make_host(system = "Windows", machine = "AMD64", driver_cuda_version = (13, 0))
assets = self._upstream("13.1", "12.4")
result = windows_cuda_attempts(host, self.TAG, assets, None)
assert len(result) == 1
assert result[0].runtime_line == "cuda12"
assert result[0].name == f"llama-{self.TAG}-bin-win-cuda-12.4-x64.zip"
def test_driver_at_published_minor_selects_cuda13(self, monkeypatch):
# A 13.1 driver matches the published 13.1 build exactly.
mock_windows_runtime(monkeypatch, ["cuda13", "cuda12"])
host = make_host(system = "Windows", machine = "AMD64", driver_cuda_version = (13, 1))
assets = self._upstream("13.1", "12.4")
result = windows_cuda_attempts(host, self.TAG, assets, None)
assert result[0].runtime_line == "cuda13"
assert result[0].name == f"llama-{self.TAG}-bin-win-cuda-13.1-x64.zip"
@ -1885,6 +2026,468 @@ class TestWindowsCudaAttempts:
assert attempt.runtime_url is None
assert attempt.runtime_name is None
def test_tracks_upstream_cuda13_minor_bump(self, monkeypatch):
# ggml-org bumped the published Windows cuda13 build 13.1 -> 13.3; the
# selector must follow it instead of the old hardcoded 13.1 (#5861).
mock_windows_runtime(monkeypatch, ["cuda13", "cuda12"])
host = make_host(system = "Windows", machine = "AMD64", driver_cuda_version = (13, 3))
assets = self._upstream("13.3", "12.4")
result = windows_cuda_attempts(host, self.TAG, assets, None)
assert result[0].runtime_line == "cuda13"
assert result[0].name == f"llama-{self.TAG}-bin-win-cuda-13.3-x64.zip"
def test_cuda13_minor_bump_pairs_matching_cudart(self, monkeypatch):
# The paired cudart bundle must track the same bumped minor.
mock_windows_runtime(monkeypatch, ["cuda13", "cuda12"])
host = make_host(system = "Windows", machine = "AMD64", driver_cuda_version = (13, 3))
assets = {
f"llama-{self.TAG}-bin-win-cuda-13.3-x64.zip": "https://example.com/llama-13.3",
"cudart-llama-bin-win-cuda-13.3-x64.zip": "https://example.com/cudart-13.3",
f"llama-{self.TAG}-bin-win-cuda-12.4-x64.zip": "https://example.com/llama-12.4",
"cudart-llama-bin-win-cuda-12.4-x64.zip": "https://example.com/cudart-12.4",
}
result = windows_cuda_attempts(host, self.TAG, assets, None)
assert result[0].name == f"llama-{self.TAG}-bin-win-cuda-13.3-x64.zip"
assert result[0].runtime_name == "cudart-llama-bin-win-cuda-13.3-x64.zip"
def test_driver_below_published_minor_does_not_get_newer_build(self, monkeypatch):
# ggml-org ships only cuda-13.3; a 13.1 driver cannot run it (forward
# minor), so it is gated to the cuda-12.4 build instead of an
# unguaranteed 13.3. A 13.3 driver still gets 13.3 (see other tests).
mock_windows_runtime(monkeypatch, ["cuda13", "cuda12"])
host = make_host(system = "Windows", machine = "AMD64", driver_cuda_version = (13, 1))
assets = self._upstream("13.3", "12.4")
result = windows_cuda_attempts(host, self.TAG, assets, None)
assert result[0].runtime_line == "cuda12"
assert result[0].name == f"llama-{self.TAG}-bin-win-cuda-12.4-x64.zip"
def test_tracks_future_cuda13_minor(self, monkeypatch):
# A later within-major bump (13.4) is tracked the same as 13.3.
mock_windows_runtime(monkeypatch, ["cuda13", "cuda12"])
host = make_host(system = "Windows", machine = "AMD64", driver_cuda_version = (13, 4))
assets = self._upstream("13.4", "12.4")
result = windows_cuda_attempts(host, self.TAG, assets, None)
assert result[0].name == f"llama-{self.TAG}-bin-win-cuda-13.4-x64.zip"
def test_new_cuda_major_selected_when_published(self, monkeypatch):
# A new CUDA major (14.x) driver picks the published cuda14 build.
mock_windows_runtime(monkeypatch, ["cuda14", "cuda13", "cuda12"])
host = make_host(system = "Windows", machine = "AMD64", driver_cuda_version = (14, 0))
assets = self._upstream("14.0", "13.3", "12.4")
result = windows_cuda_attempts(host, self.TAG, assets, None)
assert result[0].runtime_line == "cuda14"
assert result[0].name == f"llama-{self.TAG}-bin-win-cuda-14.0-x64.zip"
def test_new_cuda_major_degrades_to_published_cuda13(self, monkeypatch):
# A 14.x driver with no cuda14 build runs the newest published cuda13
# build via backward compatibility.
mock_windows_runtime(monkeypatch, ["cuda13", "cuda12"])
host = make_host(system = "Windows", machine = "AMD64", driver_cuda_version = (14, 0))
assets = self._upstream("13.3", "12.4")
result = windows_cuda_attempts(host, self.TAG, assets, None)
assert result[0].name == f"llama-{self.TAG}-bin-win-cuda-13.3-x64.zip"
# ===========================================================================
# N.1b. _pinned_windows_cuda_fallback -- pinned b9360 cuda-13.1 Blackwell fallback
# ===========================================================================
class TestPinnedBlackwellCudaFallback:
"""A Blackwell host on a 13.0/13.1/13.2 driver, gated off the in-release 13.3
build, gets the pinned immutable b9360 cuda-13.1 GPU build instead of the
CPU-only cuda-12.4 drop. The pin is dormant for everyone else."""
TAG = "b8508"
def _win_host(self, driver, caps):
return make_host(
system = "Windows",
machine = "AMD64",
driver_cuda_version = driver,
compute_caps = caps,
)
def test_pin_offered_for_driver_13_1_blackwell(self):
pin = _pinned_windows_cuda_fallback(self._win_host((13, 1), ["120"]), [])
assert pin is not None
assert pin.tag == "b9360"
assert pin.runtime_line == "cuda13"
assert pin.name == "llama-b9360-bin-win-cuda-13.1-x64.zip"
assert pin.runtime_name == "cudart-llama-bin-win-cuda-13.1-x64.zip"
assert pin.url.endswith("/b9360/llama-b9360-bin-win-cuda-13.1-x64.zip")
assert pin.runtime_url.endswith("/b9360/cudart-llama-bin-win-cuda-13.1-x64.zip")
assert pin.install_kind == "windows-cuda"
assert pin.expected_sha256 and len(pin.expected_sha256) == 64
assert pin.runtime_sha256 and len(pin.runtime_sha256) == 64
def test_pin_offered_for_driver_13_2(self):
assert (
_pinned_windows_cuda_fallback(self._win_host((13, 2), ["120"]), [])
is not None
)
def test_pin_offered_for_sm121_variant(self):
# sm_121 is Blackwell-family and also needs toolkit >= 12.8.
assert (
_pinned_windows_cuda_fallback(self._win_host((13, 1), ["121"]), [])
is not None
)
def test_pin_uses_max_of_multi_gpu_caps(self):
assert (
_pinned_windows_cuda_fallback(self._win_host((13, 1), ["86", "120"]), [])
is not None
)
@pytest.mark.parametrize("sm", ["89", "90", "100"])
def test_pin_not_offered_to_non_blackwell(self, sm):
# Ada/Hopper run the cuda-12.4 build fine; the pin must not fire.
assert _pinned_windows_cuda_fallback(self._win_host((13, 1), [sm]), []) is None
def test_pin_offered_for_driver_13_0(self):
# b9360 is native sm_120a SASS (no JIT) and ships a cuda-13.1 cudart,
# both of which run on a 13.0 r580+ driver via CUDA minor-version
# compatibility. 13.0 is the mainstream Blackwell branch, so it must fire.
assert (
_pinned_windows_cuda_fallback(self._win_host((13, 0), ["120"]), [])
is not None
)
def test_pin_not_offered_below_floor(self):
# 12.x predates Blackwell entirely; the pin stays dormant below 13.0.
assert (
_pinned_windows_cuda_fallback(self._win_host((12, 9), ["120"]), []) is None
)
def test_pin_not_offered_without_driver(self):
assert _pinned_windows_cuda_fallback(self._win_host(None, ["120"]), []) is None
def test_pin_not_offered_on_linux(self):
host = make_host(
system = "Linux",
machine = "x86_64",
driver_cuda_version = (13, 1),
compute_caps = ["120"],
)
assert _pinned_windows_cuda_fallback(host, []) is None
def test_pin_dormant_when_cuda13_attempt_present(self, monkeypatch):
# A runnable in-release cuda13 build makes the pin unnecessary.
mock_windows_runtime(monkeypatch, ["cuda13", "cuda12"])
host = self._win_host((13, 1), ["120"])
assets = {
f"llama-{self.TAG}-bin-win-cuda-13.1-x64.zip": "https://example.com/13.1",
f"llama-{self.TAG}-bin-win-cuda-12.4-x64.zip": "https://example.com/12.4",
}
existing = windows_cuda_attempts(host, self.TAG, assets, None)
assert any(a.runtime_line == "cuda13" for a in existing)
assert _pinned_windows_cuda_fallback(host, existing) is None
def _win_cuda_attempt(self, minor):
major = minor.split(".")[0]
return AssetChoice(
repo = UPSTREAM_REPO,
tag = self.TAG,
name = f"llama-{self.TAG}-bin-win-cuda-{minor}-x64.zip",
url = "https://example.com/x",
source_label = "upstream",
install_kind = "windows-cuda",
runtime_line = f"cuda{major}",
)
def test_pin_dormant_when_runnable_cuda14_present(self, monkeypatch):
# A future Blackwell host with an in-release cuda14 build (no cuda13)
# must not get the older b9360 13.1 pin ahead of the runnable cuda14.
mock_windows_runtime(monkeypatch, ["cuda14", "cuda12"])
host = self._win_host((14, 0), ["120"])
assets = {
f"llama-{self.TAG}-bin-win-cuda-14.0-x64.zip": "https://example.com/14.0",
f"llama-{self.TAG}-bin-win-cuda-12.4-x64.zip": "https://example.com/12.4",
}
existing = windows_cuda_attempts(host, self.TAG, assets, None)
assert any(a.runtime_line == "cuda14" for a in existing)
assert _pinned_windows_cuda_fallback(host, existing) is None
def test_pin_dormant_when_runnable_cuda12_8_present(self):
# A cuda-12.8 build also covers Blackwell, so the pin defers to it.
host = self._win_host((13, 1), ["120"])
existing = [self._win_cuda_attempt("12.8")]
assert _pinned_windows_cuda_fallback(host, existing) is None
def test_pin_fires_when_only_cuda12_4_present(self):
# cuda-12.4 does not cover Blackwell, so the pin still fires.
host = self._win_host((13, 1), ["120"])
existing = [self._win_cuda_attempt("12.4")]
assert _pinned_windows_cuda_fallback(host, existing) is not None
@pytest.mark.parametrize(
"minor, covers",
[
("12.4", False),
("12.8", True),
("13.1", True),
("13.3", True),
("14.0", True),
],
)
def test_attempt_covers_blackwell(self, minor, covers):
assert (
_windows_cuda_attempt_covers_blackwell(self._win_cuda_attempt(minor))
is covers
)
def test_attempt_covers_blackwell_ignores_non_cuda_kind(self):
cpu = AssetChoice(
repo = UPSTREAM_REPO,
tag = self.TAG,
name = f"llama-{self.TAG}-bin-win-cpu-x64.zip",
url = "https://example.com/x",
source_label = "upstream",
install_kind = "windows-cpu",
)
assert _windows_cuda_attempt_covers_blackwell(cpu) is False
# ===========================================================================
# N.1c. direct_upstream_release_plan -- pinned Blackwell fallback ordering
# ===========================================================================
class TestDirectUpstreamBlackwellPin:
"""End to end: the pin lands ahead of cuda-12.4 on the simple/upstream path
a Blackwell Windows host actually uses, and stays absent once a runnable
in-release cuda13 build exists."""
TAG = "b9365"
def _release(self):
names = [
f"llama-{self.TAG}-bin-win-cuda-13.3-x64.zip",
"cudart-llama-bin-win-cuda-13.3-x64.zip",
f"llama-{self.TAG}-bin-win-cuda-12.4-x64.zip",
"cudart-llama-bin-win-cuda-12.4-x64.zip",
f"llama-{self.TAG}-bin-win-cpu-x64.zip",
]
return {
"tag_name": self.TAG,
"assets": [
{"name": n, "browser_download_url": f"https://example.com/{n}"}
for n in names
],
}
def _no_torch(self, monkeypatch):
monkeypatch.setattr(
INSTALL_LLAMA_PREBUILT,
"detect_torch_cuda_runtime_preference",
lambda host: CudaRuntimePreference(runtime_line = None, selection_log = []),
)
def test_blackwell_13_1_prepends_pin(self, monkeypatch):
mock_windows_runtime(monkeypatch, ["cuda13", "cuda12"])
self._no_torch(monkeypatch)
host = make_host(
system = "Windows",
machine = "AMD64",
driver_cuda_version = (13, 1),
compute_caps = ["120"],
)
plan = direct_upstream_release_plan(
self._release(), host, UPSTREAM_REPO, "latest"
)
order = [(a.tag, a.runtime_line or a.install_kind) for a in plan.attempts]
assert order == [
("b9360", "cuda13"),
(self.TAG, "cuda12"),
(self.TAG, "windows-cpu"),
]
assert plan.attempts[0].name == "llama-b9360-bin-win-cuda-13.1-x64.zip"
# Direct/upstream path stays unverified-by-manifest (no approved hashes).
assert plan.approved_checksums.artifacts == {}
def test_blackwell_13_3_no_pin(self, monkeypatch):
mock_windows_runtime(monkeypatch, ["cuda13", "cuda12"])
self._no_torch(monkeypatch)
host = make_host(
system = "Windows",
machine = "AMD64",
driver_cuda_version = (13, 3),
compute_caps = ["120"],
)
plan = direct_upstream_release_plan(
self._release(), host, UPSTREAM_REPO, "latest"
)
assert "b9360" not in [a.tag for a in plan.attempts]
assert plan.attempts[0].tag == self.TAG
assert plan.attempts[0].runtime_line == "cuda13"
assert plan.attempts[0].name == f"llama-{self.TAG}-bin-win-cuda-13.3-x64.zip"
# ===========================================================================
# N.1d. published_windows_cuda_attempts -- version-dynamic ordering seed
# ===========================================================================
class TestPublishedWindowsCudaAttemptsDynamicMajor:
"""The published-path ordering seed is derived from the release's real
published minors, so a future CUDA major published here is selectable
instead of being hidden by a hardcoded cuda12/cuda13 seed."""
TAG = "b8508"
def _win_cuda_artifact(self, minor, runtime_line):
return make_artifact(
f"llama-{self.TAG}-bin-win-cuda-{minor}-x64.zip",
install_kind = "windows-cuda",
runtime_line = runtime_line,
max_sm = 120,
)
def _release(self, minors_lines):
artifacts = [self._win_cuda_artifact(m, line) for m, line in minors_lines]
return make_release(artifacts, upstream_tag = self.TAG)
def test_future_cuda14_published_is_selected(self, monkeypatch):
# With the dynamic seed a 14.x driver reaches a published cuda14 build;
# the old hardcoded cuda12/cuda13 seed would never order it (the cuda14
# line would be skipped for want of a 14.x asset in the seed).
mock_windows_runtime(monkeypatch, ["cuda14", "cuda13", "cuda12"])
release = self._release(
[("14.0", "cuda14"), ("13.3", "cuda13"), ("12.4", "cuda12")]
)
host = make_host(
system = "Windows",
machine = "AMD64",
driver_cuda_version = (14, 0),
compute_caps = ["120"],
)
result = published_windows_cuda_attempts(host, release, None)
assert result[0].runtime_line == "cuda14"
assert result[0].name == f"llama-{self.TAG}-bin-win-cuda-14.0-x64.zip"
def test_cuda13_minor_selected_for_13_3_driver(self, monkeypatch):
# Existing behavior unchanged: a 13.3 driver gets the real 13.3 build.
mock_windows_runtime(monkeypatch, ["cuda13", "cuda12"])
release = self._release([("13.3", "cuda13"), ("12.4", "cuda12")])
host = make_host(
system = "Windows",
machine = "AMD64",
driver_cuda_version = (13, 3),
compute_caps = ["120"],
)
result = published_windows_cuda_attempts(host, release, None)
assert result[0].runtime_line == "cuda13"
assert result[0].name == f"llama-{self.TAG}-bin-win-cuda-13.3-x64.zip"
def test_below_minor_driver_gated_to_cuda12(self, monkeypatch):
# A 13.1 driver is gated off a published 13.3 and falls to cuda12.
mock_windows_runtime(monkeypatch, ["cuda13", "cuda12"])
release = self._release([("13.3", "cuda13"), ("12.4", "cuda12")])
host = make_host(
system = "Windows",
machine = "AMD64",
driver_cuda_version = (13, 1),
compute_caps = ["120"],
)
result = published_windows_cuda_attempts(host, release, None)
assert result[0].runtime_line == "cuda12"
# ===========================================================================
# N.1e. resolve_release_asset_choice -- pin on the published install path
# ===========================================================================
class TestResolveReleaseAssetChoicePin:
"""The published (non --simple-policy) install path reaches the same b9360
Blackwell pin as the simple path, with its verified hash threaded."""
TAG = "b8508"
def _release(self, minors_lines):
artifacts = [
make_artifact(
f"llama-{self.TAG}-bin-win-cuda-{minor}-x64.zip",
install_kind = "windows-cuda",
runtime_line = line,
max_sm = 120,
)
for minor, line in minors_lines
]
assets = {}
for minor, _line in minors_lines:
assets[f"llama-{self.TAG}-bin-win-cuda-{minor}-x64.zip"] = (
f"https://example.com/llama-{minor}"
)
assets[f"cudart-llama-bin-win-cuda-{minor}-x64.zip"] = (
f"https://example.com/cudart-{minor}"
)
return make_release(artifacts, upstream_tag = self.TAG, assets = assets)
def _checksums(self, minors):
names = []
for minor in minors:
names.append(f"llama-{self.TAG}-bin-win-cuda-{minor}-x64.zip")
names.append(f"cudart-llama-bin-win-cuda-{minor}-x64.zip")
return make_checksums(names)
def _no_torch(self, monkeypatch):
monkeypatch.setattr(
INSTALL_LLAMA_PREBUILT,
"detect_torch_cuda_runtime_preference",
lambda host: CudaRuntimePreference(runtime_line = None, selection_log = []),
)
def test_pin_applied_on_published_path_for_13_1(self, monkeypatch):
mock_windows_runtime(monkeypatch, ["cuda13", "cuda12"])
self._no_torch(monkeypatch)
release = self._release([("13.3", "cuda13"), ("12.4", "cuda12")])
checksums = self._checksums(["12.4"]) # 13.3 gated off for a 13.1 driver
host = make_host(
system = "Windows",
machine = "AMD64",
driver_cuda_version = (13, 1),
compute_caps = ["120"],
)
result = resolve_release_asset_choice(host, self.TAG, release, checksums)
assert result[0].tag == "b9360"
assert result[0].name == "llama-b9360-bin-win-cuda-13.1-x64.zip"
# apply_approved_hashes threaded the pin's verified hash from the
# augmented checksums (the pin survives the approved-hash gate).
assert result[0].expected_sha256 and len(result[0].expected_sha256) == 64
assert result[0].runtime_sha256 and len(result[0].runtime_sha256) == 64
assert any(a.runtime_line == "cuda12" for a in result)
def test_pin_dormant_on_published_path_for_13_3(self, monkeypatch):
mock_windows_runtime(monkeypatch, ["cuda13", "cuda12"])
self._no_torch(monkeypatch)
release = self._release([("13.3", "cuda13"), ("12.4", "cuda12")])
checksums = self._checksums(["13.3", "12.4"])
host = make_host(
system = "Windows",
machine = "AMD64",
driver_cuda_version = (13, 3),
compute_caps = ["120"],
)
result = resolve_release_asset_choice(host, self.TAG, release, checksums)
assert "b9360" not in [a.tag for a in result]
assert result[0].name == f"llama-{self.TAG}-bin-win-cuda-13.3-x64.zip"
def test_pin_not_applied_for_non_blackwell(self, monkeypatch):
mock_windows_runtime(monkeypatch, ["cuda13", "cuda12"])
self._no_torch(monkeypatch)
release = self._release([("13.3", "cuda13"), ("12.4", "cuda12")])
checksums = self._checksums(["12.4"])
host = make_host(
system = "Windows",
machine = "AMD64",
driver_cuda_version = (13, 1),
compute_caps = ["89"],
)
result = resolve_release_asset_choice(host, self.TAG, release, checksums)
assert "b9360" not in [a.tag for a in result]
# ===========================================================================
# N.1. apply_approved_hashes -- runtime archive checksum threading
@ -2109,3 +2712,288 @@ class TestResolveUpstreamAssetChoice:
result = resolve_upstream_asset_choice(host, self.TAG)
assert result.install_kind == "windows-cuda"
assert result.name == cuda_name
# ===========================================================================
# N.2. Deterministic macOS prebuilt pin (b9415)
# ===========================================================================
def _macos_host(machine = "arm64", version = (15, 5)):
return make_host(
system = "Darwin",
machine = machine,
nvidia_smi = None,
driver_cuda_version = None,
compute_caps = [],
has_physical_nvidia = False,
has_usable_nvidia = False,
macos_version = version,
)
class TestPinnedMacosReleaseTag:
"""pinned_macos_release_tag: pin b9415 only for ggml-org upstream macOS hosts
below macOS 26; latest (None) for 26+, unknown version, the fork, non-macOS."""
def test_arm64_sequoia_pins_b9415(self):
host = _macos_host("arm64", (15, 5))
assert pinned_macos_release_tag(host, UPSTREAM_REPO) == "b9415"
def test_arm64_sonoma_pins_b9415(self):
host = _macos_host("arm64", (14, 7))
assert pinned_macos_release_tag(host, UPSTREAM_REPO) == "b9415"
def test_x64_ventura_13_3_pins_b9415(self):
# b9415's Intel slice is minos 13.3, so 13.3 Intel hosts still load it.
host = _macos_host("x86_64", (13, 3))
assert pinned_macos_release_tag(host, UPSTREAM_REPO) == "b9415"
def test_tahoe_26_0_takes_latest(self):
host = _macos_host("arm64", (26, 0))
assert pinned_macos_release_tag(host, UPSTREAM_REPO) is None
def test_tahoe_26_1_takes_latest(self):
host = _macos_host("arm64", (26, 1))
assert pinned_macos_release_tag(host, UPSTREAM_REPO) is None
def test_unknown_version_takes_latest(self):
host = _macos_host("arm64", None)
assert pinned_macos_release_tag(host, UPSTREAM_REPO) is None
def test_fork_repo_is_dormant(self):
# The unslothai/llama.cpp fork publishes its own minos-13.3 prebuilts.
host = _macos_host("arm64", (15, 5))
fork = INSTALL_LLAMA_PREBUILT.DEFAULT_PUBLISHED_REPO
assert pinned_macos_release_tag(host, fork) is None
def test_non_macos_host_is_dormant(self):
host = make_host(system = "Linux", machine = "x86_64")
assert pinned_macos_release_tag(host, UPSTREAM_REPO) is None
class TestResolveSimpleMacosPin:
"""End to end on the simple/upstream path macOS actually uses: a pre-26 host
deterministically resolves b9415 (no walk-back); a macOS 26 host takes the
latest release. Mirrors how setup.sh routes Darwin to ggml-org/llama.cpp."""
TAGS = ["b9442", "b9430", "b9428", "b9415"] # newest-first feed
def _feed(self, monkeypatch):
calls = []
def _release(tag):
name = f"llama-{tag}-bin-macos-arm64.tar.gz"
return {
"tag_name": tag,
"assets": [
{
"name": name,
"browser_download_url": f"https://example.com/{name}",
}
],
}
def fake_iter(repo, published_release_tag = "", requested_tag = ""):
calls.append((repo, published_release_tag, requested_tag))
# Emulate the real iterator: a specific tag yields only that release.
if requested_tag and requested_tag != "latest":
yield _release(requested_tag)
return
for tag in self.TAGS:
yield _release(tag)
monkeypatch.setattr(
INSTALL_LLAMA_PREBUILT, "iter_release_payloads_by_time", fake_iter
)
return calls
def test_pre26_host_pins_b9415_without_walkback(self, monkeypatch):
calls = self._feed(monkeypatch)
host = _macos_host("arm64", (15, 5))
requested_tag, plans = resolve_simple_install_release_plans(
"latest", host, "ggml-org/llama.cpp", ""
)
assert requested_tag == "b9415"
assert len(plans) == 1
assert plans[0].release_tag == "b9415"
assert plans[0].llama_tag == "b9415"
assert plans[0].attempts[0].install_kind == "macos-arm64"
assert plans[0].attempts[0].name == "llama-b9415-bin-macos-arm64.tar.gz"
# The pin overrode the requested tag before any release was fetched.
assert calls[0][2] == "b9415"
# Simple/upstream path stays unverified-by-manifest, exactly as before.
assert plans[0].approved_checksums.artifacts == {}
def test_tahoe_host_takes_latest_release(self, monkeypatch):
calls = self._feed(monkeypatch)
host = _macos_host("arm64", (26, 0))
requested_tag, plans = resolve_simple_install_release_plans(
"latest", host, "ggml-org/llama.cpp", ""
)
assert requested_tag == "latest"
assert plans[0].release_tag == "b9442"
# No pin: the iterator was asked for latest, not a specific tag.
assert calls[0][2] == "latest"
# ===========================================================================
# Linux arm64 + GPU must not install the x64-only fork bundle
# ===========================================================================
class TestLinuxArm64ForkFallsBackToSource:
"""The unslothai/llama.cpp fork ships only linux-x64 bundles. An arm64
Linux host with a GPU (GH200/GB200/DGX Spark) routes to the fork and must
fall back to a source build instead of selecting an x64 binary."""
def test_arm64_nvidia_fork_raises_before_fetching_releases(self, monkeypatch):
# Guard fires before any release is fetched: poison the iterator to prove
# it is never called.
def _boom(*_a, **_k):
raise AssertionError("iterator must not run for arm64 fork hosts")
monkeypatch.setattr(
INSTALL_LLAMA_PREBUILT, "iter_release_payloads_by_time", _boom
)
host = make_host(system = "Linux", machine = "aarch64")
with pytest.raises(PrebuiltFallback, match = "linux-x64 prebuilts"):
resolve_simple_install_release_plans(
"latest", host, "unslothai/llama.cpp", ""
)
def test_x86_64_fork_is_not_blocked_by_the_arch_guard(self, monkeypatch):
# x64 host must pass the guard and reach the iterator (here empty, so it
# raises the generic message, not the arch one).
monkeypatch.setattr(
INSTALL_LLAMA_PREBUILT,
"iter_release_payloads_by_time",
lambda *_a, **_k: iter(()),
)
host = make_host(system = "Linux", machine = "x86_64")
with pytest.raises(PrebuiltFallback) as exc:
resolve_simple_install_release_plans(
"latest", host, "unslothai/llama.cpp", ""
)
assert "linux-x64 prebuilts" not in str(exc.value)
def test_arm64_cpu_on_ggml_org_is_not_blocked(self, monkeypatch):
# CPU-only arm64 routes to ggml-org (not the fork), so the guard must not
# fire; it reaches the iterator (empty here -> generic message).
monkeypatch.setattr(
INSTALL_LLAMA_PREBUILT,
"iter_release_payloads_by_time",
lambda *_a, **_k: iter(()),
)
host = make_host(
system = "Linux",
machine = "aarch64",
nvidia_smi = None,
driver_cuda_version = None,
compute_caps = [],
has_physical_nvidia = False,
has_usable_nvidia = False,
)
with pytest.raises(PrebuiltFallback) as exc:
resolve_simple_install_release_plans(
"latest", host, "ggml-org/llama.cpp", ""
)
assert "linux-x64 prebuilts" not in str(exc.value)
# ===========================================================================
# arm64 Linux GPU: CPU prebuilt fallback after a failed source build (--cpu-fallback)
# ===========================================================================
class TestCpuFallback:
"""--cpu-fallback drops GPU attributes so the CPU prebuilt for the host's
OS/arch is selected, letting an arm64 GPU host install ggml-org's arm64 CPU
build as a last resort when its source build produced no binary."""
_SETUP_SH = PACKAGE_ROOT / "studio" / "setup.sh"
def _arm64_nvidia(self):
return make_host(
system = "Linux",
machine = "aarch64",
driver_cuda_version = (13, 0),
compute_caps = ["90"],
has_physical_nvidia = True,
has_usable_nvidia = True,
)
def test_force_cpu_drops_gpu_attrs_before_planning(self, monkeypatch, tmp_path):
captured = {}
def _capture(llama_tag, host, *a, **k):
captured["host"] = host
raise PrebuiltFallback("stop after capture")
monkeypatch.setattr(INSTALL_LLAMA_PREBUILT, "detect_host", self._arm64_nvidia)
monkeypatch.setattr(
INSTALL_LLAMA_PREBUILT,
"resolve_simple_install_release_plans",
_capture,
)
# install_prebuilt exits EXIT_FALLBACK on PrebuiltFallback; we only care
# about the host it handed to the resolver before that.
with pytest.raises(SystemExit):
INSTALL_LLAMA_PREBUILT.install_prebuilt(
install_dir = tmp_path / "llama",
llama_tag = "latest",
published_repo = "ggml-org/llama.cpp",
published_release_tag = "",
simple_policy = True,
force_cpu = True,
)
host = captured["host"]
assert host.has_usable_nvidia is False
assert host.has_physical_nvidia is False
assert host.has_rocm is False
# Arch is preserved so the arm64 CPU bundle (not x64) is chosen.
assert host.is_arm64 is True
def test_cpu_forced_arm64_selects_ubuntu_arm64(self):
tag = "b9444"
release = {
"tag_name": tag,
"assets": [
{
"name": f"llama-{tag}-bin-ubuntu-arm64.tar.gz",
"browser_download_url": f"https://x/llama-{tag}-bin-ubuntu-arm64.tar.gz",
},
{
"name": f"llama-{tag}-bin-ubuntu-x64.tar.gz",
"browser_download_url": f"https://x/llama-{tag}-bin-ubuntu-x64.tar.gz",
},
],
}
# A GPU arm64 host cannot pick the CPU arm64 bundle on its own.
with pytest.raises(PrebuiltFallback):
direct_upstream_release_plan(
release, self._arm64_nvidia(), "ggml-org/llama.cpp", "latest"
)
# force_cpu drops the GPU attributes, so the CPU arm64 bundle is selected.
cpu_host = make_host(
system = "Linux",
machine = "aarch64",
nvidia_smi = None,
driver_cuda_version = None,
compute_caps = [],
has_physical_nvidia = False,
has_usable_nvidia = False,
)
plan = direct_upstream_release_plan(
release, cpu_host, "ggml-org/llama.cpp", "latest"
)
assert plan.attempts[0].install_kind == "linux-arm64"
assert plan.attempts[0].name == f"llama-{tag}-bin-ubuntu-arm64.tar.gz"
def test_setup_sh_has_arm64_cpu_prebuilt_fallback(self):
source = self._SETUP_SH.read_text(encoding = "utf-8")
assert "--cpu-fallback" in source
# Fallback targets ggml-org (the only repo with an arm64 Linux build) and
# is gated on a degraded source build for arm64.
assert "ggml-org/llama.cpp" in source
assert "_LLAMA_CPP_DEGRADED" in source

View file

@ -0,0 +1,121 @@
#!/usr/bin/env pwsh
# Unit test for Resolve-CudaToolkit in studio/setup.ps1. No GPU required: the
# detection helpers (nvidia-smi, nvcc, Find-Nvcc, ...) are stubbed so the real
# function logic runs against a spoofed Blackwell sm_120 / driver 13.2 host.
#
# The function is extracted via AST and run in a child pwsh per scenario, because
# the -RequireOrExit path calls `exit` (which would otherwise kill this harness).
#
# Run: pwsh -NoProfile -File tests/studio/test_resolve_cuda_toolkit.ps1
$ErrorActionPreference = "Stop"
$setupPath = [System.IO.Path]::Combine($PSScriptRoot, "..", "..", "studio", "setup.ps1")
$setupPath = (Resolve-Path $setupPath).Path
# --- Extract the function source (not the whole installer) ---
$tokens = $null; $errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($setupPath, [ref]$tokens, [ref]$errors)
if ($errors) { $errors | ForEach-Object { $_.ToString() }; throw "setup.ps1 has parse errors" }
$fn = $ast.FindAll({ param($n)
$n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq "Resolve-CudaToolkit"
}, $true)
if ($fn.Count -ne 1) { throw "expected exactly one Resolve-CudaToolkit, found $($fn.Count)" }
$fnText = $fn[0].Extent.Text
# --- Spoof executables: nvidia-smi reports driver max CUDA 13.2; nvcc 13.3 ---
$work = Join-Path ([System.IO.Path]::GetTempPath()) ("rct_" + [guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Force -Path $work | Out-Null
$smiFake = Join-Path $work "nvidia-smi.ps1"
$nvccFake = Join-Path $work "nvcc.ps1"
Set-Content -LiteralPath $smiFake -Value "'CUDA Version: 13.2'"
Set-Content -LiteralPath $nvccFake -Value "'Cuda compilation tools, release 13.3, V13.3.0'"
$failures = 0
function Check($name, $cond) {
if ($cond) { Write-Host " PASS $name" }
else { Write-Host " FAIL $name" -ForegroundColor Red; $script:failures++ }
}
# Build + run one scenario in a child pwsh; returns @{ Exit; Out }.
function Run-Case {
param([string]$FindMode, [bool]$Require)
$requireLit = if ($Require) { '$true' } else { '$false' }
$child = @"
`$ErrorActionPreference = 'Continue'
[Environment]::SetEnvironmentVariable('CUDA_PATH', `$null, 'Process')
`$FindNvccMode = '$FindMode'
`$NvccFake = '$nvccFake'
function substep { param(`$m, `$c) Write-Host " `$m" }
function step { param(`$l, `$v, `$c) Write-Host "[`$l] `$v" }
function Add-ToUserPath { param(`$Directory, `$Position) `$true }
function Refresh-Environment { }
function Get-CudaComputeCapability { '120' }
function Test-NvccArchSupport { param(`$NvccExe, `$Arch) `$true }
function Get-NvccMaxArch { param(`$NvccExe) '120' }
`$script:WingetCalled = `$false
function winget { `$script:WingetCalled = `$true; 'no matching versions' }
function Find-Nvcc {
param([string]`$MaxVersion = '')
switch (`$FindNvccMode) {
'compatible' { return `$NvccFake }
'incompatible' { if (`$MaxVersion) { return `$null } else { return `$NvccFake } }
default { return `$null }
}
}
`$NvidiaSmiExe = '$smiFake'
`$VsInstallPath = `$null
`$HasNvidiaSmi = `$true
`$script:CudaToolkitReady = `$false
`$script:NvccPath = `$null; `$script:CudaToolkitRoot = `$null; `$script:CudaArch = `$null
$fnText
if ($requireLit) { Resolve-CudaToolkit -RequireOrExit } else { Resolve-CudaToolkit }
Write-Host ("RESULT ready={0} nvcc={1} winget={2}" -f `$script:CudaToolkitReady, `$script:NvccPath, `$script:WingetCalled)
"@
$childFile = Join-Path $work ("case_" + [guid]::NewGuid().ToString("N") + ".ps1")
Set-Content -LiteralPath $childFile -Value $child
$out = & pwsh -NoProfile -File $childFile 2>&1 | Out-String
return @{ Exit = $LASTEXITCODE; Out = $out }
}
try {
Write-Host "Scenario 1: prebuilt path, too-new toolkit (no -RequireOrExit) -> defers, no exit"
$r = Run-Case -FindMode "incompatible" -Require $false
Check "exits 0 (not blocked)" ($r.Exit -eq 0)
Check "CudaToolkitReady = false" ($r.Out -match "ready=False")
Check "winget NOT called" ($r.Out -match "winget=False")
Check "no INCOMPATIBLE error text" (-not ($r.Out -match "INCOMPATIBLE"))
Write-Host "Scenario 2: forced source build, too-new toolkit (-RequireOrExit) -> hard exit"
$r = Run-Case -FindMode "incompatible" -Require $true
Check "exits non-zero" ($r.Exit -ne 0)
Check "preserved INCOMPATIBLE error" ($r.Out -match "is installed but INCOMPATIBLE")
Write-Host "Scenario 3: compatible toolkit (-RequireOrExit) -> resolves, env set"
$r = Run-Case -FindMode "compatible" -Require $true
Check "exits 0" ($r.Exit -eq 0)
Check "CudaToolkitReady = true" ($r.Out -match "ready=True")
Check "NvccPath published" ($r.Out -match "nvcc=.*nvcc")
Write-Host "Scenario 4: no toolkit, prebuilt path (no -RequireOrExit) -> defers, no winget"
$r = Run-Case -FindMode "none" -Require $false
Check "exits 0" ($r.Exit -eq 0)
Check "CudaToolkitReady = false" ($r.Out -match "ready=False")
Check "winget NOT called" ($r.Out -match "winget=False")
Write-Host "Scenario 5: no toolkit, forced (-RequireOrExit) -> winget attempted then exit"
# The function exits before the RESULT line here, so assert on the winget-block
# marker in output rather than the flag.
$r = Run-Case -FindMode "none" -Require $true
Check "winget attempted" ($r.Out -match "installing via winget")
Check "exits non-zero" ($r.Exit -ne 0)
Check "preserved nvcc-required error" ($r.Out -match "CUDA Toolkit \(nvcc\) is required")
}
finally {
Remove-Item -Recurse -Force -LiteralPath $work -ErrorAction SilentlyContinue
}
Write-Host ""
if ($failures -gt 0) { Write-Host "$failures check(s) FAILED" -ForegroundColor Red; exit 1 }
Write-Host "All checks passed" -ForegroundColor Green

View file

@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "2026.5.8"
__version__ = "2026.5.10"
__all__ = [
"SUPPORTS_BFLOAT16",
@ -1216,8 +1216,6 @@ if is_openai_available():
# =============================================
# Get Flash Attention v2 if Ampere (RTX 30xx, A100)
import bitsandbytes as bnb
from transformers import AutoTokenizer
from transformers.utils.import_utils import _is_package_available