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:
commit
66a9ee258d
94 changed files with 9424 additions and 1277 deletions
126
.github/workflows/security-audit.yml
vendored
126
.github/workflows/security-audit.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
6
.github/workflows/studio-inference-smoke.yml
vendored
6
.github/workflows/studio-inference-smoke.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -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:
|
||||
|
|
|
|||
10
install.ps1
10
install.ps1
|
|
@ -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)
|
||||
|
|
|
|||
54
install.sh
54
install.sh
|
|
@ -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..."
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
|
|
|||
|
|
@ -218,6 +218,8 @@ class AnthropicStreamEmitter:
|
|||
def __init__(self) -> None:
|
||||
self.block_index: int = 0
|
||||
self._text_block_open: bool = False
|
||||
self._open_tool_call_id: Optional[str] = None
|
||||
self._open_tool_args_sent: bool = False
|
||||
self._prev_text: str = ""
|
||||
self._usage: dict = {}
|
||||
|
||||
|
|
@ -263,8 +265,10 @@ class AnthropicStreamEmitter:
|
|||
def finish(self, stop_reason: str = "end_turn") -> list[str]:
|
||||
"""Close any open block and emit message_delta + message_stop."""
|
||||
events = []
|
||||
if self._text_block_open:
|
||||
if self._text_block_open or self._open_tool_call_id is not None:
|
||||
events.append(self._close_block())
|
||||
self._open_tool_call_id = None
|
||||
self._open_tool_args_sent = False
|
||||
events.append(
|
||||
build_anthropic_sse_event(
|
||||
"message_delta",
|
||||
|
|
@ -310,12 +314,26 @@ class AnthropicStreamEmitter:
|
|||
return events
|
||||
|
||||
def _handle_tool_start(self, event: dict) -> list[str]:
|
||||
tool_call_id = event.get("tool_call_id", "")
|
||||
args = event.get("arguments", {})
|
||||
if tool_call_id and self._open_tool_call_id == tool_call_id:
|
||||
return self._tool_arguments_delta(args)
|
||||
|
||||
events = []
|
||||
# Close current text block if open
|
||||
# Close current text block if open.
|
||||
if self._text_block_open:
|
||||
events.append(self._close_block())
|
||||
# Open a tool_use block
|
||||
# Defensive: if a replacement/different tool_start arrives while a
|
||||
# tool_use block is open, close the stale block before starting another.
|
||||
elif self._open_tool_call_id is not None:
|
||||
events.append(self._close_block())
|
||||
self._open_tool_call_id = None
|
||||
self._open_tool_args_sent = False
|
||||
|
||||
# Open a tool_use block.
|
||||
self.block_index += 1
|
||||
self._open_tool_call_id = tool_call_id
|
||||
self._open_tool_args_sent = False
|
||||
events.append(
|
||||
build_anthropic_sse_event(
|
||||
"content_block_start",
|
||||
|
|
@ -324,35 +342,43 @@ class AnthropicStreamEmitter:
|
|||
"index": self.block_index,
|
||||
"content_block": {
|
||||
"type": "tool_use",
|
||||
"id": event.get("tool_call_id", ""),
|
||||
"id": tool_call_id,
|
||||
"name": event.get("tool_name", ""),
|
||||
"input": {},
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
# Emit the arguments as input_json_delta
|
||||
args = event.get("arguments", {})
|
||||
if args:
|
||||
events.append(
|
||||
build_anthropic_sse_event(
|
||||
"content_block_delta",
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": self.block_index,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": json.dumps(args),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
events.extend(self._tool_arguments_delta(args))
|
||||
return events
|
||||
|
||||
def _tool_arguments_delta(self, args: dict) -> list[str]:
|
||||
if not args:
|
||||
return []
|
||||
if self._open_tool_args_sent:
|
||||
return []
|
||||
self._open_tool_args_sent = True
|
||||
return [
|
||||
build_anthropic_sse_event(
|
||||
"content_block_delta",
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": self.block_index,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": json.dumps(args),
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
def _handle_tool_end(self, event: dict) -> list[str]:
|
||||
events = []
|
||||
# Close the tool_use block
|
||||
events.append(self._close_block())
|
||||
# Close the tool_use block.
|
||||
if self._open_tool_call_id is not None or self._text_block_open:
|
||||
events.append(self._close_block())
|
||||
self._open_tool_call_id = None
|
||||
self._open_tool_args_sent = False
|
||||
# Emit custom tool_result event (non-standard, ignored by SDKs)
|
||||
events.append(
|
||||
build_anthropic_sse_event(
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ from utils.subprocess_compat import (
|
|||
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
|
||||
)
|
||||
from core.inference.tool_call_parser import (
|
||||
RENDER_HTML_REPEAT_NUDGE,
|
||||
parse_tool_calls_from_text as _shared_parse_tool_calls_from_text,
|
||||
)
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
# =====================================================================
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
236
studio/backend/tests/test_mcp_stdio_improvements.py
Normal file
236
studio/backend/tests/test_mcp_stdio_improvements.py
Normal 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
|
||||
401
studio/backend/tests/test_mcp_stdio_pr5863.py
Normal file
401
studio/backend/tests/test_mcp_stdio_pr5863.py
Normal 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"}
|
||||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
6
studio/frontend/package-lock.json
generated
6
studio/frontend/package-lock.json
generated
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
21
studio/frontend/src/app/routes/projects.tsx
Normal file
21
studio/frontend/src/app/routes/projects.tsx
Normal 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,
|
||||
});
|
||||
|
|
@ -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">
|
||||
"{confirmingDelete.project.name}"
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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}</>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}> {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ export interface PersistedChatSettings {
|
|||
autoTitle?: boolean;
|
||||
reasoningEffort?: ReasoningEffort;
|
||||
preserveThinking?: boolean;
|
||||
collapseHtmlArtifacts?: boolean;
|
||||
allowArtifactNetworkAccess?: boolean;
|
||||
autoHealToolCalls?: boolean;
|
||||
maxToolCallsPerMessage?: number;
|
||||
toolCallTimeout?: number;
|
||||
|
|
|
|||
141
studio/frontend/src/features/chat/artifacts/artifact-card.tsx
Normal file
141
studio/frontend/src/features/chat/artifacts/artifact-card.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuiState } from "@assistant-ui/react";
|
||||
import { LayoutTwoColumnIcon as Layout2ColumnIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useLayoutEffect, useMemo } from "react";
|
||||
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
|
||||
import {
|
||||
hasAutoOpenedArtifact,
|
||||
rememberAutoOpenedArtifact,
|
||||
useChatArtifactsStore,
|
||||
} from "./store";
|
||||
import {
|
||||
type ChatArtifact,
|
||||
type ChatArtifactSource,
|
||||
createChatArtifact,
|
||||
} from "./types";
|
||||
|
||||
export function ArtifactCard({
|
||||
code,
|
||||
title,
|
||||
source,
|
||||
sourceToolCallId,
|
||||
sourceMessageId,
|
||||
className,
|
||||
autoOpen = false,
|
||||
isStreaming = false,
|
||||
}: {
|
||||
code: string;
|
||||
title?: string | null;
|
||||
source: ChatArtifactSource;
|
||||
sourceToolCallId?: string | null;
|
||||
sourceMessageId?: string | null;
|
||||
className?: string;
|
||||
autoOpen?: boolean;
|
||||
isStreaming?: boolean;
|
||||
}) {
|
||||
const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId);
|
||||
const messageIdFromContext = useAuiState(({ message }) => message.id);
|
||||
const threadIdFromContext = useAuiState(
|
||||
({ threads }) => threads.mainThreadId,
|
||||
);
|
||||
const artifactThreadId = threadIdFromContext ?? activeThreadId ?? null;
|
||||
const openArtifact = useChatArtifactsStore((state) => state.openArtifact);
|
||||
const updateArtifact = useChatArtifactsStore((state) => state.updateArtifact);
|
||||
const selectedArtifactId = useChatArtifactsStore(
|
||||
(state) => state.selectedArtifactId,
|
||||
);
|
||||
const artifact = useMemo<ChatArtifact>(
|
||||
() =>
|
||||
createChatArtifact({
|
||||
code,
|
||||
title,
|
||||
source,
|
||||
sourceMessageId: sourceMessageId ?? messageIdFromContext ?? null,
|
||||
sourceToolCallId: sourceToolCallId ?? null,
|
||||
threadId: artifactThreadId,
|
||||
isStreaming,
|
||||
}),
|
||||
[
|
||||
artifactThreadId,
|
||||
code,
|
||||
isStreaming,
|
||||
messageIdFromContext,
|
||||
source,
|
||||
sourceMessageId,
|
||||
sourceToolCallId,
|
||||
title,
|
||||
],
|
||||
);
|
||||
const surface = artifactThreadId ? "panel" : "overlay";
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (selectedArtifactId === artifact.id) {
|
||||
updateArtifact(artifact);
|
||||
}
|
||||
|
||||
if (!autoOpen) {
|
||||
return;
|
||||
}
|
||||
if (hasAutoOpenedArtifact(artifact.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
rememberAutoOpenedArtifact(artifact.id);
|
||||
openArtifact(artifact, { surface });
|
||||
}, [
|
||||
artifact,
|
||||
autoOpen,
|
||||
openArtifact,
|
||||
selectedArtifactId,
|
||||
surface,
|
||||
updateArtifact,
|
||||
]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"group/artifact-card relative my-2 flex min-h-[52px] w-full max-w-md cursor-pointer items-center overflow-hidden rounded-lg border border-border/70 bg-muted/15 px-3 py-2 text-left transition-colors hover:bg-muted/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"dark:bg-muted/10 dark:hover:bg-muted/20",
|
||||
isStreaming &&
|
||||
"border-border/80 bg-muted/20 dark:border-border/70 dark:bg-muted/15",
|
||||
className,
|
||||
)}
|
||||
onClick={() => openArtifact(artifact, { surface })}
|
||||
aria-label={`Open ${artifact.title}`}
|
||||
>
|
||||
{isStreaming ? (
|
||||
<span
|
||||
aria-hidden={true}
|
||||
className="artifact-card-shimmer pointer-events-none absolute inset-0 z-0 motion-reduce:hidden"
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative z-10 flex min-w-0 flex-1 items-center gap-2.5">
|
||||
<HugeiconsIcon
|
||||
icon={Layout2ColumnIcon}
|
||||
strokeWidth={1.75}
|
||||
className="size-5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span className="grid min-w-0 flex-1 gap-1">
|
||||
<span className="truncate text-sm font-medium leading-tight text-foreground">
|
||||
{artifact.title}
|
||||
</span>
|
||||
<span className="truncate text-[11px] leading-none text-muted-foreground">
|
||||
HTML artifact
|
||||
</span>
|
||||
</span>
|
||||
{isStreaming ? (
|
||||
<span className="shimmer shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary motion-reduce:animate-none">
|
||||
Generating
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
362
studio/frontend/src/features/chat/artifacts/artifact-surface.tsx
Normal file
362
studio/frontend/src/features/chat/artifacts/artifact-surface.tsx
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"use client";
|
||||
|
||||
import { createCodePlugin } from "@/components/assistant-ui/code-plugin";
|
||||
import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon";
|
||||
import {
|
||||
unslothDarkTheme,
|
||||
unslothLightTheme,
|
||||
} from "@/components/assistant-ui/code-themes";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
Maximize2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
import { ArtifactHtmlFrame, type ArtifactViewMode } from "./html-frame";
|
||||
import type { ChatArtifact } from "./types";
|
||||
import { getArtifactFilename } from "./types";
|
||||
|
||||
const COPY_RESET_MS = 2000;
|
||||
const artifactSourceCodePlugin = createCodePlugin({
|
||||
themes: [unslothLightTheme, unslothDarkTheme],
|
||||
});
|
||||
|
||||
function buildHtmlFence(source: string): string {
|
||||
const longestBacktickRun = Math.max(
|
||||
2,
|
||||
...(source.match(/`+/g) ?? []).map((match) => match.length),
|
||||
);
|
||||
const fence = "`".repeat(longestBacktickRun + 1);
|
||||
return `${fence}html\n${source}\n${fence}`;
|
||||
}
|
||||
// Sandboxed artifact iframes are intentionally excluded from the overlay focus
|
||||
// trap. Granting same-origin sandbox privileges would weaken isolation, so
|
||||
// keyboard users can reach Studio controls here while fully interactive artifact
|
||||
// content remains a known sandbox limitation.
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
function getFocusableElements(container: HTMLElement): HTMLElement[] {
|
||||
return Array.from(
|
||||
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
|
||||
).filter(
|
||||
(element) =>
|
||||
!element.hasAttribute("disabled") &&
|
||||
element.getAttribute("aria-hidden") !== "true" &&
|
||||
element.tabIndex !== -1,
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactLoadingLine() {
|
||||
return (
|
||||
<div className="absolute inset-x-0 bottom-0 h-[2.5px] overflow-hidden bg-border/45">
|
||||
<span
|
||||
aria-hidden={true}
|
||||
className="artifact-loading-line block h-full rounded-full motion-reduce:hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactGeneratingPanel() {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col items-center justify-center bg-muted/10 px-6 text-center">
|
||||
<div className="max-w-[30ch] space-y-1.5">
|
||||
<img
|
||||
src="/Sloth%20emojis/sloth%20w%20pc%20transparent.png"
|
||||
alt=""
|
||||
aria-hidden={true}
|
||||
className="mx-auto mb-3 size-20 object-contain"
|
||||
/>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Building artifact preview…
|
||||
</p>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
The preview will appear here when the HTML is ready.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function downloadTextFile(filename: string, text: string): void {
|
||||
const blob = new Blob([text], { type: "text/html;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
export function ArtifactSurface({
|
||||
artifact,
|
||||
variant,
|
||||
onClose,
|
||||
onOpenFullscreen,
|
||||
}: {
|
||||
artifact: ChatArtifact;
|
||||
variant: "panel" | "overlay";
|
||||
onClose: () => void;
|
||||
onOpenFullscreen?: () => void;
|
||||
}) {
|
||||
const [viewMode, setViewMode] = useState<ArtifactViewMode>("preview");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const surfaceRef = useRef<HTMLElement>(null);
|
||||
const previousFocusRef = useRef<Element | null>(null);
|
||||
const filename = getArtifactFilename(artifact);
|
||||
const sourceMarkdown = useMemo(
|
||||
() => buildHtmlFence(artifact.code),
|
||||
[artifact.code],
|
||||
);
|
||||
const hasArtifactCode = artifact.code.trim().length > 0;
|
||||
const isLoadingArtifact = Boolean(artifact.isStreaming);
|
||||
const effectiveViewMode = isLoadingArtifact ? "preview" : viewMode;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copyResetRef.current) clearTimeout(copyResetRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (variant !== "overlay") return;
|
||||
previousFocusRef.current = document.activeElement;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
const surface = surfaceRef.current;
|
||||
if (!surface) return;
|
||||
const firstFocusable = getFocusableElements(surface)[0];
|
||||
if (firstFocusable) {
|
||||
firstFocusable.focus();
|
||||
} else {
|
||||
surface.focus();
|
||||
}
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
const previousFocus = previousFocusRef.current;
|
||||
if (previousFocus instanceof HTMLElement) previousFocus.focus();
|
||||
};
|
||||
}, [variant]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!(await copyToClipboard(artifact.code))) return;
|
||||
setCopied(true);
|
||||
if (copyResetRef.current) clearTimeout(copyResetRef.current);
|
||||
copyResetRef.current = setTimeout(() => {
|
||||
setCopied(false);
|
||||
copyResetRef.current = null;
|
||||
}, COPY_RESET_MS);
|
||||
};
|
||||
|
||||
const handleDialogKeyDown = (event: KeyboardEvent<HTMLElement>) => {
|
||||
if (variant !== "overlay") return;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = getFocusableElements(event.currentTarget);
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.focus();
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const content = (
|
||||
<section
|
||||
ref={surfaceRef}
|
||||
role={variant === "overlay" ? "dialog" : undefined}
|
||||
aria-modal={variant === "overlay" ? true : undefined}
|
||||
tabIndex={variant === "overlay" ? -1 : undefined}
|
||||
onKeyDown={handleDialogKeyDown}
|
||||
className={cn(
|
||||
"relative flex min-h-0 flex-col 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;
|
||||
}
|
||||
100
studio/frontend/src/features/chat/artifacts/html-frame.tsx
Normal file
100
studio/frontend/src/features/chat/artifacts/html-frame.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"use client";
|
||||
|
||||
import { getAuthToken } from "@/features/auth";
|
||||
import { apiUrl } from "@/lib/api-base";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
|
||||
import { hashArtifactCode } from "./types";
|
||||
|
||||
const HTML_FRAME_DEFAULT_HEIGHT = 400;
|
||||
const HTML_FRAME_MAX_HEIGHT = 900;
|
||||
|
||||
export type ArtifactViewMode = "preview" | "source";
|
||||
export const ARTIFACT_VIEW_MODES: readonly ArtifactViewMode[] = [
|
||||
"preview",
|
||||
"source",
|
||||
];
|
||||
|
||||
export function isArtifactViewMode(value: string): value is ArtifactViewMode {
|
||||
return (ARTIFACT_VIEW_MODES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function buildArtifactSrcDoc(code: string): string {
|
||||
const resizeScript = `<script>(()=>{const post=()=>parent.postMessage({chatArtifactHeight:document.documentElement.scrollHeight},"*");new ResizeObserver(post).observe(document.documentElement);window.addEventListener("load",post);post();})();</script>`;
|
||||
return `${code}\n${resizeScript}`;
|
||||
}
|
||||
|
||||
// Preview iframes intentionally omit allow-downloads: generated artifacts can
|
||||
// offer their own UI, but downloads must go through Studio's explicit
|
||||
// copy/download controls outside the no-same-origin sandbox.
|
||||
export function ArtifactHtmlFrame({
|
||||
code,
|
||||
title = "HTML artifact preview",
|
||||
className,
|
||||
fill = false,
|
||||
}: {
|
||||
code: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
fill?: boolean;
|
||||
}) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const allowNetworkAccess = useChatRuntimeStore(
|
||||
(state) => state.allowArtifactNetworkAccess,
|
||||
);
|
||||
const [height, setHeight] = useState(HTML_FRAME_DEFAULT_HEIGHT);
|
||||
const artifactHtml = useMemo(() => buildArtifactSrcDoc(code), [code]);
|
||||
const src = useMemo(() => {
|
||||
const query = new URLSearchParams({ v: hashArtifactCode(code) });
|
||||
if (allowNetworkAccess) {
|
||||
const token = getAuthToken();
|
||||
if (token) {
|
||||
query.set("allow_network", "1");
|
||||
query.set("token", token);
|
||||
}
|
||||
}
|
||||
return apiUrl(`/api/inference/artifact-preview-frame?${query.toString()}`);
|
||||
}, [allowNetworkAccess, code]);
|
||||
const postArtifactHtml = useCallback(() => {
|
||||
// The sandboxed frame intentionally has an opaque origin ("null").
|
||||
// A wildcard target is required here;
|
||||
// the payload is sent only to this iframe's contentWindow.
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{ type: "unsloth:artifact-html", html: artifactHtml },
|
||||
"*",
|
||||
);
|
||||
}, [artifactHtml]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: MessageEvent) => {
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
if (event.origin !== "null") return;
|
||||
if (typeof event.data?.chatArtifactHeight !== "number") return;
|
||||
setHeight(
|
||||
Math.min(
|
||||
Math.max(event.data.chatArtifactHeight, 160),
|
||||
HTML_FRAME_MAX_HEIGHT,
|
||||
),
|
||||
);
|
||||
};
|
||||
window.addEventListener("message", handler);
|
||||
return () => window.removeEventListener("message", handler);
|
||||
}, [postArtifactHtml]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={src}
|
||||
sandbox="allow-scripts"
|
||||
referrerPolicy="no-referrer"
|
||||
onLoad={postArtifactHtml}
|
||||
className={cn("block w-full border-0 bg-background", className)}
|
||||
style={{ height: fill ? "100%" : height }}
|
||||
title={title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
107
studio/frontend/src/features/chat/artifacts/store.ts
Normal file
107
studio/frontend/src/features/chat/artifacts/store.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { ChatArtifact, ChatArtifactSurface } from "./types";
|
||||
|
||||
const autoOpenedArtifactIds = new Set<string>();
|
||||
|
||||
export function hasAutoOpenedArtifact(artifactId: string): boolean {
|
||||
return autoOpenedArtifactIds.has(artifactId);
|
||||
}
|
||||
|
||||
export function rememberAutoOpenedArtifact(artifactId: string): void {
|
||||
autoOpenedArtifactIds.add(artifactId);
|
||||
}
|
||||
|
||||
export function clearAutoOpenedArtifacts(): void {
|
||||
autoOpenedArtifactIds.clear();
|
||||
}
|
||||
|
||||
type ChatArtifactsState = {
|
||||
artifactsById: Record<string, ChatArtifact>;
|
||||
selectedArtifactId: string | null;
|
||||
surface: ChatArtifactSurface;
|
||||
openArtifact: (
|
||||
artifact: ChatArtifact,
|
||||
options?: { surface?: ChatArtifactSurface },
|
||||
) => void;
|
||||
updateArtifact: (artifact: ChatArtifact) => void;
|
||||
closeArtifactSurface: () => void;
|
||||
clearArtifactsForThread: (threadId: string | null | undefined) => void;
|
||||
clearOrphanedArtifacts: () => void;
|
||||
resetArtifacts: () => void;
|
||||
};
|
||||
|
||||
export const useChatArtifactsStore = create<ChatArtifactsState>((set) => ({
|
||||
artifactsById: {},
|
||||
selectedArtifactId: null,
|
||||
surface: "panel",
|
||||
openArtifact: (artifact, options) =>
|
||||
set((state) => ({
|
||||
artifactsById: {
|
||||
...state.artifactsById,
|
||||
[artifact.id]: artifact,
|
||||
},
|
||||
selectedArtifactId: artifact.id,
|
||||
surface: options?.surface ?? state.surface,
|
||||
})),
|
||||
updateArtifact: (artifact) =>
|
||||
set((state) =>
|
||||
state.artifactsById[artifact.id]
|
||||
? {
|
||||
artifactsById: {
|
||||
...state.artifactsById,
|
||||
[artifact.id]: artifact,
|
||||
},
|
||||
}
|
||||
: state,
|
||||
),
|
||||
closeArtifactSurface: () =>
|
||||
set({ selectedArtifactId: null, surface: "panel" }),
|
||||
clearArtifactsForThread: (threadId) =>
|
||||
set((state) => {
|
||||
if (!threadId) return state;
|
||||
const artifactsById = Object.fromEntries(
|
||||
Object.entries(state.artifactsById).filter(
|
||||
([, artifact]) => artifact.threadId !== threadId,
|
||||
),
|
||||
);
|
||||
const selected = state.selectedArtifactId
|
||||
? artifactsById[state.selectedArtifactId]
|
||||
: null;
|
||||
return {
|
||||
artifactsById,
|
||||
selectedArtifactId: selected ? selected.id : null,
|
||||
};
|
||||
}),
|
||||
clearOrphanedArtifacts: () =>
|
||||
set((state) => {
|
||||
const artifactsById = Object.fromEntries(
|
||||
Object.entries(state.artifactsById).filter(
|
||||
([, artifact]) => artifact.threadId != null,
|
||||
),
|
||||
);
|
||||
const selected = state.selectedArtifactId
|
||||
? artifactsById[state.selectedArtifactId]
|
||||
: null;
|
||||
return {
|
||||
artifactsById,
|
||||
selectedArtifactId: selected ? selected.id : null,
|
||||
};
|
||||
}),
|
||||
resetArtifacts: () =>
|
||||
set({
|
||||
artifactsById: {},
|
||||
selectedArtifactId: null,
|
||||
surface: "panel",
|
||||
}),
|
||||
}));
|
||||
|
||||
export function useSelectedChatArtifact(): ChatArtifact | null {
|
||||
return useChatArtifactsStore((state) =>
|
||||
state.selectedArtifactId
|
||||
? (state.artifactsById[state.selectedArtifactId] ?? null)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
84
studio/frontend/src/features/chat/artifacts/types.ts
Normal file
84
studio/frontend/src/features/chat/artifacts/types.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
export type ChatArtifactSource = "tool" | "fence";
|
||||
export type ChatArtifactSurface = "panel" | "overlay";
|
||||
|
||||
export interface ChatArtifact {
|
||||
id: string;
|
||||
title: string;
|
||||
code: string;
|
||||
source: ChatArtifactSource;
|
||||
sourceMessageId?: string | null;
|
||||
sourceToolCallId?: string | null;
|
||||
threadId?: string | null;
|
||||
isStreaming?: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface ChatArtifactInput {
|
||||
title?: string | null;
|
||||
code: string;
|
||||
source: ChatArtifactSource;
|
||||
sourceMessageId?: string | null;
|
||||
sourceToolCallId?: string | null;
|
||||
threadId?: string | null;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_ARTIFACT_TITLE = "HTML artifact";
|
||||
|
||||
export function normalizeArtifactTitle(title?: string | null): string {
|
||||
const trimmed = title?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : DEFAULT_ARTIFACT_TITLE;
|
||||
}
|
||||
|
||||
export function hashArtifactCode(code: string): string {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < code.length; i += 1) {
|
||||
hash = ((hash << 5) + hash) ^ code.charCodeAt(i);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
export function createArtifactId(input: ChatArtifactInput): string {
|
||||
const threadSegment = input.threadId || "no-thread";
|
||||
const messageSegment = input.sourceMessageId || "transient";
|
||||
// Backend tool call IDs (call_0, call_1, …) reset per request, so
|
||||
// the message ID is needed to scope them to a specific turn.
|
||||
const parts = [input.source, threadSegment, messageSegment];
|
||||
|
||||
if (input.source === "tool" && input.sourceToolCallId) {
|
||||
parts.push(input.sourceToolCallId);
|
||||
} else {
|
||||
parts.push(hashArtifactCode(input.code));
|
||||
}
|
||||
|
||||
return parts.join(":");
|
||||
}
|
||||
|
||||
export function createChatArtifact(input: ChatArtifactInput): ChatArtifact {
|
||||
return {
|
||||
id: createArtifactId(input),
|
||||
title: normalizeArtifactTitle(input.title),
|
||||
code: input.code,
|
||||
source: input.source,
|
||||
sourceMessageId: input.sourceMessageId ?? null,
|
||||
sourceToolCallId: input.sourceToolCallId ?? null,
|
||||
threadId: input.threadId ?? null,
|
||||
isStreaming: input.isStreaming,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function getArtifactFilename(
|
||||
artifact: Pick<ChatArtifact, "title">,
|
||||
): string {
|
||||
const slug = artifact.title
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48);
|
||||
return `${slug || "artifact"}.html`;
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
97
studio/frontend/src/features/chat/hooks/use-chat-projects.ts
Normal file
97
studio/frontend/src/features/chat/hooks/use-chat-projects.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
355
studio/frontend/src/features/chat/mcp-composer-button.tsx
Normal file
355
studio/frontend/src/features/chat/mcp-composer-button.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
401
studio/frontend/src/features/chat/projects-page.tsx
Normal file
401
studio/frontend/src/features/chat/projects-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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({});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ const CHAT_ACTIVE_PRESET_KEY = "unsloth_chat_active_preset";
|
|||
const CHAT_ACTIVE_PRESET_SOURCE_KEY = "unsloth_chat_active_preset_source";
|
||||
const REASONING_EFFORT_KEY = "unsloth_reasoning_effort";
|
||||
const PRESERVE_THINKING_KEY = "unsloth_preserve_thinking";
|
||||
const COLLAPSE_HTML_ARTIFACTS_KEY = "unsloth_chat_collapse_html_artifacts";
|
||||
const ALLOW_ARTIFACT_NETWORK_ACCESS_KEY =
|
||||
"unsloth_chat_allow_artifact_network_access";
|
||||
const CHAT_PRESETS_KEY = "unsloth_chat_custom_presets";
|
||||
const LEGACY_CHAT_SYSTEM_PROMPTS_KEY = "unsloth_chat_system_prompts";
|
||||
const LEGACY_CHAT_SETTINGS_IMPORT_KEY =
|
||||
|
|
@ -216,6 +219,10 @@ function sanitizeChatSettings(value: unknown): PersistedChatSettings {
|
|||
const reasoningEffort = sanitizeReasoningEffort(value.reasoningEffort);
|
||||
const autoTitle = sanitizeBool(value.autoTitle);
|
||||
const preserveThinking = sanitizeBool(value.preserveThinking);
|
||||
const collapseHtmlArtifacts = sanitizeBool(value.collapseHtmlArtifacts);
|
||||
const allowArtifactNetworkAccess = sanitizeBool(
|
||||
value.allowArtifactNetworkAccess,
|
||||
);
|
||||
const autoHealToolCalls = sanitizeBool(value.autoHealToolCalls);
|
||||
const maxToolCallsPerMessage = sanitizeInt(value.maxToolCallsPerMessage, 1);
|
||||
const toolCallTimeout = sanitizeInt(value.toolCallTimeout, 1);
|
||||
|
|
@ -230,6 +237,12 @@ function sanitizeChatSettings(value: unknown): PersistedChatSettings {
|
|||
if (reasoningEffort) settings.reasoningEffort = reasoningEffort;
|
||||
if (preserveThinking !== undefined)
|
||||
settings.preserveThinking = preserveThinking;
|
||||
if (collapseHtmlArtifacts !== undefined) {
|
||||
settings.collapseHtmlArtifacts = collapseHtmlArtifacts;
|
||||
}
|
||||
if (allowArtifactNetworkAccess !== undefined) {
|
||||
settings.allowArtifactNetworkAccess = allowArtifactNetworkAccess;
|
||||
}
|
||||
if (autoHealToolCalls !== undefined) {
|
||||
settings.autoHealToolCalls = autoHealToolCalls;
|
||||
}
|
||||
|
|
@ -290,6 +303,8 @@ export function isEmptyChatSettings(settings: PersistedChatSettings): boolean {
|
|||
settings.autoTitle === undefined &&
|
||||
settings.reasoningEffort === undefined &&
|
||||
settings.preserveThinking === undefined &&
|
||||
settings.collapseHtmlArtifacts === undefined &&
|
||||
settings.allowArtifactNetworkAccess === undefined &&
|
||||
settings.autoHealToolCalls === undefined &&
|
||||
settings.maxToolCallsPerMessage === undefined &&
|
||||
settings.toolCallTimeout === undefined
|
||||
|
|
@ -318,6 +333,8 @@ export function loadLegacyChatSettings(): PersistedChatSettings {
|
|||
);
|
||||
const autoTitle = loadBool(AUTO_TITLE_KEY);
|
||||
const preserveThinking = loadBool(PRESERVE_THINKING_KEY);
|
||||
const collapseHtmlArtifacts = loadBool(COLLAPSE_HTML_ARTIFACTS_KEY);
|
||||
const allowArtifactNetworkAccess = loadBool(ALLOW_ARTIFACT_NETWORK_ACCESS_KEY);
|
||||
const autoHealToolCalls = loadBool(AUTO_HEAL_TOOL_CALLS_KEY);
|
||||
const maxToolCallsPerMessage = loadInt(MAX_TOOL_CALLS_KEY, 1);
|
||||
const toolCallTimeout = loadInt(TOOL_CALL_TIMEOUT_KEY, 1);
|
||||
|
|
@ -336,6 +353,12 @@ export function loadLegacyChatSettings(): PersistedChatSettings {
|
|||
if (reasoningEffort) settings.reasoningEffort = reasoningEffort;
|
||||
if (preserveThinking !== undefined)
|
||||
settings.preserveThinking = preserveThinking;
|
||||
if (collapseHtmlArtifacts !== undefined) {
|
||||
settings.collapseHtmlArtifacts = collapseHtmlArtifacts;
|
||||
}
|
||||
if (allowArtifactNetworkAccess !== undefined) {
|
||||
settings.allowArtifactNetworkAccess = allowArtifactNetworkAccess;
|
||||
}
|
||||
if (autoHealToolCalls !== undefined) {
|
||||
settings.autoHealToolCalls = autoHealToolCalls;
|
||||
}
|
||||
|
|
|
|||
11
studio/frontend/src/features/native-intents/index.ts
Normal file
11
studio/frontend/src/features/native-intents/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
export { NativeModelChip } from "./components/native-model-chip";
|
||||
export { NativeModelDropOverlay } from "./components/native-model-drop-overlay";
|
||||
export { useNativeIntentStore } from "./store";
|
||||
export type { NativeIntent } from "./types";
|
||||
export { useChooseNativeModel } from "./use-native-dialogs";
|
||||
export { useNativeModelDrop } from "./use-native-drop";
|
||||
export type { NativeModelDropState } from "./use-native-drop";
|
||||
export { useNativePathLeasesSupported } from "./use-native-readiness";
|
||||
|
|
@ -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" };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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..."
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
121
tests/studio/test_resolve_cuda_toolkit.ps1
Normal file
121
tests/studio/test_resolve_cuda_toolkit.ps1
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue