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